Skip to content

Commit a37a1b1

Browse files
authored
feat: Create collection Modal [FC-0062] (#1259)
* 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
1 parent fd48fef commit a37a1b1

File tree

13 files changed

+486
-50
lines changed

13 files changed

+486
-50
lines changed
Lines changed: 17 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
1-
/* eslint-disable react/jsx-no-useless-fragment */
1+
import React from 'react';
22
import { Form } from '@openedx/paragon';
33
import { getIn, useFormikContext } from 'formik';
4-
import PropTypes from 'prop-types';
5-
import React from 'react';
64
import FormikErrorFeedback from './FormikErrorFeedback';
75

8-
const FormikControl = ({
6+
interface Props {
7+
name: string;
8+
label?: React.ReactElement;
9+
help?: React.ReactElement;
10+
className?: string;
11+
controlClasses?: string;
12+
value: string | number;
13+
}
14+
15+
const FormikControl: React.FC<Props & React.ComponentProps<typeof Form.Control>> = ({
916
name,
10-
label,
11-
help,
12-
className,
13-
controlClasses,
17+
// eslint-disable-next-line react/jsx-no-useless-fragment
18+
label = <></>,
19+
// eslint-disable-next-line react/jsx-no-useless-fragment
20+
help = <></>,
21+
className = '',
22+
controlClasses = 'pb-2',
1423
...params
1524
}) => {
1625
const {
@@ -39,23 +48,4 @@ const FormikControl = ({
3948
);
4049
};
4150

42-
FormikControl.propTypes = {
43-
name: PropTypes.string.isRequired,
44-
label: PropTypes.element,
45-
help: PropTypes.element,
46-
className: PropTypes.string,
47-
controlClasses: PropTypes.string,
48-
value: PropTypes.oneOfType([
49-
PropTypes.string,
50-
PropTypes.number,
51-
]).isRequired,
52-
};
53-
54-
FormikControl.defaultProps = {
55-
help: <></>,
56-
label: <></>,
57-
className: '',
58-
controlClasses: 'pb-2',
59-
};
60-
6151
export default FormikControl;

src/library-authoring/EmptyStates.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useContext } from 'react';
1+
import React, { useContext, useCallback } from 'react';
22
import { useParams } from 'react-router';
33
import { FormattedMessage } from '@edx/frontend-platform/i18n';
44
import {
@@ -15,18 +15,26 @@ type NoSearchResultsProps = {
1515
};
1616

1717
export const NoComponents = ({ searchType = 'component' }: NoSearchResultsProps) => {
18-
const { openAddContentSidebar } = useContext(LibraryContext);
18+
const { openAddContentSidebar, openCreateCollectionModal } = useContext(LibraryContext);
1919
const { libraryId } = useParams();
2020
const { data: libraryData } = useContentLibrary(libraryId);
2121
const canEditLibrary = libraryData?.canEditLibrary ?? false;
2222

23+
const handleOnClickButton = useCallback(() => {
24+
if (searchType === 'collection') {
25+
openCreateCollectionModal();
26+
} else {
27+
openAddContentSidebar();
28+
}
29+
}, [searchType]);
30+
2331
return (
2432
<Stack direction="horizontal" gap={3} className="mt-6 justify-content-center">
2533
{searchType === 'collection'
2634
? <FormattedMessage {...messages.noCollections} />
2735
: <FormattedMessage {...messages.noComponents} />}
2836
{canEditLibrary && (
29-
<Button iconBefore={Add} onClick={() => openAddContentSidebar()}>
37+
<Button iconBefore={Add} onClick={handleOnClickButton}>
3038
{searchType === 'collection'
3139
? <FormattedMessage {...messages.addCollection} />
3240
: <FormattedMessage {...messages.addComponent} />}

src/library-authoring/LibraryAuthoringPage.test.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { mockContentLibrary, mockLibraryBlockTypes, mockXBlockFields } from './d
1313
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
1414
import { mockBroadcastChannel } from '../generic/data/api.mock';
1515
import { LibraryLayout } from '.';
16+
import { getLibraryCollectionsApiUrl } from './data/api';
1617

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

168+
// Open Create collection modal
169+
const addCollectionButton = screen.getByRole('button', { name: /add collection/i });
170+
fireEvent.click(addCollectionButton);
171+
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
172+
expect(collectionModalHeading).toBeInTheDocument();
173+
174+
// Click on Cancel button
175+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
176+
fireEvent.click(cancelButton);
177+
expect(collectionModalHeading).not.toBeInTheDocument();
178+
167179
fireEvent.click(screen.getByRole('tab', { name: 'Home' }));
168180
expect(screen.getByText('You have not added any content to this library yet.')).toBeInTheDocument();
181+
182+
const addComponentButton = screen.getByRole('button', { name: /add component/i });
183+
fireEvent.click(addComponentButton);
184+
expect(screen.getByText(/add content/i)).toBeInTheDocument();
169185
});
170186

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

554+
it('should create a collection', async () => {
555+
await renderLibraryPage();
556+
const title = 'This is a Test';
557+
const description = 'This is the description of the Test';
558+
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
559+
const { axiosMock } = initializeMocks();
560+
axiosMock.onPost(url).reply(200, {
561+
id: '1',
562+
slug: 'this-is-a-test',
563+
title,
564+
description,
565+
});
566+
567+
expect(await screen.findByRole('heading')).toBeInTheDocument();
568+
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
569+
570+
// Open Add content sidebar
571+
const newButton = screen.getByRole('button', { name: /new/i });
572+
fireEvent.click(newButton);
573+
expect(screen.getByText(/add content/i)).toBeInTheDocument();
574+
575+
// Open New collection Modal
576+
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
577+
fireEvent.click(newCollectionButton);
578+
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
579+
expect(collectionModalHeading).toBeInTheDocument();
580+
581+
// Click on Cancel button
582+
const cancelButton = screen.getByRole('button', { name: /cancel/i });
583+
fireEvent.click(cancelButton);
584+
expect(collectionModalHeading).not.toBeInTheDocument();
585+
586+
// Open new collection modal again and create a collection
587+
fireEvent.click(newCollectionButton);
588+
const createButton = screen.getByRole('button', { name: /create/i });
589+
const nameField = screen.getByRole('textbox', { name: /name your collection/i });
590+
const descriptionField = screen.getByRole('textbox', { name: /add a description \(optional\)/i });
591+
592+
fireEvent.change(nameField, { target: { value: title } });
593+
fireEvent.change(descriptionField, { target: { value: description } });
594+
fireEvent.click(createButton);
595+
});
596+
597+
it('should show validations in create collection', async () => {
598+
await renderLibraryPage();
599+
600+
const title = 'This is a Test';
601+
const description = 'This is the description of the Test';
602+
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
603+
const { axiosMock } = initializeMocks();
604+
axiosMock.onPost(url).reply(200, {
605+
id: '1',
606+
slug: 'this-is-a-test',
607+
title,
608+
description,
609+
});
610+
611+
expect(await screen.findByRole('heading')).toBeInTheDocument();
612+
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
613+
614+
// Open Add content sidebar
615+
const newButton = screen.getByRole('button', { name: /new/i });
616+
fireEvent.click(newButton);
617+
expect(screen.getByText(/add content/i)).toBeInTheDocument();
618+
619+
// Open New collection Modal
620+
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
621+
fireEvent.click(newCollectionButton);
622+
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
623+
expect(collectionModalHeading).toBeInTheDocument();
624+
625+
const nameField = screen.getByRole('textbox', { name: /name your collection/i });
626+
fireEvent.focus(nameField);
627+
fireEvent.blur(nameField);
628+
629+
// Click on create with an empty name
630+
const createButton = screen.getByRole('button', { name: /create/i });
631+
fireEvent.click(createButton);
632+
633+
expect(await screen.findByText(/collection name is required/i)).toBeInTheDocument();
634+
});
635+
636+
it('should show error on create collection', async () => {
637+
await renderLibraryPage();
638+
const title = 'This is a Test';
639+
const description = 'This is the description of the Test';
640+
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
641+
const { axiosMock } = initializeMocks();
642+
axiosMock.onPost(url).reply(500);
643+
644+
expect(await screen.findByRole('heading')).toBeInTheDocument();
645+
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
646+
647+
// Open Add content sidebar
648+
const newButton = screen.getByRole('button', { name: /new/i });
649+
fireEvent.click(newButton);
650+
expect(screen.getByText(/add content/i)).toBeInTheDocument();
651+
652+
// Open New collection Modal
653+
const newCollectionButton = screen.getAllByRole('button', { name: /collection/i })[4];
654+
fireEvent.click(newCollectionButton);
655+
const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i });
656+
expect(collectionModalHeading).toBeInTheDocument();
657+
658+
// Create a normal collection
659+
const createButton = screen.getByRole('button', { name: /create/i });
660+
const nameField = screen.getByRole('textbox', { name: /name your collection/i });
661+
const descriptionField = screen.getByRole('textbox', { name: /add a description \(optional\)/i });
662+
663+
fireEvent.change(nameField, { target: { value: title } });
664+
fireEvent.change(descriptionField, { target: { value: description } });
665+
fireEvent.click(createButton);
666+
});
667+
538668
it('shows both components and collections in recently modified section', async () => {
539669
await renderLibraryPage();
540670

src/library-authoring/LibraryLayout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useQueryClient } from '@tanstack/react-query';
1111
import EditorContainer from '../editors/EditorContainer';
1212
import LibraryAuthoringPage from './LibraryAuthoringPage';
1313
import { LibraryProvider } from './common/context';
14+
import { CreateCollectionModal } from './create-collection';
1415
import { invalidateComponentData } from './data/apiHooks';
1516

1617
const LibraryLayout = () => {
@@ -49,6 +50,7 @@ const LibraryLayout = () => {
4950
element={<LibraryAuthoringPage />}
5051
/>
5152
</Routes>
53+
<CreateCollectionModal />
5254
</LibraryProvider>
5355
);
5456
};

src/library-authoring/add-content/AddContentContainer.tsx

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,39 @@ import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHook
2525
import { getEditUrl } from '../components/utils';
2626

2727
import messages from './messages';
28+
import { LibraryContext } from '../common/context';
29+
30+
type ContentType = {
31+
name: string,
32+
disabled: boolean,
33+
icon: React.ComponentType,
34+
blockType: string,
35+
};
36+
37+
type AddContentButtonProps = {
38+
contentType: ContentType,
39+
onCreateContent: (blockType: string) => void,
40+
};
41+
42+
const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonProps) => {
43+
const {
44+
name,
45+
disabled,
46+
icon,
47+
blockType,
48+
} = contentType;
49+
return (
50+
<Button
51+
variant="outline-primary"
52+
disabled={disabled}
53+
className="m-2"
54+
iconBefore={icon}
55+
onClick={() => onCreateContent(blockType)}
56+
>
57+
{name}
58+
</Button>
59+
);
60+
};
2861

2962
const AddContentContainer = () => {
3063
const intl = useIntl();
@@ -35,7 +68,16 @@ const AddContentContainer = () => {
3568
const { showToast } = useContext(ToastContext);
3669
const canEdit = useSelector(getCanEdit);
3770
const { showPasteXBlock } = useCopyToClipboard(canEdit);
71+
const {
72+
openCreateCollectionModal,
73+
} = React.useContext(LibraryContext);
3874

75+
const collectionButtonData = {
76+
name: intl.formatMessage(messages.collectionButton),
77+
disabled: false,
78+
icon: BookOpen,
79+
blockType: 'collection',
80+
};
3981
const contentTypes = [
4082
{
4183
name: intl.formatMessage(messages.textTypeButton),
@@ -98,6 +140,8 @@ const AddContentContainer = () => {
98140
}).catch(() => {
99141
showToast(intl.formatMessage(messages.errorPasteClipboardMessage));
100142
});
143+
} else if (blockType === 'collection') {
144+
openCreateCollectionModal();
101145
} else {
102146
createBlockMutation.mutateAsync({
103147
libraryId,
@@ -124,26 +168,14 @@ const AddContentContainer = () => {
124168

125169
return (
126170
<Stack direction="vertical">
127-
<Button
128-
variant="outline-primary"
129-
disabled
130-
className="m-2 rounded-0"
131-
iconBefore={BookOpen}
132-
>
133-
{intl.formatMessage(messages.collectionButton)}
134-
</Button>
171+
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
135172
<hr className="w-100 bg-gray-500" />
136173
{contentTypes.map((contentType) => (
137-
<Button
174+
<AddContentButton
138175
key={`add-content-${contentType.blockType}`}
139-
variant="outline-primary"
140-
disabled={contentType.disabled}
141-
className="m-2 rounded-0"
142-
iconBefore={contentType.icon}
143-
onClick={() => onCreateContent(contentType.blockType)}
144-
>
145-
{contentType.name}
146-
</Button>
176+
contentType={contentType}
177+
onCreateContent={onCreateContent}
178+
/>
147179
))}
148180
</Stack>
149181
);

0 commit comments

Comments
 (0)