diff --git a/src/generic/clipboard/hooks/useCopyToClipboard.js b/src/generic/clipboard/hooks/useCopyToClipboard.js index 86303fab95..ce911c6b36 100644 --- a/src/generic/clipboard/hooks/useCopyToClipboard.js +++ b/src/generic/clipboard/hooks/useCopyToClipboard.js @@ -1,6 +1,9 @@ +// @ts-check import { useEffect, useState } from 'react'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; +import { getClipboard } from '../../data/api'; +import { updateClipboardData } from '../../data/slice'; import { CLIPBOARD_STATUS, STRUCTURAL_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants'; import { getClipboardData } from '../../data/selectors'; @@ -14,6 +17,7 @@ import { getClipboardData } from '../../data/selectors'; * @property {Object} sharedClipboardData - The shared clipboard data object. */ const useCopyToClipboard = (canEdit = true) => { + const dispatch = useDispatch(); const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL)); const [showPasteUnit, setShowPasteUnit] = useState(false); const [showPasteXBlock, setShowPasteXBlock] = useState(false); @@ -30,6 +34,22 @@ const useCopyToClipboard = (canEdit = true) => { setShowPasteUnit(!!isPasteableUnit); }; + // Called on initial render to fetch and populate the initial clipboard data in redux state. + // Without this, the initial clipboard data redux state is always null. + useEffect(() => { + const fetchInitialClipboardData = async () => { + try { + const userClipboard = await getClipboard(); + dispatch(updateClipboardData(userClipboard)); + } catch (error) { + // eslint-disable-next-line no-console + console.error(`Failed to fetch initial clipboard data: ${error}`); + } + }; + + fetchInitialClipboardData(); + }, [dispatch]); + useEffect(() => { // Handle updates to clipboard data if (canEdit) { diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 8e7369184b..3dbd737bc5 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -97,6 +97,13 @@ const libraryData: ContentLibrary = { updated: '2024-07-20', }; +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const RootWrapper = () => ( diff --git a/src/library-authoring/add-content/AddContentContainer.test.tsx b/src/library-authoring/add-content/AddContentContainer.test.tsx index 51b07843c9..6db80f15b5 100644 --- a/src/library-authoring/add-content/AddContentContainer.test.tsx +++ b/src/library-authoring/add-content/AddContentContainer.test.tsx @@ -10,7 +10,10 @@ import MockAdapter from 'axios-mock-adapter'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import AddContentContainer from './AddContentContainer'; import initializeStore from '../../store'; -import { getCreateLibraryBlockUrl } from '../data/api'; +import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api'; +import { getClipboardUrl } from '../../generic/data/api'; + +import { clipboardXBlock } from '../../__mocks__'; const mockUseParams = jest.fn(); let axiosMock; @@ -31,6 +34,13 @@ const queryClient = new QueryClient({ }, }); +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const RootWrapper = () => ( @@ -69,6 +79,7 @@ describe('', () => { expect(screen.getByRole('button', { name: /drag drop/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /video/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /copy from clipboard/i })).not.toBeInTheDocument(); }); it('should create a content', async () => { @@ -82,4 +93,49 @@ describe('', () => { await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url)); }); + + it('should render paste button if clipboard contains pastable xblock', async () => { + const url = getClipboardUrl(); + axiosMock.onGet(url).reply(200, clipboardXBlock); + + render(); + + await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(url)); + + expect(screen.getByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument(); + }); + + it('should paste content', async () => { + const clipboardUrl = getClipboardUrl(); + axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock); + + const pasteUrl = getLibraryPasteClipboardUrl(libraryId); + axiosMock.onPost(pasteUrl).reply(200); + + render(); + + await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl)); + + const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i }); + fireEvent.click(pasteButton); + + await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl)); + }); + + it('should fail pasting content', async () => { + const clipboardUrl = getClipboardUrl(); + axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock); + + const pasteUrl = getLibraryPasteClipboardUrl(libraryId); + axiosMock.onPost(pasteUrl).reply(400); + + render(); + + await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl)); + + const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i }); + fireEvent.click(pasteButton); + + await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl)); + }); }); diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index 9af31593cb..421c81be68 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -1,4 +1,5 @@ import React, { useContext } from 'react'; +import { useSelector } from 'react-redux'; import { Stack, Button, @@ -12,18 +13,25 @@ import { ThumbUpOutline, Question, VideoCamera, + ContentPaste, } from '@openedx/paragon/icons'; import { v4 as uuid4 } from 'uuid'; import { useParams } from 'react-router-dom'; import { ToastContext } from '../../generic/toast-context'; -import { useCreateLibraryBlock } from '../data/apiHooks'; +import { useCopyToClipboard } from '../../generic/clipboard'; +import { getCanEdit } from '../../course-unit/data/selectors'; +import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHooks'; + import messages from './messages'; const AddContentContainer = () => { const intl = useIntl(); const { libraryId } = useParams(); const createBlockMutation = useCreateLibraryBlock(); + const pasteClipboardMutation = useLibraryPasteClipboard(); const { showToast } = useContext(ToastContext); + const canEdit = useSelector(getCanEdit); + const { showPasteXBlock } = useCopyToClipboard(canEdit); const contentTypes = [ { @@ -64,20 +72,47 @@ const AddContentContainer = () => { }, ]; + // Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard + // that can be pasted + if (showPasteXBlock) { + const pasteButton = { + name: intl.formatMessage(messages.pasteButton), + disabled: false, + icon: ContentPaste, + blockType: 'paste', + }; + contentTypes.push(pasteButton); + } + const onCreateContent = (blockType: string) => { if (libraryId) { - createBlockMutation.mutateAsync({ - libraryId, - blockType, - definitionId: `${uuid4()}`, - }).then(() => { - showToast(intl.formatMessage(messages.successCreateMessage)); - }).catch(() => { - showToast(intl.formatMessage(messages.errorCreateMessage)); - }); + if (blockType === 'paste') { + pasteClipboardMutation.mutateAsync({ + libraryId, + blockId: `${uuid4()}`, + }).then(() => { + showToast(intl.formatMessage(messages.successPasteClipboardMessage)); + }).catch(() => { + showToast(intl.formatMessage(messages.errorPasteClipboardMessage)); + }); + } else { + createBlockMutation.mutateAsync({ + libraryId, + blockType, + definitionId: `${uuid4()}`, + }).then(() => { + showToast(intl.formatMessage(messages.successCreateMessage)); + }).catch(() => { + showToast(intl.formatMessage(messages.errorCreateMessage)); + }); + } } }; + if (pasteClipboardMutation.isLoading) { + showToast(intl.formatMessage(messages.pastingClipboardMessage)); + } + return (