Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Library Home - Paste Content [FC-0059] #1187

Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a general approach, I would prefer we replace the redux code with something that uses React Query, rather than finding a way to make the redux code work better. But this is fine for now.

See recently updated frontend recommendations: https://docs.openedx.org/projects/openedx-proposals/en/latest/best-practices/oep-0067-bp-tools-and-technology.html#frontend-technology-selection

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, makes sense, I just wasn't sure if I wanted to do the full refactor to React Query as part of this task/PR, as opposed to a separate PR/task, since it's not specific to pasting in the library.


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
41 changes: 40 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,32 @@ 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));
});
});
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
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'] });
},
});
};
Loading