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
This commit is contained in:
@@ -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 {
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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();
|
||||
@@ -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 () => {
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
@@ -49,6 +50,7 @@ const LibraryLayout = () => {
|
||||
element={<LibraryAuthoringPage />}
|
||||
/>
|
||||
</Routes>
|
||||
<CreateCollectionModal />
|
||||
</LibraryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
@@ -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),
|
||||
@@ -98,6 +140,8 @@ const AddContentContainer = () => {
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorPasteClipboardMessage));
|
||||
});
|
||||
} else if (blockType === 'collection') {
|
||||
openCreateCollectionModal();
|
||||
} else {
|
||||
createBlockMutation.mutateAsync({
|
||||
libraryId,
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import React from 'react';
|
||||
|
||||
export enum SidebarBodyComponentId {
|
||||
@@ -13,6 +14,9 @@ export interface LibraryContextData {
|
||||
openInfoSidebar: () => void;
|
||||
openComponentInfoSidebar: (usageKey: string) => void;
|
||||
currentComponentUsageKey?: string;
|
||||
isCreateCollectionModalOpen: boolean;
|
||||
openCreateCollectionModal: () => void;
|
||||
closeCreateCollectionModal: () => void;
|
||||
}
|
||||
|
||||
export const LibraryContext = React.createContext({
|
||||
@@ -21,6 +25,9 @@ export const LibraryContext = React.createContext({
|
||||
openAddContentSidebar: () => {},
|
||||
openInfoSidebar: () => {},
|
||||
openComponentInfoSidebar: (_usageKey: string) => {}, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
isCreateCollectionModalOpen: false,
|
||||
openCreateCollectionModal: () => {},
|
||||
closeCreateCollectionModal: () => {},
|
||||
} as LibraryContextData);
|
||||
|
||||
/**
|
||||
@@ -29,6 +36,7 @@ export const LibraryContext = React.createContext({
|
||||
export const LibraryProvider = (props: { children?: React.ReactNode }) => {
|
||||
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
|
||||
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();
|
||||
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
|
||||
|
||||
const closeLibrarySidebar = React.useCallback(() => {
|
||||
setSidebarBodyComponent(null);
|
||||
@@ -57,6 +65,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
|
||||
openInfoSidebar,
|
||||
openComponentInfoSidebar,
|
||||
currentComponentUsageKey,
|
||||
isCreateCollectionModalOpen,
|
||||
openCreateCollectionModal,
|
||||
closeCreateCollectionModal,
|
||||
}), [
|
||||
sidebarBodyComponent,
|
||||
closeLibrarySidebar,
|
||||
@@ -64,6 +75,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => {
|
||||
openInfoSidebar,
|
||||
openComponentInfoSidebar,
|
||||
currentComponentUsageKey,
|
||||
isCreateCollectionModalOpen,
|
||||
openCreateCollectionModal,
|
||||
closeCreateCollectionModal,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
Form,
|
||||
ModalDialog,
|
||||
} from '@openedx/paragon';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Formik } from 'formik';
|
||||
import * as Yup from 'yup';
|
||||
import FormikControl from '../../generic/FormikControl';
|
||||
import { LibraryContext } from '../common/context';
|
||||
import messages from './messages';
|
||||
import { useCreateLibraryCollection } from '../data/apiHooks';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
|
||||
const CreateCollectionModal = () => {
|
||||
const intl = useIntl();
|
||||
const { libraryId } = useParams();
|
||||
if (!libraryId) {
|
||||
throw new Error('Rendered without libraryId URL parameter');
|
||||
}
|
||||
const create = useCreateLibraryCollection(libraryId!);
|
||||
const {
|
||||
isCreateCollectionModalOpen,
|
||||
closeCreateCollectionModal,
|
||||
} = React.useContext(LibraryContext);
|
||||
const { showToast } = React.useContext(ToastContext);
|
||||
|
||||
const handleCreate = React.useCallback((values) => {
|
||||
create.mutateAsync(values).then(() => {
|
||||
closeCreateCollectionModal();
|
||||
showToast(intl.formatMessage(messages.createCollectionSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.createCollectionError));
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={intl.formatMessage(messages.createCollectionModalTitle)}
|
||||
isOpen={isCreateCollectionModalOpen}
|
||||
onClose={closeCreateCollectionModal}
|
||||
size="xl"
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
{intl.formatMessage(messages.createCollectionModalTitle)}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
|
||||
<Formik
|
||||
initialValues={{
|
||||
title: '',
|
||||
description: '',
|
||||
}}
|
||||
validationSchema={
|
||||
Yup.object().shape({
|
||||
title: Yup.string()
|
||||
.required(intl.formatMessage(messages.createCollectionModalNameInvalid)),
|
||||
description: Yup.string(),
|
||||
})
|
||||
}
|
||||
onSubmit={handleCreate}
|
||||
>
|
||||
{(formikProps) => (
|
||||
<>
|
||||
<ModalDialog.Body className="mw-sm">
|
||||
<Form onSubmit={formikProps.handleSubmit}>
|
||||
<FormikControl
|
||||
name="title"
|
||||
label={(
|
||||
<Form.Label className="font-weight-bold h3">
|
||||
{intl.formatMessage(messages.createCollectionModalNameLabel)}
|
||||
</Form.Label>
|
||||
)}
|
||||
value={formikProps.values.title}
|
||||
placeholder={intl.formatMessage(messages.createCollectionModalNamePlaceholder)}
|
||||
controlClasses="pb-2"
|
||||
/>
|
||||
<FormikControl
|
||||
name="description"
|
||||
as="textarea"
|
||||
label={(
|
||||
<Form.Label className="font-weight-bold h3">
|
||||
{intl.formatMessage(messages.createCollectionModalDescriptionLabel)}
|
||||
</Form.Label>
|
||||
)}
|
||||
value={formikProps.values.description}
|
||||
placeholder={intl.formatMessage(messages.createCollectionModalDescriptionPlaceholder)}
|
||||
help={(
|
||||
<Form.Text>
|
||||
{intl.formatMessage(messages.createCollectionModalDescriptionDetails)}
|
||||
</Form.Text>
|
||||
)}
|
||||
controlClasses="pb-2"
|
||||
rows="5"
|
||||
/>
|
||||
</Form>
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.createCollectionModalCancel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={formikProps.submitForm}
|
||||
disabled={formikProps.isSubmitting || !formikProps.isValid || !formikProps.dirty}
|
||||
>
|
||||
{intl.formatMessage(messages.createCollectionModalCreate)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</>
|
||||
)}
|
||||
</Formik>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateCollectionModal;
|
||||
2
src/library-authoring/create-collection/index.tsx
Normal file
2
src/library-authoring/create-collection/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as CreateCollectionModal } from './CreateCollectionModal';
|
||||
65
src/library-authoring/create-collection/messages.ts
Normal file
65
src/library-authoring/create-collection/messages.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
|
||||
import type { defineMessages as defineMessagesType } from 'react-intl';
|
||||
|
||||
// frontend-platform currently doesn't provide types... do it ourselves.
|
||||
const defineMessages = _defineMessages as typeof defineMessagesType;
|
||||
|
||||
const messages = defineMessages({
|
||||
createCollectionModalTitle: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.title',
|
||||
defaultMessage: 'New Collection',
|
||||
description: 'Title of the Create Collection modal',
|
||||
},
|
||||
createCollectionModalCancel: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Label of the Cancel button of the Create Collection modal',
|
||||
},
|
||||
createCollectionModalCreate: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.create',
|
||||
defaultMessage: 'Create',
|
||||
description: 'Label of the Create button of the Create Collection modal',
|
||||
},
|
||||
createCollectionModalNameLabel: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.form.name',
|
||||
defaultMessage: 'Name your collection',
|
||||
description: 'Label of the Name field of the Create Collection modal form',
|
||||
},
|
||||
createCollectionModalNamePlaceholder: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.form.name.placeholder',
|
||||
defaultMessage: 'Give a descriptive title',
|
||||
description: 'Placeholder of the Name field of the Create Collection modal form',
|
||||
},
|
||||
createCollectionModalNameInvalid: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.form.name.invalid',
|
||||
defaultMessage: 'Collection name is required',
|
||||
description: 'Message when the Name field of the Create Collection modal form is invalid',
|
||||
},
|
||||
createCollectionModalDescriptionLabel: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.form.description',
|
||||
defaultMessage: 'Add a description (optional)',
|
||||
description: 'Label of the Description field of the Create Collection modal form',
|
||||
},
|
||||
createCollectionModalDescriptionPlaceholder: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.form.description.placeholder',
|
||||
defaultMessage: 'Add description',
|
||||
description: 'Placeholder of the Description field of the Create Collection modal form',
|
||||
},
|
||||
createCollectionModalDescriptionDetails: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.form.description.details',
|
||||
defaultMessage: 'Descriptions can help you and your team better organize and find what you are looking for',
|
||||
description: 'Details of the Description field of the Create Collection modal form',
|
||||
},
|
||||
createCollectionSuccess: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.success',
|
||||
defaultMessage: 'Collection created successfully',
|
||||
description: 'Success message when creating a library collection',
|
||||
},
|
||||
createCollectionError: {
|
||||
id: 'course-authoring.library-authoring.modals.create-collection.error',
|
||||
defaultMessage: 'There is an error when creating the library collection',
|
||||
description: 'Error message when creating a library collection',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -43,4 +43,19 @@ describe('library data API', () => {
|
||||
expect(axiosMock.history.delete[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
it('should create collection', async () => {
|
||||
const { axiosMock } = initializeMocks();
|
||||
const libraryId = 'lib:org:1';
|
||||
const url = api.getLibraryCollectionsApiUrl(libraryId);
|
||||
|
||||
axiosMock.onPost(url).reply(200);
|
||||
|
||||
await api.createCollection(libraryId, {
|
||||
title: 'This is a test',
|
||||
description: 'This is only a test',
|
||||
});
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,10 @@ export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/a
|
||||
* Get the URL for the xblock OLX API
|
||||
*/
|
||||
export const getXBlockOLXApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/libraries/v2/blocks/${usageKey}/olx/`;
|
||||
/**
|
||||
* Get the URL for the Library Collections API.
|
||||
*/
|
||||
export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/collections/`;
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
@@ -131,6 +135,11 @@ export interface UpdateXBlockFieldsRequest {
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateLibraryCollectionDataRequest {
|
||||
title: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of XBlock types that can be added to this library
|
||||
*/
|
||||
@@ -236,11 +245,21 @@ export async function getXBlockFields(usageKey: string): Promise<XBlockFields> {
|
||||
/**
|
||||
* Update xblock fields.
|
||||
*/
|
||||
export async function updateXBlockFields(usageKey:string, xblockData: UpdateXBlockFieldsRequest) {
|
||||
export async function updateXBlockFields(usageKey: string, xblockData: UpdateXBlockFieldsRequest) {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
await client.post(getXBlockFieldsApiUrl(usageKey), xblockData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a library collection
|
||||
*/
|
||||
export async function createCollection(libraryId: string, collectionData: CreateLibraryCollectionDataRequest) {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.post(getLibraryCollectionsApiUrl(libraryId), collectionData);
|
||||
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the OLX for the given XBlock.
|
||||
*/
|
||||
|
||||
@@ -5,8 +5,13 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { getCommitLibraryChangesUrl, getCreateLibraryBlockUrl } from './api';
|
||||
import { useCommitLibraryChanges, useCreateLibraryBlock, useRevertLibraryChanges } from './apiHooks';
|
||||
import { getCommitLibraryChangesUrl, getCreateLibraryBlockUrl, getLibraryCollectionsApiUrl } from './api';
|
||||
import {
|
||||
useCommitLibraryChanges,
|
||||
useCreateLibraryBlock,
|
||||
useCreateLibraryCollection,
|
||||
useRevertLibraryChanges,
|
||||
} from './apiHooks';
|
||||
|
||||
let axiosMock;
|
||||
|
||||
@@ -70,4 +75,18 @@ describe('library api hooks', () => {
|
||||
|
||||
expect(axiosMock.history.delete[0].url).toEqual(url);
|
||||
});
|
||||
|
||||
it('should create collection', async () => {
|
||||
const libraryId = 'lib:org:1';
|
||||
const url = getLibraryCollectionsApiUrl(libraryId);
|
||||
axiosMock.onPost(url).reply(200);
|
||||
|
||||
const { result } = renderHook(() => useCreateLibraryCollection(libraryId), { wrapper });
|
||||
await result.current.mutateAsync({
|
||||
title: 'This is a test',
|
||||
description: 'This is only a test',
|
||||
});
|
||||
|
||||
expect(axiosMock.history.post[0].url).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
libraryPasteClipboard,
|
||||
getXBlockFields,
|
||||
updateXBlockFields,
|
||||
createCollection,
|
||||
getXBlockOLX,
|
||||
type CreateLibraryCollectionDataRequest,
|
||||
} from './api';
|
||||
|
||||
const libraryQueryPredicate = (query: Query, libraryId: string): boolean => {
|
||||
@@ -238,6 +240,19 @@ export const useUpdateXBlockFields = (usageKey: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Use this mutation to create a library collection
|
||||
*/
|
||||
export const useCreateLibraryCollection = (libraryId: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateLibraryCollectionDataRequest) => createCollection(libraryId, data),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/* istanbul ignore next */ // This is only used in developer builds, and the associated UI doesn't work in test or prod
|
||||
export const useXBlockOLX = (usageKey: string) => (
|
||||
useQuery({
|
||||
|
||||
Reference in New Issue
Block a user