Files
frontend-app-authoring/src/library-authoring/add-content/AddContent.test.tsx
Jillian 747f7b6133 Add subsection/unit to section/subsection full-page view within library [FC-0090] (#2152)
* Removes the "content type" tab bar from the "Add Existing Component/Unit/Subsection" modal, and in all cases where only one type of content is shown for selection.
* Updates the section/subsection sidebar to show "Existing Library Content" + "New Subsection" / "New Unit" buttons.
* Updates the "Add New Unit/Subsection" buttons to directly launch the new container modal, instead of going via the container sidebar.
* Ensures that whenever a subsection/unit is created from within a section/subsection, that it is linked to the parent section/subsection after created.
2025-06-24 02:05:35 -05:00

434 lines
18 KiB
TypeScript

import MockAdapter from 'axios-mock-adapter/types';
import {
fireEvent,
render as baseRender,
screen,
waitFor,
initializeMocks,
} from '../../testUtils';
import {
mockContentLibrary,
mockBlockTypesMetadata,
mockXBlockFields,
} from '../data/api.mocks';
import {
getContentLibraryApiUrl,
getCreateLibraryBlockUrl,
getLibraryCollectionItemsApiUrl,
getLibraryContainerChildrenApiUrl,
getLibraryPasteClipboardUrl,
getXBlockFieldsApiUrl,
} from '../data/api';
import { mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
import AddContent from './AddContent';
import { ComponentEditorModal } from '../components/ComponentEditorModal';
import editorCmsApi from '../../editors/data/services/cms/api';
import { ToastActionData } from '../../generic/toast-context';
import * as textEditorHooks from '../../editors/containers/TextEditor/hooks';
// mockCreateLibraryBlock.applyMock();
// Mocks for ComponentEditorModal to work in tests.
jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCss: '' }));
const { libraryId } = mockContentLibrary;
const render = (collectionId?: string) => {
const params: { libraryId: string, collectionId?: string } = { libraryId, collectionId };
return baseRender(<AddContent />, {
path: '/library/:libraryId/collection/:collectionId?',
params,
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
>
{ children }
<ComponentEditorModal />
</LibraryProvider>
),
});
};
const renderWithContainer = (containerId: string, containerType: 'unit' | 'section' | 'subsection' = 'unit') => {
const params: { libraryId: string, containerId?: string } = { libraryId, containerId };
return baseRender(<AddContent />, {
path: `/library/:libraryId/${containerType}/:containerId?`,
params,
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
>
{ children }
<ComponentEditorModal />
</LibraryProvider>
),
});
};
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
describe('<AddContent />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
axiosMock.onGet(getContentLibraryApiUrl(libraryId)).reply(200, {});
jest.spyOn(textEditorHooks, 'getContent').mockImplementation(() => () => '<p>Edited HTML content</p>');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should render content buttons', async () => {
mockBlockTypesMetadata.applyMock();
mockClipboardEmpty.applyMock();
render();
expect(screen.queryByRole('button', { name: /collection/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /text/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /problem/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /open reponse/i })).toBeInTheDocument(); // Excluded from MVP
expect(screen.queryByRole('button', { name: /drag drop/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /video/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /copy from clipboard/i })).not.toBeInTheDocument();
expect(await screen.findByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument();
expect(await screen.queryByRole('button', { name: /existing library content/i })).not.toBeInTheDocument();
});
it('should render advanced content buttons', async () => {
mockBlockTypesMetadata.applyMock();
mockClipboardEmpty.applyMock();
render();
const advancedButton = await screen.findByRole('button', { name: /advanced \/ other/i });
fireEvent.click(advancedButton);
expect(await screen.findByRole('button', { name: /poll/i })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: /survey/i })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: /google document/i })).toBeInTheDocument();
});
it('should return to content view fron advanced block creation view', async () => {
mockBlockTypesMetadata.applyMock();
mockClipboardEmpty.applyMock();
render();
const advancedButton = await screen.findByRole('button', { name: /advanced \/ other/i });
fireEvent.click(advancedButton);
expect(await screen.findByRole('button', { name: /poll/i })).toBeInTheDocument();
const returnButton = screen.getByRole('button', { name: /back to list/i });
fireEvent.click(returnButton);
expect(screen.queryByRole('button', { name: /text/i })).toBeInTheDocument();
});
it('should create an advanced content', async () => {
mockBlockTypesMetadata.applyMock();
mockClipboardEmpty.applyMock();
const url = getCreateLibraryBlockUrl(libraryId);
axiosMock.onPost(url).reply(200);
render();
const advancedButton = await screen.findByRole('button', { name: /advanced \/ other/i });
fireEvent.click(advancedButton);
const surveyButton = await screen.findByRole('button', { name: /survey/i });
fireEvent.click(surveyButton);
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(0));
});
it('should open the editor modal to create a content when the block is supported', async () => {
mockClipboardEmpty.applyMock();
const url = getCreateLibraryBlockUrl(libraryId);
axiosMock.onPost(url).reply(200);
render();
const textButton = screen.getByRole('button', { name: /text/i });
fireEvent.click(textButton);
expect(await screen.findByRole('heading', { name: /Text/ })).toBeInTheDocument();
});
it('should create a content in a collection for editable blocks', async () => {
mockClipboardEmpty.applyMock();
const collectionId = 'some-collection-id';
const url = getCreateLibraryBlockUrl(libraryId);
const usageKey = mockXBlockFields.usageKeyNewHtml;
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
libraryId,
collectionId,
);
// Mocks for ComponentEditorModal to work in tests.
jest.spyOn(editorCmsApi, 'fetchCourseImages').mockImplementation(async () => ( // eslint-disable-next-line
{ data: { assets: [], start: 0, end: 0, page: 0, pageSize: 50, totalCount: 0 } }
));
axiosMock.onPost(url).reply(200, {
id: usageKey,
});
axiosMock.onPost(updateBlockUrl).reply(200, mockXBlockFields.dataHtml);
axiosMock.onPatch(collectionComponentUrl).reply(200);
render(collectionId);
const textButton = screen.getByRole('button', { name: /text/i });
fireEvent.click(textButton);
// Component should be linked to Collection on saving the changes in the editor.
const saveButton = screen.getByLabelText('Save changes and return to learning context');
fireEvent.click(saveButton);
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
});
it('should render paste button if clipboard contains pastable xblock', async () => {
// Simulate having an HTML block in the clipboard:
const getClipboardSpy = mockClipboardHtml.applyMock();
render();
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called three times! Refactor to use react-query.
await waitFor(() => expect(screen.queryByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument());
});
it('should paste content', async () => {
// Simulate having an HTML block in the clipboard:
const getClipboardSpy = mockClipboardHtml.applyMock();
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
axiosMock.onPost(pasteUrl).reply(200);
render();
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
});
it('should show error toast on paste failure', async () => {
// Simulate having an HTML block in the clipboard:
mockClipboardHtml.applyMock();
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
axiosMock.onPost(pasteUrl).reply(500, { block_type: 'Unsupported block type.' });
render();
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
expect(mockShowToast).toHaveBeenCalledWith(
'There was an error pasting the content: {"block_type":"Unsupported block type."}',
);
});
it('should paste content inside a collection', async () => {
// Simulate having an HTML block in the clipboard:
const getClipboardSpy = mockClipboardHtml.applyMock();
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
const collectionId = 'some-collection-id';
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
libraryId,
collectionId,
);
axiosMock.onPatch(collectionComponentUrl).reply(200);
axiosMock.onPost(pasteUrl).reply(200, { id: 'some-component-id' });
render(collectionId);
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
});
it('should show error toast on linking failure', async () => {
// Simulate having an HTML block in the clipboard:
const getClipboardSpy = mockClipboardHtml.applyMock();
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
const collectionId = 'some-collection-id';
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
libraryId,
collectionId,
);
axiosMock.onPatch(collectionComponentUrl).reply(500);
axiosMock.onPost(pasteUrl).reply(200, { id: 'some-component-id' });
render(collectionId);
expect(getClipboardSpy).toHaveBeenCalled(); // Hmm, this is getting called four times! Refactor to use react-query.
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
expect(mockShowToast).toHaveBeenCalledWith('Failed to add content to collection.');
});
it('should stop user from pasting unsupported blocks and show toast', async () => {
// Simulate having an HTML block in the clipboard:
mockClipboardHtml.applyMock('conditional');
const errMsg = 'Libraries do not support this type of content yet.';
render();
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
fireEvent.click(pasteButton);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(0);
expect(mockShowToast).toHaveBeenCalledWith(errMsg);
});
});
test.each([
{
label: 'should handle failure to paste content',
mockUrl: getLibraryPasteClipboardUrl(libraryId),
mockResponse: undefined,
expectedError: 'There was an error pasting the content.',
buttonName: /paste from clipboard/i,
},
{
label: 'should show detailed error in toast on paste failure',
mockUrl: getLibraryPasteClipboardUrl(libraryId),
mockResponse: ['library cannot have more than 100000 components'],
expectedError: 'There was an error pasting the content: library cannot have more than 100000 components',
buttonName: /paste from clipboard/i,
},
])('$label', async ({
mockUrl, mockResponse, buttonName, expectedError,
}) => {
axiosMock.onPost(mockUrl).reply(400, mockResponse);
// Simulate having an HTML block in the clipboard:
mockClipboardHtml.applyMock();
render();
const button = await screen.findByRole('button', { name: buttonName });
fireEvent.click(button);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(mockUrl);
expect(mockShowToast).toHaveBeenCalledWith(expectedError);
});
});
it('should not show collection, unit, section and subsection buttons when create component in unit', async () => {
const unitId = 'lct:orf1:lib1:unit:test-1';
renderWithContainer(unitId);
expect(await screen.findByRole('button', { name: /existing library content/i })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Text' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument();
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 () => {
const unitId = 'lct:orf1:lib1:unit:test-1';
const usageKey = mockXBlockFields.usageKeyNewHtml;
const createUrl = getCreateLibraryBlockUrl(libraryId);
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
const linkUrl = getLibraryContainerChildrenApiUrl(unitId);
axiosMock.onPost(createUrl).reply(200, {
id: usageKey,
});
axiosMock.onPost(updateBlockUrl).reply(200, mockXBlockFields.dataHtml);
axiosMock.onPost(linkUrl).reply(200);
renderWithContainer(unitId);
const textButton = screen.getByRole('button', { name: /text/i });
fireEvent.click(textButton);
// Component should be linked to Unit on saving the changes in the editor.
const saveButton = screen.getByLabelText('Save changes and return to learning context');
fireEvent.click(saveButton);
await waitFor(() => expect(axiosMock.history.post.length).toEqual(3));
expect(axiosMock.history.post[0].url).toEqual(createUrl);
expect(axiosMock.history.post[1].url).toEqual(updateBlockUrl);
expect(axiosMock.history.post[2].url).toEqual(linkUrl);
});
it('should show error on create a component in unit', async () => {
const unitId = 'lct:orf1:lib1:unit:test-1';
const usageKey = mockXBlockFields.usageKeyNewHtml;
const createUrl = getCreateLibraryBlockUrl(libraryId);
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
const linkUrl = getLibraryContainerChildrenApiUrl(unitId);
axiosMock.onPost(createUrl).reply(200, {
id: usageKey,
});
axiosMock.onPost(updateBlockUrl).reply(200, mockXBlockFields.dataHtml);
axiosMock.onPost(linkUrl).reply(400);
renderWithContainer(unitId);
const textButton = screen.getByRole('button', { name: /text/i });
fireEvent.click(textButton);
const saveButton = screen.getByLabelText('Save changes and return to learning context');
fireEvent.click(saveButton);
await waitFor(() => expect(axiosMock.history.post.length).toEqual(3));
expect(axiosMock.history.post[0].url).toEqual(createUrl);
expect(axiosMock.history.post[1].url).toEqual(updateBlockUrl);
expect(axiosMock.history.post[2].url).toEqual(linkUrl);
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this container.');
});
it('should only show subsection buttons when inside a section', async () => {
mockClipboardEmpty.applyMock();
const sectionId = 'lct:orf1:lib1:section:test-1';
renderWithContainer(sectionId, 'section');
expect(await screen.findByRole('button', { name: /existing subsection/i })).toBeInTheDocument();
// Button is labeled "New Subsection" , not "Subsection"
expect(await screen.findByRole('button', { name: /new subsection/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Subsection' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Section' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Text' })).not.toBeInTheDocument();
});
it('should only show unit buttons when inside a subsection', async () => {
mockClipboardEmpty.applyMock();
const subsectionId = 'lct:orf1:lib1:subsection:test-1';
renderWithContainer(subsectionId, 'subsection');
expect(await screen.findByRole('button', { name: /existing unit/i })).toBeInTheDocument();
// Button is labeled "New Unit" in this context, not just "Unit"
expect(await screen.findByRole('button', { name: /new unit/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Subsection' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Section' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Text' })).not.toBeInTheDocument();
});
});