Skip to content

Commit

Permalink
feat: Create collection Modal [FC-0062] (#1259)
Browse files Browse the repository at this point in the history
* feat: Enable Collection button on Create Component in Library

* feat: CreateCollectionModal added

* test: For CreateCollectionModal

* refactor: Migrate FormikControl to TypeScript

* test: Add tests for EmptyStates
  • Loading branch information
ChrisChV authored Sep 14, 2024
1 parent fd48fef commit a37a1b1
Show file tree
Hide file tree
Showing 13 changed files with 486 additions and 50 deletions.
44 changes: 17 additions & 27 deletions src/generic/FormikControl.jsx → src/generic/FormikControl.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,25 @@
/* eslint-disable react/jsx-no-useless-fragment */
import React from 'react';
import { Form } from '@openedx/paragon';
import { getIn, useFormikContext } from 'formik';
import PropTypes from 'prop-types';
import React from 'react';
import FormikErrorFeedback from './FormikErrorFeedback';

const FormikControl = ({
interface Props {
name: string;
label?: React.ReactElement;
help?: React.ReactElement;
className?: string;
controlClasses?: string;
value: string | number;
}

const FormikControl: React.FC<Props & React.ComponentProps<typeof Form.Control>> = ({
name,
label,
help,
className,
controlClasses,
// eslint-disable-next-line react/jsx-no-useless-fragment
label = <></>,
// eslint-disable-next-line react/jsx-no-useless-fragment
help = <></>,
className = '',
controlClasses = 'pb-2',
...params
}) => {
const {
Expand Down Expand Up @@ -39,23 +48,4 @@ const FormikControl = ({
);
};

FormikControl.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.element,
help: PropTypes.element,
className: PropTypes.string,
controlClasses: PropTypes.string,
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
};

FormikControl.defaultProps = {
help: <></>,
label: <></>,
className: '',
controlClasses: 'pb-2',
};

export default FormikControl;
14 changes: 11 additions & 3 deletions src/library-authoring/EmptyStates.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useContext } from 'react';
import React, { useContext, useCallback } from 'react';
import { useParams } from 'react-router';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import {
Expand All @@ -15,18 +15,26 @@ type NoSearchResultsProps = {
};

export const NoComponents = ({ searchType = 'component' }: NoSearchResultsProps) => {
const { openAddContentSidebar } = useContext(LibraryContext);
const { openAddContentSidebar, openCreateCollectionModal } = useContext(LibraryContext);
const { libraryId } = useParams();
const { data: libraryData } = useContentLibrary(libraryId);
const canEditLibrary = libraryData?.canEditLibrary ?? false;

const handleOnClickButton = useCallback(() => {
if (searchType === 'collection') {
openCreateCollectionModal();
} else {
openAddContentSidebar();
}
}, [searchType]);

return (
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
{searchType === 'collection'
? <FormattedMessage {...messages.noCollections} />
: <FormattedMessage {...messages.noComponents} />}
{canEditLibrary && (
<Button iconBefore={Add} onClick={() => openAddContentSidebar()}>
<Button iconBefore={Add} onClick={handleOnClickButton}>
{searchType === 'collection'
? <FormattedMessage {...messages.addCollection} />
: <FormattedMessage {...messages.addComponent} />}
Expand Down
130 changes: 130 additions & 0 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields } from './d
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
import { mockBroadcastChannel } from '../generic/data/api.mock';
import { LibraryLayout } from '.';
import { getLibraryCollectionsApiUrl } from './data/api';

mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
Expand Down Expand Up @@ -164,8 +165,23 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Collections' }));
expect(screen.getByText('You have not added any collection to this library yet.')).toBeInTheDocument();

// Open Create collection modal
const addCollectionButton = screen.getByRole('button', { name: /add collection/i });
fireEvent.click(addCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();

// Click on Cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(collectionModalHeading).not.toBeInTheDocument();

fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();

const addComponentButton = screen.getByRole('button', { name: /add component/i });
fireEvent.click(addComponentButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
});

it('shows the new content button', async () => {
Expand Down Expand Up @@ -535,6 +551,120 @@ describe('<LibraryAuthoringPage />', () => {
expect(screen.getByText(/no matching components/i)).toBeInTheDocument();
});

it('should create a collection', async () => {
await renderLibraryPage();
const title = 'This is a Test';
const description = 'This is the description of the Test';
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
const { axiosMock } = initializeMocks();
axiosMock.onPost(url).reply(200, {
id: '1',
slug: 'this-is-a-test',
title,
description,
});

expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();

// Open Add content sidebar
const newButton = screen.getByRole('button', { name: /new/i });
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();

// Open New collection Modal
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
fireEvent.click(newCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();

// Click on Cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(collectionModalHeading).not.toBeInTheDocument();

// Open new collection modal again and create a collection
fireEvent.click(newCollectionButton);
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your collection/i });
const descriptionField = screen.getByRole('textbox', { name: /add a description \(optional\)/i });

fireEvent.change(nameField, { target: { value: title } });
fireEvent.change(descriptionField, { target: { value: description } });
fireEvent.click(createButton);
});

it('should show validations in create collection', async () => {
await renderLibraryPage();

const title = 'This is a Test';
const description = 'This is the description of the Test';
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
const { axiosMock } = initializeMocks();
axiosMock.onPost(url).reply(200, {
id: '1',
slug: 'this-is-a-test',
title,
description,
});

expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();

// Open Add content sidebar
const newButton = screen.getByRole('button', { name: /new/i });
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();

// Open New collection Modal
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
fireEvent.click(newCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();

const nameField = screen.getByRole('textbox', { name: /name your collection/i });
fireEvent.focus(nameField);
fireEvent.blur(nameField);

// Click on create with an empty name
const createButton = screen.getByRole('button', { name: /create/i });
fireEvent.click(createButton);

expect(await screen.findByText(/collection name is required/i)).toBeInTheDocument();
});

it('should show error on create collection', async () => {
await renderLibraryPage();
const title = 'This is a Test';
const description = 'This is the description of the Test';
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
const { axiosMock } = initializeMocks();
axiosMock.onPost(url).reply(500);

expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();

// Open Add content sidebar
const newButton = screen.getByRole('button', { name: /new/i });
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();

// Open New collection Modal
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
fireEvent.click(newCollectionButton);
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
expect(collectionModalHeading).toBeInTheDocument();

// Create a normal collection
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your collection/i });
const descriptionField = screen.getByRole('textbox', { name: /add a description \(optional\)/i });

fireEvent.change(nameField, { target: { value: title } });
fireEvent.change(descriptionField, { target: { value: description } });
fireEvent.click(createButton);
});

it('shows both components and collections in recently modified section', async () => {
await renderLibraryPage();

Expand Down
2 changes: 2 additions & 0 deletions src/library-authoring/LibraryLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useQueryClient } from '@tanstack/react-query';
import EditorContainer from '../editors/EditorContainer';
import LibraryAuthoringPage from './LibraryAuthoringPage';
import { LibraryProvider } from './common/context';
import { CreateCollectionModal } from './create-collection';
import { invalidateComponentData } from './data/apiHooks';

const LibraryLayout = () => {
Expand Down Expand Up @@ -49,6 +50,7 @@ const LibraryLayout = () => {
element={<LibraryAuthoringPage />}
/>
</Routes>
<CreateCollectionModal />
</LibraryProvider>
);
};
Expand Down
66 changes: 49 additions & 17 deletions src/library-authoring/add-content/AddContentContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,39 @@ import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHook
import { getEditUrl } from '../components/utils';

import messages from './messages';
import { LibraryContext } from '../common/context';

type ContentType = {
name: string,
disabled: boolean,
icon: React.ComponentType,
blockType: string,
};

type AddContentButtonProps = {
contentType: ContentType,
onCreateContent: (blockType: string) => void,
};

const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonProps) => {
const {
name,
disabled,
icon,
blockType,
} = contentType;
return (
<Button
variant="outline-primary"
disabled={disabled}
className="m-2"
iconBefore={icon}
onClick={() => onCreateContent(blockType)}
>
{name}
</Button>
);
};

const AddContentContainer = () => {
const intl = useIntl();
Expand All @@ -35,7 +68,16 @@ const AddContentContainer = () => {
const { showToast } = useContext(ToastContext);
const canEdit = useSelector(getCanEdit);
const { showPasteXBlock } = useCopyToClipboard(canEdit);
const {
openCreateCollectionModal,
} = React.useContext(LibraryContext);

const collectionButtonData = {
name: intl.formatMessage(messages.collectionButton),
disabled: false,
icon: BookOpen,
blockType: 'collection',
};
const contentTypes = [
{
name: intl.formatMessage(messages.textTypeButton),
Expand Down Expand Up @@ -98,6 +140,8 @@ const AddContentContainer = () => {
}).catch(() => {
showToast(intl.formatMessage(messages.errorPasteClipboardMessage));
});
} else if (blockType === 'collection') {
openCreateCollectionModal();
} else {
createBlockMutation.mutateAsync({
libraryId,
Expand All @@ -124,26 +168,14 @@ const AddContentContainer = () => {

return (
<Stack direction="vertical">
<Button
variant="outline-primary"
disabled
className="m-2 rounded-0"
iconBefore={BookOpen}
>
{intl.formatMessage(messages.collectionButton)}
</Button>
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
<hr className="w-100 bg-gray-500" />
{contentTypes.map((contentType) => (
<Button
<AddContentButton
key={`add-content-${contentType.blockType}`}
variant="outline-primary"
disabled={contentType.disabled}
className="m-2 rounded-0"
iconBefore={contentType.icon}
onClick={() => onCreateContent(contentType.blockType)}
>
{contentType.name}
</Button>
contentType={contentType}
onCreateContent={onCreateContent}
/>
))}
</Stack>
);
Expand Down
Loading

0 comments on commit a37a1b1

Please sign in to comment.