feat: create unit workflow (#1741)
Implements the basic workflow to create a Unit in a Library.
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import React from 'react';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
|
||||
107
src/library-authoring/create-unit/CreateUnitModal.tsx
Normal file
107
src/library-authoring/create-unit/CreateUnitModal.tsx
Normal 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;
|
||||
1
src/library-authoring/create-unit/index.tsx
Normal file
1
src/library-authoring/create-unit/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default as CreateUnitModal } from './CreateUnitModal';
|
||||
46
src/library-authoring/create-unit/messages.ts
Normal file
46
src/library-authoring/create-unit/messages.ts
Normal 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;
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user