From 107f13fb66b19b18adb38f382aac1ccdd94321e5 Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Thu, 12 Jun 2025 05:43:19 +0930 Subject: [PATCH 01/11] feat: remove content picker tab bar when only 1 visible tab --- src/library-authoring/LibraryAuthoringPage.tsx | 18 ++++++++++-------- .../component-picker/ComponentPicker.test.tsx | 9 ++++----- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index a49224ad25..1d11dbae98 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -298,14 +298,16 @@ const LibraryAuthoringPage = ({ headerActions={<HeaderActions />} hideBorder /> - <Tabs - variant="tabs" - activeKey={activeKey} - onSelect={handleTabChange} - className="my-3" - > - {visibleTabsToRender} - </Tabs> + {visibleTabs.length > 1 && ( + <Tabs + variant="tabs" + activeKey={activeKey} + onSelect={handleTabChange} + className="my-3" + > + {visibleTabsToRender} + </Tabs> + )} <ActionRow className="my-3"> <SearchKeywordsField className="mr-3" /> <FilterByTags /> diff --git a/src/library-authoring/component-picker/ComponentPicker.test.tsx b/src/library-authoring/component-picker/ComponentPicker.test.tsx index 668b8e8692..dc028f7580 100644 --- a/src/library-authoring/component-picker/ComponentPicker.test.tsx +++ b/src/library-authoring/component-picker/ComponentPicker.test.tsx @@ -377,16 +377,15 @@ describe('<ComponentPicker />', () => { expect(screen.getByRole('tab', { name: /units/i })).toBeInTheDocument(); }); - it('should display only unit tab', async () => { + it('should display only units', async () => { render(<ComponentPicker visibleTabs={[ContentType.units]} />); expect(await screen.findByText('Test Library 1')).toBeInTheDocument(); fireEvent.click(screen.getByDisplayValue(/lib:sampletaxonomyorg1:tl1/i)); - expect(await screen.findByRole('tab', { name: /units/i })).toBeInTheDocument(); - expect(screen.queryByRole('tab', { name: /all content/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('tab', { name: /collections/i })).not.toBeInTheDocument(); - expect(screen.queryByRole('tab', { name: /components/i })).not.toBeInTheDocument(); + expect(await screen.findByText('Published Test Unit')).toBeInTheDocument(); + // No tabs shown when only one tab is visible + expect(screen.queryByRole('tab')).not.toBeInTheDocument(); }); it('should not display never published filter', async () => { From 4f17d8bb24231335aa83155065343d91d9597177 Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Sat, 14 Jun 2025 00:18:07 +0930 Subject: [PATCH 02/11] refactor: simplify AddContent to make it easier to show or hide the "add new component" buttons. Also removes the need to convert between Chapter => Section, Sequential => Subsection, and Vertical => Unit. --- .../add-content/AddContent.tsx | 162 ++++++++++-------- .../create-container/CreateContainerModal.tsx | 19 +- 2 files changed, 99 insertions(+), 82 deletions(-) diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index 489ac79bc2..c9bd612791 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -37,7 +37,7 @@ import { ContainerType } from '../../generic/key-utils'; type ContentType = { name: string, - disabled: boolean, + disabled?: boolean, icon?: React.ComponentType, blockType: string, }; @@ -64,8 +64,7 @@ type AddAdvancedContentViewProps = { const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonProps) => { const { name, - disabled, - icon, + disabled = false, blockType, } = contentType; return ( @@ -73,7 +72,7 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro variant="outline-primary" disabled={disabled} className="m-2" - iconBefore={icon || getItemIcon(blockType)} + iconBefore={getItemIcon(blockType)} onClick={() => onCreateContent(blockType)} > {name} @@ -96,97 +95,126 @@ const AddContentView = ({ insideSubsection, } = useLibraryRoutes(); - const collectionButtonData = { - name: intl.formatMessage(messages.collectionButton), - disabled: false, - blockType: 'collection', - }; + const collectionButton = ( + <AddContentButton + key="collection" + contentType={{ + name: intl.formatMessage(messages.collectionButton), + blockType: 'collection', + }} + onCreateContent={onCreateContent} + /> + ); - const unitButtonData = { - name: intl.formatMessage(messages.unitButton), - disabled: false, - blockType: 'vertical', - }; + const unitButton = ( + <AddContentButton + key="unit" + contentType={{ + name: intl.formatMessage(messages.unitButton), + blockType: 'unit', + }} + onCreateContent={onCreateContent} + /> + ); - const sectionButtonData = { - name: intl.formatMessage(messages.sectionButton), - disabled: false, - blockType: 'chapter', - }; + const sectionButton = ( + <AddContentButton + key="section" + contentType={{ + name: intl.formatMessage(messages.sectionButton), + blockType: 'section', + }} + onCreateContent={onCreateContent} + /> + ); - const subsectionButtonData = { - name: intl.formatMessage(messages.subsectionButton), - disabled: false, - blockType: 'sequential', - }; + const subsectionButton = ( + <AddContentButton + key="subsection" + contentType={{ + name: intl.formatMessage(messages.subsectionButton), + blockType: 'subsection', + }} + onCreateContent={onCreateContent} + /> + ); - const libraryContentButtonData = { - name: intl.formatMessage(messages.libraryContentButton), - disabled: false, - blockType: 'libraryContent', - }; + const existingContentButton = ( + <AddContentButton + key="libraryContent" + contentType={{ + name: intl.formatMessage(messages.libraryContentButton), + blockType: 'libraryContent', + }} + onCreateContent={onCreateContent} + /> + ); - /** List container content types that should be displayed based on current path */ - const visibleContentTypes = useMemo(() => { + /* Note: for MVP we are hiding the unsupported types, not just disabling them. */ + const componentButtons = contentTypes.filter(ct => !ct.disabled).map((contentType) => ( + <AddContentButton + key={`add-content-${contentType.blockType}`} + contentType={contentType} + onCreateContent={onCreateContent} + /> + )); + const separator = ( + <hr className="w-100 bg-gray-500" /> + ); + + /** List buttons that should be displayed based on current path */ + const visibleButtons = useMemo(() => { if (insideCollection) { - // except for add collection button, show everthing. + // except for add collection button, show everything. return [ - libraryContentButtonData, - sectionButtonData, - subsectionButtonData, - unitButtonData, + existingContentButton, + sectionButton, + subsectionButton, + unitButton, + separator, + ...componentButtons, ]; } if (insideUnit) { - // Only show libraryContentButton - return [libraryContentButtonData]; + // Only show existing content button + component buttons + return [ + existingContentButton, + separator, + ...componentButtons, + ]; } // istanbul ignore if if (insideSection) { // Should only allow adding subsections throw new Error('Not implemented'); - // return [subsectionButtonData]; + // return [subsectionButton]; } // istanbul ignore if if (insideSubsection) { // Should only allow adding units throw new Error('Not implemented'); - // return [unitButtonData]; + // return [unitButton]; } - // except for libraryContentButton, show everthing. + // except for existing content, show everything. return [ - collectionButtonData, - sectionButtonData, - subsectionButtonData, - unitButtonData, + collectionButton, + sectionButton, + subsectionButton, + unitButton, + separator, + ...componentButtons, ]; - }, [insideCollection, insideUnit, insideSection, insideSubsection]); + }, [componentButtons, insideCollection, insideUnit, insideSection, insideSubsection]); return ( <> - {visibleContentTypes.map((contentType) => ( - <AddContentButton - key={contentType.blockType} - contentType={contentType} - onCreateContent={onCreateContent} - /> - ))} - {componentPicker && visibleContentTypes.includes(libraryContentButtonData) && ( - /// Show the "Add Library Content" button for units and collections + {visibleButtons} + {componentPicker && visibleButtons.includes(existingContentButton) && ( <PickLibraryContentModal isOpen={isAddLibraryContentModalOpen} onClose={closeAddLibraryContentModal} /> )} - <hr className="w-100 bg-gray-500" /> - {/* Note: for MVP we are hiding the unuspported types, not just disabling them. */} - {contentTypes.filter(ct => !ct.disabled).map((contentType) => ( - <AddContentButton - key={`add-content-${contentType.blockType}`} - contentType={contentType} - onCreateContent={onCreateContent} - /> - ))} </> ); }; @@ -423,9 +451,9 @@ const AddContent = () => { } else if (blockType === 'advancedXBlock') { showAdvancedList(); } else if ([ - ContainerType.Vertical, - ContainerType.Chapter, - ContainerType.Sequential, + ContainerType.Unit, + ContainerType.Subsection, + ContainerType.Section, ].includes(blockType as ContainerType)) { setCreateContainerModalType(blockType as ContainerType); } else { diff --git a/src/library-authoring/create-container/CreateContainerModal.tsx b/src/library-authoring/create-container/CreateContainerModal.tsx index a5fd56b610..743350b534 100644 --- a/src/library-authoring/create-container/CreateContainerModal.tsx +++ b/src/library-authoring/create-container/CreateContainerModal.tsx @@ -32,7 +32,7 @@ const CreateContainerModal = () => { /** labels based on the type of modal open, i.e., section, subsection or unit */ const labels = React.useMemo(() => { - if (createContainerModalType === ContainerType.Chapter) { + if (createContainerModalType === ContainerType.Section) { return { modalTitle: intl.formatMessage(messages.createSectionModalTitle), validationError: intl.formatMessage(messages.createSectionModalNameInvalid), @@ -42,7 +42,7 @@ const CreateContainerModal = () => { errorMsg: intl.formatMessage(messages.createSectionError), }; } - if (createContainerModalType === ContainerType.Sequential) { + if (createContainerModalType === ContainerType.Subsection) { return { modalTitle: intl.formatMessage(messages.createSubsectionModalTitle), validationError: intl.formatMessage(messages.createSubsectionModalNameInvalid), @@ -65,21 +65,10 @@ const CreateContainerModal = () => { /** 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, + containerType: createContainerModalType, ...values, }); // link container to parent @@ -94,7 +83,7 @@ const CreateContainerModal = () => { } finally { handleClose(); } - }, [containerType, labels, handleClose, navigateTo]); + }, [createContainerModalType, labels, handleClose, navigateTo]); return ( <ModalDialog From 509dbe8d3229a45d920cfa636e21e83b7edd2a3a Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Mon, 16 Jun 2025 01:04:45 +0930 Subject: [PATCH 03/11] feat: implements "add container to parent container" --- .../add-content/AddContent.tsx | 16 +++++--- .../create-container/CreateContainerModal.tsx | 41 +++++++++++++++---- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index c9bd612791..fd3b326a38 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -185,15 +185,19 @@ const AddContentView = ({ } // istanbul ignore if if (insideSection) { - // Should only allow adding subsections - throw new Error('Not implemented'); - // return [subsectionButton]; + // Only allow adding subsections + return [ + existingContentButton, + subsectionButton, + ]; } // istanbul ignore if if (insideSubsection) { - // Should only allow adding units - throw new Error('Not implemented'); - // return [unitButton]; + // Only allow adding units + return [ + existingContentButton, + unitButton, + ]; } // except for existing content, show everything. return [ diff --git a/src/library-authoring/create-container/CreateContainerModal.tsx b/src/library-authoring/create-container/CreateContainerModal.tsx index 743350b534..cff7304352 100644 --- a/src/library-authoring/create-container/CreateContainerModal.tsx +++ b/src/library-authoring/create-container/CreateContainerModal.tsx @@ -10,7 +10,11 @@ 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 { + useAddItemsToCollection, + useAddItemsToContainer, + useCreateLibraryContainer, +} from '../data/apiHooks'; import { ToastContext } from '../../generic/toast-context'; import LoadingButton from '../../generic/loading-button'; import { ContainerType } from '../../generic/key-utils'; @@ -21,13 +25,20 @@ const CreateContainerModal = () => { const intl = useIntl(); const { collectionId, + containerId, libraryId, createContainerModalType, setCreateContainerModalType, } = useLibraryContext(); - const { navigateTo, insideCollection } = useLibraryRoutes(); + const { + navigateTo, + insideCollection, + insideSection, + insideSubsection, + } = useLibraryRoutes(); const create = useCreateLibraryContainer(libraryId); - const updateItemsMutation = useAddItemsToCollection(libraryId, collectionId); + const addItemsToCollection = useAddItemsToCollection(libraryId, collectionId); + const addItemsToContainer = useAddItemsToContainer(containerId); const { showToast } = React.useContext(ToastContext); /** labels based on the type of modal open, i.e., section, subsection or unit */ @@ -71,19 +82,33 @@ const CreateContainerModal = () => { containerType: createContainerModalType, ...values, }); - // link container to parent - if (collectionId && insideCollection) { - await updateItemsMutation.mutateAsync([container.id]); - } // Navigate to the new container navigateTo({ containerId: container.id }); + + // Link container to parent after navigating -- we may still show an + // error if this linking fails, but at least the user can see that + // the container was created. + if (collectionId && insideCollection) { + await addItemsToCollection.mutateAsync([container.id]); + } + if (containerId && (insideSection || insideSubsection)) { + await addItemsToContainer.mutateAsync([container.id]); + } + showToast(labels.successMsg); } catch (error) { showToast(labels.errorMsg); } finally { handleClose(); } - }, [createContainerModalType, labels, handleClose, navigateTo]); + }, [ + addItemsToCollection, + addItemsToContainer, + createContainerModalType, + handleClose, + labels, + navigateTo, + ]); return ( <ModalDialog From 5df245992f08cf30526772fb2fb13c7e4171f6d5 Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Mon, 16 Jun 2025 01:16:38 +0930 Subject: [PATCH 04/11] fix: allow "add new" button to open new container modal instead of just opening the sidebar, when adding a container to another container. This saves the user a click and makes this consistent with the "add new component" button on unit page. --- src/library-authoring/containers/FooterActions.tsx | 14 ++++++++++++-- .../section-subsections/LibrarySectionPage.tsx | 2 ++ .../section-subsections/LibrarySubsectionPage.tsx | 2 ++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/containers/FooterActions.tsx b/src/library-authoring/containers/FooterActions.tsx index 7f6de80b1e..2fd3767e0b 100644 --- a/src/library-authoring/containers/FooterActions.tsx +++ b/src/library-authoring/containers/FooterActions.tsx @@ -1,21 +1,31 @@ import { Button, useToggle } from '@openedx/paragon'; import { Add } from '@openedx/paragon/icons'; +import { type ContainerType } from '../../generic/key-utils'; import { PickLibraryContentModal } from '../add-content'; import { useLibraryContext } from '../common/context/LibraryContext'; import { useSidebarContext } from '../common/context/SidebarContext'; interface FooterActionsProps { + addContentType?: ContainerType; addContentBtnText: string; addExistingContentBtnText: string; } export const FooterActions = ({ + addContentType, addContentBtnText, addExistingContentBtnText, }: FooterActionsProps) => { const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); const { openAddContentSidebar } = useSidebarContext(); - const { readOnly } = useLibraryContext(); + const { readOnly, setCreateContainerModalType } = useLibraryContext(); + const addContent = () => { + if (addContentType) { + setCreateContainerModalType(addContentType); + } else { + openAddContentSidebar(); + } + }; return ( <div className="d-flex"> <div className="w-100 mr-2"> @@ -23,7 +33,7 @@ export const FooterActions = ({ className="ml-2" iconBefore={Add} variant="outline-primary rounded-0" - onClick={openAddContentSidebar} + onClick={addContent} disabled={readOnly} block > diff --git a/src/library-authoring/section-subsections/LibrarySectionPage.tsx b/src/library-authoring/section-subsections/LibrarySectionPage.tsx index 95da17add4..63055a2c21 100644 --- a/src/library-authoring/section-subsections/LibrarySectionPage.tsx +++ b/src/library-authoring/section-subsections/LibrarySectionPage.tsx @@ -8,6 +8,7 @@ import { useContainer, useContentLibrary } from '../data/apiHooks'; import Loading from '../../generic/Loading'; import NotFoundAlert from '../../generic/NotFoundAlert'; import ErrorAlert from '../../generic/alert-error'; +import { ContainerType } from '../../generic/key-utils'; import Header from '../../header'; import SubHeader from '../../generic/sub-header/SubHeader'; import { SubHeaderTitle } from '../LibraryAuthoringPage'; @@ -105,6 +106,7 @@ export const LibrarySectionPage = () => { <Container className="px-4 py-4"> <LibraryContainerChildren containerKey={containerId} /> <FooterActions + addContentType={ContainerType.Subsection} addContentBtnText={intl.formatMessage(sectionMessages.addContentButton)} addExistingContentBtnText={intl.formatMessage(sectionMessages.addExistingContentButton)} /> diff --git a/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx b/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx index 1b60d5d3a3..04feb83db0 100644 --- a/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx +++ b/src/library-authoring/section-subsections/LibrarySubsectionPage.tsx @@ -11,6 +11,7 @@ import { useContentFromSearchIndex, useContentLibrary } from '../data/apiHooks'; import Loading from '../../generic/Loading'; import NotFoundAlert from '../../generic/NotFoundAlert'; import ErrorAlert from '../../generic/alert-error'; +import { ContainerType } from '../../generic/key-utils'; import Header from '../../header'; import SubHeader from '../../generic/sub-header/SubHeader'; import { SubHeaderTitle } from '../LibraryAuthoringPage'; @@ -154,6 +155,7 @@ export const LibrarySubsectionPage = () => { <Container className="px-4 py-4"> <LibraryContainerChildren containerKey={containerId} /> <FooterActions + addContentType={ContainerType.Unit} addContentBtnText={intl.formatMessage(subsectionMessages.addContentButton)} addExistingContentBtnText={intl.formatMessage(subsectionMessages.addExistingContentButton)} /> From 9aa7b70d0ee6031c9e703b27816f9e8580542ede Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Mon, 16 Jun 2025 09:20:52 +0930 Subject: [PATCH 05/11] test: adds tests for adding children to container --- .../add-content/AddContent.test.tsx | 32 +++++- .../LibrarySectionSubsectionPage.test.tsx | 101 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/add-content/AddContent.test.tsx b/src/library-authoring/add-content/AddContent.test.tsx index 1ed18593f0..5171eeb6eb 100644 --- a/src/library-authoring/add-content/AddContent.test.tsx +++ b/src/library-authoring/add-content/AddContent.test.tsx @@ -19,6 +19,7 @@ import { getLibraryPasteClipboardUrl, getXBlockFieldsApiUrl, } from '../data/api'; +import { getBlockType } from '../../generic/key-utils'; import { mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock'; import { LibraryProvider } from '../common/context/LibraryContext'; import AddContent from './AddContent'; @@ -50,8 +51,9 @@ const render = (collectionId?: string) => { }; const renderWithContainer = (containerId: string) => { const params: { libraryId: string, containerId?: string } = { libraryId, containerId }; + const containerType = containerId ? getBlockType(containerId) : 'unit'; return baseRender(<AddContent />, { - path: '/library/:libraryId/unit/:containerId?', + path: `/library/:libraryId/${containerType}/:containerId?`, params, extraWrapper: ({ children }) => ( <LibraryProvider @@ -394,4 +396,32 @@ describe('<AddContent />', () => { expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this container.'); }); + + it('should show unit buttons when add content in subsection', async () => { + const subsectionId = 'lct:org1:lib1:subsection:test-1'; + renderWithContainer(subsectionId); + + expect(await screen.findByRole('button', { name: /existing library content/i })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Unit' })).toBeInTheDocument(); + + // other container, collection, and component buttons are not shown + expect(screen.queryByRole('button', { name: 'Subsection' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Section' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Text' })).not.toBeInTheDocument(); + }); + + it('should show subsection buttons when add content in section', async () => { + const sectionId = 'lct:org1:lib1:section:test-1'; + renderWithContainer(sectionId); + + expect(await screen.findByRole('button', { name: /existing library content/i })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Subsection' })).toBeInTheDocument(); + + // other container, collection, and component buttons are not shown + expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Section' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Text' })).not.toBeInTheDocument(); + }); }); diff --git a/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx b/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx index 727773c914..553a74cf22 100644 --- a/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx +++ b/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx @@ -12,6 +12,7 @@ import { import { getLibraryContainerApiUrl, getLibraryContainerChildrenApiUrl, + getLibraryContainersApiUrl, } from '../data/api'; import { mockContentLibrary, @@ -377,5 +378,105 @@ describe('<LibrarySectionPage / LibrarySubsectionPage />', () => { expect((await screen.findAllByText(new RegExp(`Test ${childType}`, 'i')))[0]).toBeInTheDocument(); expect(await screen.findByRole('button', { name: new RegExp(`${childType} Info`, 'i') })).toBeInTheDocument(); }); + + it(`${cType} sidebar should render "new ${childType}" and "existing library content" buttons`, async () => { + renderLibrarySectionPage(undefined, undefined, cType); + const addChild = await screen.findByRole('button', { name: new RegExp(`add ${childType}`, 'i') }); + userEvent.click(addChild); + const addNew = await screen.findByRole('button', { name: new RegExp(`^${childType}$`, 'i') }); + const addExisting = await screen.findByRole('button', { name: /existing library content/i }); + + // Clicking "add new" shows create container modal (tested below) + userEvent.click(addNew); + expect(await screen.findByLabelText(new RegExp(`name your ${childType}`, 'i'))).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: /cancel/i })); + + // Clicking "add existing" shows content picker modal + userEvent.click(addExisting); + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: new RegExp(`add to ${cType}`, 'i') })).toBeInTheDocument(); + }); + + it(`"add new" button should add ${childType} to the ${cType}`, async () => { + const { libraryId } = mockContentLibrary; + const containerId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionId + : mockGetContainerMetadata.subsectionId; + const childId = cType === ContainerType.Section + ? mockGetContainerMetadata.subsectionId + : mockGetContainerMetadata.unitId; + + axiosMock + .onPost(getLibraryContainersApiUrl(libraryId)) + .reply(200, { id: childId }); + axiosMock + .onPost(getLibraryContainerChildrenApiUrl(containerId)) + .reply(200); + renderLibrarySectionPage(containerId, libraryId, cType); + + const addChild = await screen.findByRole('button', { name: new RegExp(`add new ${childType}`, 'i') }); + userEvent.click(addChild); + const textBox = await screen.findByLabelText(new RegExp(`name your ${childType}`, 'i')); + fireEvent.change(textBox, { target: { value: `New ${childType} Title` } }); + fireEvent.click(screen.getByRole('button', { name: /create/i })); + await waitFor(() => { + expect(axiosMock.history.post.length).toEqual(2); + }); + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ + container_type: childType, + display_name: `New ${childType} Title`, + })); + expect(axiosMock.history.post[1].data).toEqual(JSON.stringify({ usage_keys: [childId] })); + expect(textBox).not.toBeInTheDocument(); + const childTypeTitle = childType.charAt(0).toUpperCase() + childType.slice(1); + expect(mockShowToast).toHaveBeenCalledWith(`${childTypeTitle} created successfully`); + }); + + it(`"add new" button should show error when adding ${childType} to the ${cType}`, async () => { + const { libraryId } = mockContentLibrary; + const containerId = cType === ContainerType.Section + ? mockGetContainerMetadata.sectionId + : mockGetContainerMetadata.subsectionId; + const childId = cType === ContainerType.Section + ? mockGetContainerMetadata.subsectionId + : mockGetContainerMetadata.unitId; + + axiosMock + .onPost(getLibraryContainersApiUrl(libraryId)) + .reply(200, { id: childId }); + axiosMock + .onPost(getLibraryContainerChildrenApiUrl(containerId)) + .reply(500); + renderLibrarySectionPage(containerId, libraryId, cType); + + const addChild = await screen.findByRole('button', { name: new RegExp(`add new ${childType}`, 'i') }); + userEvent.click(addChild); + const textBox = await screen.findByLabelText(new RegExp(`name your ${childType}`, 'i')); + fireEvent.change(textBox, { target: { value: `New ${childType} Title` } }); + fireEvent.click(screen.getByRole('button', { name: /create/i })); + await waitFor(() => { + expect(axiosMock.history.post.length).toEqual(2); + }); + expect(axiosMock.history.post[0].data).toEqual(JSON.stringify({ + container_type: childType, + display_name: `New ${childType} Title`, + })); + expect(axiosMock.history.post[1].data).toEqual(JSON.stringify({ usage_keys: [childId] })); + expect(textBox).not.toBeInTheDocument(); + expect(mockShowToast).toHaveBeenCalledWith(`There is an error when creating the library ${childType}`); + }); + + it(`"add existing ${childType}" button should load ${cType} content picker modal`, async () => { + renderLibrarySectionPage(undefined, undefined, cType); + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + + const addChild = await screen.findByRole('button', { name: new RegExp(`add existing ${childType}`, 'i') }); + userEvent.click(addChild); + + // Content picker loaded (modal behavior is tested elsewhere) + expect(await screen.findByRole('dialog')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: new RegExp(`add to ${cType}`, 'i') })).toBeInTheDocument(); + }); }); }); From b68de23c0f6fcdd24a6ce44e12bbbfa6cb15342e Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Tue, 17 Jun 2025 00:00:22 +0930 Subject: [PATCH 06/11] test: ensure Unit Add Content sidebar is opened on "add new content" --- src/library-authoring/units/LibraryUnitPage.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index e9584a8491..b5ad52ca1e 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -430,4 +430,14 @@ describe('<LibraryUnitPage />', () => { userEvent.click(component.parentElement!.parentElement!.parentElement!, undefined, { clickCount: 2 }); expect(await screen.findByRole('dialog', { name: 'Editor Dialog' })).toBeInTheDocument(); }); + + it('"Add New Content" button should open "Add Content" sidebar', async () => { + renderLibraryUnitPage(); + const addContent = await screen.findByRole('button', { name: /add new content/i }); + userEvent.click(addContent); + + expect(await screen.findByRole('button', { name: /existing library content/i })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /text/i })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /problem/i })).toBeInTheDocument(); + }); }); From 2e5020925f2fbde50cca20dedd94d49b1e4e5921 Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Thu, 19 Jun 2025 07:24:43 +0930 Subject: [PATCH 07/11] fix: reinstate custom icon for add content buttons --- src/library-authoring/add-content/AddContent.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index b9eafca488..8a79d093ff 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -65,6 +65,7 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro const { name, disabled = false, + icon, blockType, } = contentType; return ( @@ -72,7 +73,7 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro variant="outline-primary" disabled={disabled} className="m-2" - iconBefore={getItemIcon(blockType)} + iconBefore={icon || getItemIcon(blockType)} onClick={() => onCreateContent(blockType)} > {name} From ec7491796d4031bf2a7f8fe0482f7795bfd23cdf Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Thu, 19 Jun 2025 07:35:15 +0930 Subject: [PATCH 08/11] test: reinstate one skipped test --- src/library-authoring/containers/ContainerInfo.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/containers/ContainerInfo.test.tsx b/src/library-authoring/containers/ContainerInfo.test.tsx index 75c5cc91b3..5370fb0b63 100644 --- a/src/library-authoring/containers/ContainerInfo.test.tsx +++ b/src/library-authoring/containers/ContainerInfo.test.tsx @@ -120,10 +120,10 @@ describe('<ContainerInfo />', () => { expect(mockShowToast).toHaveBeenCalledWith('Failed to publish changes'); }); - testIf(containerType === 'Unit')(`show only published ${containerType} content`, async () => { + it(`show only published ${containerType} content`, async () => { render(containerId, true); expect(await screen.findByTestId('container-info-menu-toggle')).toBeInTheDocument(); - expect(screen.getByText(/text block published 1/i)).toBeInTheDocument(); + expect(screen.getByText(/block published 1/i)).toBeInTheDocument(); }); it(`shows the ${containerType} Preview tab by default and the children are readonly`, async () => { From aba7f70b801aaaffb0b960af0bcda3481254eefb Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Sat, 21 Jun 2025 03:27:32 +0930 Subject: [PATCH 09/11] refactor: make library routes return booleans for inside* to make it easier to type these variables in other places --- src/library-authoring/routes.ts | 59 +++++++++++++++++---------------- 1 file changed, 30 insertions(+), 29 deletions(-) diff --git a/src/library-authoring/routes.ts b/src/library-authoring/routes.ts index e7760c6fb9..a65781c297 100644 --- a/src/library-authoring/routes.ts +++ b/src/library-authoring/routes.ts @@ -9,7 +9,6 @@ import { useLocation, useNavigate, useSearchParams, - type PathMatch, } from 'react-router-dom'; import { ContainerType, getBlockType } from '../generic/key-utils'; @@ -62,15 +61,15 @@ export type NavigateToData = { }; export type LibraryRoutesData = { - insideCollection: PathMatch<string> | null; - insideCollections: PathMatch<string> | null; - insideComponents: PathMatch<string> | null; - insideSections: PathMatch<string> | null; - insideSection: PathMatch<string> | null; - insideSubsections: PathMatch<string> | null; - insideSubsection: PathMatch<string> | null; - insideUnits: PathMatch<string> | null; - insideUnit: PathMatch<string> | null; + insideCollection: boolean; + insideCollections: boolean; + insideComponents: boolean; + insideSections: boolean; + insideSection: boolean; + insideSubsections: boolean; + insideSubsection: boolean; + insideUnits: boolean; + insideUnit: boolean; /** Navigate using the best route from the current location for the given parameters. * This function can be mutated if there are changes in the current route, so always include @@ -85,15 +84,17 @@ export const useLibraryRoutes = (): LibraryRoutesData => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); - 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); + // Convert the returned PathMatch<string> | null values to PathMatch<string> | false + // to make it easier to return them as booleans below. + const insideCollection = matchPath(BASE_ROUTE + ROUTES.COLLECTION, pathname) || false; + const insideCollections = matchPath(BASE_ROUTE + ROUTES.COLLECTIONS, pathname) || false; + const insideComponents = matchPath(BASE_ROUTE + ROUTES.COMPONENTS, pathname) || false; + const insideSections = matchPath(BASE_ROUTE + ROUTES.SECTIONS, pathname) || false; + const insideSection = matchPath(BASE_ROUTE + ROUTES.SECTION, pathname) || false; + const insideSubsections = matchPath(BASE_ROUTE + ROUTES.SUBSECTIONS, pathname) || false; + const insideSubsection = matchPath(BASE_ROUTE + ROUTES.SUBSECTION, pathname) || false; + const insideUnits = matchPath(BASE_ROUTE + ROUTES.UNITS, pathname) || false; + const insideUnit = matchPath(BASE_ROUTE + ROUTES.UNIT, pathname) || false; // Sanity check to ensure that we are not inside more than one route at the same time. // istanbul ignore if: this is a developer error, not a user error. @@ -108,7 +109,7 @@ export const useLibraryRoutes = (): LibraryRoutesData => { insideSubsection, insideUnits, insideUnit, - ].filter((match): match is PathMatch<string> => match !== null).length > 1) { + ].filter((match) => match).length > 1) { throw new Error('Cannot be inside more than one route at the same time.'); } @@ -247,15 +248,15 @@ export const useLibraryRoutes = (): LibraryRoutesData => { return useMemo(() => ({ navigateTo, - insideCollection, - insideCollections, - insideComponents, - insideSections, - insideSection, - insideSubsections, - insideSubsection, - insideUnits, - insideUnit, + insideCollection: !!insideCollection, + insideCollections: !!insideCollections, + insideComponents: !!insideComponents, + insideSections: !!insideSections, + insideSection: !!insideSection, + insideSubsections: !!insideSubsections, + insideSubsection: !!insideSubsection, + insideUnits: !!insideUnits, + insideUnit: !!insideUnit, }), [ navigateTo, insideCollection, From 3c629252a9cd9b0b37506458057c3fb2e14591bf Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Sat, 21 Jun 2025 03:28:21 +0930 Subject: [PATCH 10/11] fix: use context-appropriate messages when adding existing content When adding units to a subsection: * button names are "Add Unit" and "Existing Unit" * modal title and text is "Select units" and "N units selected" * modal button is "Add to subsection" When adding subsection to a section: * button names are "Add Subsection" and "Existing Subsection" * modal title and text is "Select subsections" and "N subsections selected" * modal button is "Add to section" Otherwise, the more generic "component" text is used. --- .../add-content/AddContent.test.tsx | 19 ++- .../add-content/AddContent.tsx | 16 ++- .../add-content/AddContentHeader.tsx | 2 +- .../PickLibraryContentModal.test.tsx | 47 +++++-- .../add-content/PickLibraryContentModal.tsx | 60 ++++----- src/library-authoring/add-content/messages.ts | 119 +++++++++++++++--- .../CreateContainerModal.test.tsx | 2 +- .../LibrarySectionSubsectionPage.test.tsx | 6 +- 8 files changed, 188 insertions(+), 83 deletions(-) diff --git a/src/library-authoring/add-content/AddContent.test.tsx b/src/library-authoring/add-content/AddContent.test.tsx index 2953a6f5ad..a8789d2b33 100644 --- a/src/library-authoring/add-content/AddContent.test.tsx +++ b/src/library-authoring/add-content/AddContent.test.tsx @@ -90,6 +90,7 @@ describe('<AddContent />', () => { expect(screen.queryByRole('button', { name: /video/i })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: /copy from clipboard/i })).not.toBeInTheDocument(); expect(await screen.findByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument(); + expect(await screen.queryByRole('button', { name: /existing library content/i })).not.toBeInTheDocument(); }); it('should render advanced content buttons', async () => { @@ -331,6 +332,7 @@ describe('<AddContent />', () => { const unitId = 'lct:orf1:lib1:unit:test-1'; renderWithContainer(unitId); + expect(await screen.findByRole('button', { name: /existing library content/i })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: 'Text' })).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument(); @@ -396,13 +398,16 @@ describe('<AddContent />', () => { expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this container.'); }); - it('should only show subsection button when inside a section', async () => { + it('should only show subsection buttons when inside a section', async () => { mockClipboardEmpty.applyMock(); const sectionId = 'lct:orf1:lib1:section:test-1'; renderWithContainer(sectionId, 'section'); - expect(await screen.findByRole('button', { name: /existing library content/i })).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: 'Subsection' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /existing subsection/i })).toBeInTheDocument(); + + // Button is labeled "New Subsection" , not "Subsection" + expect(await screen.findByRole('button', { name: /new subsection/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Subsection' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument(); @@ -410,13 +415,15 @@ describe('<AddContent />', () => { expect(screen.queryByRole('button', { name: 'Text' })).not.toBeInTheDocument(); }); - it('should only show unit button when inside a subsection', async () => { + it('should only show unit buttons when inside a subsection', async () => { mockClipboardEmpty.applyMock(); const subsectionId = 'lct:orf1:lib1:subsection:test-1'; renderWithContainer(subsectionId, 'subsection'); - expect(await screen.findByRole('button', { name: /existing library content/i })).toBeInTheDocument(); - expect(await screen.findByRole('button', { name: 'Unit' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /existing unit/i })).toBeInTheDocument(); + // Button is labeled "New Unit" in this context, not just "Unit" + expect(await screen.findByRole('button', { name: /new unit/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Subsection' })).not.toBeInTheDocument(); diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index 8a79d093ff..0698b04574 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -31,7 +31,7 @@ import { blockTypes } from '../../editors/data/constants/app'; import { useLibraryRoutes } from '../routes'; import genericMessages from '../generic/messages'; -import messages from './messages'; +import { messages, getContentMessages } from './messages'; import type { BlockTypeMetadata } from '../data/api'; import { ContainerType } from '../../generic/key-utils'; @@ -96,11 +96,15 @@ const AddContentView = ({ insideSubsection, } = useLibraryRoutes(); + const contentMessages = useMemo(() => ( + getContentMessages(insideSection, insideSubsection, insideUnit) + ), [insideSection, insideSubsection, insideUnit]); + const collectionButton = ( <AddContentButton key="collection" contentType={{ - name: intl.formatMessage(messages.collectionButton), + name: intl.formatMessage(contentMessages.collectionButton), blockType: 'collection', }} onCreateContent={onCreateContent} @@ -111,7 +115,7 @@ const AddContentView = ({ <AddContentButton key="unit" contentType={{ - name: intl.formatMessage(messages.unitButton), + name: intl.formatMessage(contentMessages.unitButton), blockType: 'unit', }} onCreateContent={onCreateContent} @@ -122,7 +126,7 @@ const AddContentView = ({ <AddContentButton key="section" contentType={{ - name: intl.formatMessage(messages.sectionButton), + name: intl.formatMessage(contentMessages.sectionButton), blockType: 'section', }} onCreateContent={onCreateContent} @@ -133,7 +137,7 @@ const AddContentView = ({ <AddContentButton key="subsection" contentType={{ - name: intl.formatMessage(messages.subsectionButton), + name: intl.formatMessage(contentMessages.subsectionButton), blockType: 'subsection', }} onCreateContent={onCreateContent} @@ -144,7 +148,7 @@ const AddContentView = ({ <AddContentButton key="libraryContent" contentType={{ - name: intl.formatMessage(messages.libraryContentButton), + name: intl.formatMessage(contentMessages.libraryContentButton), blockType: 'libraryContent', }} onCreateContent={onCreateContent} diff --git a/src/library-authoring/add-content/AddContentHeader.tsx b/src/library-authoring/add-content/AddContentHeader.tsx index a73a41a039..07bf86ea65 100644 --- a/src/library-authoring/add-content/AddContentHeader.tsx +++ b/src/library-authoring/add-content/AddContentHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import messages from './messages'; +import { messages } from './messages'; const AddContentHeader = () => ( <span className="font-weight-bold m-1.5"> diff --git a/src/library-authoring/add-content/PickLibraryContentModal.test.tsx b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx index c874a6fe6f..03126f610e 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.test.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.test.tsx @@ -81,12 +81,24 @@ describe('<PickLibraryContentModal />', () => { }); [ - 'collection' as const, - 'unit' as const, - 'section' as const, - 'subsection' as const, - ].forEach((context) => { - it(`can pick components from the modal (${context})`, async () => { + { + context: 'collection' as const, + addType: 'component', + }, + { + context: 'unit' as const, + addType: 'component', + }, + { + context: 'subsection' as const, + addType: 'unit', + }, + { + context: 'section' as const, + addType: 'subsection', + }, + ].forEach(({ context, addType }) => { + it(`can pick content from the modal (${context})`, async () => { render(context); // Wait for the content library to load @@ -95,11 +107,16 @@ describe('<PickLibraryContentModal />', () => { expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument(); }); - // Select the first component - fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]); - expect(await screen.findByText('1 Selected Component')).toBeInTheDocument(); + expect(screen.getByText(`Select ${addType}s`)).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /add to .*/i })); + // Select and add the first item + fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]); + expect(await screen.findByText( + new RegExp(`1 selected ${addType}`, 'i'), + )).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { + name: new RegExp(`add to ${context}`, 'i'), + })); await waitFor(() => { switch (context) { @@ -143,11 +160,15 @@ describe('<PickLibraryContentModal />', () => { expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument(); }); - // Select the first component + // Select and add the first item fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]); - expect(await screen.findByText('1 Selected Component')).toBeInTheDocument(); + expect(await screen.findByText( + new RegExp(`1 selected ${addType}`, 'i'), + )).toBeInTheDocument(); - fireEvent.click(screen.getByRole('button', { name: /add to .*/i })); + fireEvent.click(screen.getByRole('button', { + name: new RegExp(`add to ${context}`, 'i'), + })); await waitFor(() => { switch (context) { diff --git a/src/library-authoring/add-content/PickLibraryContentModal.tsx b/src/library-authoring/add-content/PickLibraryContentModal.tsx index 3e9f396d76..43993d3754 100644 --- a/src/library-authoring/add-content/PickLibraryContentModal.tsx +++ b/src/library-authoring/add-content/PickLibraryContentModal.tsx @@ -1,4 +1,9 @@ -import React, { useCallback, useContext, useState } from 'react'; +import React, { + useCallback, + useContext, + useMemo, + useState, +} from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, Button, StandardModal } from '@openedx/paragon'; @@ -8,27 +13,7 @@ import type { SelectedComponent } from '../common/context/ComponentPickerContext import { useAddItemsToCollection, useAddItemsToContainer } from '../data/apiHooks'; import genericMessages from '../generic/messages'; import { allLibraryPageTabs, ContentType, useLibraryRoutes } from '../routes'; -import messages from './messages'; - -interface PickLibraryContentModalFooterProps { - onSubmit: () => void; - selectedComponents: SelectedComponent[]; - buttonText: React.ReactNode; -} - -const PickLibraryContentModalFooter: React.FC<PickLibraryContentModalFooterProps> = ({ - onSubmit, - selectedComponents, - buttonText, -}) => ( - <ActionRow> - <FormattedMessage {...messages.selectedComponents} values={{ count: selectedComponents.length }} /> - <ActionRow.Spacer /> - <Button variant="primary" onClick={onSubmit}> - {buttonText} - </Button> - </ActionRow> -); +import { messages, getContentMessages } from './messages'; interface PickLibraryContentModalProps { isOpen: boolean; @@ -57,10 +42,14 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ( const { showToast } = useContext(ToastContext); - const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]); + const contentMessages = useMemo(() => ( + getContentMessages(insideSection, insideSubsection, insideUnit) + ), [insideSection, insideSubsection, insideUnit]); + + const [selectedContent, setSelectedComponents] = useState<SelectedComponent[]>([]); const onSubmit = useCallback(() => { - const usageKeys = selectedComponents.map(({ usageKey }) => usageKey); + const usageKeys = selectedContent.map(({ usageKey }) => usageKey); onClose(); if (insideCollection && collectionId) { updateCollectionItemsMutation.mutateAsync(usageKeys) @@ -80,7 +69,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ( }); } }, [ - selectedComponents, + selectedContent, insideSection, insideSubsection, insideUnit, @@ -91,16 +80,13 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ( // determine filter an visibleTabs based on current location let extraFilter = ['NOT type = "collection"']; let visibleTabs = allLibraryPageTabs.filter((tab) => tab !== ContentType.collections); - let addBtnText = messages.addToCollectionButton; if (insideSection) { // show only subsections extraFilter = ['block_type = "subsection"']; - addBtnText = messages.addToSectionButton; visibleTabs = [ContentType.subsections]; } else if (insideSubsection) { // show only units extraFilter = ['block_type = "unit"']; - addBtnText = messages.addToSubsectionButton; visibleTabs = [ContentType.units]; } else if (insideUnit) { // show only components @@ -109,7 +95,6 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ( 'NOT block_type = "subsection"', 'NOT block_type = "section"', ]; - addBtnText = messages.addToUnitButton; visibleTabs = [ContentType.components]; } @@ -120,17 +105,22 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ( return ( <StandardModal - title="Select components" + title={intl.formatMessage(contentMessages.selectContentTitle)} isOverflowVisible={false} size="xl" isOpen={isOpen} onClose={onClose} footerNode={( - <PickLibraryContentModalFooter - onSubmit={onSubmit} - selectedComponents={selectedComponents} - buttonText={intl.formatMessage(addBtnText)} - /> + <ActionRow> + <FormattedMessage + {...contentMessages.selectedContent} + values={{ count: selectedContent.length }} + /> + <ActionRow.Spacer /> + <Button variant="primary" onClick={onSubmit}> + {intl.formatMessage(contentMessages.addToButton)} + </Button> + </ActionRow> )} > <ComponentPicker diff --git a/src/library-authoring/add-content/messages.ts b/src/library-authoring/add-content/messages.ts index 99198a6082..d57c48d9e1 100644 --- a/src/library-authoring/add-content/messages.ts +++ b/src/library-authoring/add-content/messages.ts @@ -1,6 +1,6 @@ import { defineMessages } from '@edx/frontend-platform/i18n'; -const messages = defineMessages({ +export const messages = defineMessages({ collectionButton: { id: 'course-authoring.library-authoring.add-content.buttons.collection', defaultMessage: 'Collection', @@ -24,29 +24,19 @@ const messages = defineMessages({ libraryContentButton: { id: 'course-authoring.library-authoring.add-content.buttons.library-content', defaultMessage: 'Existing Library Content', - description: 'Content of button to add existing library content to a collection.', + description: 'Content of button to add existing library content to a collection or container.', }, - addToCollectionButton: { + addToButton: { id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-collection', defaultMessage: 'Add to Collection', description: 'Button to add library content to a collection.', }, - addToUnitButton: { - id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-unit', - defaultMessage: 'Add to Unit', - description: 'Button to add library content to a unit.', - }, - addToSectionButton: { - id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-section', - defaultMessage: 'Add to Section', - description: 'Button to add library content to a section.', - }, - addToSubsectionButton: { - id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-subsection', - defaultMessage: 'Add to Subsection', - description: 'Button to add library content to a subsection.', + selectContentTitle: { + id: 'course-authoring.library-authoring.add-content.select-components', + defaultMessage: 'Select components', + description: 'Title for the content picker when selecting components in library.', }, - selectedComponents: { + selectedContent: { id: 'course-authoring.library-authoring.add-content.selected-components', defaultMessage: '{count, plural, one {# Selected Component} other {# Selected Components}}', description: 'Title for selected components in library.', @@ -154,4 +144,97 @@ const messages = defineMessages({ }, }); +export const unitMessages = defineMessages({ + addToButton: { + id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-unit', + defaultMessage: 'Add to Unit', + description: 'Button to add library content to a unit.', + }, +}); + +export const subsectionMessages = defineMessages({ + unitButton: { + id: 'course-authoring.library-authoring.add-content.buttons.new-unit', + defaultMessage: 'New Unit', + description: 'Content of button to create a new Unit in a Subsection.', + }, + libraryContentButton: { + id: 'course-authoring.library-authoring.add-content.buttons.library-unit', + defaultMessage: 'Existing Unit', + description: 'Content of button to add an existing Unit to a Subsection.', + }, + addToButton: { + id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-subsection', + defaultMessage: 'Add to Subsection', + description: 'Button to add Units to a Subsection.', + }, + selectContentTitle: { + id: 'course-authoring.library-authoring.add-content.select-units', + defaultMessage: 'Select units', + description: 'Title for the content picker when selecting units in library.', + }, + selectedContent: { + id: 'course-authoring.library-authoring.add-content.selected-units', + defaultMessage: '{count, plural, one {# Selected Unit} other {# Selected Units}}', + description: 'Title for selected units in library.', + }, +}); + +export const sectionMessages = defineMessages({ + subsectionButton: { + id: 'course-authoring.library-authoring.add-content.buttons.new-subsection', + defaultMessage: 'New Subsection', + description: 'Content of button to create a new Subsection in a Section.', + }, + libraryContentButton: { + id: 'course-authoring.library-authoring.add-content.buttons.library-subsection', + defaultMessage: 'Existing Subsection', + description: 'Content of button to add an existing Subsection to a Section.', + }, + addToButton: { + id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-section', + defaultMessage: 'Add to Section', + description: 'Button to add library content to a section.', + }, + selectContentTitle: { + id: 'course-authoring.library-authoring.add-content.select-subsections', + defaultMessage: 'Select subsections', + description: 'Title for the content picker when selecting subsections in library.', + }, + selectedContent: { + id: 'course-authoring.library-authoring.add-content.selected-subsections', + defaultMessage: '{count, plural, one {# Selected Subsections} other {# Selected Subsections}}', + description: 'Title for selected subsections in library.', + }, +}); + +/* + * Returns the appropriate message set for the given route conditions. + */ +export const getContentMessages = ( + insideSection: boolean, + insideSubsection: boolean, + insideUnit: boolean, +) => { + if (insideSection) { + return { + ...messages, + ...sectionMessages, + }; + } + if (insideSubsection) { + return { + ...messages, + ...subsectionMessages, + }; + } + if (insideUnit) { + return { + ...messages, + ...unitMessages, + }; + } + return messages; +}; + export default messages; diff --git a/src/library-authoring/create-container/CreateContainerModal.test.tsx b/src/library-authoring/create-container/CreateContainerModal.test.tsx index bff91e8dc0..7c7fb96fa9 100644 --- a/src/library-authoring/create-container/CreateContainerModal.test.tsx +++ b/src/library-authoring/create-container/CreateContainerModal.test.tsx @@ -105,7 +105,7 @@ describe('CreateContainerModal container linking', () => { { containerId: newSectionId, routeType: 'section' }, ); - const subsectionButton = await screen.findByRole('button', { name: /^Subsection$/ }); + const subsectionButton = await screen.findByRole('button', { name: /New subsection/i }); userEvent.click(subsectionButton); const nameInput = await screen.findByLabelText(/name your subsection/i); userEvent.type(nameInput, 'Test Subsection'); diff --git a/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx b/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx index b8ec61186d..cce36ab5ea 100644 --- a/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx +++ b/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx @@ -379,12 +379,12 @@ describe('<LibrarySectionPage / LibrarySubsectionPage />', () => { expect(await screen.findByRole('button', { name: new RegExp(`${childType} Info`, 'i') })).toBeInTheDocument(); }); - it(`${cType} sidebar should render "new ${childType}" and "existing library content" buttons`, async () => { + it(`${cType} sidebar should render "new ${childType}" and "existing ${childType}" buttons`, async () => { renderLibrarySectionPage(undefined, undefined, cType); const addChild = await screen.findByRole('button', { name: new RegExp(`add ${childType}`, 'i') }); userEvent.click(addChild); - const addNew = await screen.findByRole('button', { name: new RegExp(`^${childType}$`, 'i') }); - const addExisting = await screen.findByRole('button', { name: /existing library content/i }); + const addNew = await screen.findByRole('button', { name: new RegExp(`^new ${childType}$`, 'i') }); + const addExisting = await screen.findByRole('button', { name: new RegExp(`^existing ${childType}$`, 'i') }); // Clicking "add new" shows create container modal (tested below) userEvent.click(addNew); From 50aedd21b6974bf721a1bc0df7f0261db7db39ef Mon Sep 17 00:00:00 2001 From: Jillian Vogel <jill@opencraft.com> Date: Mon, 23 Jun 2025 04:06:56 +0930 Subject: [PATCH 11/11] feat: hide Types filter in the section/subsection content picker --- src/library-authoring/LibraryAuthoringPage.tsx | 10 ++++++++-- .../LibrarySectionSubsectionPage.test.tsx | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 1d11dbae98..bfc13677c2 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -158,7 +158,9 @@ const LibraryAuthoringPage = ({ insideCollections, insideComponents, insideUnits, + insideSection, insideSections, + insideSubsection, insideSubsections, navigateTo, } = useLibraryRoutes(); @@ -251,8 +253,12 @@ const LibraryAuthoringPage = ({ extraFilter.push(activeTypeFilters[activeKey]); } - // Disable filtering by block/problem type when viewing the Collections/Units/Sections/Subsections tab. - const onlyOneType = (insideCollections || insideUnits || insideSections || insideSubsections); + // Disable filtering by block/problem type when viewing the Collections/Units/Sections/Subsections tab, + // or when inside a specific Section or Subsection. + const onlyOneType = ( + insideCollections || insideUnits || insideSections || insideSubsections + || insideSection || insideSubsection + ); const overrideTypesFilter = onlyOneType ? new TypesFilterData() : undefined; diff --git a/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx b/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx index cce36ab5ea..cf4dc82a1e 100644 --- a/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx +++ b/src/library-authoring/section-subsections/LibrarySectionSubsectionPage.test.tsx @@ -395,6 +395,8 @@ describe('<LibrarySectionPage / LibrarySubsectionPage />', () => { userEvent.click(addExisting); expect(await screen.findByRole('dialog')).toBeInTheDocument(); expect(await screen.findByRole('button', { name: new RegExp(`add to ${cType}`, 'i') })).toBeInTheDocument(); + // No "Types" filter shown + expect(screen.queryByRole('button', { name: /type/i })).not.toBeInTheDocument(); }); it(`"add new" button should add ${childType} to the ${cType}`, async () => {