Add subsection/unit to section/subsection full-page view within library [FC-0090] (#2152)

* Removes the "content type" tab bar from the "Add Existing Component/Unit/Subsection" modal, and in all cases where only one type of content is shown for selection.
* Updates the section/subsection sidebar to show "Existing Library Content" + "New Subsection" / "New Unit" buttons.
* Updates the "Add New Unit/Subsection" buttons to directly launch the new container modal, instead of going via the container sidebar.
* Ensures that whenever a subsection/unit is created from within a section/subsection, that it is linked to the parent section/subsection after created.
This commit is contained in:
Jillian
2025-06-24 03:05:35 -04:00
committed by GitHub
parent ebf4b7c162
commit 747f7b6133
17 changed files with 486 additions and 210 deletions

View File

@@ -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;
@@ -298,14 +304,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 />

View File

@@ -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,12 +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: '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();
@@ -409,12 +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: '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();

View File

@@ -31,13 +31,13 @@ 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';
type ContentType = {
name: string,
disabled: boolean,
disabled?: boolean,
icon?: React.ComponentType,
blockType: string,
};
@@ -64,7 +64,7 @@ type AddAdvancedContentViewProps = {
const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonProps) => {
const {
name,
disabled,
disabled = false,
icon,
blockType,
} = contentType;
@@ -96,97 +96,132 @@ const AddContentView = ({
insideSubsection,
} = useLibraryRoutes();
const collectionButtonData = {
name: intl.formatMessage(messages.collectionButton),
disabled: false,
blockType: 'collection',
};
const contentMessages = useMemo(() => (
getContentMessages(insideSection, insideSubsection, insideUnit)
), [insideSection, insideSubsection, insideUnit]);
const unitButtonData = {
name: intl.formatMessage(messages.unitButton),
disabled: false,
blockType: 'vertical',
};
const collectionButton = (
<AddContentButton
key="collection"
contentType={{
name: intl.formatMessage(contentMessages.collectionButton),
blockType: 'collection',
}}
onCreateContent={onCreateContent}
/>
);
const sectionButtonData = {
name: intl.formatMessage(messages.sectionButton),
disabled: false,
blockType: 'chapter',
};
const unitButton = (
<AddContentButton
key="unit"
contentType={{
name: intl.formatMessage(contentMessages.unitButton),
blockType: 'unit',
}}
onCreateContent={onCreateContent}
/>
);
const subsectionButtonData = {
name: intl.formatMessage(messages.subsectionButton),
disabled: false,
blockType: 'sequential',
};
const sectionButton = (
<AddContentButton
key="section"
contentType={{
name: intl.formatMessage(contentMessages.sectionButton),
blockType: 'section',
}}
onCreateContent={onCreateContent}
/>
);
const libraryContentButtonData = {
name: intl.formatMessage(messages.libraryContentButton),
disabled: false,
blockType: 'libraryContent',
};
const subsectionButton = (
<AddContentButton
key="subsection"
contentType={{
name: intl.formatMessage(contentMessages.subsectionButton),
blockType: 'subsection',
}}
onCreateContent={onCreateContent}
/>
);
/** List container content types that should be displayed based on current path */
const visibleContentTypes = useMemo(() => {
const existingContentButton = (
<AddContentButton
key="libraryContent"
contentType={{
name: intl.formatMessage(contentMessages.libraryContentButton),
blockType: 'libraryContent',
}}
onCreateContent={onCreateContent}
/>
);
/* 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,
];
}
if (insideSection) {
// Should only allow adding subsections
return [subsectionButtonData];
// Only allow adding subsections
return [
existingContentButton,
subsectionButton,
];
}
if (insideSubsection) {
// Should only allow adding units
return [unitButtonData];
// Only allow adding units
return [
existingContentButton,
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}
/>
)}
{(!insideSection && !insideSubsection) && (
<>
<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 +458,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 {

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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

View File

@@ -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.',
selectContentTitle: {
id: 'course-authoring.library-authoring.add-content.select-components',
defaultMessage: 'Select components',
description: 'Title for the content picker when selecting components in library.',
},
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.',
},
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;

View File

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

View File

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

View File

@@ -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
>

View File

@@ -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');

View File

@@ -39,7 +39,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),
@@ -49,7 +49,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),
@@ -72,40 +72,41 @@ 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 canStandAlone = !(insideCollection || insideSection || insideSubsection);
const container = await create.mutateAsync({
canStandAlone,
containerType,
containerType: createContainerModalType,
...values,
});
// link container to parent
// 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 updateCollectionItemsMutation.mutateAsync([container.id]);
} else if (containerId && (insideSection || insideSubsection)) {
await updateContainerItemsMutation.mutateAsync([container.id]);
}
// Navigate to the new container
navigateTo({ containerId: container.id });
showToast(labels.successMsg);
} catch (error) {
showToast(labels.errorMsg);
} finally {
handleClose();
}
}, [containerType, labels, handleClose, navigateTo]);
}, [
createContainerModalType,
handleClose,
labels,
navigateTo,
updateCollectionItemsMutation,
updateContainerItemsMutation,
]);
return (
<ModalDialog

View File

@@ -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,

View File

@@ -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)}
/>

View File

@@ -13,6 +13,7 @@ import {
import {
getLibraryContainerApiUrl,
getLibraryContainerChildrenApiUrl,
getLibraryContainersApiUrl,
} from '../data/api';
import {
mockContentLibrary,
@@ -390,6 +391,110 @@ describe('<LibrarySectionPage / LibrarySubsectionPage />', () => {
expect(await screen.findByRole('button', { name: new RegExp(`${childType} Info`, 'i') })).toBeInTheDocument();
});
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(`^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);
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();
// No "Types" filter shown
expect(screen.queryByRole('button', { name: /type/i })).not.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({
can_stand_alone: false,
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({
can_stand_alone: false,
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();
});
it(`should open manage tags on click tag count in ${cType} page`, async () => {
const cId = cType === ContainerType.Section
? mockGetContainerMetadata.sectionId

View File

@@ -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)}
/>

View File

@@ -442,4 +442,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();
});
});