feat: create section and subsection in library (#2013)
Adds create section and subsection buttons in sidebar.
This commit is contained in:
@@ -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',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
165
src/library-authoring/create-container/CreateContainerModal.tsx
Normal file
165
src/library-authoring/create-container/CreateContainerModal.tsx
Normal 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;
|
||||
1
src/library-authoring/create-container/index.tsx
Normal file
1
src/library-authoring/create-container/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CreateContainerModal } from './CreateContainerModal';
|
||||
106
src/library-authoring/create-container/messages.ts
Normal file
106
src/library-authoring/create-container/messages.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -1 +0,0 @@
|
||||
export { default as CreateUnitModal } from './CreateUnitModal';
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user