feat: create unit workflow (#1741)

Implements the basic workflow to create a Unit in a Library.
This commit is contained in:
Rômulo Penido
2025-03-28 17:44:58 -03:00
committed by GitHub
parent d497bf2ccc
commit df7405ec39
17 changed files with 409 additions and 110 deletions

View File

@@ -3,9 +3,10 @@ import {
BackHand as BackHandIcon,
BookOpen as BookOpenIcon,
Casino as ProblemBankIcon,
ContentPaste as ContentPasteIcon,
Edit as EditIcon,
EditNote as EditNoteIcon,
FormatListBulleted as FormatListBulletedIcon,
CalendarViewDay,
HelpOutline as HelpOutlineIcon,
LibraryAdd as LibraryIcon,
Lock as LockIcon,
@@ -35,7 +36,7 @@ export const COMPONENT_TYPES = {
export const UNIT_TYPE_ICONS_MAP: Record<string, React.ComponentType> = {
video: VideoCameraIcon,
other: BookOpenIcon,
vertical: FormatListBulletedIcon,
vertical: CalendarViewDay,
problem: EditIcon,
lock: LockIcon,
};
@@ -58,6 +59,8 @@ export const STRUCTURAL_TYPE_ICONS: Record<string, React.ComponentType> = {
sequential: Folder,
chapter: Folder,
collection: Folder,
libraryContent: Folder,
paste: ContentPasteIcon,
};
export const COMPONENT_TYPE_STYLE_COLOR_MAP = {

View File

@@ -32,6 +32,8 @@ describe('component utils', () => {
['lb:Axim:beta:problem:571fe018-f3ce-45c9-8f53-5dafcb422fdd', 'lib:Axim:beta'],
['lib-collection:org:lib:coll', 'lib:org:lib'],
['lib-collection:OpenCraftX:ALPHA:coll', 'lib:OpenCraftX:ALPHA'],
['lct:org:lib:unit:my-unit-9284e2', 'lib:org:lib'],
['lct:OpenCraftX:ALPHA:my-unit-a3223f', 'lib:OpenCraftX:ALPHA'],
]) {
it(`returns '${expected}' for usage key '${input}'`, () => {
expect(getLibraryId(input)).toStrictEqual(expected);

View File

@@ -19,12 +19,10 @@ export function getBlockType(usageKey: string): string {
* @returns The library key, e.g. `lib:org:lib`
*/
export function getLibraryId(usageKey: string): string {
if (usageKey && (usageKey.startsWith('lb:') || usageKey.startsWith('lib-collection:'))) {
const org = usageKey.split(':')[1];
const lib = usageKey.split(':')[2];
if (org && lib) {
return `lib:${org}:${lib}`;
}
const [blockType, org, lib] = usageKey?.split(':') || [];
if (['lb', 'lib-collection', 'lct'].includes(blockType) && org && lib) {
return `lib:${org}:${lib}`;
}
throw new Error(`Invalid usageKey: ${usageKey}`);
}

View File

@@ -1,4 +1,3 @@
import React from 'react';
import {
act,
fireEvent,
@@ -9,7 +8,7 @@ import LoadingButton from '.';
const buttonTitle = 'Button Title';
const RootWrapper = (onClick) => (
const RootWrapper = (onClick?: () => (Promise<void> | void)) => (
<LoadingButton label={buttonTitle} onClick={onClick} />
);
@@ -31,8 +30,8 @@ describe('<LoadingButton />', () => {
});
it('renders the spinner correctly', async () => {
let resolver;
const longFunction = () => new Promise((resolve) => {
let resolver: () => void;
const longFunction = () => new Promise<void>((resolve) => {
resolver = resolve;
});
const { container, getByRole, getByText } = render(RootWrapper(longFunction));
@@ -51,8 +50,8 @@ describe('<LoadingButton />', () => {
});
it('renders the spinner correctly even with error', async () => {
let rejecter;
const longFunction = () => new Promise((_resolve, reject) => {
let rejecter: (err: Error) => void;
const longFunction = () => new Promise<void>((_resolve, reject) => {
rejecter = reject;
});
const { container, getByRole, getByText } = render(RootWrapper(longFunction));

View File

@@ -1,4 +1,3 @@
// @ts-check
import React, {
useCallback,
useEffect,
@@ -8,20 +7,20 @@ import React, {
import {
StatefulButton,
} from '@openedx/paragon';
import PropTypes from 'prop-types';
interface LoadingButtonProps {
label: string;
onClick?: (e: any) => (Promise<void> | void);
disabled?: boolean;
size?: string;
variant?: string;
className?: string;
}
/**
* A button that shows a loading spinner when clicked.
* @param {object} props
* @param {string} props.label
* @param {function=} props.onClick
* @param {boolean=} props.disabled
* @param {string=} props.size
* @param {string=} props.variant
* @param {string=} props.className
* @returns {JSX.Element}
* A button that shows a loading spinner when clicked, if the onClick function returns a Promise.
*/
const LoadingButton = ({
const LoadingButton: React.FC<LoadingButtonProps> = ({
label,
onClick,
disabled,
@@ -37,7 +36,7 @@ const LoadingButton = ({
componentMounted.current = false;
}, []);
const loadingOnClick = useCallback(async (e) => {
const loadingOnClick = useCallback(async (e: any) => {
if (!onClick) {
return;
}
@@ -67,21 +66,4 @@ const LoadingButton = ({
);
};
LoadingButton.propTypes = {
label: PropTypes.string.isRequired,
onClick: PropTypes.func,
disabled: PropTypes.bool,
size: PropTypes.string,
variant: PropTypes.string,
className: PropTypes.string,
};
LoadingButton.defaultProps = {
onClick: undefined,
disabled: undefined,
size: undefined,
variant: '',
className: '',
};
export default LoadingButton;

View File

@@ -22,7 +22,10 @@ import { studioHomeMock } from '../studio-home/__mocks__';
import { getStudioHomeApiUrl } from '../studio-home/data/api';
import { mockBroadcastChannel } from '../generic/data/api.mock';
import { LibraryLayout } from '.';
import { getLibraryCollectionsApiUrl } from './data/api';
import { getLibraryCollectionsApiUrl, getLibraryContainersApiUrl } from './data/api';
let axiosMock;
let mockShowToast;
mockGetCollectionMetadata.applyMock();
mockContentSearchConfig.applyMock();
@@ -53,7 +56,9 @@ const libraryTitle = mockContentLibrary.libraryData.title;
describe('<LibraryAuthoringPage />', () => {
beforeEach(async () => {
const { axiosMock } = initializeMocks();
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
// The Meilisearch client-side API uses fetch, not Axios.
@@ -452,15 +457,15 @@ describe('<LibraryAuthoringPage />', () => {
expect(screen.getByRole('tab', { name: 'Manage' })).toHaveAttribute('aria-selected', 'true');
});
it('can filter by capa problem type', async () => {
const problemTypes = {
'Multiple Choice': 'choiceresponse',
Checkboxes: 'multiplechoiceresponse',
'Numerical Input': 'numericalresponse',
Dropdown: 'optionresponse',
'Text Input': 'stringresponse',
};
const problemTypes = {
'Multiple Choice': 'choiceresponse',
Checkboxes: 'multiplechoiceresponse',
'Numerical Input': 'numericalresponse',
Dropdown: 'optionresponse',
'Text Input': 'stringresponse',
};
it.each(Object.keys(problemTypes))('can filter by capa problem type (%s)', async (submenuText) => {
await renderLibraryPage();
// Ensure the search endpoint is called
@@ -474,36 +479,27 @@ describe('<LibraryAuthoringPage />', () => {
expect(showProbTypesSubmenuBtn).not.toBeNull();
fireEvent.click(showProbTypesSubmenuBtn!);
const validateSubmenu = async (submenuText: string) => {
const submenu = screen.getByText(submenuText);
expect(submenu).toBeInTheDocument();
fireEvent.click(submenu);
const submenu = screen.getByText(submenuText);
expect(submenu).toBeInTheDocument();
fireEvent.click(submenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`),
method: 'POST',
headers: expect.anything(),
});
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`),
method: 'POST',
headers: expect.anything(),
});
});
fireEvent.click(submenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.not.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`),
method: 'POST',
headers: expect.anything(),
});
fireEvent.click(submenu);
await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.not.stringContaining(`content.problem_types = ${problemTypes[submenuText]}`),
method: 'POST',
headers: expect.anything(),
});
};
// Validate per submenu
// eslint-disable-next-line no-restricted-syntax
for (const key of Object.keys(problemTypes)) {
// eslint-disable-next-line no-await-in-loop
await validateSubmenu(key);
}
}, 10000);
});
});
it('can filter by block type', async () => {
await renderLibraryPage();
@@ -563,7 +559,6 @@ describe('<LibraryAuthoringPage />', () => {
const title = 'This is a Test';
const description = 'This is the description of the Test';
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
const { axiosMock } = initializeMocks();
axiosMock.onPost(url).reply(200, {
id: '1',
slug: 'this-is-a-test',
@@ -600,6 +595,13 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.change(nameField, { target: { value: title } });
fireEvent.change(descriptionField, { target: { value: description } });
fireEvent.click(createButton);
// Check success toast
await waitFor(() => expect(axiosMock.history.post.length).toBe(1));
expect(axiosMock.history.post[0].url).toBe(url);
expect(axiosMock.history.post[0].data).toContain(`"title":"${title}"`);
expect(axiosMock.history.post[0].data).toContain(`"description":"${description}"`);
expect(mockShowToast).toHaveBeenCalledWith('Collection created successfully');
});
it('should show validations in create collection', async () => {
@@ -608,7 +610,6 @@ describe('<LibraryAuthoringPage />', () => {
const title = 'This is a Test';
const description = 'This is the description of the Test';
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
const { axiosMock } = initializeMocks();
axiosMock.onPost(url).reply(200, {
id: '1',
slug: 'this-is-a-test',
@@ -647,7 +648,6 @@ describe('<LibraryAuthoringPage />', () => {
const title = 'This is a Test';
const description = 'This is the description of the Test';
const url = getLibraryCollectionsApiUrl(mockContentLibrary.libraryId);
const { axiosMock } = initializeMocks();
axiosMock.onPost(url).reply(500);
expect(await screen.findByRole('heading')).toBeInTheDocument();
@@ -673,6 +673,127 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.change(nameField, { target: { value: title } });
fireEvent.change(descriptionField, { target: { value: description } });
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 collection');
});
it('should create a unit', async () => {
await renderLibraryPage();
const title = 'This is a Test';
const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId);
axiosMock.onPost(url).reply(200, {
id: '1',
slug: 'this-is-a-test',
title,
});
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
// Open Add content sidebar
const newButton = screen.getByRole('button', { name: /new/i });
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// 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();
// Click on Cancel button
const cancelButton = screen.getByRole('button', { name: /cancel/i });
fireEvent.click(cancelButton);
expect(unitModalHeading).not.toBeInTheDocument();
// Open new unit modal again and create a collection
fireEvent.click(newUnitButton);
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
fireEvent.change(nameField, { target: { value: title } });
fireEvent.click(createButton);
// Check success
await waitFor(() => expect(axiosMock.history.post.length).toBe(1));
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');
});
it('should show validations in create unit', async () => {
await renderLibraryPage();
const title = 'This is a Test';
const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId);
axiosMock.onPost(url).reply(200, {
id: '1',
slug: 'this-is-a-test',
title,
});
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
// Open Add content sidebar
const newButton = screen.getByRole('button', { name: /new/i });
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// 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 nameField = screen.getByRole('textbox', { name: /name your unit/i });
fireEvent.focus(nameField);
fireEvent.blur(nameField);
// Click on create with an empty name
const createButton = screen.getByRole('button', { name: /create/i });
fireEvent.click(createButton);
expect(await screen.findByText(/unit name is required/i)).toBeInTheDocument();
});
it('should show error on create unit', async () => {
await renderLibraryPage();
const displayName = 'This is a Test';
const url = getLibraryContainersApiUrl(mockContentLibrary.libraryId);
axiosMock.onPost(url).reply(500);
expect(await screen.findByRole('heading')).toBeInTheDocument();
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
// Open Add content sidebar
const newButton = screen.getByRole('button', { name: /new/i });
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New collection 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();
// Create a unit
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your unit/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');
});
it('shows a single block when usageKey query param is set', async () => {
@@ -806,7 +927,6 @@ describe('<LibraryAuthoringPage />', () => {
});
it('Shows an error if libraries V2 is disabled', async () => {
const { axiosMock } = initializeMocks();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, {
...studioHomeMock,
libraries_v2_enabled: false,

View File

@@ -12,6 +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 LibraryCollectionPage from './collections/LibraryCollectionPage';
import { ComponentPicker } from './component-picker';
import { ComponentEditorModal } from './components/ComponentEditorModal';
@@ -44,6 +45,7 @@ const LibraryLayout = () => {
<>
{childPage}
<CreateCollectionModal />
<CreateUnitModal />
<ComponentEditorModal />
</>
</SidebarProvider>

View File

@@ -9,20 +9,13 @@ import {
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import {
Article,
AutoAwesome,
BookOpen,
Create,
Folder,
ThumbUpOutline,
Question,
VideoCamera,
ContentPaste,
KeyboardBackspace,
} from '@openedx/paragon/icons';
import { v4 as uuid4 } from 'uuid';
import { ToastContext } from '../../generic/toast-context';
import { getItemIcon } from '../../generic/block-type-utils';
import { useClipboard } from '../../generic/clipboard';
import { getCanEdit } from '../../course-unit/data/selectors';
import {
@@ -41,7 +34,7 @@ import type { BlockTypeMetadata } from '../data/api';
type ContentType = {
name: string,
disabled: boolean,
icon: React.ComponentType,
icon?: React.ComponentType,
blockType: string,
};
@@ -76,7 +69,7 @@ const AddContentButton = ({ contentType, onCreateContent } : AddContentButtonPro
variant="outline-primary"
disabled={disabled}
className="m-2"
iconBefore={icon}
iconBefore={icon || getItemIcon(blockType)}
onClick={() => onCreateContent(blockType)}
>
{name}
@@ -99,14 +92,18 @@ const AddContentView = ({
const collectionButtonData = {
name: intl.formatMessage(messages.collectionButton),
disabled: false,
icon: BookOpen,
blockType: 'collection',
};
const unitButtonData = {
name: intl.formatMessage(messages.unitButton),
disabled: false,
blockType: 'vertical',
};
const libraryContentButtonData = {
name: intl.formatMessage(messages.libraryContentButton),
disabled: false,
icon: Folder,
blockType: 'libraryContent',
};
@@ -125,6 +122,7 @@ const AddContentView = ({
) : (
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
)}
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
<hr className="w-100 bg-gray-500" />
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
@@ -194,6 +192,7 @@ const AddContentContainer = () => {
libraryId,
collectionId,
openCreateCollectionModal,
openCreateUnitModal,
openComponentEditor,
} = useLibraryContext();
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
@@ -220,31 +219,26 @@ const AddContentContainer = () => {
{
name: intl.formatMessage(messages.textTypeButton),
disabled: !isBlockTypeEnabled('html'),
icon: Article,
blockType: 'html',
},
{
name: intl.formatMessage(messages.problemTypeButton),
disabled: !isBlockTypeEnabled('problem'),
icon: Question,
blockType: 'problem',
},
{
name: intl.formatMessage(messages.openResponseTypeButton),
disabled: !isBlockTypeEnabled('openassessment'),
icon: Create,
blockType: 'openassessment',
},
{
name: intl.formatMessage(messages.dragDropTypeButton),
disabled: !isBlockTypeEnabled('drag-and-drop-v2'),
icon: ThumbUpOutline,
blockType: 'drag-and-drop-v2',
},
{
name: intl.formatMessage(messages.videoTypeButton),
disabled: !isBlockTypeEnabled('video'),
icon: VideoCamera,
blockType: 'video',
},
];
@@ -259,13 +253,13 @@ const AddContentContainer = () => {
// Include the 'Advanced / Other' button if there are enabled advanced Xblocks
if (Object.keys(advancedBlocks).length > 0) {
const pasteButton = {
const advancedButton = {
name: intl.formatMessage(messages.otherTypeButton),
disabled: false,
icon: AutoAwesome,
blockType: 'advancedXBlock',
};
contentTypes.push(pasteButton);
contentTypes.push(advancedButton);
}
// Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard
@@ -274,7 +268,6 @@ const AddContentContainer = () => {
const pasteButton = {
name: intl.formatMessage(messages.pasteButton),
disabled: false,
icon: ContentPaste,
blockType: 'paste',
};
contentTypes.push(pasteButton);
@@ -313,6 +306,7 @@ const AddContentContainer = () => {
));
});
};
const onCreateBlock = (blockType: string) => {
const suportedEditorTypes = Object.values(blockTypes);
if (suportedEditorTypes.includes(blockType)) {
@@ -347,6 +341,8 @@ const AddContentContainer = () => {
showAddLibraryContentModal();
} else if (blockType === 'advancedXBlock') {
showAdvancedList();
} else if (blockType === 'vertical') {
openCreateUnitModal();
} else {
onCreateBlock(blockType);
}

View File

@@ -6,6 +6,11 @@ const messages = defineMessages({
defaultMessage: 'Collection',
description: 'Content of button to create a Collection.',
},
unitButton: {
id: 'course-authoring.library-authoring.add-content.buttons.unit',
defaultMessage: 'Unit',
description: 'Content of button to create a Unit.',
},
libraryContentButton: {
id: 'course-authoring.library-authoring.add-content.buttons.library-content',
defaultMessage: 'Existing Library Content',

View File

@@ -35,6 +35,10 @@ export type LibraryContextData = {
isCreateCollectionModalOpen: boolean;
openCreateCollectionModal: () => void;
closeCreateCollectionModal: () => void;
// "Create New Unit" modal
isCreateUnitModalOpen: boolean;
openCreateUnitModal: () => void;
closeCreateUnitModal: () => 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;
@@ -80,6 +84,7 @@ export const LibraryProvider = ({
componentPicker,
}: LibraryProviderProps) => {
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
const [isCreateUnitModalOpen, openCreateUnitModal, closeCreateUnitModal] = useToggle(false);
const [componentBeingEdited, setComponentBeingEdited] = useState<ComponentEditorInfo | undefined>();
const closeComponentEditor = useCallback((data) => {
setComponentBeingEdited((prev) => {
@@ -122,6 +127,9 @@ export const LibraryProvider = ({
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
isCreateUnitModalOpen,
openCreateUnitModal,
closeCreateUnitModal,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,
@@ -142,6 +150,9 @@ export const LibraryProvider = ({
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
isCreateUnitModalOpen,
openCreateUnitModal,
closeCreateUnitModal,
componentBeingEdited,
openComponentEditor,
closeComponentEditor,

View File

@@ -1,8 +1,4 @@
import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n';
import type { defineMessages as defineMessagesType } from 'react-intl';
// frontend-platform currently doesn't provide types... do it ourselves.
const defineMessages = _defineMessages as typeof defineMessagesType;
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
createCollectionModalTitle: {

View File

@@ -1,4 +1,3 @@
import React from 'react';
import { StudioFooter } from '@edx/frontend-component-footer';
import { useIntl } from '@edx/frontend-platform/i18n';
import {

View File

@@ -0,0 +1,107 @@
import React from 'react';
import {
ActionRow,
Form,
ModalDialog,
} from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Formik } from 'formik';
import * as Yup from 'yup';
import FormikControl from '../../generic/FormikControl';
import { useLibraryContext } from '../common/context/LibraryContext';
import messages from './messages';
import { useCreateLibraryContainer } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
const CreateUnitModal = () => {
const intl = useIntl();
const {
libraryId,
isCreateUnitModalOpen,
closeCreateUnitModal,
} = useLibraryContext();
const create = useCreateLibraryContainer(libraryId);
const { showToast } = React.useContext(ToastContext);
const handleCreate = React.useCallback(async (values) => {
try {
await create.mutateAsync({
containerType: 'unit',
...values,
});
// TODO: Navigate to the new unit
// navigate(`/library/${libraryId}/units/${data.key}`);
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) => (
<>
<ModalDialog.Body className="mw-sm">
<Form onSubmit={formikProps.handleSubmit}>
<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"
/>
</Form>
</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}
label={intl.formatMessage(messages.createUnitModalCreate)}
/>
</ActionRow>
</ModalDialog.Footer>
</>
)}
</Formik>
</ModalDialog>
);
};
export default CreateUnitModal;

View File

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

View File

@@ -0,0 +1,46 @@
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

@@ -103,6 +103,10 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
* Get the URL for the content store api.
*/
export const getContentStoreApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/`;
/**
* Get the URL for the library container api.
*/
export const getLibraryContainersApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/containers/`;
export interface ContentLibrary {
id: string;
@@ -557,3 +561,16 @@ export async function updateComponentCollections(usageKey: string, collectionKey
collection_keys: collectionKeys,
});
}
export interface CreateLibraryContainerDataRequest {
title: string;
containerType: string;
}
/**
* Create a library container
*/
export async function createLibraryContainer(libraryId: string, containerData: CreateLibraryContainerDataRequest) {
const client = getAuthenticatedHttpClient();
await client.post(getLibraryContainersApiUrl(libraryId), snakeCaseObject(containerData));
}

View File

@@ -46,6 +46,8 @@ import {
deleteXBlockAsset,
restoreLibraryBlock,
getBlockTypes,
createLibraryContainer,
type CreateLibraryContainerDataRequest,
} from './api';
import { VersionSpec } from '../LibraryBlock';
@@ -560,3 +562,16 @@ export const useUpdateComponentCollections = (libraryId: string, usageKey: strin
},
});
};
/**
* Use this mutation to create a library container
*/
export const useCreateLibraryContainer = (libraryId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateLibraryContainerDataRequest) => createLibraryContainer(libraryId, data),
onSettled: () => {
queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) });
},
});
};