feat: Add "Paste from Clipboard" to lib v2 sidebar (#1187)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
|
||||
@@ -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 = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
@@ -69,6 +79,7 @@ describe('<AddContentContainer />', () => {
|
||||
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('<AddContentContainer />', () => {
|
||||
|
||||
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(<RootWrapper />);
|
||||
|
||||
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(<RootWrapper />);
|
||||
|
||||
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(<RootWrapper />);
|
||||
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 (
|
||||
<Stack direction="vertical">
|
||||
<Button
|
||||
|
||||
@@ -40,6 +40,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Advanced / Other',
|
||||
description: 'Content of button to create a Advanced / Other component.',
|
||||
},
|
||||
pasteButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.paste',
|
||||
defaultMessage: 'Paste From Clipboard',
|
||||
description: 'Content of button to paste from clipboard.',
|
||||
},
|
||||
successCreateMessage: {
|
||||
id: 'course-authoring.library-authoring.add-content.success.text',
|
||||
defaultMessage: 'Content created successfully.',
|
||||
@@ -55,6 +60,21 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Add Content',
|
||||
description: 'Title of add content in library container.',
|
||||
},
|
||||
successPasteClipboardMessage: {
|
||||
id: 'course-authoring.library-authoring.paste-clipboard.success.text',
|
||||
defaultMessage: 'Content pasted successfully.',
|
||||
description: 'Message when pasting clipboard in library is successful',
|
||||
},
|
||||
errorPasteClipboardMessage: {
|
||||
id: 'course-authoring.library-authoring.paste-clipboard.error.text',
|
||||
defaultMessage: 'There was an error pasting the content.',
|
||||
description: 'Message when pasting clipboard in library errors',
|
||||
},
|
||||
pastingClipboardMessage: {
|
||||
id: 'course-authoring.library-authoring.paste-clipboard.loading.text',
|
||||
defaultMessage: 'Pasting content from clipboard...',
|
||||
description: 'Message when in process of pasting content in library',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -40,6 +40,13 @@ const contentHit: ContentHit = {
|
||||
lastPublished: null,
|
||||
};
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import React, { useContext, useMemo, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
@@ -17,6 +17,7 @@ import TagCount from '../../generic/tag-count';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { type ContentHit, Highlight } from '../../search-manager';
|
||||
import messages from './messages';
|
||||
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
|
||||
|
||||
type ComponentCardProps = {
|
||||
contentHit: ContentHit,
|
||||
@@ -26,9 +27,13 @@ type ComponentCardProps = {
|
||||
const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
const intl = useIntl();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
const updateClipboardClick = () => {
|
||||
updateClipboard(usageKey)
|
||||
.then(() => showToast(intl.formatMessage(messages.copyToClipboardSuccess)))
|
||||
.then((clipboardData) => {
|
||||
clipboardBroadcastChannel.postMessage(clipboardData);
|
||||
showToast(intl.formatMessage(messages.copyToClipboardSuccess));
|
||||
})
|
||||
.catch(() => showToast(intl.formatMessage(messages.copyToClipboardError)));
|
||||
};
|
||||
|
||||
|
||||
@@ -84,6 +84,13 @@ jest.mock('../../search-manager', () => ({
|
||||
useSearchContext: () => mockUseSearchContext(),
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const RootWrapper = (props) => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
|
||||
@@ -20,6 +20,10 @@ export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libra
|
||||
* Get the URL for commit/revert changes in library.
|
||||
*/
|
||||
export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/`;
|
||||
/**
|
||||
* Get the URL for paste clipboard content into library.
|
||||
*/
|
||||
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
|
||||
|
||||
export interface ContentLibrary {
|
||||
id: string;
|
||||
@@ -101,6 +105,11 @@ export interface UpdateLibraryDataRequest {
|
||||
license?: string;
|
||||
}
|
||||
|
||||
export interface LibraryPasteClipboardRequest {
|
||||
libraryId: string;
|
||||
blockId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch block types of a library
|
||||
*/
|
||||
@@ -185,3 +194,20 @@ export async function revertLibraryChanges(libraryId: string) {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
await client.delete(getCommitLibraryChangesUrl(libraryId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Paste clipboard content into library.
|
||||
*/
|
||||
export async function libraryPasteClipboard({
|
||||
libraryId,
|
||||
blockId,
|
||||
}: LibraryPasteClipboardRequest): Promise<CreateBlockDataResponse> {
|
||||
const client = getAuthenticatedHttpClient();
|
||||
const { data } = await client.post(
|
||||
getLibraryPasteClipboardUrl(libraryId),
|
||||
{
|
||||
block_id: blockId,
|
||||
},
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
revertLibraryChanges,
|
||||
updateLibraryMetadata,
|
||||
ContentLibrary,
|
||||
libraryPasteClipboard,
|
||||
} from './api';
|
||||
|
||||
export const libraryAuthoringQueryKeys = {
|
||||
@@ -124,3 +125,14 @@ export const useRevertLibraryChanges = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useLibraryPasteClipboard = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: libraryPasteClipboard,
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.libraryId) });
|
||||
queryClient.invalidateQueries({ queryKey: ['content_search'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user