From f24b89c8474183615f0a9fb9e819b9f018364dcb Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Tue, 15 Apr 2025 15:26:19 -0700 Subject: [PATCH] feat: allow pasting units from a course into a library (#1812) --- src/__mocks__/clipboardSubsection.js | 16 ++++++++++++++++ src/__mocks__/index.js | 1 + .../clipboard/hooks/useClipboard.test.tsx | 4 +++- .../add-content/AddContent.test.tsx | 17 +++++++++++++++++ .../add-content/AddContent.tsx | 15 +++++++++++---- src/library-authoring/data/api.mocks.ts | 10 +--------- src/library-authoring/data/api.ts | 14 +++----------- src/library-authoring/data/apiHooks.test.tsx | 4 ---- 8 files changed, 52 insertions(+), 29 deletions(-) create mode 100644 src/__mocks__/clipboardSubsection.js diff --git a/src/__mocks__/clipboardSubsection.js b/src/__mocks__/clipboardSubsection.js new file mode 100644 index 000000000..541e95f97 --- /dev/null +++ b/src/__mocks__/clipboardSubsection.js @@ -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', +}; diff --git a/src/__mocks__/index.js b/src/__mocks__/index.js index b3b5984d3..abacaea4a 100644 --- a/src/__mocks__/index.js +++ b/src/__mocks__/index.js @@ -1,2 +1,3 @@ export { default as clipboardUnit } from './clipboardUnit'; +export { default as clipboardSubsection } from './clipboardSubsection'; export { default as clipboardXBlock } from './clipboardXBlock'; diff --git a/src/generic/clipboard/hooks/useClipboard.test.tsx b/src/generic/clipboard/hooks/useClipboard.test.tsx index 0b73ef8b4..74a0fb1e0 100644 --- a/src/generic/clipboard/hooks/useClipboard.test.tsx +++ b/src/generic/clipboard/hooks/useClipboard.test.tsx @@ -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(); diff --git a/src/library-authoring/add-content/AddContent.test.tsx b/src/library-authoring/add-content/AddContent.test.tsx index b761b3e92..d6c1ea1b8 100644 --- a/src/library-authoring/add-content/AddContent.test.tsx +++ b/src/library-authoring/add-content/AddContent.test.tsx @@ -207,6 +207,23 @@ describe('', () => { 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(); diff --git a/src/library-authoring/add-content/AddContent.tsx b/src/library-authoring/add-content/AddContent.tsx index 08b924d7a..7610b743f 100644 --- a/src/library-authoring/add-content/AddContent.tsx +++ b/src/library-authoring/add-content/AddContent.tsx @@ -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)); diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts index 5b5ba4348..9a71c8aa8 100644 --- a/src/library-authoring/data/api.mocks.ts +++ b/src/library-authoring/data/api.mocks.ts @@ -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, diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 30feaa9bc..b4a7bca67 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -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 { 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; diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index e4c6a7dba..57e0a8fa7 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -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,