diff --git a/src/generic/FormikControl.jsx b/src/generic/FormikControl.tsx similarity index 62% rename from src/generic/FormikControl.jsx rename to src/generic/FormikControl.tsx index 048ad991ab..e3d56af966 100644 --- a/src/generic/FormikControl.jsx +++ b/src/generic/FormikControl.tsx @@ -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> = ({ 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; 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 && ( - + ); +}; 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 ( - +
{contentTypes.map((contentType) => ( - + contentType={contentType} + onCreateContent={onCreateContent} + /> ))}
); diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx index 8b11d61d1b..6ad0f29b58 100644 --- a/src/library-authoring/common/context.tsx +++ b/src/library-authoring/common/context.tsx @@ -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(null); const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState(); + 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 ( diff --git a/src/library-authoring/create-collection/CreateCollectionModal.tsx b/src/library-authoring/create-collection/CreateCollectionModal.tsx new file mode 100644 index 0000000000..905272b181 --- /dev/null +++ b/src/library-authoring/create-collection/CreateCollectionModal.tsx @@ -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 ( + + + + {intl.formatMessage(messages.createCollectionModalTitle)} + + + + + {(formikProps) => ( + <> + +
+ + {intl.formatMessage(messages.createCollectionModalNameLabel)} + + )} + value={formikProps.values.title} + placeholder={intl.formatMessage(messages.createCollectionModalNamePlaceholder)} + controlClasses="pb-2" + /> + + {intl.formatMessage(messages.createCollectionModalDescriptionLabel)} + + )} + value={formikProps.values.description} + placeholder={intl.formatMessage(messages.createCollectionModalDescriptionPlaceholder)} + help={( + + {intl.formatMessage(messages.createCollectionModalDescriptionDetails)} + + )} + controlClasses="pb-2" + rows="5" + /> + +
+ + + + {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..36a11138e8 --- /dev/null +++ b/src/library-authoring/create-collection/messages.ts @@ -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; diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index 36200ff91c..c3e7831869 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -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); + }); }); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index e5ca502c56..f8e6008827 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -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 { /** * 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. */ 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); + }); }); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 365bd8d1e6..656ac21f63 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -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({