Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Create collection Modal [FC-0062] #1259

Merged
merged 17 commits into from
Sep 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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