Skip to content

Commit 95ac098

Browse files
authored
feat: Add "Paste from Clipboard" to lib v2 sidebar (#1187)
1 parent 7c59b4a commit 95ac098

File tree

10 files changed

+209
-14
lines changed

10 files changed

+209
-14
lines changed

src/generic/clipboard/hooks/useCopyToClipboard.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
// @ts-check
12
import { useEffect, useState } from 'react';
2-
import { useSelector } from 'react-redux';
3+
import { useDispatch, useSelector } from 'react-redux';
34

5+
import { getClipboard } from '../../data/api';
6+
import { updateClipboardData } from '../../data/slice';
47
import { CLIPBOARD_STATUS, STRUCTURAL_XBLOCK_TYPES, STUDIO_CLIPBOARD_CHANNEL } from '../../../constants';
58
import { getClipboardData } from '../../data/selectors';
69

@@ -14,6 +17,7 @@ import { getClipboardData } from '../../data/selectors';
1417
* @property {Object} sharedClipboardData - The shared clipboard data object.
1518
*/
1619
const useCopyToClipboard = (canEdit = true) => {
20+
const dispatch = useDispatch();
1721
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
1822
const [showPasteUnit, setShowPasteUnit] = useState(false);
1923
const [showPasteXBlock, setShowPasteXBlock] = useState(false);
@@ -30,6 +34,22 @@ const useCopyToClipboard = (canEdit = true) => {
3034
setShowPasteUnit(!!isPasteableUnit);
3135
};
3236

37+
// Called on initial render to fetch and populate the initial clipboard data in redux state.
38+
// Without this, the initial clipboard data redux state is always null.
39+
useEffect(() => {
40+
const fetchInitialClipboardData = async () => {
41+
try {
42+
const userClipboard = await getClipboard();
43+
dispatch(updateClipboardData(userClipboard));
44+
} catch (error) {
45+
// eslint-disable-next-line no-console
46+
console.error(`Failed to fetch initial clipboard data: ${error}`);
47+
}
48+
};
49+
50+
fetchInitialClipboardData();
51+
}, [dispatch]);
52+
3353
useEffect(() => {
3454
// Handle updates to clipboard data
3555
if (canEdit) {

src/library-authoring/LibraryAuthoringPage.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ const libraryData: ContentLibrary = {
9797
updated: '2024-07-20',
9898
};
9999

100+
const clipboardBroadcastChannelMock = {
101+
postMessage: jest.fn(),
102+
close: jest.fn(),
103+
};
104+
105+
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
106+
100107
const RootWrapper = () => (
101108
<AppProvider store={store}>
102109
<IntlProvider locale="en" messages={{}}>

src/library-authoring/add-content/AddContentContainer.test.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import MockAdapter from 'axios-mock-adapter';
1010
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
1111
import AddContentContainer from './AddContentContainer';
1212
import initializeStore from '../../store';
13-
import { getCreateLibraryBlockUrl } from '../data/api';
13+
import { getCreateLibraryBlockUrl, getLibraryPasteClipboardUrl } from '../data/api';
14+
import { getClipboardUrl } from '../../generic/data/api';
15+
16+
import { clipboardXBlock } from '../../__mocks__';
1417

1518
const mockUseParams = jest.fn();
1619
let axiosMock;
@@ -31,6 +34,13 @@ const queryClient = new QueryClient({
3134
},
3235
});
3336

37+
const clipboardBroadcastChannelMock = {
38+
postMessage: jest.fn(),
39+
close: jest.fn(),
40+
};
41+
42+
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
43+
3444
const RootWrapper = () => (
3545
<AppProvider store={store}>
3646
<IntlProvider locale="en" messages={{}}>
@@ -69,6 +79,7 @@ describe('<AddContentContainer />', () => {
6979
expect(screen.getByRole('button', { name: /drag drop/i })).toBeInTheDocument();
7080
expect(screen.getByRole('button', { name: /video/i })).toBeInTheDocument();
7181
expect(screen.getByRole('button', { name: /advanced \/ other/i })).toBeInTheDocument();
82+
expect(screen.queryByRole('button', { name: /copy from clipboard/i })).not.toBeInTheDocument();
7283
});
7384

7485
it('should create a content', async () => {
@@ -82,4 +93,49 @@ describe('<AddContentContainer />', () => {
8293

8394
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(url));
8495
});
96+
97+
it('should render paste button if clipboard contains pastable xblock', async () => {
98+
const url = getClipboardUrl();
99+
axiosMock.onGet(url).reply(200, clipboardXBlock);
100+
101+
render(<RootWrapper />);
102+
103+
await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(url));
104+
105+
expect(screen.getByRole('button', { name: /paste from clipboard/i })).toBeInTheDocument();
106+
});
107+
108+
it('should paste content', async () => {
109+
const clipboardUrl = getClipboardUrl();
110+
axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock);
111+
112+
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
113+
axiosMock.onPost(pasteUrl).reply(200);
114+
115+
render(<RootWrapper />);
116+
117+
await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl));
118+
119+
const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i });
120+
fireEvent.click(pasteButton);
121+
122+
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
123+
});
124+
125+
it('should fail pasting content', async () => {
126+
const clipboardUrl = getClipboardUrl();
127+
axiosMock.onGet(clipboardUrl).reply(200, clipboardXBlock);
128+
129+
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
130+
axiosMock.onPost(pasteUrl).reply(400);
131+
132+
render(<RootWrapper />);
133+
134+
await waitFor(() => expect(axiosMock.history.get[0].url).toEqual(clipboardUrl));
135+
136+
const pasteButton = screen.getByRole('button', { name: /paste from clipboard/i });
137+
fireEvent.click(pasteButton);
138+
139+
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
140+
});
85141
});

