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 () => {