feat: allow pasting units from a course into a library (#1812)

This commit is contained in:
Braden MacDonald
2025-04-15 15:26:19 -07:00
committed by GitHub
parent d9dcdfe1e3
commit f24b89c847
8 changed files with 52 additions and 29 deletions

View File

@@ -0,0 +1,16 @@
export default {
content: {
id: 67,
userId: 3,
created: '2024-01-16T13:09:11.540615Z',
purpose: 'clipboard',
status: 'ready',
blockType: 'sequential',
blockTypeDisplay: 'Subsection',
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
displayName: 'Sequences',
},
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
sourceContextTitle: 'Demonstration Course',
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
};

View File

@@ -1,2 +1,3 @@
export { default as clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock';

View File

@@ -2,6 +2,7 @@ import { renderHook } from '@testing-library/react';
import MockAdapter from 'axios-mock-adapter';
import {
clipboardSubsection,
clipboardUnit,
clipboardXBlock,
} from '../../../__mocks__';
@@ -42,7 +43,7 @@ describe('useClipboard', () => {
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
.reply(200, clipboardSubsection);
await result.current.copyToClipboard(unitId);
@@ -89,6 +90,7 @@ describe('useClipboard', () => {
describe('broadcast channel message handling', () => {
it('updates states correctly on receiving a broadcast message', async () => {
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
// Subsections cannot be pasted:
clipboardBroadcastChannelMock.postMessage({ data: clipboardUnit });
rerender();

View File

@@ -207,6 +207,23 @@ describe('<AddContent />', () => {
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();

View File

@@ -191,7 +191,15 @@ export const parseErrorMsg = (
) => {
try {
const { response: { data } } = error;
const detail = data && (Array.isArray(data) ? data.join() : String(data));
let detail = '';
if (Array.isArray(data)) {
detail = data.join(', ');
} else if (typeof data === 'string') {
/* istanbul ignore next */
detail = data.substring(0, 400); // In case this is a giant HTML response, only show the first little bit.
} else if (data) {
detail = JSON.stringify(data);
}
if (detail) {
return intl.formatMessage(detailedMessage, { detail });
}
@@ -217,7 +225,7 @@ const AddContent = () => {
const pasteClipboardMutation = useLibraryPasteClipboard();
const { showToast } = useContext(ToastContext);
const canEdit = useSelector(getCanEdit);
const { showPasteXBlock, sharedClipboardData } = useClipboard(canEdit);
const { showPasteUnit, showPasteXBlock, sharedClipboardData } = useClipboard(canEdit);
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [isAdvancedListOpen, showAdvancedList, closeAdvancedList] = useToggle();
@@ -281,7 +289,7 @@ const AddContent = () => {
// Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard
// that can be pasted
if (showPasteXBlock) {
if (showPasteXBlock || showPasteUnit) {
const pasteButton = {
name: intl.formatMessage(messages.pasteButton),
disabled: false,
@@ -317,7 +325,6 @@ const AddContent = () => {
}
pasteClipboardMutation.mutateAsync({
libraryId,
blockId: `${uuid4()}`,
}).then((data) => {
linkComponent(data.id);
showToast(intl.formatMessage(messages.successPasteClipboardMessage));

View File

@@ -186,7 +186,6 @@ export async function mockCreateLibraryBlock(
}
mockCreateLibraryBlock.newHtmlData = {
id: 'lb:Axim:TEST:html:123',
defKey: '123',
blockType: 'html',
displayName: 'New Text Component',
hasUnpublishedChanges: true,
@@ -201,7 +200,6 @@ mockCreateLibraryBlock.newHtmlData = {
} satisfies api.LibraryBlockMetadata;
mockCreateLibraryBlock.newProblemData = {
id: 'lb:Axim:TEST:problem:prob1',
defKey: 'prob1',
blockType: 'problem',
displayName: 'New Problem',
hasUnpublishedChanges: true,
@@ -216,7 +214,6 @@ mockCreateLibraryBlock.newProblemData = {
} satisfies api.LibraryBlockMetadata;
mockCreateLibraryBlock.newVideoData = {
id: 'lb:Axim:TEST:video:vid1',
defKey: 'vid1',
blockType: 'video',
displayName: 'New Video',
hasUnpublishedChanges: true,
@@ -349,7 +346,6 @@ mockLibraryBlockMetadata.usageKeyError404 = 'lb:Axim:error404:html:123';
mockLibraryBlockMetadata.usageKeyNeverPublished = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1';
mockLibraryBlockMetadata.dataNeverPublished = {
id: 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1',
defKey: null,
blockType: 'html',
displayName: 'Introduction to Testing 1',
lastPublished: null,
@@ -365,7 +361,6 @@ mockLibraryBlockMetadata.dataNeverPublished = {
mockLibraryBlockMetadata.usageKeyPublished = 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2';
mockLibraryBlockMetadata.dataPublished = {
id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fd2',
defKey: null,
blockType: 'html',
displayName: 'Introduction to Testing 2',
lastPublished: '2024-06-22T00:00:00',
@@ -394,7 +389,6 @@ mockLibraryBlockMetadata.usageKeyForTags = mockContentTaxonomyTagsData.largeTags
mockLibraryBlockMetadata.usageKeyWithCollections = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd';
mockLibraryBlockMetadata.dataWithCollections = {
id: 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd',
defKey: null,
blockType: 'html',
displayName: 'Introduction to Testing 2',
lastPublished: '2024-06-21T00:00:00',
@@ -411,7 +405,6 @@ mockLibraryBlockMetadata.usageKeyPublishedWithChanges = 'lb:Axim:TEST:html:571fe
mockLibraryBlockMetadata.usageKeyPublishedWithChangesV2 = 'lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fv2';
mockLibraryBlockMetadata.dataPublishedWithChanges = {
id: 'lb:Axim:TEST2:html:571fe018-f3ce-45c9-8f53-5dafcb422fvv',
defKey: null,
blockType: 'html',
displayName: 'Introduction to Testing 2',
lastPublished: '2024-06-22T00:00:00',
@@ -492,7 +485,7 @@ mockGetContainerMetadata.containerIdLoading = 'lct:org:lib:unit:container_loadin
mockGetContainerMetadata.containerIdForTags = mockContentTaxonomyTagsData.largeTagsId;
mockGetContainerMetadata.containerIdWithCollections = 'lct:org:lib:unit:container_collections';
mockGetContainerMetadata.containerData = {
containerKey: 'lct:org:lib:unit:test-unit-9a2072',
id: 'lct:org:lib:unit:test-unit-9a2072',
containerType: 'unit',
displayName: 'Test Unit',
created: '2024-09-19T10:00:00Z',
@@ -552,7 +545,6 @@ mockGetContainerChildren.sixChildren = 'lct:org1:Demo_Course:unit:unit-6';
mockGetContainerChildren.childTemplate = {
id: 'lb:org1:Demo_course:html:text',
blockType: 'html',
defKey: 'def_key',
displayName: 'text block',
lastPublished: null,
publishedBy: null,

View File

@@ -245,7 +245,6 @@ export interface CollectionMetadata {
export interface LibraryBlockMetadata {
id: string;
blockType: string;
defKey: string | null;
displayName: string;
lastPublished: string | null;
publishedBy: string | null;
@@ -270,7 +269,6 @@ export interface UpdateLibraryDataRequest {
export interface LibraryPasteClipboardRequest {
libraryId: string;
blockId: string;
}
export interface UpdateXBlockFieldsRequest {
@@ -426,16 +424,10 @@ export async function getBlockTypes(libraryId: string): Promise<BlockTypeMetadat
*/
export async function libraryPasteClipboard({
libraryId,
blockId,
}: LibraryPasteClipboardRequest): Promise<LibraryBlockMetadata> {
const client = getAuthenticatedHttpClient();
const { data } = await client.post(
getLibraryPasteClipboardUrl(libraryId),
{
block_id: blockId,
},
);
return data;
const { data } = await client.post(getLibraryPasteClipboardUrl(libraryId), {});
return camelCaseObject(data);
}
/**
@@ -597,7 +589,7 @@ export async function createLibraryContainer(
}
export interface Container {
containerKey: string;
id: string;
containerType: 'unit';
displayName: string;
lastPublished: string | null;

View File

@@ -191,7 +191,6 @@ describe('library api hooks', () => {
{
id: 'lb:org1:Demo_course:html:text',
block_type: 'html',
def_key: 'def_key',
display_name: 'text block',
last_published: null,
published_by: null,
@@ -206,7 +205,6 @@ describe('library api hooks', () => {
{
id: 'lb:org1:Demo_course:video:video1',
block_type: 'video',
def_key: 'def_key',
display_name: 'video block',
last_published: null,
published_by: null,
@@ -227,7 +225,6 @@ describe('library api hooks', () => {
{
id: 'lb:org1:Demo_course:html:text',
blockType: 'html',
defKey: 'def_key',
displayName: 'text block',
lastPublished: null,
publishedBy: null,
@@ -242,7 +239,6 @@ describe('library api hooks', () => {
{
id: 'lb:org1:Demo_course:video:video1',
blockType: 'video',
defKey: 'def_key',
displayName: 'video block',
lastPublished: null,
publishedBy: null,