From afd6afdbb9309a05bc69948098f93c1ceeb8ccff Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 28 May 2025 21:10:17 +0000 Subject: [PATCH] feat: create section and subsection in library (#2013) Adds create section and subsection buttons in sidebar. --- src/generic/block-type-utils/constants.ts | 20 ++- src/generic/block-type-utils/index.scss | 50 ++++++ src/generic/key-utils.ts | 16 +- .../LibraryAuthoringPage.test.tsx | 105 +++++++---- src/library-authoring/LibraryLayout.tsx | 4 +- .../add-content/AddContent.test.tsx | 8 +- .../add-content/AddContent.tsx | 119 +++++++++---- src/library-authoring/add-content/messages.ts | 10 ++ .../LibraryCollectionPage.test.tsx | 41 +++-- .../common/context/LibraryContext.tsx | 18 +- .../create-container/CreateContainerModal.tsx | 165 ++++++++++++++++++ .../create-container/index.tsx | 1 + .../create-container/messages.ts | 106 +++++++++++ .../create-unit/CreateUnitModal.tsx | 115 ------------ src/library-authoring/create-unit/index.tsx | 1 - src/library-authoring/create-unit/messages.ts | 46 ----- src/library-authoring/routes.ts | 22 +++ 17 files changed, 578 insertions(+), 269 deletions(-) create mode 100644 src/library-authoring/create-container/CreateContainerModal.tsx create mode 100644 src/library-authoring/create-container/index.tsx create mode 100644 src/library-authoring/create-container/messages.ts delete mode 100644 src/library-authoring/create-unit/CreateUnitModal.tsx delete mode 100644 src/library-authoring/create-unit/index.tsx delete mode 100644 src/library-authoring/create-unit/messages.ts diff --git a/src/generic/block-type-utils/constants.ts b/src/generic/block-type-utils/constants.ts index a9a87a0c7..34a2346c8 100644 --- a/src/generic/block-type-utils/constants.ts +++ b/src/generic/block-type-utils/constants.ts @@ -6,7 +6,6 @@ import { ContentPaste as ContentPasteIcon, Edit as EditIcon, EditNote as EditNoteIcon, - CalendarViewDay, HelpOutline as HelpOutlineIcon, LibraryAdd as LibraryIcon, Lock as LockIcon, @@ -15,6 +14,9 @@ import { TextFields as TextFieldsIcon, VideoCamera as VideoCameraIcon, Folder, + ViewCarousel, + ViewDay, + WidthWide, } from '@openedx/paragon/icons'; import NewsstandIcon from '../NewsstandIcon'; @@ -36,7 +38,9 @@ export const COMPONENT_TYPES = { export const UNIT_TYPE_ICONS_MAP: Record = { video: VideoCameraIcon, other: BookOpenIcon, - vertical: CalendarViewDay, + vertical: ViewDay, + sequential: WidthWide, + chapter: ViewCarousel, problem: EditIcon, lock: LockIcon, }; @@ -57,8 +61,10 @@ export const COMPONENT_TYPE_ICON_MAP: Record = { export const STRUCTURAL_TYPE_ICONS: Record = { vertical: UNIT_TYPE_ICONS_MAP.vertical, unit: UNIT_TYPE_ICONS_MAP.vertical, - sequential: Folder, - chapter: Folder, + sequential: UNIT_TYPE_ICONS_MAP.sequential, + subsection: UNIT_TYPE_ICONS_MAP.sequential, + chapter: UNIT_TYPE_ICONS_MAP.chapter, + section: UNIT_TYPE_ICONS_MAP.chapter, collection: Folder, libraryContent: Folder, paste: ContentPasteIcon, @@ -75,8 +81,10 @@ export const COMPONENT_TYPE_STYLE_COLOR_MAP = { [COMPONENT_TYPES.dragAndDrop]: 'component-style-default', vertical: 'component-style-vertical', unit: 'component-style-vertical', - sequential: 'component-style-default', - chapter: 'component-style-default', + sequential: 'component-style-sequential', + subsection: 'component-style-sequential', + chapter: 'component-style-chapter', + section: 'component-style-chapter', collection: 'component-style-collection', other: 'component-style-other', }; diff --git a/src/generic/block-type-utils/index.scss b/src/generic/block-type-utils/index.scss index 713509c0b..a33b2c578 100644 --- a/src/generic/block-type-utils/index.scss +++ b/src/generic/block-type-utils/index.scss @@ -123,6 +123,56 @@ } } +.component-style-sequential { + background-color: #EA3E3E; + + .pgn__icon:not(.btn-icon-before) { + color: white; + } + + .btn-icon { + &:hover, &:active, &:focus { + background-color: darken(#EA3E3E, 15%); + } + } + + .btn { + background-color: lighten(#0B8E77, 10%); + border: 0; + + &:hover, &:active, &:focus { + background-color: lighten(#0B8E77, 20%); + border: 1px solid $primary; + margin: -1px; + } + } +} + +.component-style-chapter { + background-color: #45009E; + + .pgn__icon:not(.btn-icon-before) { + color: white; + } + + .btn-icon { + &:hover, &:active, &:focus { + background-color: darken(#45009E, 15%); + } + } + + .btn { + background-color: lighten(#0B8E77, 10%); + border: 0; + + &:hover, &:active, &:focus { + background-color: lighten(#0B8E77, 20%); + border: 1px solid $primary; + margin: -1px; + } + } +} + .component-style-other { background-color: #646464; diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index eeb5202d2..63498927a 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -51,15 +51,19 @@ export const buildCollectionUsageKey = (learningContextKey: string, collectionId }; export enum ContainerType { + Section = 'section', + Subsection = 'subsection', Unit = 'unit', /** - * Vertical is the old name for Unit. Generally, **please avoid using this term entirely in any libraries code** or - * anything based on the new Learning Core "Containers" framework - just call it a unit. We do still need to use this - * in the modulestore-based courseware, and currently the /xblock/ API used to copy library containers into courses - * also requires specifying this, though that should change to a better API that does the unit->vertical conversion - * automatically in the future. - * TODO: we should probably move this to a separate enum/mapping, and keep this for the new container types only. + * Chapter, Sequential and Vertical are the old names for section, subsection and unit. + * Generally, **please avoid using this term entirely in any libraries code** or + * anything based on the new Learning Core "Containers" framework - just call it a unit, section or subsection. We + * do still need to use this in the modulestore-based courseware, and currently the /xblock/ API used to copy + * library containers into courses also requires specifying this, though that should change to a better API + * that does the unit->vertical conversion automatically in the future. */ + Chapter = 'chapter', + Sequential = 'sequential', Vertical = 'vertical', } diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 5865d65a6..1fc72f368 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -735,9 +735,22 @@ describe('', () => { expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library collection'); }); - it('should create a unit', async () => { + test.each([ + { + label: 'should create a unit', + containerType: 'unit', + }, + { + label: 'should create a section', + containerType: 'section', + }, + { + label: 'should create a subsection', + containerType: 'subsection', + }, + ])('$label', async ({ containerType }) => { await renderLibraryPage(); - const title = 'This is a Test'; + const title = `This is a Test ${containerType}`; const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId); axiosMock.onPost(url).reply(200, { id: '1', @@ -753,22 +766,22 @@ describe('', () => { fireEvent.click(newButton); expect(screen.getByText(/add content/i)).toBeInTheDocument(); - // Open New unit Modal + // Open New container 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 newContainerButton = within(sidebar).getAllByRole('button', { name: new RegExp(containerType, 'i') })[0]; + fireEvent.click(newContainerButton); + const containerModalHeading = await screen.findByRole('heading', { name: new RegExp(`new ${containerType}`, 'i') }); + expect(containerModalHeading).toBeInTheDocument(); // Click on Cancel button const cancelButton = screen.getByRole('button', { name: /cancel/i }); fireEvent.click(cancelButton); - expect(unitModalHeading).not.toBeInTheDocument(); + expect(containerModalHeading).not.toBeInTheDocument(); - // Open new unit modal again and create a unit - fireEvent.click(newUnitButton); + // Open new container modal again and create a container + fireEvent.click(newContainerButton); const createButton = screen.getByRole('button', { name: /create/i }); - const nameField = screen.getByRole('textbox', { name: /name your unit/i }); + const nameField = screen.getByRole('textbox', { name: new RegExp(`name your ${containerType}`, 'i') }); fireEvent.change(nameField, { target: { value: title } }); fireEvent.click(createButton); @@ -778,14 +791,27 @@ describe('', () => { 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'); + expect(axiosMock.history.post[0].data).toContain(`"container_type":"${containerType}"`); + expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${containerType} created successfully`, 'i'))); }); - it('should show validations in create unit', async () => { + test.each([ + { + label: 'should show validations in create unit', + containerType: 'unit', + }, + { + label: 'should show validations in create section', + containerType: 'section', + }, + { + label: 'should show validations in create subsection', + containerType: 'subsection', + }, + ])('$label', async ({ containerType }) => { await renderLibraryPage(); - const title = 'This is a Test'; + const title = `This is a Test ${containerType}`; const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId); axiosMock.onPost(url).reply(200, { id: '1', @@ -801,14 +827,14 @@ describe('', () => { fireEvent.click(newButton); expect(screen.getByText(/add content/i)).toBeInTheDocument(); - // Open New unit Modal + // Open New container 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 newContainerButton = within(sidebar).getAllByRole('button', { name: new RegExp(containerType, 'i') })[0]; + fireEvent.click(newContainerButton); + const containerModalHeading = await screen.findByRole('heading', { name: new RegExp(`new ${containerType}`, 'i') }); + expect(containerModalHeading).toBeInTheDocument(); - const nameField = screen.getByRole('textbox', { name: /name your unit/i }); + const nameField = screen.getByRole('textbox', { name: new RegExp(`name your ${containerType}`, 'i') }); fireEvent.focus(nameField); fireEvent.blur(nameField); @@ -816,12 +842,25 @@ describe('', () => { const createButton = screen.getByRole('button', { name: /create/i }); fireEvent.click(createButton); - expect(await screen.findByText(/unit name is required/i)).toBeInTheDocument(); + expect(await screen.findByText(new RegExp(`${containerType} name is required`, 'i'))).toBeInTheDocument(); }); - it('should show error on create unit', async () => { + test.each([ + { + label: 'should show error on create unit', + containerType: 'unit', + }, + { + label: 'should show error on create section', + containerType: 'section', + }, + { + label: 'should show error on create subsection', + containerType: 'subsection', + }, + ])('$label', async ({ containerType }) => { await renderLibraryPage(); - const displayName = 'This is a Test'; + const displayName = `This is a Test ${containerType}`; const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId); axiosMock.onPost(url).reply(500); @@ -833,23 +872,25 @@ describe('', () => { fireEvent.click(newButton); expect(screen.getByText(/add content/i)).toBeInTheDocument(); - // Open New Unit Modal + // Open New container 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 newContainerButton = within(sidebar).getAllByRole('button', { name: new RegExp(containerType, 'i') })[0]; + fireEvent.click(newContainerButton); + const containerModalHeading = await screen.findByRole('heading', { name: new RegExp(`new ${containerType}`, 'i') }); + expect(containerModalHeading).toBeInTheDocument(); - // Create a unit + // Create a container const createButton = screen.getByRole('button', { name: /create/i }); - const nameField = screen.getByRole('textbox', { name: /name your unit/i }); + const nameField = screen.getByRole('textbox', { name: new RegExp(`name your ${containerType}`, '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'); + expect(mockShowToast).toHaveBeenCalledWith( + expect.stringMatching(new RegExp(`There is an error when creating the library ${containerType}`, 'i')), + ); }); it('shows a single block when usageKey query param is set', async () => { diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index f6f425912..9a2896566 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -12,7 +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 { CreateContainerModal } from './create-container'; import LibraryCollectionPage from './collections/LibraryCollectionPage'; import { ComponentPicker } from './component-picker'; import { ComponentEditorModal } from './components/ComponentEditorModal'; @@ -50,7 +50,7 @@ const LibraryLayout = () => { <> {childPage} - + diff --git a/src/library-authoring/add-content/AddContent.test.tsx b/src/library-authoring/add-content/AddContent.test.tsx index 09f01bd17..ac686b1ed 100644 --- a/src/library-authoring/add-content/AddContent.test.tsx +++ b/src/library-authoring/add-content/AddContent.test.tsx @@ -36,7 +36,7 @@ const { libraryId } = mockContentLibrary; const render = (collectionId?: string) => { const params: { libraryId: string, collectionId?: string } = { libraryId, collectionId }; return baseRender(, { - path: '/library/:libraryId/:collectionId?', + path: '/library/:libraryId/collection/:collectionId?', params, extraWrapper: ({ children }) => ( { const renderWithUnit = (unitId: string) => { const params: { libraryId: string, unitId?: string } = { libraryId, unitId }; return baseRender(, { - path: '/library/:libraryId/:unitId?', + path: '/library/:libraryId/unit/:unitId?', params, extraWrapper: ({ children }) => ( ', () => { }); }); - it('should not show collection/unit buttons when create component in container', async () => { + it('should not show collection, unit, section and subsection buttons when create component in unit', async () => { const unitId = 'lct:orf1:lib1:unit:test-1'; renderWithUnit(unitId); @@ -334,6 +334,8 @@ describe('', () => { expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Section' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Subsection' })).not.toBeInTheDocument(); }); it('should create a component in unit', async () => { diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index 8ddbdae35..48be2ba3d 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -1,5 +1,5 @@ import React, { useContext, useMemo } from 'react'; -import type { MessageDescriptor } from 'react-intl'; +import type { IntlShape, MessageDescriptor } from 'react-intl'; import { useSelector } from 'react-redux'; import { Stack, @@ -29,11 +29,11 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import { PickLibraryContentModal } from './PickLibraryContentModal'; import { blockTypes } from '../../editors/data/constants/app'; -import { ContentType as LibraryContentTypes } from '../routes'; +import { ContentType as LibraryContentTypes, useLibraryRoutes } from '../routes'; import genericMessages from '../generic/messages'; import messages from './messages'; import type { BlockTypeMetadata } from '../data/api'; -import { getContainerTypeFromId, ContainerType } from '../../generic/key-utils'; +import { ContainerType } from '../../generic/key-utils'; type ContentType = { name: string, @@ -58,7 +58,7 @@ type AddAdvancedContentViewProps = { closeAdvancedList: () => void, onCreateContent: (blockType: string) => void, advancedBlocks: Record, - isBlockTypeEnabled: (blockType) => boolean, + isBlockTypeEnabled: (blockType: string) => boolean, }; const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonProps) => { @@ -89,14 +89,15 @@ const AddContentView = ({ }: AddContentViewProps) => { const intl = useIntl(); const { - collectionId, componentPicker, unitId, } = useLibraryContext(); - let upstreamContainerType: ContainerType | undefined; - if (unitId) { - upstreamContainerType = getContainerTypeFromId(unitId); - } + const { + insideCollection, + insideUnit, + insideSection, + insideSubsection, + } = useLibraryRoutes(); const collectionButtonData = { name: intl.formatMessage(messages.collectionButton), @@ -110,6 +111,18 @@ const AddContentView = ({ blockType: 'vertical', }; + const sectionButtonData = { + name: intl.formatMessage(messages.sectionButton), + disabled: false, + blockType: 'chapter', + }; + + const subsectionButtonData = { + name: intl.formatMessage(messages.subsectionButton), + disabled: false, + blockType: 'sequential', + }; + const libraryContentButtonData = { name: intl.formatMessage(messages.libraryContentButton), disabled: false, @@ -119,27 +132,59 @@ const AddContentView = ({ const extraFilter = unitId ? ['NOT block_type = "unit"', 'NOT type = "collections"'] : undefined; const visibleTabs = unitId ? [LibraryContentTypes.components] : undefined; + /** List container content types that should be displayed based on current path */ + const visibleContentTypes = useMemo(() => { + if (insideCollection) { + // except for add collection button, show everthing. + return [ + libraryContentButtonData, + sectionButtonData, + subsectionButtonData, + unitButtonData, + ]; + } + if (insideUnit) { + // Only show libraryContentButton + return [libraryContentButtonData]; + } + // istanbul ignore if + if (insideSection) { + // Should only allow adding subsections + throw new Error('Not implemented'); + // return [subsectionButtonData]; + } + // istanbul ignore if + if (insideSubsection) { + // Should only allow adding units + throw new Error('Not implemented'); + // return [unitButtonData]; + } + // except for libraryContentButton, show everthing. + return [ + collectionButtonData, + sectionButtonData, + subsectionButtonData, + unitButtonData, + ]; + }, [insideCollection, insideUnit, insideSection, insideSubsection]); + return ( <> - {(collectionId || unitId) && componentPicker && ( + {visibleContentTypes.map((contentType) => ( + + ))} + {componentPicker && visibleContentTypes.includes(libraryContentButtonData) && ( /// Show the "Add Library Content" button for units and collections - <> - - - - )} - {!collectionId && !unitId && ( - // Doesn't show the "Collection" button if we are in a unit or collection - - )} - {upstreamContainerType !== ContainerType.Unit && ( - // Doesn't show the "Unit" button if we are in a unit - + )}
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */} @@ -191,7 +236,7 @@ const AddAdvancedContentView = ({ }; export const parseErrorMsg = ( - intl, + intl: IntlShape, error: any, detailedMessage: MessageDescriptor, defaultMessage: MessageDescriptor, @@ -222,10 +267,14 @@ const AddContent = () => { libraryId, collectionId, openCreateCollectionModal, - openCreateUnitModal, + setCreateContainerModalType, openComponentEditor, unitId, } = useLibraryContext(); + const { + insideCollection, + insideUnit, + } = useLibraryRoutes(); const addComponentsToCollectionMutation = useAddItemsToCollection(libraryId, collectionId); const addComponentsToContainerMutation = useAddComponentsToContainer(unitId); const createBlockMutation = useCreateLibraryBlock(); @@ -306,12 +355,12 @@ const AddContent = () => { } const linkComponent = (opaqueKey: string) => { - if (collectionId) { + if (collectionId && insideCollection) { addComponentsToCollectionMutation.mutateAsync([opaqueKey]).catch(() => { showToast(intl.formatMessage(genericMessages.manageCollectionsFailed)); }); } - if (unitId) { + if (unitId && insideUnit) { addComponentsToContainerMutation.mutateAsync([opaqueKey]).catch(() => { showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage)); }); @@ -379,8 +428,12 @@ const AddContent = () => { showAddLibraryContentModal(); } else if (blockType === 'advancedXBlock') { showAdvancedList(); - } else if (blockType === 'vertical') { - openCreateUnitModal(); + } else if ([ + ContainerType.Vertical, + ContainerType.Chapter, + ContainerType.Sequential, + ].includes(blockType as ContainerType)) { + setCreateContainerModalType(blockType as ContainerType); } else { onCreateBlock(blockType); } diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index cd7e688c5..4a2b17661 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -11,6 +11,16 @@ const messages = defineMessages({ defaultMessage: 'Unit', description: 'Content of button to create a Unit.', }, + sectionButton: { + id: 'course-authoring.library-authoring.add-content.buttons.section', + defaultMessage: 'Section', + description: 'Content of button to create a Section.', + }, + subsectionButton: { + id: 'course-authoring.library-authoring.add-content.buttons.subsection', + defaultMessage: 'Subsection', + description: 'Content of button to create a Subsection.', + }, libraryContentButton: { id: 'course-authoring.library-authoring.add-content.buttons.library-content', defaultMessage: 'Existing Library Content', diff --git a/src/library-authoring/collections/LibraryCollectionPage.test.tsx b/src/library-authoring/collections/LibraryCollectionPage.test.tsx index d9a576830..0e90d4d57 100644 --- a/src/library-authoring/collections/LibraryCollectionPage.test.tsx +++ b/src/library-authoring/collections/LibraryCollectionPage.test.tsx @@ -429,14 +429,27 @@ describe('', () => { await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); - it('should create a unit inside a collection', async () => { + test.each([ + { + label: 'should create a unit inside a collection', + containerType: 'unit', + }, + { + label: 'should create a section inside a collection', + containerType: 'section', + }, + { + label: 'should create a subsection inside a collection', + containerType: 'subsection', + }, + ])('$label', async ({ containerType }) => { await renderLibraryCollectionPage(); - const unitTitle = 'This is a Test'; + const containerTitle = `This is a Test ${containerType}`; const containerUrl = getLibraryContainersApiUrl(mockContentLibrary.libraryId); axiosMock.onPost(containerUrl).reply(200, { - id: 'unit-1', + id: 'container-id', slug: 'this-is-a-test', - title: unitTitle, + title: containerTitle, }); const collectionUrl = getLibraryCollectionItemsApiUrl( mockContentLibrary.libraryId, @@ -454,16 +467,16 @@ describe('', () => { // 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 newContainerButton = within(sidebar).getAllByRole('button', { name: new RegExp(containerType, 'i') })[0]; + fireEvent.click(newContainerButton); + const containerModalHeading = await screen.findByRole('heading', { name: new RegExp(`new ${containerType}`, 'i') }); + expect(containerModalHeading).toBeInTheDocument(); // Fill the form const createButton = screen.getByRole('button', { name: /create/i }); - const nameField = screen.getByRole('textbox', { name: /name your unit/i }); + const nameField = screen.getByRole('textbox', { name: new RegExp(`name your ${containerType}`, 'i') }); - fireEvent.change(nameField, { target: { value: unitTitle } }); + fireEvent.change(nameField, { target: { value: containerTitle } }); fireEvent.click(createButton); // Check success @@ -471,13 +484,13 @@ describe('', () => { // Check that the unit was created expect(axiosMock.history.post[0].url).toBe(containerUrl); - expect(axiosMock.history.post[0].data).toContain(`"display_name":"${unitTitle}"`); - expect(axiosMock.history.post[0].data).toContain('"container_type":"unit"'); - expect(mockShowToast).toHaveBeenCalledWith('Unit created successfully'); + expect(axiosMock.history.post[0].data).toContain(`"display_name":"${containerTitle}"`); + expect(axiosMock.history.post[0].data).toContain(`"container_type":"${containerType}"`); + expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${containerType} created successfully`, 'i'))); // Check that the unit was added to the collection expect(axiosMock.history.patch.length).toBe(1); expect(axiosMock.history.patch[0].url).toBe(collectionUrl); - expect(axiosMock.history.patch[0].data).toContain('"usage_keys":["unit-1"]'); + expect(axiosMock.history.patch[0].data).toContain('"usage_keys":["container-id"]'); }); }); diff --git a/src/library-authoring/common/context/LibraryContext.tsx b/src/library-authoring/common/context/LibraryContext.tsx index 678ee310e..40bd3a69b 100644 --- a/src/library-authoring/common/context/LibraryContext.tsx +++ b/src/library-authoring/common/context/LibraryContext.tsx @@ -7,6 +7,7 @@ import { useState, } from 'react'; import { useParams } from 'react-router-dom'; +import { ContainerType } from '../../../generic/key-utils'; import type { ComponentPicker } from '../../component-picker'; import type { ContentLibrary, BlockTypeMetadata } from '../../data/api'; @@ -40,10 +41,9 @@ export type LibraryContextData = { isCreateCollectionModalOpen: boolean; openCreateCollectionModal: () => void; closeCreateCollectionModal: () => void; - // "Create New Unit" modal - isCreateUnitModalOpen: boolean; - openCreateUnitModal: () => void; - closeCreateUnitModal: () => void; + // "Create new container" modal + createContainerModalType: ContainerType | undefined; + setCreateContainerModalType: (containerType?: ContainerType) => 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; @@ -91,7 +91,7 @@ export const LibraryProvider = ({ componentPicker, }: LibraryProviderProps) => { const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false); - const [isCreateUnitModalOpen, openCreateUnitModal, closeCreateUnitModal] = useToggle(false); + const [createContainerModalType, setCreateContainerModalType] = useState(undefined); const [componentBeingEdited, setComponentBeingEdited] = useState(); const closeComponentEditor = useCallback((data) => { setComponentBeingEdited((prev) => { @@ -147,9 +147,8 @@ export const LibraryProvider = ({ isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, - isCreateUnitModalOpen, - openCreateUnitModal, - closeCreateUnitModal, + createContainerModalType, + setCreateContainerModalType, componentBeingEdited, openComponentEditor, closeComponentEditor, @@ -173,9 +172,6 @@ export const LibraryProvider = ({ isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, - isCreateUnitModalOpen, - openCreateUnitModal, - closeCreateUnitModal, componentBeingEdited, openComponentEditor, closeComponentEditor, diff --git a/src/library-authoring/create-container/CreateContainerModal.tsx b/src/library-authoring/create-container/CreateContainerModal.tsx new file mode 100644 index 000000000..752a450ad --- /dev/null +++ b/src/library-authoring/create-container/CreateContainerModal.tsx @@ -0,0 +1,165 @@ +import React from 'react'; +import { + ActionRow, + Form, + ModalDialog, +} from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Formik } from 'formik'; +import { useNavigate } from 'react-router'; +import * as Yup from 'yup'; +import FormikControl from '../../generic/FormikControl'; +import { useLibraryContext } from '../common/context/LibraryContext'; +import messages from './messages'; +import { useAddItemsToCollection, useCreateLibraryContainer } from '../data/apiHooks'; +import { ToastContext } from '../../generic/toast-context'; +import LoadingButton from '../../generic/loading-button'; +import { ContainerType } from '../../generic/key-utils'; +import { useLibraryRoutes } from '../routes'; + +/** Common modal to create section, subsection or unit in library */ +const CreateContainerModal = () => { + const intl = useIntl(); + const navigate = useNavigate(); + const { + collectionId, + libraryId, + createContainerModalType, + setCreateContainerModalType, + } = useLibraryContext(); + const { insideCollection } = useLibraryRoutes(); + const create = useCreateLibraryContainer(libraryId); + const updateItemsMutation = useAddItemsToCollection(libraryId, collectionId); + const { showToast } = React.useContext(ToastContext); + + /** labels based on the type of modal open, i.e., section, subsection or unit */ + const labels = React.useMemo(() => { + if (createContainerModalType === ContainerType.Chapter) { + return { + modalTitle: intl.formatMessage(messages.createSectionModalTitle), + validationError: intl.formatMessage(messages.createSectionModalNameInvalid), + nameLabel: intl.formatMessage(messages.createSectionModalNameLabel), + placeholder: intl.formatMessage(messages.createSectionModalNamePlaceholder), + successMsg: intl.formatMessage(messages.createSectionSuccess), + errorMsg: intl.formatMessage(messages.createSectionError), + }; + } + if (createContainerModalType === ContainerType.Sequential) { + return { + modalTitle: intl.formatMessage(messages.createSubsectionModalTitle), + validationError: intl.formatMessage(messages.createSubsectionModalNameInvalid), + nameLabel: intl.formatMessage(messages.createSubsectionModalNameLabel), + placeholder: intl.formatMessage(messages.createSubsectionModalNamePlaceholder), + successMsg: intl.formatMessage(messages.createSubsectionSuccess), + errorMsg: intl.formatMessage(messages.createSubsectionError), + }; + } + return { + modalTitle: intl.formatMessage(messages.createUnitModalTitle), + validationError: intl.formatMessage(messages.createUnitModalNameInvalid), + nameLabel: intl.formatMessage(messages.createUnitModalNameLabel), + placeholder: intl.formatMessage(messages.createUnitModalNamePlaceholder), + successMsg: intl.formatMessage(messages.createUnitSuccess), + errorMsg: intl.formatMessage(messages.createUnitError), + }; + }, [createContainerModalType]); + + /** Call close for section, subsection and unit as the operation is idempotent */ + const handleClose = () => setCreateContainerModalType(undefined); + + /** Calculate containerType based on type of open modal */ + const containerType = React.useMemo(() => { + if (createContainerModalType === ContainerType.Chapter) { + return ContainerType.Section; + } + if (createContainerModalType === ContainerType.Sequential) { + return ContainerType.Subsection; + } + return ContainerType.Unit; + }, [createContainerModalType]); + + const handleCreate = React.useCallback(async (values) => { + try { + const container = await create.mutateAsync({ + containerType, + ...values, + }); + // link container to parent + if (collectionId && insideCollection) { + await updateItemsMutation.mutateAsync([container.id]); + } + // Navigate to the new container + navigate(`/library/${libraryId}/${containerType}/${container.id}`); + showToast(labels.successMsg); + } catch (error) { + showToast(labels.errorMsg); + } finally { + handleClose(); + } + }, [containerType, labels, handleClose]); + + return ( + + + + {labels.modalTitle} + + + + + {(formikProps) => ( +
+ + + {labels.nameLabel} + + )} + value={formikProps.values.displayName} + placeholder={labels.placeholder} + controlClasses="pb-2" + /> + + + + + {intl.formatMessage(messages.createModalCancel)} + + + + +
+ )} +
+
+ ); +}; + +export default CreateContainerModal; diff --git a/src/library-authoring/create-container/index.tsx b/src/library-authoring/create-container/index.tsx new file mode 100644 index 000000000..f15e01cbe --- /dev/null +++ b/src/library-authoring/create-container/index.tsx @@ -0,0 +1 @@ +export { default as CreateContainerModal } from './CreateContainerModal'; diff --git a/src/library-authoring/create-container/messages.ts b/src/library-authoring/create-container/messages.ts new file mode 100644 index 000000000..25d576f48 --- /dev/null +++ b/src/library-authoring/create-container/messages.ts @@ -0,0 +1,106 @@ +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', + }, + createSubsectionModalTitle: { + id: 'course-authoring.library-authoring.modals.create-subsection.title', + defaultMessage: 'New Subsection', + description: 'Title of the Create Subsection modal', + }, + createSectionModalTitle: { + id: 'course-authoring.library-authoring.modals.create-section.title', + defaultMessage: 'New Section', + description: 'Title of the Create Section modal', + }, + createModalCancel: { + id: 'course-authoring.library-authoring.modals.create-container.cancel', + defaultMessage: 'Cancel', + description: 'Label of the Cancel button of the Create container modal', + }, + createContainerModalCreate: { + id: 'course-authoring.library-authoring.modals.create-container.create', + defaultMessage: 'Create', + description: 'Label of the Create button of the Create container 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', + }, + createSectionModalNameLabel: { + id: 'course-authoring.library-authoring.modals.create-section.form.name', + defaultMessage: 'Name your section', + description: 'Label of the Name field of the Create Section modal form', + }, + createSubsectionModalNameLabel: { + id: 'course-authoring.library-authoring.modals.create-subsection.form.name', + defaultMessage: 'Name your subsection', + description: 'Label of the Name field of the Create Subsection 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', + }, + createSubsectionModalNamePlaceholder: { + id: 'course-authoring.library-authoring.modals.create-subsection.form.name.placeholder', + defaultMessage: 'Give a descriptive title', + description: 'Placeholder of the Name field of the Create Subsection modal form', + }, + createSectionModalNamePlaceholder: { + id: 'course-authoring.library-authoring.modals.create-section.form.name.placeholder', + defaultMessage: 'Give a descriptive title', + description: 'Placeholder of the Name field of the Create Section 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', + }, + createSectionModalNameInvalid: { + id: 'course-authoring.library-authoring.modals.create-section.form.name.invalid', + defaultMessage: 'Section name is required', + description: 'Message when the Name field of the Create Section modal form is invalid', + }, + createSubsectionModalNameInvalid: { + id: 'course-authoring.library-authoring.modals.create-subsection.form.name.invalid', + defaultMessage: 'Subsection name is required', + description: 'Message when the Name field of the Create Subsection modal form is invalid', + }, + createSectionSuccess: { + id: 'course-authoring.library-authoring.modals.create-section.success', + defaultMessage: 'Section created successfully', + description: 'Success message when creating a library section', + }, + createSectionError: { + id: 'course-authoring.library-authoring.modals.create-section.error', + defaultMessage: 'There is an error when creating the library section', + description: 'Error message when creating a library section', + }, + createSubsectionSuccess: { + id: 'course-authoring.library-authoring.modals.create-subsection.success', + defaultMessage: 'Subsection created successfully', + description: 'Success message when creating a library subsection', + }, + createSubsectionError: { + id: 'course-authoring.library-authoring.modals.create-subsection.error', + defaultMessage: 'There is an error when creating the library subsection', + description: 'Error message when creating a library subsection', + }, + 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/create-unit/CreateUnitModal.tsx b/src/library-authoring/create-unit/CreateUnitModal.tsx deleted file mode 100644 index 0d83303d3..000000000 --- a/src/library-authoring/create-unit/CreateUnitModal.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from 'react'; -import { - ActionRow, - Form, - ModalDialog, -} from '@openedx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Formik } from 'formik'; -import { useNavigate } from 'react-router'; -import * as Yup from 'yup'; -import FormikControl from '../../generic/FormikControl'; -import { useLibraryContext } from '../common/context/LibraryContext'; -import messages from './messages'; -import { useAddItemsToCollection, useCreateLibraryContainer } from '../data/apiHooks'; -import { ToastContext } from '../../generic/toast-context'; -import LoadingButton from '../../generic/loading-button'; -import { ContainerType } from '../../generic/key-utils'; - -const CreateUnitModal = () => { - const intl = useIntl(); - const navigate = useNavigate(); - const { - collectionId, - libraryId, - isCreateUnitModalOpen, - closeCreateUnitModal, - } = useLibraryContext(); - const create = useCreateLibraryContainer(libraryId); - const updateItemsMutation = useAddItemsToCollection(libraryId, collectionId); - const { showToast } = React.useContext(ToastContext); - - const handleCreate = React.useCallback(async (values) => { - try { - const container = await create.mutateAsync({ - containerType: ContainerType.Unit, - ...values, - }); - if (collectionId) { - await updateItemsMutation.mutateAsync([container.id]); - } - // Navigate to the new unit - navigate(`/library/${libraryId}/unit/${container.id}`); - 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 deleted file mode 100644 index ee82f395f..000000000 --- a/src/library-authoring/create-unit/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { default as CreateUnitModal } from './CreateUnitModal'; diff --git a/src/library-authoring/create-unit/messages.ts b/src/library-authoring/create-unit/messages.ts deleted file mode 100644 index db52c1ed6..000000000 --- a/src/library-authoring/create-unit/messages.ts +++ /dev/null @@ -1,46 +0,0 @@ -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/routes.ts b/src/library-authoring/routes.ts index 4615f229d..541648372 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -21,6 +21,10 @@ export const ROUTES = { COMPONENTS: '/components/:componentId?', // * Collections tab, with an optionally selected collectionId in the sidebar. COLLECTIONS: '/collections/:collectionId?', + // * Sections tab, with an optionally selected sectionId in the sidebar. + SECTIONS: '/sections/:sectionId?', + // * Subsections tab, with an optionally selected subsectionId in the sidebar. + SUBSECTIONS: '/subsections/:subsectionId?', // * Units tab, with an optionally selected unitId in the sidebar. UNITS: '/units/:unitId?', // * All Content tab, with an optionally selected componentId in the sidebar. @@ -30,6 +34,12 @@ export const ROUTES = { // LibraryCollectionPage route: // * with a selected collectionId and/or an optionally selected componentId. COLLECTION: '/collection/:collectionId/:componentId?', + // LibrarySectionPage route: + // * with a selected sectionId and/or an optionally selected subsectionId. + SECTION: '/section/:sectionId/:subsectionId?', + // LibrarySubsectionPage route: + // * with a selected subsectionId and/or an optionally selected unitId. + SUBSECTION: '/subsection/:subsectionId/:unitId?', // LibraryUnitPage route: // * with a selected unitId and/or an optionally selected componentId. UNIT: '/unit/:unitId/:componentId?', @@ -56,6 +66,10 @@ export type LibraryRoutesData = { insideCollection: PathMatch | null; insideCollections: PathMatch | null; insideComponents: PathMatch | null; + insideSections: PathMatch | null; + insideSection: PathMatch | null; + insideSubsections: PathMatch | null; + insideSubsection: PathMatch | null; insideUnits: PathMatch | null; insideUnit: PathMatch | null; @@ -73,6 +87,10 @@ export const useLibraryRoutes = (): LibraryRoutesData => { const insideCollection = matchPath(BASE_ROUTE + ROUTES.COLLECTION, pathname); const insideCollections = matchPath(BASE_ROUTE + ROUTES.COLLECTIONS, pathname); const insideComponents = matchPath(BASE_ROUTE + ROUTES.COMPONENTS, pathname); + const insideSections = matchPath(BASE_ROUTE + ROUTES.SECTIONS, pathname); + const insideSection = matchPath(BASE_ROUTE + ROUTES.SECTION, pathname); + const insideSubsections = matchPath(BASE_ROUTE + ROUTES.SUBSECTIONS, pathname); + const insideSubsection = matchPath(BASE_ROUTE + ROUTES.SUBSECTION, pathname); const insideUnits = matchPath(BASE_ROUTE + ROUTES.UNITS, pathname); const insideUnit = matchPath(BASE_ROUTE + ROUTES.UNIT, pathname); @@ -189,6 +207,10 @@ export const useLibraryRoutes = (): LibraryRoutesData => { insideCollection, insideCollections, insideComponents, + insideSections, + insideSection, + insideSubsections, + insideSubsection, insideUnits, insideUnit, };