diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts index 97f9ae0f7..604519e53 100644 --- a/src/generic/block-type-utils/constants.ts +++ b/src/generic/block-type-utils/constants.ts @@ -3,9 +3,10 @@ import { BackHand as BackHandIcon, BookOpen as BookOpenIcon, Casino as ProblemBankIcon, + ContentPaste as ContentPasteIcon, Edit as EditIcon, EditNote as EditNoteIcon, - FormatListBulleted as FormatListBulletedIcon, + CalendarViewDay, HelpOutline as HelpOutlineIcon, LibraryAdd as LibraryIcon, Lock as LockIcon, @@ -35,7 +36,7 @@ export const COMPONENT_TYPES = { export const UNIT_TYPE_ICONS_MAP: Record = { video: VideoCameraIcon, other: BookOpenIcon, - vertical: FormatListBulletedIcon, + vertical: CalendarViewDay, problem: EditIcon, lock: LockIcon, }; @@ -58,6 +59,8 @@ export const STRUCTURAL_TYPE_ICONS: Record = { sequential: Folder, chapter: Folder, collection: Folder, + libraryContent: Folder, + paste: ContentPasteIcon, }; export const COMPONENT_TYPE_STYLE_COLOR_MAP = { diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts index 224f4a963..3a1316793 100644 --- a/src/generic/key-utils.test.ts +++ b/src/generic/key-utils.test.ts @@ -32,6 +32,8 @@ describe('component utils', () => { ['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:Axim:beta'], ['lib-collection:org:lib:coll', 'lib:org:lib'], ['lib-collection:OpenCraftX:ALPHA:coll', 'lib:OpenCraftX:ALPHA'], + ['lct:org:lib:unit:my-unit-9284e2', 'lib:org:lib'], + ['lct:OpenCraftX:ALPHA:my-unit-a3223f', 'lib:OpenCraftX:ALPHA'], ]) { it(`returns '${expected}' for usage key '${input}'`, () => { expect(getLibraryId(input)).toStrictEqual(expected); diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index 633417119..c1e4ad0ac 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -19,12 +19,10 @@ export function getBlockType(usageKey: string): string { * @returns The library key, e.g. `lib:org:lib` */ export function getLibraryId(usageKey: string): string { - if (usageKey && (usageKey.startsWith('lb:') || usageKey.startsWith('lib-collection:'))) { - const org = usageKey.split(':')[1]; - const lib = usageKey.split(':')[2]; - if (org && lib) { - return `lib:${org}:${lib}`; - } + const [blockType, org, lib] = usageKey?.split(':') || []; + + if (['lb', 'lib-collection', 'lct'].includes(blockType) && org && lib) { + return `lib:${org}:${lib}`; } throw new Error(`Invalid usageKey: ${usageKey}`); } diff --git a/src/generic/loading-button/LoadingButton.test.jsx b/src/generic/loading-button/LoadingButton.test.tsx similarity index 90% rename from src/generic/loading-button/LoadingButton.test.jsx rename to src/generic/loading-button/LoadingButton.test.tsx index f52d43c3c..dbcd5fdad 100644 --- a/src/generic/loading-button/LoadingButton.test.jsx +++ b/src/generic/loading-button/LoadingButton.test.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { act, fireEvent, @@ -9,7 +8,7 @@ import LoadingButton from '.'; const buttonTitle = 'Button Title'; -const RootWrapper = (onClick) => ( +const RootWrapper = (onClick?: () => (Promise | void)) => ( ); @@ -31,8 +30,8 @@ describe('', () => { }); it('renders the spinner correctly', async () => { - let resolver; - const longFunction = () => new Promise((resolve) => { + let resolver: () => void; + const longFunction = () => new Promise((resolve) => { resolver = resolve; }); const { container, getByRole, getByText } = render(RootWrapper(longFunction)); @@ -51,8 +50,8 @@ describe('', () => { }); it('renders the spinner correctly even with error', async () => { - let rejecter; - const longFunction = () => new Promise((_resolve, reject) => { + let rejecter: (err: Error) => void; + const longFunction = () => new Promise((_resolve, reject) => { rejecter = reject; }); const { container, getByRole, getByText } = render(RootWrapper(longFunction)); diff --git a/src/generic/loading-button/index.jsx b/src/generic/loading-button/index.tsx similarity index 57% rename from src/generic/loading-button/index.jsx rename to src/generic/loading-button/index.tsx index f41bdea39..5b9cbd4a4 100644 --- a/src/generic/loading-button/index.jsx +++ b/src/generic/loading-button/index.tsx @@ -1,4 +1,3 @@ -// @ts-check import React, { useCallback, useEffect, @@ -8,20 +7,20 @@ import React, { import { StatefulButton, } from '@openedx/paragon'; -import PropTypes from 'prop-types'; + +interface LoadingButtonProps { + label: string; + onClick?: (e: any) => (Promise | void); + disabled?: boolean; + size?: string; + variant?: string; + className?: string; +} /** - * A button that shows a loading spinner when clicked. - * @param {object} props - * @param {string} props.label - * @param {function=} props.onClick - * @param {boolean=} props.disabled - * @param {string=} props.size - * @param {string=} props.variant - * @param {string=} props.className - * @returns {JSX.Element} + * A button that shows a loading spinner when clicked, if the onClick function returns a Promise. */ -const LoadingButton = ({ +const LoadingButton: React.FC = ({ label, onClick, disabled, @@ -37,7 +36,7 @@ const LoadingButton = ({ componentMounted.current = false; }, []); - const loadingOnClick = useCallback(async (e) => { + const loadingOnClick = useCallback(async (e: any) => { if (!onClick) { return; } @@ -67,21 +66,4 @@ const LoadingButton = ({ ); }; -LoadingButton.propTypes = { - label: PropTypes.string.isRequired, - onClick: PropTypes.func, - disabled: PropTypes.bool, - size: PropTypes.string, - variant: PropTypes.string, - className: PropTypes.string, -}; - -LoadingButton.defaultProps = { - onClick: undefined, - disabled: undefined, - size: undefined, - variant: '', - className: '', -}; - export default LoadingButton; diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index cbe824f79..61b99d0a6 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -22,7 +22,10 @@ import { studioHomeMock } from '../studio-home/__mocks__'; import { getStudioHomeApiUrl } from '../studio-home/data/api'; import { mockBroadcastChannel } from '../generic/data/api.mock'; import { LibraryLayout } from '.'; -import { getLibraryCollectionsApiUrl } from './data/api'; +import { getLibraryCollectionsApiUrl, getLibraryContainersApiUrl } from './data/api'; + +let axiosMock; +let mockShowToast; mockGetCollectionMetadata.applyMock(); mockContentSearchConfig.applyMock(); @@ -53,7 +56,9 @@ const libraryTitle = mockContentLibrary.libraryData.title; describe('', () => { beforeEach(async () => { - const { axiosMock } = initializeMocks(); + const mocks = initializeMocks(); + axiosMock = mocks.axiosMock; + mockShowToast = mocks.mockShowToast; axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock); // The Meilisearch client-side API uses fetch, not Axios. @@ -452,15 +457,15 @@ describe('', () => { expect(screen.getByRole('tab', { name: 'Manage' })).toHaveAttribute('aria-selected', 'true'); }); - it('can filter by capa problem type', async () => { - const problemTypes = { - 'Multiple Choice': 'choiceresponse', - Checkboxes: 'multiplechoiceresponse', - 'Numerical Input': 'numericalresponse', - Dropdown: 'optionresponse', - 'Text Input': 'stringresponse', - }; + const problemTypes = { + 'Multiple Choice': 'choiceresponse', + Checkboxes: 'multiplechoiceresponse', + 'Numerical Input': 'numericalresponse', + Dropdown: 'optionresponse', + 'Text Input': 'stringresponse', + }; + it.each(Object.keys(problemTypes))('can filter by capa problem type (%s)', async (submenuText) => { await renderLibraryPage(); // Ensure the search endpoint is called @@ -474,36 +479,27 @@ describe('', () => { expect(showProbTypesSubmenuBtn).not.toBeNull(); fireEvent.click(showProbTypesSubmenuBtn!); - const validateSubmenu = async (submenuText: string) => { - const submenu = screen.getByText(submenuText); - expect(submenu).toBeInTheDocument(); - fireEvent.click(submenu); + const submenu = screen.getByText(submenuText); + expect(submenu).toBeInTheDocument(); + fireEvent.click(submenu); - await waitFor(() => { - expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { - body: expect.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`), - method: 'POST', - headers: expect.anything(), - }); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`), + method: 'POST', + headers: expect.anything(), }); + }); - fireEvent.click(submenu); - await waitFor(() => { - expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { - body: expect.not.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`), - method: 'POST', - headers: expect.anything(), - }); + fireEvent.click(submenu); + await waitFor(() => { + expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { + body: expect.not.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`), + method: 'POST', + headers: expect.anything(), }); - }; - - // Validate per submenu - // eslint-disable-next-line no-restricted-syntax - for (const key of Object.keys(problemTypes)) { - // eslint-disable-next-line no-await-in-loop - await validateSubmenu(key); - } - }, 10000); + }); + }); it('can filter by block type', async () => { await renderLibraryPage(); @@ -563,7 +559,6 @@ describe('', () => { 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', @@ -600,6 +595,13 @@ describe('', () => { fireEvent.change(nameField, { target: { value: title } }); fireEvent.change(descriptionField, { target: { value: description } }); fireEvent.click(createButton); + + // Check success toast + await waitFor(() => expect(axiosMock.history.post.length).toBe(1)); + expect(axiosMock.history.post[0].url).toBe(url); + expect(axiosMock.history.post[0].data).toContain(`"title":"${title}"`); + expect(axiosMock.history.post[0].data).toContain(`"description":"${description}"`); + expect(mockShowToast).toHaveBeenCalledWith('Collection created successfully'); }); it('should show validations in create collection', async () => { @@ -608,7 +610,6 @@ describe('', () => { 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', @@ -647,7 +648,6 @@ describe('', () => { 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(); @@ -673,6 +673,127 @@ describe('', () => { fireEvent.change(nameField, { target: { value: title } }); fireEvent.change(descriptionField, { target: { value: description } }); fireEvent.click(createButton); + + // Check error toast + await waitFor(() => expect(axiosMock.history.post.length).toBe(1)); + expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library collection'); + }); + + it('should create a unit', async () => { + await renderLibraryPage(); + const title = 'This is a Test'; + const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId); + axiosMock.onPost(url).reply(200, { + id: '1', + slug: 'this-is-a-test', + title, + }); + + 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 unit Modal + const sidebar = screen.getByTestId('library-sidebar'); + const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0]; + fireEvent.click(newUnitButton); + const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i }); + expect(unitModalHeading).toBeInTheDocument(); + + // Click on Cancel button + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + fireEvent.click(cancelButton); + expect(unitModalHeading).not.toBeInTheDocument(); + + // Open new unit modal again and create a collection + fireEvent.click(newUnitButton); + const createButton = screen.getByRole('button', { name: /create/i }); + const nameField = screen.getByRole('textbox', { name: /name your unit/i }); + + fireEvent.change(nameField, { target: { value: title } }); + fireEvent.click(createButton); + + // Check success + await waitFor(() => expect(axiosMock.history.post.length).toBe(1)); + + expect(axiosMock.history.post[0].url).toBe(url); + expect(axiosMock.history.post[0].data).toContain(`"display_name":"${title}"`); + expect(axiosMock.history.post[0].data).toContain('"container_type":"unit"'); + expect(mockShowToast).toHaveBeenCalledWith('Unit created successfully'); + }); + + it('should show validations in create unit', async () => { + await renderLibraryPage(); + + const title = 'This is a Test'; + const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId); + axiosMock.onPost(url).reply(200, { + id: '1', + slug: 'this-is-a-test', + title, + }); + + 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 unit Modal + const sidebar = screen.getByTestId('library-sidebar'); + const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0]; + fireEvent.click(newUnitButton); + const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i }); + expect(unitModalHeading).toBeInTheDocument(); + + const nameField = screen.getByRole('textbox', { name: /name your unit/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(/unit name is required/i)).toBeInTheDocument(); + }); + + it('should show error on create unit', async () => { + await renderLibraryPage(); + const displayName = 'This is a Test'; + const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId); + 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 sidebar = screen.getByTestId('library-sidebar'); + const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0]; + fireEvent.click(newUnitButton); + const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i }); + expect(unitModalHeading).toBeInTheDocument(); + + // Create a unit + const createButton = screen.getByRole('button', { name: /create/i }); + const nameField = screen.getByRole('textbox', { name: /name your unit/i }); + + fireEvent.change(nameField, { target: { value: displayName } }); + fireEvent.click(createButton); + + // Check error toast + await waitFor(() => expect(axiosMock.history.post.length).toBe(1)); + expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library unit'); }); it('shows a single block when usageKey query param is set', async () => { @@ -806,7 +927,6 @@ describe('', () => { }); it('Shows an error if libraries V2 is disabled', async () => { - const { axiosMock } = initializeMocks(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, { ...studioHomeMock, libraries_v2_enabled: false, diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index add33f95a..d0231cea9 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -12,6 +12,7 @@ import LibraryAuthoringPage from './LibraryAuthoringPage'; import { LibraryProvider } from './common/context/LibraryContext'; import { SidebarProvider } from './common/context/SidebarContext'; import { CreateCollectionModal } from './create-collection'; +import { CreateUnitModal } from './create-unit'; import LibraryCollectionPage from './collections/LibraryCollectionPage'; import { ComponentPicker } from './component-picker'; import { ComponentEditorModal } from './components/ComponentEditorModal'; @@ -44,6 +45,7 @@ const LibraryLayout = () => { <> {childPage} + diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index 94e2692d4..1899318d5 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -9,20 +9,13 @@ import { import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig } from '@edx/frontend-platform'; import { - Article, AutoAwesome, - BookOpen, - Create, - Folder, - ThumbUpOutline, - Question, - VideoCamera, - ContentPaste, KeyboardBackspace, } from '@openedx/paragon/icons'; import { v4 as uuid4 } from 'uuid'; import { ToastContext } from '../../generic/toast-context'; +import { getItemIcon } from '../../generic/block-type-utils'; import { useClipboard } from '../../generic/clipboard'; import { getCanEdit } from '../../course-unit/data/selectors'; import { @@ -41,7 +34,7 @@ import type { BlockTypeMetadata } from '../data/api'; type ContentType = { name: string, disabled: boolean, - icon: React.ComponentType, + icon?: React.ComponentType, blockType: string, }; @@ -76,7 +69,7 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro variant="outline-primary" disabled={disabled} className="m-2" - iconBefore={icon} + iconBefore={icon || getItemIcon(blockType)} onClick={() => onCreateContent(blockType)} > {name} @@ -99,14 +92,18 @@ const AddContentView = ({ const collectionButtonData = { name: intl.formatMessage(messages.collectionButton), disabled: false, - icon: BookOpen, blockType: 'collection', }; + const unitButtonData = { + name: intl.formatMessage(messages.unitButton), + disabled: false, + blockType: 'vertical', + }; + const libraryContentButtonData = { name: intl.formatMessage(messages.libraryContentButton), disabled: false, - icon: Folder, blockType: 'libraryContent', }; @@ -125,6 +122,7 @@ const AddContentView = ({ ) : ( )} +
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */} {contentTypes.filter(ct => !ct.disabled).map((contentType) => ( @@ -194,6 +192,7 @@ const AddContentContainer = () => { libraryId, collectionId, openCreateCollectionModal, + openCreateUnitModal, openComponentEditor, } = useLibraryContext(); const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId); @@ -220,31 +219,26 @@ const AddContentContainer = () => { { name: intl.formatMessage(messages.textTypeButton), disabled: !isBlockTypeEnabled('html'), - icon: Article, blockType: 'html', }, { name: intl.formatMessage(messages.problemTypeButton), disabled: !isBlockTypeEnabled('problem'), - icon: Question, blockType: 'problem', }, { name: intl.formatMessage(messages.openResponseTypeButton), disabled: !isBlockTypeEnabled('openassessment'), - icon: Create, blockType: 'openassessment', }, { name: intl.formatMessage(messages.dragDropTypeButton), disabled: !isBlockTypeEnabled('drag-and-drop-v2'), - icon: ThumbUpOutline, blockType: 'drag-and-drop-v2', }, { name: intl.formatMessage(messages.videoTypeButton), disabled: !isBlockTypeEnabled('video'), - icon: VideoCamera, blockType: 'video', }, ]; @@ -259,13 +253,13 @@ const AddContentContainer = () => { // Include the 'Advanced / Other' button if there are enabled advanced Xblocks if (Object.keys(advancedBlocks).length > 0) { - const pasteButton = { + const advancedButton = { name: intl.formatMessage(messages.otherTypeButton), disabled: false, icon: AutoAwesome, blockType: 'advancedXBlock', }; - contentTypes.push(pasteButton); + contentTypes.push(advancedButton); } // Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard @@ -274,7 +268,6 @@ const AddContentContainer = () => { const pasteButton = { name: intl.formatMessage(messages.pasteButton), disabled: false, - icon: ContentPaste, blockType: 'paste', }; contentTypes.push(pasteButton); @@ -313,6 +306,7 @@ const AddContentContainer = () => { )); }); }; + const onCreateBlock = (blockType: string) => { const suportedEditorTypes = Object.values(blockTypes); if (suportedEditorTypes.includes(blockType)) { @@ -347,6 +341,8 @@ const AddContentContainer = () => { showAddLibraryContentModal(); } else if (blockType === 'advancedXBlock') { showAdvancedList(); + } else if (blockType === 'vertical') { + openCreateUnitModal(); } else { onCreateBlock(blockType); } diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index dc0f33eff..e8e6d601b 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -6,6 +6,11 @@ const messages = defineMessages({ defaultMessage: 'Collection', description: 'Content of button to create a Collection.', }, + unitButton: { + id: 'course-authoring.library-authoring.add-content.buttons.unit', + defaultMessage: 'Unit', + description: 'Content of button to create a Unit.', + }, libraryContentButton: { id: 'course-authoring.library-authoring.add-content.buttons.library-content', defaultMessage: 'Existing Library Content', diff --git a/src/library-authoring/common/context/LibraryContext.tsx b/src/library-authoring/common/context/LibraryContext.tsx index 061a1326d..76d7af288 100644 --- a/src/library-authoring/common/context/LibraryContext.tsx +++ b/src/library-authoring/common/context/LibraryContext.tsx @@ -35,6 +35,10 @@ export type LibraryContextData = { isCreateCollectionModalOpen: boolean; openCreateCollectionModal: () => void; closeCreateCollectionModal: () => void; + // "Create New Unit" modal + isCreateUnitModalOpen: boolean; + openCreateUnitModal: () => void; + closeCreateUnitModal: () => void; // Editor modal - for editing some component /** If the editor is open and the user is editing some component, this is the component being edited. */ componentBeingEdited: ComponentEditorInfo | undefined; @@ -80,6 +84,7 @@ export const LibraryProvider = ({ componentPicker, }: LibraryProviderProps) => { const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false); + const [isCreateUnitModalOpen, openCreateUnitModal, closeCreateUnitModal] = useToggle(false); const [componentBeingEdited, setComponentBeingEdited] = useState(); const closeComponentEditor = useCallback((data) => { setComponentBeingEdited((prev) => { @@ -122,6 +127,9 @@ export const LibraryProvider = ({ isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, + isCreateUnitModalOpen, + openCreateUnitModal, + closeCreateUnitModal, componentBeingEdited, openComponentEditor, closeComponentEditor, @@ -142,6 +150,9 @@ export const LibraryProvider = ({ isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, + isCreateUnitModalOpen, + openCreateUnitModal, + closeCreateUnitModal, componentBeingEdited, openComponentEditor, closeComponentEditor, diff --git a/src/library-authoring/create-collection/messages.ts b/src/library-authoring/create-collection/messages.ts index 36a11138e..eed95a67e 100644 --- a/src/library-authoring/create-collection/messages.ts +++ b/src/library-authoring/create-collection/messages.ts @@ -1,8 +1,4 @@ -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; +import { defineMessages } from '@edx/frontend-platform/i18n'; const messages = defineMessages({ createCollectionModalTitle: { diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx index c4f3695c7..7939a2b4f 100644 --- a/src/library-authoring/create-library/CreateLibrary.tsx +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import { StudioFooter } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { diff --git a/src/library-authoring/create-unit/CreateUnitModal.tsx b/src/library-authoring/create-unit/CreateUnitModal.tsx new file mode 100644 index 000000000..33f491460 --- /dev/null +++ b/src/library-authoring/create-unit/CreateUnitModal.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { + ActionRow, + Form, + ModalDialog, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Formik } from 'formik'; +import * as Yup from 'yup'; +import FormikControl from '../../generic/FormikControl'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import messages from './messages'; +import { useCreateLibraryContainer } from '../data/apiHooks'; +import { ToastContext } from '../../generic/toast-context'; +import LoadingButton from '../../generic/loading-button'; + +const CreateUnitModal = () => { + const intl = useIntl(); + const { + libraryId, + isCreateUnitModalOpen, + closeCreateUnitModal, + } = useLibraryContext(); + const create = useCreateLibraryContainer(libraryId); + const { showToast } = React.useContext(ToastContext); + + const handleCreate = React.useCallback(async (values) => { + try { + await create.mutateAsync({ + containerType: 'unit', + ...values, + }); + // TODO: Navigate to the new unit + // navigate(`/library/${libraryId}/units/${data.key}`); + showToast(intl.formatMessage(messages.createUnitSuccess)); + } catch (error) { + showToast(intl.formatMessage(messages.createUnitError)); + } finally { + closeCreateUnitModal(); + } + }, []); + + return ( + + + + {intl.formatMessage(messages.createUnitModalTitle)} + + + + + {(formikProps) => ( + <> + +
+ + {intl.formatMessage(messages.createUnitModalNameLabel)} + + )} + value={formikProps.values.displayName} + placeholder={intl.formatMessage(messages.createUnitModalNamePlaceholder)} + controlClasses="pb-2" + /> + +
+ + + + {intl.formatMessage(messages.createUnitModalCancel)} + + + + + + )} +
+
+ ); +}; + +export default CreateUnitModal; diff --git a/src/library-authoring/create-unit/index.tsx b/src/library-authoring/create-unit/index.tsx new file mode 100644 index 000000000..ee82f395f --- /dev/null +++ b/src/library-authoring/create-unit/index.tsx @@ -0,0 +1 @@ +export { default as CreateUnitModal } from './CreateUnitModal'; diff --git a/src/library-authoring/create-unit/messages.ts b/src/library-authoring/create-unit/messages.ts new file mode 100644 index 000000000..db52c1ed6 --- /dev/null +++ b/src/library-authoring/create-unit/messages.ts @@ -0,0 +1,46 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + createUnitModalTitle: { + id: 'course-authoring.library-authoring.modals.create-unit.title', + defaultMessage: 'New Unit', + description: 'Title of the Create Unit modal', + }, + createUnitModalCancel: { + id: 'course-authoring.library-authoring.modals.create-unit.cancel', + defaultMessage: 'Cancel', + description: 'Label of the Cancel button of the Create Unit modal', + }, + createUnitModalCreate: { + id: 'course-authoring.library-authoring.modals.create-unit.create', + defaultMessage: 'Create', + description: 'Label of the Create button of the Create Unit modal', + }, + createUnitModalNameLabel: { + id: 'course-authoring.library-authoring.modals.create-unit.form.name', + defaultMessage: 'Name your unit', + description: 'Label of the Name field of the Create Unit modal form', + }, + createUnitModalNamePlaceholder: { + id: 'course-authoring.library-authoring.modals.create-unit.form.name.placeholder', + defaultMessage: 'Give a descriptive title', + description: 'Placeholder of the Name field of the Create Unit modal form', + }, + createUnitModalNameInvalid: { + id: 'course-authoring.library-authoring.modals.create-unit.form.name.invalid', + defaultMessage: 'Unit name is required', + description: 'Message when the Name field of the Create Unit modal form is invalid', + }, + createUnitSuccess: { + id: 'course-authoring.library-authoring.modals.create-unit.success', + defaultMessage: 'Unit created successfully', + description: 'Success message when creating a library unit', + }, + createUnitError: { + id: 'course-authoring.library-authoring.modals.create-unit.error', + defaultMessage: 'There is an error when creating the library unit', + description: 'Error message when creating a library unit', + }, +}); + +export default messages; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 49009f972..f0079ca13 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -103,6 +103,10 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; * Get the URL for the content store api. */ export const getContentStoreApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/`; +/** + * Get the URL for the library container api. + */ +export const getLibraryContainersApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/containers/`; export interface ContentLibrary { id: string; @@ -557,3 +561,16 @@ export async function updateComponentCollections(usageKey: string, collectionKey collection_keys: collectionKeys, }); } + +export interface CreateLibraryContainerDataRequest { + title: string; + containerType: string; +} + +/** + * Create a library container + */ +export async function createLibraryContainer(libraryId: string, containerData: CreateLibraryContainerDataRequest) { + const client = getAuthenticatedHttpClient(); + await client.post(getLibraryContainersApiUrl(libraryId), snakeCaseObject(containerData)); +} diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 2b16615a3..abab56ffc 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -46,6 +46,8 @@ import { deleteXBlockAsset, restoreLibraryBlock, getBlockTypes, + createLibraryContainer, + type CreateLibraryContainerDataRequest, } from './api'; import { VersionSpec } from '../LibraryBlock'; @@ -560,3 +562,16 @@ export const useUpdateComponentCollections = (libraryId: string, usageKey: strin }, }); }; + +/** + * Use this mutation to create a library container + */ +export const useCreateLibraryContainer = (libraryId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateLibraryContainerDataRequest) => createLibraryContainer(libraryId, data), + onSettled: () => { + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + }, + }); +};