feat: create section and subsection in library (#2013)

Adds create section and subsection buttons in sidebar.
This commit is contained in:
Navin Karkera
2025-05-28 21:10:17 +00:00
committed by GitHub
parent 01243afdd9
commit afd6afdbb9
17 changed files with 578 additions and 269 deletions

View File

@@ -6,7 +6,6 @@ import {
ContentPaste as ContentPasteIcon,
Edit as EditIcon,
EditNote as EditNoteIcon,
CalendarViewDay,
HelpOutline as HelpOutlineIcon,
LibraryAdd as LibraryIcon,
Lock as LockIcon,
@@ -15,6 +14,9 @@ import {
TextFields as TextFieldsIcon,
VideoCamera as VideoCameraIcon,
Folder,
ViewCarousel,
ViewDay,
WidthWide,
} from '@openedx/paragon/icons';
import NewsstandIcon from '../NewsstandIcon';
@@ -36,7 +38,9 @@ export const COMPONENT_TYPES = {
export const UNIT_TYPE_ICONS_MAP: Record<string, React.ComponentType> = {
video: VideoCameraIcon,
other: BookOpenIcon,
vertical: CalendarViewDay,
vertical: ViewDay,
sequential: WidthWide,
chapter: ViewCarousel,
problem: EditIcon,
lock: LockIcon,
};
@@ -57,8 +61,10 @@ export const COMPONENT_TYPE_ICON_MAP: Record<string, React.ComponentType> = {
export const STRUCTURAL_TYPE_ICONS: Record<string, React.ComponentType> = {
vertical: UNIT_TYPE_ICONS_MAP.vertical,
unit: UNIT_TYPE_ICONS_MAP.vertical,
sequential: Folder,
chapter: Folder,
sequential: UNIT_TYPE_ICONS_MAP.sequential,
subsection: UNIT_TYPE_ICONS_MAP.sequential,
chapter: UNIT_TYPE_ICONS_MAP.chapter,
section: UNIT_TYPE_ICONS_MAP.chapter,
collection: Folder,
libraryContent: Folder,
paste: ContentPasteIcon,
@@ -75,8 +81,10 @@ export const COMPONENT_TYPE_STYLE_COLOR_MAP = {
[COMPONENT_TYPES.dragAndDrop]: 'component-style-default',
vertical: 'component-style-vertical',
unit: 'component-style-vertical',
sequential: 'component-style-default',
chapter: 'component-style-default',
sequential: 'component-style-sequential',
subsection: 'component-style-sequential',
chapter: 'component-style-chapter',
section: 'component-style-chapter',
collection: 'component-style-collection',
other: 'component-style-other',
};

View File

@@ -123,6 +123,56 @@
}
}
.component-style-sequential {
background-color: #EA3E3E;
.pgn__icon:not(.btn-icon-before) {
color: white;
}
.btn-icon {
&:hover, &:active, &:focus {
background-color: darken(#EA3E3E, 15%);
}
}
.btn {
background-color: lighten(#0B8E77, 10%);
border: 0;
&:hover, &:active, &:focus {
background-color: lighten(#0B8E77, 20%);
border: 1px solid $primary;
margin: -1px;
}
}
}
.component-style-chapter {
background-color: #45009E;
.pgn__icon:not(.btn-icon-before) {
color: white;
}
.btn-icon {
&:hover, &:active, &:focus {
background-color: darken(#45009E, 15%);
}
}
.btn {
background-color: lighten(#0B8E77, 10%);
border: 0;
&:hover, &:active, &:focus {
background-color: lighten(#0B8E77, 20%);
border: 1px solid $primary;
margin: -1px;
}
}
}
.component-style-other {
background-color: #646464;

View File

@@ -51,15 +51,19 @@ export const buildCollectionUsageKey = (learningContextKey: string, collectionId
};
export enum ContainerType {
Section = 'section',
Subsection = 'subsection',
Unit = 'unit',
/**
* Vertical is the old name for Unit. Generally, **please avoid using this term entirely in any libraries code** or
* anything based on the new Learning Core "Containers" framework - just call it a unit. We do still need to use this
* in the modulestore-based courseware, and currently the /xblock/ API used to copy library containers into courses
* also requires specifying this, though that should change to a better API that does the unit->vertical conversion
* automatically in the future.
* TODO: we should probably move this to a separate enum/mapping, and keep this for the new container types only.
* Chapter, Sequential and Vertical are the old names for section, subsection and unit.
* Generally, **please avoid using this term entirely in any libraries code** or
* anything based on the new Learning Core "Containers" framework - just call it a unit, section or subsection. We
* do still need to use this in the modulestore-based courseware, and currently the /xblock/ API used to copy
* library containers into courses also requires specifying this, though that should change to a better API
* that does the unit->vertical conversion automatically in the future.
*/
Chapter = 'chapter',
Sequential = 'sequential',
Vertical = 'vertical',
}

View File

@@ -735,9 +735,22 @@ describe('<LibraryAuthoringPage />', () => {
expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library collection');
});
it('should create a unit', async () => {
test.each([
{
label: 'should create a unit',
containerType: 'unit',
},
{
label: 'should create a section',
containerType: 'section',
},
{
label: 'should create a subsection',
containerType: 'subsection',
},
])('$label', async ({ containerType }) => {
await renderLibraryPage();
const title = 'This is a Test';
const title = `This is a Test ${containerType}`;
const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId);
axiosMock.onPost(url).reply(200, {
id: '1',
@@ -753,22 +766,22 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New unit Modal
// Open New container Modal
const sidebar = screen.getByTestId('library-sidebar');
const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0];
fireEvent.click(newUnitButton);
const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i });
expect(unitModalHeading).toBeInTheDocument();
const newContainerButton = within(sidebar).getAllByRole('button', { name: new RegExp(containerType, 'i') })[0];
fireEvent.click(newContainerButton);
const containerModalHeading = await screen.findByRole('heading', { name: new RegExp(`new ${containerType}`, 'i') });
expect(containerModalHeading).toBeInTheDocument();
// Click on Cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(unitModalHeading).not.toBeInTheDocument();
expect(containerModalHeading).not.toBeInTheDocument();
// Open new unit modal again and create a unit
fireEvent.click(newUnitButton);
// Open new container modal again and create a container
fireEvent.click(newContainerButton);
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
const nameField = screen.getByRole('textbox', { name: new RegExp(`name your ${containerType}`, 'i') });
fireEvent.change(nameField, { target: { value: title } });
fireEvent.click(createButton);
@@ -778,14 +791,27 @@ describe('<LibraryAuthoringPage />', () => {
expect(axiosMock.history.post[0].url).toBe(url);
expect(axiosMock.history.post[0].data).toContain(`"display_name":"${title}"`);
expect(axiosMock.history.post[0].data).toContain('"container_type":"unit"');
expect(mockShowToast).toHaveBeenCalledWith('Unit created successfully');
expect(axiosMock.history.post[0].data).toContain(`"container_type":"${containerType}"`);
expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${containerType} created successfully`, 'i')));
});
it('should show validations in create unit', async () => {
test.each([
{
label: 'should show validations in create unit',
containerType: 'unit',
},
{
label: 'should show validations in create section',
containerType: 'section',
},
{
label: 'should show validations in create subsection',
containerType: 'subsection',
},
])('$label', async ({ containerType }) => {
await renderLibraryPage();
const title = 'This is a Test';
const title = `This is a Test ${containerType}`;
const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId);
axiosMock.onPost(url).reply(200, {
id: '1',
@@ -801,14 +827,14 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New unit Modal
// Open New container Modal
const sidebar = screen.getByTestId('library-sidebar');
const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0];
fireEvent.click(newUnitButton);
const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i });
expect(unitModalHeading).toBeInTheDocument();
const newContainerButton = within(sidebar).getAllByRole('button', { name: new RegExp(containerType, 'i') })[0];
fireEvent.click(newContainerButton);
const containerModalHeading = await screen.findByRole('heading', { name: new RegExp(`new ${containerType}`, 'i') });
expect(containerModalHeading).toBeInTheDocument();
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
const nameField = screen.getByRole('textbox', { name: new RegExp(`name your ${containerType}`, 'i') });
fireEvent.focus(nameField);
fireEvent.blur(nameField);
@@ -816,12 +842,25 @@ describe('<LibraryAuthoringPage />', () => {
const createButton = screen.getByRole('button', { name: /create/i });
fireEvent.click(createButton);
expect(await screen.findByText(/unit name is required/i)).toBeInTheDocument();
expect(await screen.findByText(new RegExp(`${containerType} name is required`, 'i'))).toBeInTheDocument();
});
it('should show error on create unit', async () => {
test.each([
{
label: 'should show error on create unit',
containerType: 'unit',
},
{
label: 'should show error on create section',
containerType: 'section',
},
{
label: 'should show error on create subsection',
containerType: 'subsection',
},
])('$label', async ({ containerType }) => {
await renderLibraryPage();
const displayName = 'This is a Test';
const displayName = `This is a Test ${containerType}`;
const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId);
axiosMock.onPost(url).reply(500);
@@ -833,23 +872,25 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New Unit Modal
// Open New container Modal
const sidebar = screen.getByTestId('library-sidebar');
const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0];
fireEvent.click(newUnitButton);
const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i });
expect(unitModalHeading).toBeInTheDocument();
const newContainerButton = within(sidebar).getAllByRole('button', { name: new RegExp(containerType, 'i') })[0];
fireEvent.click(newContainerButton);
const containerModalHeading = await screen.findByRole('heading', { name: new RegExp(`new ${containerType}`, 'i') });
expect(containerModalHeading).toBeInTheDocument();
// Create a unit
// Create a container
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
const nameField = screen.getByRole('textbox', { name: new RegExp(`name your ${containerType}`, 'i') });
fireEvent.change(nameField, { target: { value: displayName } });
fireEvent.click(createButton);
// Check error toast
await waitFor(() => expect(axiosMock.history.post.length).toBe(1));
expect(mockShowToast).toHaveBeenCalledWith('There is an error when creating the library unit');
expect(mockShowToast).toHaveBeenCalledWith(
expect.stringMatching(new RegExp(`There is an error when creating the library ${containerType}`, 'i')),
);
});
it('shows a single block when usageKey query param is set', async () => {

View File

@@ -12,7 +12,7 @@ import LibraryAuthoringPage from './LibraryAuthoringPage';
import { LibraryProvider } from './common/context/LibraryContext';
import { SidebarProvider } from './common/context/SidebarContext';
import { CreateCollectionModal } from './create-collection';
import { CreateUnitModal } from './create-unit';
import { CreateContainerModal } from './create-container';
import LibraryCollectionPage from './collections/LibraryCollectionPage';
import { ComponentPicker } from './component-picker';
import { ComponentEditorModal } from './components/ComponentEditorModal';
@@ -50,7 +50,7 @@ const LibraryLayout = () => {
<>
{childPage}
<CreateCollectionModal />
<CreateUnitModal />
<CreateContainerModal />
<ComponentEditorModal />
</>
</SidebarProvider>

View File

@@ -36,7 +36,7 @@ const { libraryId } = mockContentLibrary;
const render = (collectionId?: string) => {
const params: { libraryId: string, collectionId?: string } = { libraryId, collectionId };
return baseRender(<AddContent />, {
path: '/library/:libraryId/:collectionId?',
path: '/library/:libraryId/collection/:collectionId?',
params,
extraWrapper: ({ children }) => (
<LibraryProvider
@@ -51,7 +51,7 @@ const render = (collectionId?: string) => {
const renderWithUnit = (unitId: string) => {
const params: { libraryId: string, unitId?: string } = { libraryId, unitId };
return baseRender(<AddContent />, {
path: '/library/:libraryId/:unitId?',
path: '/library/:libraryId/unit/:unitId?',
params,
extraWrapper: ({ children }) => (
<LibraryProvider
@@ -326,7 +326,7 @@ describe('<AddContent />', () => {
});
});
it('should not show collection/unit buttons when create component in container', async () => {
it('should not show collection, unit, section and subsection buttons when create component in unit', async () => {
const unitId = 'lct:orf1:lib1:unit:test-1';
renderWithUnit(unitId);
@@ -334,6 +334,8 @@ describe('<AddContent />', () => {
expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Section' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Subsection' })).not.toBeInTheDocument();
});
it('should create a component in unit', async () => {

View File

@@ -1,5 +1,5 @@
import React, { useContext, useMemo } from 'react';
import type { MessageDescriptor } from 'react-intl';
import type { IntlShape, MessageDescriptor } from 'react-intl';
import { useSelector } from 'react-redux';
import {
Stack,
@@ -29,11 +29,11 @@ import { useLibraryContext } from '../common/context/LibraryContext';
import { PickLibraryContentModal } from './PickLibraryContentModal';
import { blockTypes } from '../../editors/data/constants/app';
import { ContentType as LibraryContentTypes } from '../routes';
import { ContentType as LibraryContentTypes, useLibraryRoutes } from '../routes';
import genericMessages from '../generic/messages';
import messages from './messages';
import type { BlockTypeMetadata } from '../data/api';
import { getContainerTypeFromId, ContainerType } from '../../generic/key-utils';
import { ContainerType } from '../../generic/key-utils';
type ContentType = {
name: string,
@@ -58,7 +58,7 @@ type AddAdvancedContentViewProps = {
closeAdvancedList: () => void,
onCreateContent: (blockType: string) => void,
advancedBlocks: Record<string, BlockTypeMetadata>,
isBlockTypeEnabled: (blockType) => boolean,
isBlockTypeEnabled: (blockType: string) => boolean,
};
const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonProps) => {
@@ -89,14 +89,15 @@ const AddContentView = ({
}: AddContentViewProps) => {
const intl = useIntl();
const {
collectionId,
componentPicker,
unitId,
} = useLibraryContext();
let upstreamContainerType: ContainerType | undefined;
if (unitId) {
upstreamContainerType = getContainerTypeFromId(unitId);
}
const {
insideCollection,
insideUnit,
insideSection,
insideSubsection,
} = useLibraryRoutes();
const collectionButtonData = {
name: intl.formatMessage(messages.collectionButton),
@@ -110,6 +111,18 @@ const AddContentView = ({
blockType: 'vertical',
};
const sectionButtonData = {
name: intl.formatMessage(messages.sectionButton),
disabled: false,
blockType: 'chapter',
};
const subsectionButtonData = {
name: intl.formatMessage(messages.subsectionButton),
disabled: false,
blockType: 'sequential',
};
const libraryContentButtonData = {
name: intl.formatMessage(messages.libraryContentButton),
disabled: false,
@@ -119,27 +132,59 @@ const AddContentView = ({
const extraFilter = unitId ? ['NOT block_type = "unit"', 'NOT type = "collections"'] : undefined;
const visibleTabs = unitId ? [LibraryContentTypes.components] : undefined;
/** List container content types that should be displayed based on current path */
const visibleContentTypes = useMemo(() => {
if (insideCollection) {
// except for add collection button, show everthing.
return [
libraryContentButtonData,
sectionButtonData,
subsectionButtonData,
unitButtonData,
];
}
if (insideUnit) {
// Only show libraryContentButton
return [libraryContentButtonData];
}
// istanbul ignore if
if (insideSection) {
// Should only allow adding subsections
throw new Error('Not implemented');
// return [subsectionButtonData];
}
// istanbul ignore if
if (insideSubsection) {
// Should only allow adding units
throw new Error('Not implemented');
// return [unitButtonData];
}
// except for libraryContentButton, show everthing.
return [
collectionButtonData,
sectionButtonData,
subsectionButtonData,
unitButtonData,
];
}, [insideCollection, insideUnit, insideSection, insideSubsection]);
return (
<>
{(collectionId || unitId) && componentPicker && (
{visibleContentTypes.map((contentType) => (
<AddContentButton
key={contentType.blockType}
contentType={contentType}
onCreateContent={onCreateContent}
/>
))}
{componentPicker && visibleContentTypes.includes(libraryContentButtonData) && (
/// Show the "Add Library Content" button for units and collections
<>
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
extraFilter={extraFilter}
visibleTabs={visibleTabs}
/>
</>
)}
{!collectionId && !unitId && (
// Doesn't show the "Collection" button if we are in a unit or collection
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
)}
{upstreamContainerType !== ContainerType.Unit && (
// Doesn't show the "Unit" button if we are in a unit
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
extraFilter={extraFilter}
visibleTabs={visibleTabs}
/>
)}
<hr className="w-100 bg-gray-500" />
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
@@ -191,7 +236,7 @@ const AddAdvancedContentView = ({
};
export const parseErrorMsg = (
intl,
intl: IntlShape,
error: any,
detailedMessage: MessageDescriptor,
defaultMessage: MessageDescriptor,
@@ -222,10 +267,14 @@ const AddContent = () => {
libraryId,
collectionId,
openCreateCollectionModal,
openCreateUnitModal,
setCreateContainerModalType,
openComponentEditor,
unitId,
} = useLibraryContext();
const {
insideCollection,
insideUnit,
} = useLibraryRoutes();
const addComponentsToCollectionMutation = useAddItemsToCollection(libraryId, collectionId);
const addComponentsToContainerMutation = useAddComponentsToContainer(unitId);
const createBlockMutation = useCreateLibraryBlock();
@@ -306,12 +355,12 @@ const AddContent = () => {
}
const linkComponent = (opaqueKey: string) => {
if (collectionId) {
if (collectionId && insideCollection) {
addComponentsToCollectionMutation.mutateAsync([opaqueKey]).catch(() => {
showToast(intl.formatMessage(genericMessages.manageCollectionsFailed));
});
}
if (unitId) {
if (unitId && insideUnit) {
addComponentsToContainerMutation.mutateAsync([opaqueKey]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
});
@@ -379,8 +428,12 @@ const AddContent = () => {
showAddLibraryContentModal();
} else if (blockType === 'advancedXBlock') {
showAdvancedList();
} else if (blockType === 'vertical') {
openCreateUnitModal();
} else if ([
ContainerType.Vertical,
ContainerType.Chapter,
ContainerType.Sequential,
].includes(blockType as ContainerType)) {
setCreateContainerModalType(blockType as ContainerType);
} else {
onCreateBlock(blockType);
}

View File

@@ -11,6 +11,16 @@ const messages = defineMessages({
defaultMessage: 'Unit',
description: 'Content of button to create a Unit.',
},
sectionButton: {
id: 'course-authoring.library-authoring.add-content.buttons.section',
defaultMessage: 'Section',
description: 'Content of button to create a Section.',
},
subsectionButton: {
id: 'course-authoring.library-authoring.add-content.buttons.subsection',
defaultMessage: 'Subsection',
description: 'Content of button to create a Subsection.',
},
libraryContentButton: {
id: 'course-authoring.library-authoring.add-content.buttons.library-content',
defaultMessage: 'Existing Library Content',

View File

@@ -429,14 +429,27 @@ describe('<LibraryCollectionPage />', () => {
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
});
it('should create a unit inside a collection', async () => {
test.each([
{
label: 'should create a unit inside a collection',
containerType: 'unit',
},
{
label: 'should create a section inside a collection',
containerType: 'section',
},
{
label: 'should create a subsection inside a collection',
containerType: 'subsection',
},
])('$label', async ({ containerType }) => {
await renderLibraryCollectionPage();
const unitTitle = 'This is a Test';
const containerTitle = `This is a Test ${containerType}`;
const containerUrl = getLibraryContainersApiUrl(mockContentLibrary.libraryId);
axiosMock.onPost(containerUrl).reply(200, {
id: 'unit-1',
id: 'container-id',
slug: 'this-is-a-test',
title: unitTitle,
title: containerTitle,
});
const collectionUrl = getLibraryCollectionItemsApiUrl(
mockContentLibrary.libraryId,
@@ -454,16 +467,16 @@ describe('<LibraryCollectionPage />', () => {
// Open New unit Modal
const sidebar = screen.getByTestId('library-sidebar');
const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0];
fireEvent.click(newUnitButton);
const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i });
expect(unitModalHeading).toBeInTheDocument();
const newContainerButton = within(sidebar).getAllByRole('button', { name: new RegExp(containerType, 'i') })[0];
fireEvent.click(newContainerButton);
const containerModalHeading = await screen.findByRole('heading', { name: new RegExp(`new ${containerType}`, 'i') });
expect(containerModalHeading).toBeInTheDocument();
// Fill the form
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
const nameField = screen.getByRole('textbox', { name: new RegExp(`name your ${containerType}`, 'i') });
fireEvent.change(nameField, { target: { value: unitTitle } });
fireEvent.change(nameField, { target: { value: containerTitle } });
fireEvent.click(createButton);
// Check success
@@ -471,13 +484,13 @@ describe('<LibraryCollectionPage />', () => {
// Check that the unit was created
expect(axiosMock.history.post[0].url).toBe(containerUrl);
expect(axiosMock.history.post[0].data).toContain(`"display_name":"${unitTitle}"`);
expect(axiosMock.history.post[0].data).toContain('"container_type":"unit"');
expect(mockShowToast).toHaveBeenCalledWith('Unit created successfully');
expect(axiosMock.history.post[0].data).toContain(`"display_name":"${containerTitle}"`);
expect(axiosMock.history.post[0].data).toContain(`"container_type":"${containerType}"`);
expect(mockShowToast).toHaveBeenCalledWith(expect.stringMatching(new RegExp(`${containerType} created successfully`, 'i')));
// Check that the unit was added to the collection
expect(axiosMock.history.patch.length).toBe(1);
expect(axiosMock.history.patch[0].url).toBe(collectionUrl);
expect(axiosMock.history.patch[0].data).toContain('"usage_keys":["unit-1"]');
expect(axiosMock.history.patch[0].data).toContain('"usage_keys":["container-id"]');
});
});

View File

@@ -7,6 +7,7 @@ import {
useState,
} from 'react';
import { useParams } from 'react-router-dom';
import { ContainerType } from '../../../generic/key-utils';
import type { ComponentPicker } from '../../component-picker';
import type { ContentLibrary, BlockTypeMetadata } from '../../data/api';
@@ -40,10 +41,9 @@ export type LibraryContextData = {
isCreateCollectionModalOpen: boolean;
openCreateCollectionModal: () => void;
closeCreateCollectionModal: () => void;
// "Create New Unit" modal
isCreateUnitModalOpen: boolean;
openCreateUnitModal: () => void;
closeCreateUnitModal: () => void;
// "Create new container" modal
createContainerModalType: ContainerType | undefined;
setCreateContainerModalType: (containerType?: ContainerType) => void;
// Editor modal - for editing some component
/** If the editor is open and the user is editing some component, this is the component being edited. */
componentBeingEdited: ComponentEditorInfo | undefined;
@@ -91,7 +91,7 @@ export const LibraryProvider = ({
componentPicker,
}: LibraryProviderProps) => {
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
const [isCreateUnitModalOpen, openCreateUnitModal, closeCreateUnitModal] = useToggle(false);
const [createContainerModalType, setCreateContainerModalType] = useState<ContainerType | undefined>(undefined);
const [componentBeingEdited, setComponentBeingEdited] = useState<ComponentEditorInfo | undefined>();
const closeComponentEditor = useCallback((data) => {
setComponentBeingEdited((prev) => {
@@ -147,9 +147,8 @@ export const LibraryProvider = ({
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
isCreateUnitModalOpen,
openCreateUnitModal,
closeCreateUnitModal,
createContainerModalType,
setCreateContainerModalType,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
@@ -173,9 +172,6 @@ export const LibraryProvider = ({
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
isCreateUnitModalOpen,
openCreateUnitModal,
closeCreateUnitModal,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,

View File

@@ -0,0 +1,165 @@
import React from 'react';
import {
ActionRow,
Form,
ModalDialog,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik } from 'formik';
import { useNavigate } from 'react-router';
import * as Yup from 'yup';
import FormikControl from '../../generic/FormikControl';
import { useLibraryContext } from '../common/context/LibraryContext';
import messages from './messages';
import { useAddItemsToCollection, useCreateLibraryContainer } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
import { ContainerType } from '../../generic/key-utils';
import { useLibraryRoutes } from '../routes';
/** Common modal to create section, subsection or unit in library */
const CreateContainerModal = () => {
const intl = useIntl();
const navigate = useNavigate();
const {
collectionId,
libraryId,
createContainerModalType,
setCreateContainerModalType,
} = useLibraryContext();
const { insideCollection } = useLibraryRoutes();
const create = useCreateLibraryContainer(libraryId);
const updateItemsMutation = useAddItemsToCollection(libraryId, collectionId);
const { showToast } = React.useContext(ToastContext);
/** labels based on the type of modal open, i.e., section, subsection or unit */
const labels = React.useMemo(() => {
if (createContainerModalType === ContainerType.Chapter) {
return {
modalTitle: intl.formatMessage(messages.createSectionModalTitle),
validationError: intl.formatMessage(messages.createSectionModalNameInvalid),
nameLabel: intl.formatMessage(messages.createSectionModalNameLabel),
placeholder: intl.formatMessage(messages.createSectionModalNamePlaceholder),
successMsg: intl.formatMessage(messages.createSectionSuccess),
errorMsg: intl.formatMessage(messages.createSectionError),
};
}
if (createContainerModalType === ContainerType.Sequential) {
return {
modalTitle: intl.formatMessage(messages.createSubsectionModalTitle),
validationError: intl.formatMessage(messages.createSubsectionModalNameInvalid),
nameLabel: intl.formatMessage(messages.createSubsectionModalNameLabel),
placeholder: intl.formatMessage(messages.createSubsectionModalNamePlaceholder),
successMsg: intl.formatMessage(messages.createSubsectionSuccess),
errorMsg: intl.formatMessage(messages.createSubsectionError),
};
}
return {
modalTitle: intl.formatMessage(messages.createUnitModalTitle),
validationError: intl.formatMessage(messages.createUnitModalNameInvalid),
nameLabel: intl.formatMessage(messages.createUnitModalNameLabel),
placeholder: intl.formatMessage(messages.createUnitModalNamePlaceholder),
successMsg: intl.formatMessage(messages.createUnitSuccess),
errorMsg: intl.formatMessage(messages.createUnitError),
};
}, [createContainerModalType]);
/** Call close for section, subsection and unit as the operation is idempotent */
const handleClose = () => setCreateContainerModalType(undefined);
/** Calculate containerType based on type of open modal */
const containerType = React.useMemo(() => {
if (createContainerModalType === ContainerType.Chapter) {
return ContainerType.Section;
}
if (createContainerModalType === ContainerType.Sequential) {
return ContainerType.Subsection;
}
return ContainerType.Unit;
}, [createContainerModalType]);
const handleCreate = React.useCallback(async (values) => {
try {
const container = await create.mutateAsync({
containerType,
...values,
});
// link container to parent
if (collectionId && insideCollection) {
await updateItemsMutation.mutateAsync([container.id]);
}
// Navigate to the new container
navigate(`/library/${libraryId}/${containerType}/${container.id}`);
showToast(labels.successMsg);
} catch (error) {
showToast(labels.errorMsg);
} finally {
handleClose();
}
}, [containerType, labels, handleClose]);
return (
<ModalDialog
title={labels.modalTitle}
isOpen={!!createContainerModalType}
onClose={handleClose}
hasCloseButton
isFullscreenOnMobile
isOverflowVisible={false}
>
<ModalDialog.Header>
<ModalDialog.Title>
{labels.modalTitle}
</ModalDialog.Title>
</ModalDialog.Header>
<Formik
initialValues={{
displayName: '',
}}
validationSchema={
Yup.object().shape({
displayName: Yup.string()
.required(labels.validationError),
})
}
onSubmit={handleCreate}
>
{(formikProps) => (
<Form onSubmit={formikProps.handleSubmit}>
<ModalDialog.Body className="mw-sm">
<FormikControl
name="displayName"
label={(
<Form.Label className="font-weight-bold h3">
{labels.nameLabel}
</Form.Label>
)}
value={formikProps.values.displayName}
placeholder={labels.placeholder}
controlClasses="pb-2"
/>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.createModalCancel)}
</ModalDialog.CloseButton>
<LoadingButton
variant="primary"
onClick={formikProps.submitForm}
disabled={!formikProps.isValid || !formikProps.dirty}
isLoading={formikProps.isSubmitting}
label={intl.formatMessage(messages.createContainerModalCreate)}
type="submit"
/>
</ActionRow>
</ModalDialog.Footer>
</Form>
)}
</Formik>
</ModalDialog>
);
};
export default CreateContainerModal;

View File

@@ -0,0 +1 @@
export { default as CreateContainerModal } from './CreateContainerModal';

View File

@@ -0,0 +1,106 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
createUnitModalTitle: {
id: 'course-authoring.library-authoring.modals.create-unit.title',
defaultMessage: 'New Unit',
description: 'Title of the Create Unit modal',
},
createSubsectionModalTitle: {
id: 'course-authoring.library-authoring.modals.create-subsection.title',
defaultMessage: 'New Subsection',
description: 'Title of the Create Subsection modal',
},
createSectionModalTitle: {
id: 'course-authoring.library-authoring.modals.create-section.title',
defaultMessage: 'New Section',
description: 'Title of the Create Section modal',
},
createModalCancel: {
id: 'course-authoring.library-authoring.modals.create-container.cancel',
defaultMessage: 'Cancel',
description: 'Label of the Cancel button of the Create container modal',
},
createContainerModalCreate: {
id: 'course-authoring.library-authoring.modals.create-container.create',
defaultMessage: 'Create',
description: 'Label of the Create button of the Create container modal',
},
createUnitModalNameLabel: {
id: 'course-authoring.library-authoring.modals.create-unit.form.name',
defaultMessage: 'Name your unit',
description: 'Label of the Name field of the Create Unit modal form',
},
createSectionModalNameLabel: {
id: 'course-authoring.library-authoring.modals.create-section.form.name',
defaultMessage: 'Name your section',
description: 'Label of the Name field of the Create Section modal form',
},
createSubsectionModalNameLabel: {
id: 'course-authoring.library-authoring.modals.create-subsection.form.name',
defaultMessage: 'Name your subsection',
description: 'Label of the Name field of the Create Subsection modal form',
},
createUnitModalNamePlaceholder: {
id: 'course-authoring.library-authoring.modals.create-unit.form.name.placeholder',
defaultMessage: 'Give a descriptive title',
description: 'Placeholder of the Name field of the Create Unit modal form',
},
createSubsectionModalNamePlaceholder: {
id: 'course-authoring.library-authoring.modals.create-subsection.form.name.placeholder',
defaultMessage: 'Give a descriptive title',
description: 'Placeholder of the Name field of the Create Subsection modal form',
},
createSectionModalNamePlaceholder: {
id: 'course-authoring.library-authoring.modals.create-section.form.name.placeholder',
defaultMessage: 'Give a descriptive title',
description: 'Placeholder of the Name field of the Create Section modal form',
},
createUnitModalNameInvalid: {
id: 'course-authoring.library-authoring.modals.create-unit.form.name.invalid',
defaultMessage: 'Unit name is required',
description: 'Message when the Name field of the Create Unit modal form is invalid',
},
createSectionModalNameInvalid: {
id: 'course-authoring.library-authoring.modals.create-section.form.name.invalid',
defaultMessage: 'Section name is required',
description: 'Message when the Name field of the Create Section modal form is invalid',
},
createSubsectionModalNameInvalid: {
id: 'course-authoring.library-authoring.modals.create-subsection.form.name.invalid',
defaultMessage: 'Subsection name is required',
description: 'Message when the Name field of the Create Subsection modal form is invalid',
},
createSectionSuccess: {
id: 'course-authoring.library-authoring.modals.create-section.success',
defaultMessage: 'Section created successfully',
description: 'Success message when creating a library section',
},
createSectionError: {
id: 'course-authoring.library-authoring.modals.create-section.error',
defaultMessage: 'There is an error when creating the library section',
description: 'Error message when creating a library section',
},
createSubsectionSuccess: {
id: 'course-authoring.library-authoring.modals.create-subsection.success',
defaultMessage: 'Subsection created successfully',
description: 'Success message when creating a library subsection',
},
createSubsectionError: {
id: 'course-authoring.library-authoring.modals.create-subsection.error',
defaultMessage: 'There is an error when creating the library subsection',
description: 'Error message when creating a library subsection',
},
createUnitSuccess: {
id: 'course-authoring.library-authoring.modals.create-unit.success',
defaultMessage: 'Unit created successfully',
description: 'Success message when creating a library unit',
},
createUnitError: {
id: 'course-authoring.library-authoring.modals.create-unit.error',
defaultMessage: 'There is an error when creating the library unit',
description: 'Error message when creating a library unit',
},
});
export default messages;

View File

@@ -1,115 +0,0 @@
import React from 'react';
import {
ActionRow,
Form,
ModalDialog,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik } from 'formik';
import { useNavigate } from 'react-router';
import * as Yup from 'yup';
import FormikControl from '../../generic/FormikControl';
import { useLibraryContext } from '../common/context/LibraryContext';
import messages from './messages';
import { useAddItemsToCollection, useCreateLibraryContainer } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
import { ContainerType } from '../../generic/key-utils';
const CreateUnitModal = () => {
const intl = useIntl();
const navigate = useNavigate();
const {
collectionId,
libraryId,
isCreateUnitModalOpen,
closeCreateUnitModal,
} = useLibraryContext();
const create = useCreateLibraryContainer(libraryId);
const updateItemsMutation = useAddItemsToCollection(libraryId, collectionId);
const { showToast } = React.useContext(ToastContext);
const handleCreate = React.useCallback(async (values) => {
try {
const container = await create.mutateAsync({
containerType: ContainerType.Unit,
...values,
});
if (collectionId) {
await updateItemsMutation.mutateAsync([container.id]);
}
// Navigate to the new unit
navigate(`/library/${libraryId}/unit/${container.id}`);
showToast(intl.formatMessage(messages.createUnitSuccess));
} catch (error) {
showToast(intl.formatMessage(messages.createUnitError));
} finally {
closeCreateUnitModal();
}
}, []);
return (
<ModalDialog
title={intl.formatMessage(messages.createUnitModalTitle)}
isOpen={isCreateUnitModalOpen}
onClose={closeCreateUnitModal}
hasCloseButton
isFullscreenOnMobile
isOverflowVisible={false}
>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages.createUnitModalTitle)}
</ModalDialog.Title>
</ModalDialog.Header>
<Formik
initialValues={{
displayName: '',
}}
validationSchema={
Yup.object().shape({
displayName: Yup.string()
.required(intl.formatMessage(messages.createUnitModalNameInvalid)),
})
}
onSubmit={handleCreate}
>
{(formikProps) => (
<Form onSubmit={formikProps.handleSubmit}>
<ModalDialog.Body className="mw-sm">
<FormikControl
name="displayName"
label={(
<Form.Label className="font-weight-bold h3">
{intl.formatMessage(messages.createUnitModalNameLabel)}
</Form.Label>
)}
value={formikProps.values.displayName}
placeholder={intl.formatMessage(messages.createUnitModalNamePlaceholder)}
controlClasses="pb-2"
/>
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages.createUnitModalCancel)}
</ModalDialog.CloseButton>
<LoadingButton
variant="primary"
onClick={formikProps.submitForm}
disabled={!formikProps.isValid || !formikProps.dirty}
isLoading={formikProps.isSubmitting}
label={intl.formatMessage(messages.createUnitModalCreate)}
type="submit"
/>
</ActionRow>
</ModalDialog.Footer>
</Form>
)}
</Formik>
</ModalDialog>
);
};
export default CreateUnitModal;

View File

@@ -1 +0,0 @@
export { default as CreateUnitModal } from './CreateUnitModal';

View File

@@ -1,46 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
createUnitModalTitle: {
id: 'course-authoring.library-authoring.modals.create-unit.title',
defaultMessage: 'New Unit',
description: 'Title of the Create Unit modal',
},
createUnitModalCancel: {
id: 'course-authoring.library-authoring.modals.create-unit.cancel',
defaultMessage: 'Cancel',
description: 'Label of the Cancel button of the Create Unit modal',
},
createUnitModalCreate: {
id: 'course-authoring.library-authoring.modals.create-unit.create',
defaultMessage: 'Create',
description: 'Label of the Create button of the Create Unit modal',
},
createUnitModalNameLabel: {
id: 'course-authoring.library-authoring.modals.create-unit.form.name',
defaultMessage: 'Name your unit',
description: 'Label of the Name field of the Create Unit modal form',
},
createUnitModalNamePlaceholder: {
id: 'course-authoring.library-authoring.modals.create-unit.form.name.placeholder',
defaultMessage: 'Give a descriptive title',
description: 'Placeholder of the Name field of the Create Unit modal form',
},
createUnitModalNameInvalid: {
id: 'course-authoring.library-authoring.modals.create-unit.form.name.invalid',
defaultMessage: 'Unit name is required',
description: 'Message when the Name field of the Create Unit modal form is invalid',
},
createUnitSuccess: {
id: 'course-authoring.library-authoring.modals.create-unit.success',
defaultMessage: 'Unit created successfully',
description: 'Success message when creating a library unit',
},
createUnitError: {
id: 'course-authoring.library-authoring.modals.create-unit.error',
defaultMessage: 'There is an error when creating the library unit',
description: 'Error message when creating a library unit',
},
});
export default messages;

View File

@@ -21,6 +21,10 @@ export const ROUTES = {
COMPONENTS: '/components/:componentId?',
// * Collections tab, with an optionally selected collectionId in the sidebar.
COLLECTIONS: '/collections/:collectionId?',
// * Sections tab, with an optionally selected sectionId in the sidebar.
SECTIONS: '/sections/:sectionId?',
// * Subsections tab, with an optionally selected subsectionId in the sidebar.
SUBSECTIONS: '/subsections/:subsectionId?',
// * Units tab, with an optionally selected unitId in the sidebar.
UNITS: '/units/:unitId?',
// * All Content tab, with an optionally selected componentId in the sidebar.
@@ -30,6 +34,12 @@ export const ROUTES = {
// LibraryCollectionPage route:
// * with a selected collectionId and/or an optionally selected componentId.
COLLECTION: '/collection/:collectionId/:componentId?',
// LibrarySectionPage route:
// * with a selected sectionId and/or an optionally selected subsectionId.
SECTION: '/section/:sectionId/:subsectionId?',
// LibrarySubsectionPage route:
// * with a selected subsectionId and/or an optionally selected unitId.
SUBSECTION: '/subsection/:subsectionId/:unitId?',
// LibraryUnitPage route:
// * with a selected unitId and/or an optionally selected componentId.
UNIT: '/unit/:unitId/:componentId?',
@@ -56,6 +66,10 @@ export type LibraryRoutesData = {
insideCollection: PathMatch<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;
@@ -73,6 +87,10 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
const insideCollection = matchPath(BASE_ROUTE + ROUTES.COLLECTION, pathname);
const insideCollections = matchPath(BASE_ROUTE + ROUTES.COLLECTIONS, pathname);
const insideComponents = matchPath(BASE_ROUTE + ROUTES.COMPONENTS, pathname);
const insideSections = matchPath(BASE_ROUTE + ROUTES.SECTIONS, pathname);
const insideSection = matchPath(BASE_ROUTE + ROUTES.SECTION, pathname);
const insideSubsections = matchPath(BASE_ROUTE + ROUTES.SUBSECTIONS, pathname);
const insideSubsection = matchPath(BASE_ROUTE + ROUTES.SUBSECTION, pathname);
const insideUnits = matchPath(BASE_ROUTE + ROUTES.UNITS, pathname);
const insideUnit = matchPath(BASE_ROUTE + ROUTES.UNIT, pathname);
@@ -189,6 +207,10 @@ export const useLibraryRoutes = (): LibraryRoutesData => {
insideCollection,
insideCollections,
insideComponents,
insideSections,
insideSection,
insideSubsections,
insideSubsection,
insideUnits,
insideUnit,
};