Skip to content

Commit

Permalink
feat: Add "Paste from Clipboard" to lib v2 sidebar (#1187)
Browse files Browse the repository at this point in the history
  • Loading branch information
yusuf-musleh authored Aug 15, 2024
1 parent 7c59b4a commit 95ac098
Show file tree
Hide file tree
Showing 10 changed files with 209 additions and 14 deletions.
22 changes: 21 additions & 1 deletion src/generic/clipboard/hooks/useCopyToClipboard.js
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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);
Expand All @@ -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) {
Expand Down
7 changes: 7 additions & 0 deletions src/library-authoring/LibraryAuthoringPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={{}}>
Expand Down
58 changes: 57 additions & 1 deletion src/library-authoring/add-content/AddContentContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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={{}}>
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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));
});
});
55 changes: 45 additions & 10 deletions src/library-authoring/add-content/AddContentContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useContext } from 'react';
import { useSelector } from 'react-redux';
import {
Stack,
Button,
Expand All @@ -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 = [
{
Expand Down Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/library-authoring/add-content/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand All @@ -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;
7 changes: 7 additions & 0 deletions src/library-authoring/components/ComponentCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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">
Expand Down
9 changes: 7 additions & 2 deletions src/library-authoring/components/ComponentCard.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand All @@ -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)));
};

Expand Down
7 changes: 7 additions & 0 deletions src/library-authoring/components/LibraryComponents.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={{}}>
Expand Down
26 changes: 26 additions & 0 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,6 +105,11 @@ export interface UpdateLibraryDataRequest {
license?: string;
}

export interface LibraryPasteClipboardRequest {
libraryId: string;
blockId: string;
}

/**
* Fetch block types of a library
*/
Expand Down Expand Up @@ -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;
}
12 changes: 12 additions & 0 deletions src/library-authoring/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
revertLibraryChanges,
updateLibraryMetadata,
ContentLibrary,
libraryPasteClipboard,
} from './api';

export const libraryAuthoringQueryKeys = {
Expand Down Expand Up @@ -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'] });
},
});
};

0 comments on commit 95ac098

Please sign in to comment.