From e3c307616ea4eab828afa8c5015176d1b456a9ab Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 5 Sep 2024 16:30:54 -0500 Subject: [PATCH 01/14] feat: Enable Collection button on Create Component in Library --- .../add-content/AddContentContainer.tsx | 61 +++++++++++++------ 1 file changed, 43 insertions(+), 18 deletions(-) diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index 421c81be68..1624746a2c 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -24,6 +24,39 @@ import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHook import messages from './messages'; +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 ( + + ); +}; + const AddContentContainer = () => { const intl = useIntl(); const { libraryId } = useParams(); @@ -33,6 +66,12 @@ const AddContentContainer = () => { const canEdit = useSelector(getCanEdit); const { showPasteXBlock } = useCopyToClipboard(canEdit); + const collectionButtonData = { + name: intl.formatMessage(messages.collectionButton), + disabled: false, + icon: BookOpen, + blockType: 'collection', + }; const contentTypes = [ { name: intl.formatMessage(messages.textTypeButton), @@ -95,6 +134,8 @@ const AddContentContainer = () => { }).catch(() => { showToast(intl.formatMessage(messages.errorPasteClipboardMessage)); }); + } else if (blockType === 'collection') { + // TODO } else { createBlockMutation.mutateAsync({ libraryId, @@ -115,26 +156,10 @@ const AddContentContainer = () => { return ( - +
{contentTypes.map((contentType) => ( - + ))}
); From 718ff7f9d15fd21a7053e1a86851640a1d557b20 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 5 Sep 2024 17:54:57 -0500 Subject: [PATCH 02/14] feat: CreateCollectionModal added --- src/library-authoring/LibraryLayout.tsx | 2 + .../add-content/AddContentContainer.tsx | 6 +- src/library-authoring/common/context.tsx | 14 +++ .../CreateCollectionModal.tsx | 102 ++++++++++++++++++ .../create-collection/index.tsx | 2 + .../create-collection/messages.ts | 55 ++++++++++ 6 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 src/library-authoring/create-collection/CreateCollectionModal.tsx create mode 100644 src/library-authoring/create-collection/index.tsx create mode 100644 src/library-authoring/create-collection/messages.ts diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 95d829606f..b02c6aab1f 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -1,10 +1,12 @@ import React from 'react'; import LibraryAuthoringPage from './LibraryAuthoringPage'; import { LibraryProvider } from './common/context'; +import { CreateCollectionModal } from './create-collection'; const LibraryLayout = () => ( + ); diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index 1624746a2c..fe560f766e 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -23,6 +23,7 @@ import { getCanEdit } from '../../course-unit/data/selectors'; import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHooks'; import messages from './messages'; +import { LibraryContext } from '../common/context'; type ContentType = { name: string, @@ -65,6 +66,9 @@ 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), @@ -135,7 +139,7 @@ const AddContentContainer = () => { showToast(intl.formatMessage(messages.errorPasteClipboardMessage)); }); } else if (blockType === 'collection') { - // TODO + openCreateCollectionModal(); } else { createBlockMutation.mutateAsync({ libraryId, diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index bf7f98e982..faa33f1e9a 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -1,4 +1,5 @@ /* eslint-disable react/require-default-props */ +import { useToggle } from '@openedx/paragon'; import React from 'react'; export enum SidebarBodyComponentId { @@ -14,6 +15,9 @@ export interface LibraryContextData { openInfoSidebar: () => void; openComponentInfoSidebar: (usageKey: string) => void; currentComponentUsageKey?: string; + isCreateCollectionModalOpen: boolean; + openCreateCollectionModal: () => void; + closeCreateCollectionModal: () => void; } export const LibraryContext = React.createContext({ @@ -22,6 +26,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); /** @@ -30,6 +37,7 @@ export const LibraryContext = React.createContext({ export const LibraryProvider = (props: { children?: React.ReactNode }) => { const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState(null); const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState(); + const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false); const closeLibrarySidebar = React.useCallback(() => { setSidebarBodyComponent(null); @@ -58,6 +66,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { openInfoSidebar, openComponentInfoSidebar, currentComponentUsageKey, + isCreateCollectionModalOpen, + openCreateCollectionModal, + closeCreateCollectionModal, }), [ sidebarBodyComponent, closeLibrarySidebar, @@ -65,6 +76,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode }) => { openInfoSidebar, openComponentInfoSidebar, currentComponentUsageKey, + isCreateCollectionModalOpen, + openCreateCollectionModal, + closeCreateCollectionModal, ]); return ( diff --git a/src/library-authoring/create-collection/CreateCollectionModal.tsx b/src/library-authoring/create-collection/CreateCollectionModal.tsx new file mode 100644 index 0000000000..e49a2fdb35 --- /dev/null +++ b/src/library-authoring/create-collection/CreateCollectionModal.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { + ActionRow, + Button, + Form, + ModalDialog, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { LibraryContext } from '../common/context'; +import messages from './messages'; + +const CreateCollectionModal = () => { + const intl = useIntl(); + const { + isCreateCollectionModalOpen, + closeCreateCollectionModal, + } = React.useContext(LibraryContext); + + const [collectionName, setCollectionName] = React.useState(null); + const [isCollectionNameInvalid, setIsCollectionNameInvalid] = React.useState(false); + const [collectionDescription, setCollectionDescription] = React.useState(null); + + const handleNameOnChange = React.useCallback((value : string) => { + setCollectionName(value); + setIsCollectionNameInvalid(false); + }, []); + + const handleCreate = React.useCallback(() => { + if (collectionName === null || collectionName === '') { + setIsCollectionNameInvalid(true); + return; + } + // TODO call API + setCollectionName(null); + setCollectionDescription(null); + closeCreateCollectionModal(); + }, [collectionName, collectionDescription]); + + return ( + + + + {intl.formatMessage(messages.createCollectionModalTitle)} + + + + +
+ + + {intl.formatMessage(messages.createCollectionModalNameLabel)} + + handleNameOnChange(e.target.value)} + /> + { isCollectionNameInvalid && ( + + {intl.formatMessage(messages.createCollectionModalNameInvalid)} + + )} + + + + {intl.formatMessage(messages.createCollectionModalDescriptionLabel)} + + setCollectionDescription(e.target.value)} + rows="5" + /> + + {intl.formatMessage(messages.createCollectionModalDescriptionDetails)} + + +
+
+ + + + {intl.formatMessage(messages.createCollectionModalCancel)} + + + + +
+ ); +}; + +export default CreateCollectionModal; diff --git a/src/library-authoring/create-collection/index.tsx b/src/library-authoring/create-collection/index.tsx new file mode 100644 index 0000000000..56813a7aed --- /dev/null +++ b/src/library-authoring/create-collection/index.tsx @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as CreateCollectionModal } from './CreateCollectionModal'; diff --git a/src/library-authoring/create-collection/messages.ts b/src/library-authoring/create-collection/messages.ts new file mode 100644 index 0000000000..7ee82070ad --- /dev/null +++ b/src/library-authoring/create-collection/messages.ts @@ -0,0 +1,55 @@ +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: 'Mesasge 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', + }, +}); + +export default messages; From ea2ba754223101ce62fb79d4cf8d149b10f15ccc Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Sat, 7 Sep 2024 14:31:24 -0500 Subject: [PATCH 03/14] feat: Connect CreateCollectionModal with API --- .../CreateCollectionModal.tsx | 54 +++++++++++++++---- .../create-collection/messages.ts | 15 ++++++ src/library-authoring/data/api.ts | 26 +++++++-- src/library-authoring/data/apiHooks.ts | 17 +++++- 4 files changed, 97 insertions(+), 15 deletions(-) diff --git a/src/library-authoring/create-collection/CreateCollectionModal.tsx b/src/library-authoring/create-collection/CreateCollectionModal.tsx index e49a2fdb35..45c83b3a2d 100644 --- a/src/library-authoring/create-collection/CreateCollectionModal.tsx +++ b/src/library-authoring/create-collection/CreateCollectionModal.tsx @@ -6,41 +6,73 @@ import { ModalDialog, } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; +import { useParams } from 'react-router-dom'; 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(); + const create = useCreateLibraryCollection(libraryId); const { isCreateCollectionModalOpen, closeCreateCollectionModal, } = React.useContext(LibraryContext); + const { showToast } = React.useContext(ToastContext); const [collectionName, setCollectionName] = React.useState(null); - const [isCollectionNameInvalid, setIsCollectionNameInvalid] = React.useState(false); + const [collectionNameInvalidMsg, setCollectionNameInvalidMsg] = React.useState(null); const [collectionDescription, setCollectionDescription] = React.useState(null); + const [isCreatingCollection, setIsCreatingCollection] = React.useState(false); const handleNameOnChange = React.useCallback((value : string) => { setCollectionName(value); - setIsCollectionNameInvalid(false); + setCollectionNameInvalidMsg(null); + }, []); + + const handleOnClose = React.useCallback(() => { + closeCreateCollectionModal(); + setCollectionNameInvalidMsg(null); + setCollectionName(null); + setCollectionDescription(null); + setIsCreatingCollection(false); }, []); const handleCreate = React.useCallback(() => { if (collectionName === null || collectionName === '') { - setIsCollectionNameInvalid(true); + setCollectionNameInvalidMsg( + intl.formatMessage(messages.createCollectionModalNameInvalid), + ); return; } - // TODO call API - setCollectionName(null); - setCollectionDescription(null); - closeCreateCollectionModal(); + + setIsCreatingCollection(true); + + create.mutateAsync({ + title: collectionName, + description: collectionDescription || '', + }).then(() => { + handleOnClose(); + showToast(intl.formatMessage(messages.createCollectionSuccess)); + }).catch((err) => { + setIsCreatingCollection(false); + if (err.customAttributes.httpErrorStatus === 409) { + setCollectionNameInvalidMsg( + intl.formatMessage(messages.createCollectionModalNameConflict), + ); + } else { + showToast(intl.formatMessage(messages.createCollectionError)); + } + }); }, [collectionName, collectionDescription]); return ( { value={collectionName} onChange={(e) => handleNameOnChange(e.target.value)} /> - { isCollectionNameInvalid && ( + { collectionNameInvalidMsg && ( - {intl.formatMessage(messages.createCollectionModalNameInvalid)} + {collectionNameInvalidMsg} )} @@ -90,7 +122,7 @@ const CreateCollectionModal = () => { {intl.formatMessage(messages.createCollectionModalCancel)} - diff --git a/src/library-authoring/create-collection/messages.ts b/src/library-authoring/create-collection/messages.ts index 7ee82070ad..1f5ad0757d 100644 --- a/src/library-authoring/create-collection/messages.ts +++ b/src/library-authoring/create-collection/messages.ts @@ -35,6 +35,11 @@ const messages = defineMessages({ defaultMessage: 'Collection name is required', description: 'Mesasge when the Name field of the Create Collection modal form is invalid', }, + createCollectionModalNameConflict: { + id: 'course-authoring.library-authoring.modals.create-collection.form.name.conflict', + defaultMessage: 'There is another collection with the same name', + description: 'Mesasge when the Name field of the Create Collection modal form is not unique', + }, createCollectionModalDescriptionLabel: { id: 'course-authoring.library-authoring.modals.create-collection.form.description', defaultMessage: 'Add a description (optional)', @@ -50,6 +55,16 @@ const messages = defineMessages({ 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; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index e622e6addf..0d5fdf4e11 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -25,9 +25,13 @@ export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUr */ export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`; /** - * Get the URL for the xblock metadata API. - */ + * Get the URL for the xblock metadata API. + */ export const getXBlockFieldsApiUrl = (usageKey: string) => `${getApiBaseUrl()}/api/xblock/v2/xblocks/${usageKey}/fields/`; +/** + * Get the URL for the Library Collections API. + */ +export const getLibraryCollectionsApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/collections/`; export interface ContentLibrary { id: string; @@ -127,6 +131,11 @@ export interface UpdateXBlockFieldsRequest { }; } +export interface CreateLibraryCollectionDataRequest { + title: string; + description: string | null; +} + /** * Fetch block types of a library */ @@ -240,7 +249,18 @@ export async function getXBlockFields(usageKey: string): Promise { /** * 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); } + +export async function createCollection(collectionData: CreateLibraryCollectionDataRequest, libraryId?: string) { + if (!libraryId) { + throw new Error('libraryId is required'); + } + + const client = getAuthenticatedHttpClient(); + const { data } = await client.post(getLibraryCollectionsApiUrl(libraryId), collectionData); + + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 2ebed19ff9..263f326c26 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -18,9 +18,11 @@ import { libraryPasteClipboard, getXBlockFields, updateXBlockFields, + createCollection, + CreateLibraryCollectionDataRequest, } from './api'; -const libraryQueryPredicate = (query: Query, libraryId: string): boolean => { +const libraryQueryPredicate = (query: Query, libraryId?: string): boolean => { // Invalidate all content queries related to this library. // If we allow searching "all courses and libraries" in the future, // then we'd have to invalidate all `["content_search", "results"]` @@ -209,3 +211,16 @@ export const useUpdateXBlockFields = (contentLibraryId: string, usageKey: string }, }); }; + +/** + * Use this mutation to create a library collection + */ +export const useCreateLibraryCollection = (libraryId?: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateLibraryCollectionDataRequest) => createCollection(data, libraryId), + onSettled: () => { + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + }, + }); +}; From ac50a2f5c6739ced3e08ec81b950d941b1226cee Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 9 Sep 2024 13:40:21 -0500 Subject: [PATCH 04/14] test: For CreateCollectionModal --- .../LibraryAuthoringPage.test.tsx | 119 +++++++++++++++++- .../CreateCollectionModal.tsx | 2 +- src/library-authoring/data/api.test.ts | 16 +++ src/library-authoring/data/api.ts | 3 + src/library-authoring/data/apiHooks.test.tsx | 23 +++- 5 files changed, 159 insertions(+), 4 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index f7b6544355..46a0c323f8 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -17,7 +17,12 @@ import initializeStore from '../store'; import { getContentSearchConfigUrl } from '../search-manager/data/api'; import mockResult from '../search-modal/__mocks__/search-result.json'; import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'; -import { getContentLibraryApiUrl, getXBlockFieldsApiUrl, type ContentLibrary } from './data/api'; +import { + getContentLibraryApiUrl, + getLibraryCollectionsApiUrl, + getXBlockFieldsApiUrl, + type ContentLibrary, +} from './data/api'; import { LibraryLayout } from '.'; let store; @@ -653,4 +658,116 @@ describe('', () => { expect(screen.getByText(/no matching components/i)).toBeInTheDocument(); }); + + it('should create a collection', async () => { + const title = 'This is a Test'; + const description = 'This is the description of the Test'; + const url = getLibraryCollectionsApiUrl(libraryData.id); + axiosMock.onPost(url).reply(200, { + id: '1', + slug: 'this-is-a-test', + title, + description, + }); + await renderLibraryPage(); + + 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.getByRole('button', { name: /collection/i }); + 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 () => { + const title = 'This is a Test'; + const description = 'This is the description of the Test'; + const url = getLibraryCollectionsApiUrl(libraryData.id); + axiosMock.onPost(url).reply(200, { + id: '1', + slug: 'this-is-a-test', + title, + description, + }); + await renderLibraryPage(); + + 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.getByRole('button', { name: /collection/i }); + fireEvent.click(newCollectionButton); + const collectionModalHeading = await screen.findByRole('heading', { name: /new collection/i }); + expect(collectionModalHeading).toBeInTheDocument(); + + // Click on create with an empty name + const createButton = screen.getByRole('button', { name: /create/i }); + fireEvent.click(createButton); + + expect(screen.getByText(/collection name is required/i)).toBeInTheDocument(); + }); + + it('should show error on conflict response', async () => { + const title = 'This is a Test'; + const description = 'This is the description of the Test'; + const url = getLibraryCollectionsApiUrl(libraryData.id); + axiosMock.onPost(url).reply(409, { + customAttributes: { + httpErrorStatus: 409, + }, + }); + await renderLibraryPage(); + + 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.getByRole('button', { name: /collection/i }); + 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); + + expect(await screen.findByText(/there is another collection with the same name/i)).toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/create-collection/CreateCollectionModal.tsx b/src/library-authoring/create-collection/CreateCollectionModal.tsx index 45c83b3a2d..7a2f23bb43 100644 --- a/src/library-authoring/create-collection/CreateCollectionModal.tsx +++ b/src/library-authoring/create-collection/CreateCollectionModal.tsx @@ -58,7 +58,7 @@ const CreateCollectionModal = () => { showToast(intl.formatMessage(messages.createCollectionSuccess)); }).catch((err) => { setIsCreatingCollection(false); - if (err.customAttributes.httpErrorStatus === 409) { + if (err.response.status === 409) { setCollectionNameInvalidMsg( intl.formatMessage(messages.createCollectionModalNameConflict), ); diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index 557488900d..da919c3f39 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -3,9 +3,11 @@ import { initializeMockApp } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { commitLibraryChanges, + createCollection, createLibraryBlock, getCommitLibraryChangesUrl, getCreateLibraryBlockUrl, + getLibraryCollectionsApiUrl, revertLibraryChanges, } from './api'; @@ -62,4 +64,18 @@ describe('library api calls', () => { 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); + + await createCollection({ + title: 'This is a test', + description: 'This is only a test', + }, libraryId); + + expect(axiosMock.history.post[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 0d5fdf4e11..e0513c825c 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -254,6 +254,9 @@ export async function updateXBlockFields(usageKey: string, xblockData: UpdateXBl await client.post(getXBlockFieldsApiUrl(usageKey), xblockData); } +/** + * Create a library collection + */ export async function createCollection(collectionData: CreateLibraryCollectionDataRequest, libraryId?: string) { if (!libraryId) { throw new Error('libraryId is required'); diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 686e114018..a92546c10f 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -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); + }); }); From 36f2e8ba76195e6d0ea279df14f9e5b5b58bf73b Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 9 Sep 2024 14:03:30 -0500 Subject: [PATCH 05/14] style: Nits on library authoring code --- .../add-content/AddContentContainer.tsx | 7 +++++-- .../create-collection/messages.ts | 4 ++-- src/library-authoring/data/api.test.ts | 4 ++-- src/library-authoring/data/api.ts | 6 +----- src/library-authoring/data/apiHooks.ts | 15 +++++++++++---- 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index fe560f766e..526c8769b7 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -46,7 +46,6 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro } = contentType; return ( - - + handleCreate(values)} + > + {(formikProps) => ( + <> + +
+ + {intl.formatMessage(messages.createCollectionModalNameLabel)} + + } + value={formikProps.values.title} + placeholder={intl.formatMessage(messages.createCollectionModalNamePlaceholder)} + help="" + className="" + controlClasses="pb-2" + /> + + {intl.formatMessage(messages.createCollectionModalDescriptionLabel)} + + } + value={formikProps.values.description} + placeholder={intl.formatMessage(messages.createCollectionModalDescriptionPlaceholder)} + help={intl.formatMessage(messages.createCollectionModalDescriptionDetails)} + className="" + controlClasses="pb-2" + rows="5" + /> + +
+ + + + {intl.formatMessage(messages.createCollectionModalCancel)} + + + + + + )} +
); }; From 3dfe252aabc7262492abc6668f85a5fddfb12483 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Mon, 9 Sep 2024 17:11:23 -0500 Subject: [PATCH 07/14] style: Fix lint on the code --- .../CreateCollectionModal.tsx | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/library-authoring/create-collection/CreateCollectionModal.tsx b/src/library-authoring/create-collection/CreateCollectionModal.tsx index cf78054fde..ce8741f149 100644 --- a/src/library-authoring/create-collection/CreateCollectionModal.tsx +++ b/src/library-authoring/create-collection/CreateCollectionModal.tsx @@ -5,11 +5,11 @@ import { 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 { useParams } from 'react-router-dom'; import { LibraryContext } from '../common/context'; import messages from './messages'; import { useCreateLibraryCollection } from '../data/apiHooks'; @@ -60,44 +60,44 @@ const CreateCollectionModal = () => { handleCreate(values)} - > + initialValues={{ + title: '', + description: '', + }} + validationSchema={ + Yup.object().shape({ + title: Yup.string() + .required(intl.formatMessage(messages.createCollectionModalNameInvalid)), + description: Yup.string(), + }) + } + onSubmit={(values) => handleCreate(values)} + > {(formikProps) => ( <>
{intl.formatMessage(messages.createCollectionModalNameLabel)} - } + )} value={formikProps.values.title} placeholder={intl.formatMessage(messages.createCollectionModalNamePlaceholder)} help="" className="" controlClasses="pb-2" - /> + /> {intl.formatMessage(messages.createCollectionModalDescriptionLabel)} - } + )} value={formikProps.values.description} placeholder={intl.formatMessage(messages.createCollectionModalDescriptionPlaceholder)} help={intl.formatMessage(messages.createCollectionModalDescriptionDetails)} From b0a47ccb4fa004bfc41d031e54bffc0ce62e8820 Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Tue, 10 Sep 2024 14:23:25 -0500 Subject: [PATCH 08/14] style: Delete rounded-0 in AddContentButton --- src/library-authoring/add-content/AddContentContainer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index 526c8769b7..97a8f75483 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -48,7 +48,7 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 189d80808a..aa5cb6ce38 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -19,7 +19,7 @@ import { getXBlockFields, updateXBlockFields, createCollection, - CreateLibraryCollectionDataRequest, + type CreateLibraryCollectionDataRequest, } from './api'; const libraryQueryPredicate = (query: Query, libraryId: string): boolean => { From 73fe5ff82ebaca4b62e7175b3afe214e6ac14cff Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 12 Sep 2024 20:07:10 -0500 Subject: [PATCH 10/14] style: Update code from review --- src/generic/FormikControl.jsx | 4 ++-- .../create-collection/CreateCollectionModal.tsx | 8 ++++++-- src/library-authoring/create-collection/messages.ts | 5 ----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/generic/FormikControl.jsx b/src/generic/FormikControl.jsx index 4ef4618295..048ad991ab 100644 --- a/src/generic/FormikControl.jsx +++ b/src/generic/FormikControl.jsx @@ -41,8 +41,8 @@ const FormikControl = ({ FormikControl.propTypes = { name: PropTypes.string.isRequired, - label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), - help: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), + label: PropTypes.element, + help: PropTypes.element, className: PropTypes.string, controlClasses: PropTypes.string, value: PropTypes.oneOfType([ diff --git a/src/library-authoring/create-collection/CreateCollectionModal.tsx b/src/library-authoring/create-collection/CreateCollectionModal.tsx index 1404ccb4a5..651911196d 100644 --- a/src/library-authoring/create-collection/CreateCollectionModal.tsx +++ b/src/library-authoring/create-collection/CreateCollectionModal.tsx @@ -76,7 +76,7 @@ const CreateCollectionModal = () => { )} value={formikProps.values.title} placeholder={intl.formatMessage(messages.createCollectionModalNamePlaceholder)} - help="" + help={null} className="" controlClasses="pb-2" /> @@ -90,7 +90,11 @@ const CreateCollectionModal = () => { )} value={formikProps.values.description} placeholder={intl.formatMessage(messages.createCollectionModalDescriptionPlaceholder)} - help={intl.formatMessage(messages.createCollectionModalDescriptionDetails)} + help={( + + {intl.formatMessage(messages.createCollectionModalDescriptionDetails)} + + )} className="" controlClasses="pb-2" rows="5" diff --git a/src/library-authoring/create-collection/messages.ts b/src/library-authoring/create-collection/messages.ts index 94e3e03bda..36a11138e8 100644 --- a/src/library-authoring/create-collection/messages.ts +++ b/src/library-authoring/create-collection/messages.ts @@ -35,11 +35,6 @@ const messages = defineMessages({ defaultMessage: 'Collection name is required', description: 'Message when the Name field of the Create Collection modal form is invalid', }, - createCollectionModalNameConflict: { - id: 'course-authoring.library-authoring.modals.create-collection.form.name.conflict', - defaultMessage: 'There is another collection with the same name', - description: 'Message when the Name field of the Create Collection modal form is not unique', - }, createCollectionModalDescriptionLabel: { id: 'course-authoring.library-authoring.modals.create-collection.form.description', defaultMessage: 'Add a description (optional)', From 104fce1438e44ffdd8715acc181948caba9498ab Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Thu, 12 Sep 2024 20:39:40 -0500 Subject: [PATCH 11/14] style: Trow error on CreateCollectionModal if libraryId is null --- .../create-collection/CreateCollectionModal.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/library-authoring/create-collection/CreateCollectionModal.tsx b/src/library-authoring/create-collection/CreateCollectionModal.tsx index f2ae8a911c..e9cd2153c2 100644 --- a/src/library-authoring/create-collection/CreateCollectionModal.tsx +++ b/src/library-authoring/create-collection/CreateCollectionModal.tsx @@ -18,6 +18,9 @@ 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, From fac82ea0f729a9127fd10c180b6bc4f7d4d42ecf Mon Sep 17 00:00:00 2001 From: XnpioChV Date: Fri, 13 Sep 2024 19:34:00 -0500 Subject: [PATCH 12/14] fix: Open CreateCollectionModal on EmptyStates add collection button --- src/library-authoring/EmptyStates.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx index 7fa0d51900..39e43ed1ed 100644 --- a/src/library-authoring/EmptyStates.tsx +++ b/src/library-authoring/EmptyStates.tsx @@ -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 ( {searchType === 'collection' ? : } {canEditLibrary && ( -