src/library-authoring/add-content/AddContentContainer.tsx

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React, { useContext } from 'react';
2+
import { useSelector } from 'react-redux';
23
import {
34
Stack,
45
Button,
@@ -12,18 +13,25 @@ import {
1213
ThumbUpOutline,
1314
Question,
1415
VideoCamera,
16+
ContentPaste,
1517
} from '@openedx/paragon/icons';
1618
import { v4 as uuid4 } from 'uuid';
1719
import { useParams } from 'react-router-dom';
1820
import { ToastContext } from '../../generic/toast-context';
19-
import { useCreateLibraryBlock } from '../data/apiHooks';
21+
import { useCopyToClipboard } from '../../generic/clipboard';
22+
import { getCanEdit } from '../../course-unit/data/selectors';
23+
import { useCreateLibraryBlock, useLibraryPasteClipboard } from '../data/apiHooks';
24+
2025
import messages from './messages';
2126

2227
const AddContentContainer = () => {
2328
const intl = useIntl();
2429
const { libraryId } = useParams();
2530
const createBlockMutation = useCreateLibraryBlock();
31+
const pasteClipboardMutation = useLibraryPasteClipboard();
2632
const { showToast } = useContext(ToastContext);
33+
const canEdit = useSelector(getCanEdit);
34+
const { showPasteXBlock } = useCopyToClipboard(canEdit);
2735

2836
const contentTypes = [
2937
{
@@ -64,20 +72,47 @@ const AddContentContainer = () => {
6472
},
6573
];
6674

75+
// Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard
76+
// that can be pasted
77+
if (showPasteXBlock) {
78+
const pasteButton = {
79+
name: intl.formatMessage(messages.pasteButton),
80+
disabled: false,
81+
icon: ContentPaste,
82+
blockType: 'paste',
83+
};
84+
contentTypes.push(pasteButton);
85+
}
86+
6787
const onCreateContent = (blockType: string) => {
6888
if (libraryId) {
69-
createBlockMutation.mutateAsync({
70-
libraryId,
71-
blockType,
72-
definitionId: `${uuid4()}`,
73-
}).then(() => {
74-
showToast(intl.formatMessage(messages.successCreateMessage));
75-
}).catch(() => {
76-
showToast(intl.formatMessage(messages.errorCreateMessage));
77-
});
89+
if (blockType === 'paste') {
90+
pasteClipboardMutation.mutateAsync({
91+
libraryId,
92+
blockId: `${uuid4()}`,
93+
}).then(() => {
94+
showToast(intl.formatMessage(messages.successPasteClipboardMessage));
95+
}).catch(() => {
96+
showToast(intl.formatMessage(messages.errorPasteClipboardMessage));
97+
});
98+
} else {
99+
createBlockMutation.mutateAsync({
100+
libraryId,
101+
blockType,
102+
definitionId: `${uuid4()}`,
103+
}).then(() => {
104+
showToast(intl.formatMessage(messages.successCreateMessage));
105+
}).catch(() => {
106+
showToast(intl.formatMessage(messages.errorCreateMessage));
107+
});
108+
}
78109
}
79110
};
80111

112+
if (pasteClipboardMutation.isLoading) {
113+
showToast(intl.formatMessage(messages.pastingClipboardMessage));
114+
}
115+
81116
return (
82117
<Stack direction="vertical">
83118
<Button

src/library-authoring/add-content/messages.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ const messages = defineMessages({
4040
defaultMessage: 'Advanced / Other',
4141
description: 'Content of button to create a Advanced / Other component.',
4242
},
43+
pasteButton: {
44+
id: 'course-authoring.library-authoring.add-content.buttons.paste',
45+
defaultMessage: 'Paste From Clipboard',
46+
description: 'Content of button to paste from clipboard.',
47+
},
4348
successCreateMessage: {
4449
id: 'course-authoring.library-authoring.add-content.success.text',
4550
defaultMessage: 'Content created successfully.',
@@ -55,6 +60,21 @@ const messages = defineMessages({
5560
defaultMessage: 'Add Content',
5661
description: 'Title of add content in library container.',
5762
},
63+
successPasteClipboardMessage: {
64+
id: 'course-authoring.library-authoring.paste-clipboard.success.text',
65+
defaultMessage: 'Content pasted successfully.',
66+
description: 'Message when pasting clipboard in library is successful',
67+
},
68+
errorPasteClipboardMessage: {
69+
id: 'course-authoring.library-authoring.paste-clipboard.error.text',
70+
defaultMessage: 'There was an error pasting the content.',
71+
description: 'Message when pasting clipboard in library errors',
72+
},
73+
pastingClipboardMessage: {
74+
id: 'course-authoring.library-authoring.paste-clipboard.loading.text',
75+
defaultMessage: 'Pasting content from clipboard...',
76+
description: 'Message when in process of pasting content in library',
77+
},
5878
});
5979

6080
export default messages;

src/library-authoring/components/ComponentCard.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ const contentHit: ContentHit = {
4040
lastPublished: null,
4141
};
4242

43+
const clipboardBroadcastChannelMock = {
44+
postMessage: jest.fn(),
45+
close: jest.fn(),
46+
};
47+
48+
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
49+
4350
const RootWrapper = () => (
4451
<AppProvider store={store}>
4552
<IntlProvider locale="en">

src/library-authoring/components/ComponentCard.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useContext, useMemo } from 'react';
1+
import React, { useContext, useMemo, useState } from 'react';
22
import { useIntl } from '@edx/frontend-platform/i18n';
33
import {
44
ActionRow,
@@ -17,6 +17,7 @@ import TagCount from '../../generic/tag-count';
1717
import { ToastContext } from '../../generic/toast-context';
1818
import { type ContentHit, Highlight } from '../../search-manager';
1919
import messages from './messages';
20+
import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants';
2021

2122
type ComponentCardProps = {
2223
contentHit: ContentHit,
@@ -26,9 +27,13 @@ type ComponentCardProps = {
2627
const ComponentCardMenu = ({ usageKey }: { usageKey: string }) => {
2728
const intl = useIntl();
2829
const { showToast } = useContext(ToastContext);
30+
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
2931
const updateClipboardClick = () => {
3032
updateClipboard(usageKey)
31-
.then(() => showToast(intl.formatMessage(messages.copyToClipboardSuccess)))
33+
.then((clipboardData) => {
34+
clipboardBroadcastChannel.postMessage(clipboardData);
35+
showToast(intl.formatMessage(messages.copyToClipboardSuccess));
36+
})
3237
.catch(() => showToast(intl.formatMessage(messages.copyToClipboardError)));
3338
};
3439

src/library-authoring/components/LibraryComponents.test.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ jest.mock('../../search-manager', () => ({
8484
useSearchContext: () => mockUseSearchContext(),
8585
}));
8686

87+
const clipboardBroadcastChannelMock = {
88+
postMessage: jest.fn(),
89+
close: jest.fn(),
90+
};
91+
92+
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
93+
8794
const RootWrapper = (props) => (
8895
<AppProvider store={store}>
8996
<IntlProvider locale="en" messages={{}}>

src/library-authoring/data/api.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libra
2020
* Get the URL for commit/revert changes in library.
2121
*/
2222
export const getCommitLibraryChangesUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/commit/`;
23+
/**
24+
* Get the URL for paste clipboard content into library.
25+
*/
26+
export const getLibraryPasteClipboardUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/paste_clipboard/`;
2327

2428
export interface ContentLibrary {
2529
id: string;
@@ -101,6 +105,11 @@ export interface UpdateLibraryDataRequest {
101105
license?: string;
102106
}
103107

108+
export interface LibraryPasteClipboardRequest {
109+
libraryId: string;
110+
blockId: string;
111+
}
112+
104113
/**
105114
* Fetch block types of a library
106115
*/
@@ -185,3 +194,20 @@ export async function revertLibraryChanges(libraryId: string) {
185194
const client = getAuthenticatedHttpClient();
186195
await client.delete(getCommitLibraryChangesUrl(libraryId));
187196
}
197+
198+
/**
199+
* Paste clipboard content into library.
200+
*/
201+
export async function libraryPasteClipboard({
202+
libraryId,
203+
blockId,
204+
}: LibraryPasteClipboardRequest): Promise<CreateBlockDataResponse> {
205+
const client = getAuthenticatedHttpClient();
206+
const { data } = await client.post(
207+
getLibraryPasteClipboardUrl(libraryId),
208+
{
209+
block_id: blockId,
210+
},
211+
);
212+
return data;
213+
}

src/library-authoring/data/apiHooks.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
revertLibraryChanges,
1111
updateLibraryMetadata,
1212
ContentLibrary,
13+
libraryPasteClipboard,
1314
} from './api';
1415

1516
export const libraryAuthoringQueryKeys = {
@@ -124,3 +125,14 @@ export const useRevertLibraryChanges = () => {
124125
},
125126
});
126127
};
128+
129+
export const useLibraryPasteClipboard = () => {
130+
const queryClient = useQueryClient();
131+
return useMutation({
132+
mutationFn: libraryPasteClipboard,
133+
onSettled: (_data, _error, variables) => {
134+
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibrary(variables.libraryId) });
135+
queryClient.invalidateQueries({ queryKey: ['content_search'] });
136+
},
137+
});
138+
};

0 commit comments

Comments
 (0)