fix: excessive calls to the clipboard API endpoint (#1700)

This commit is contained in:
Rômulo Penido
2025-03-07 13:16:39 -03:00
committed by GitHub
parent 0eda5aec23
commit dbba4dd296
37 changed files with 424 additions and 510 deletions

View File

@@ -0,0 +1,21 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
copying: {
id: 'copypaste.copying',
defaultMessage: 'Copying',
description: 'Message shown when copying content to clipboard',
},
done: {
id: 'copypaste.done',
defaultMessage: 'Copied to clipboard',
description: 'Message shown when content is copied to clipboard',
},
error: {
id: 'copypaste.error',
defaultMessage: 'Error copying to clipboard',
description: 'Message shown when an error occurs while copying content to clipboard',
},
});
export default messages;

View File

@@ -0,0 +1,119 @@
import { renderHook } from '@testing-library/react-hooks';
import MockAdapter from 'axios-mock-adapter';
import {
clipboardUnit,
clipboardXBlock,
} from '../../../__mocks__';
import { initializeMocks, makeWrapper } from '../../../testUtils';
import { getClipboardUrl } from '../../data/api';
import useClipboard from './useClipboard';
initializeMocks();
let axiosMock: MockAdapter;
let mockShowToast: jest.Mock;
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
onmessage: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
describe('useClipboard', () => {
beforeEach(async () => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast as jest.Mock;
});
afterEach(() => {
axiosMock.restore();
});
describe('clipboard data update effect', () => {
it('returns falsy flags if canEdit = false', async () => {
const { result, rerender } = renderHook(() => useClipboard(false), { wrapper: makeWrapper() });
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
await result.current.copyToClipboard(unitId);
rerender();
expect(mockShowToast).toHaveBeenCalledWith('Copying');
expect(mockShowToast).toHaveBeenCalledWith('Copied to clipboard');
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(false);
});
it('returns flag to display the Paste Unit button', async () => {
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
await result.current.copyToClipboard(unitId);
rerender();
expect(result.current.showPasteUnit).toBe(true);
expect(result.current.showPasteXBlock).toBe(false);
});
it('returns flag to display the Paste XBlock button', async () => {
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardXBlock);
await result.current.copyToClipboard(xblockId);
rerender();
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(true);
});
});
describe('broadcast channel message handling', () => {
it('updates states correctly on receiving a broadcast message', async () => {
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
rerender();
expect(result.current.showPasteUnit).toBe(true);
expect(result.current.showPasteXBlock).toBe(false);
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
rerender();
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(true);
});
});
it('shows the current status while copying to clipboard', async () => {
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
axiosMock
.onPost(getClipboardUrl())
.networkError();
await result.current.copyToClipboard(unitId);
rerender();
expect(mockShowToast).toHaveBeenCalledWith('Error copying to clipboard');
});
});

View File

@@ -0,0 +1,82 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useContext, useEffect, useState } from 'react';
import { getClipboard, updateClipboard } from '../../data/api';
import {
CLIPBOARD_STATUS,
STRUCTURAL_XBLOCK_TYPES,
STUDIO_CLIPBOARD_CHANNEL,
} from '../../../constants';
import { ToastContext } from '../../toast-context';
import messages from './messages';
/**
* Custom React hook for managing clipboard functionality.
*
* @param canEdit - Flag indicating whether the clipboard is editable.
* @returns - An object containing state variables and functions related to clipboard functionality.
* @property showPasteUnit - Flag indicating whether the "Paste Unit" button should be visible.
* @property showPasteXBlock - Flag indicating whether the "Paste XBlock" button should be visible.
* @property sharedClipboardData - The shared clipboard data object.
* @property copyToClipboard - Function to copy the current selection to the clipboard.
*/
const useClipboard = (canEdit: boolean = true) => {
const intl = useIntl();
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
const { data: clipboardData } = useQuery({
queryKey: ['clipboard'],
queryFn: getClipboard,
refetchInterval: (data) => (data?.content?.status === CLIPBOARD_STATUS.loading ? 1000 : false),
});
const { showToast } = useContext(ToastContext);
const queryClient = useQueryClient();
const copyToClipboard = async (usageKey: string) => {
// This code is synchronous for now, but it could be made asynchronous in the future.
// In that case, the `done` message should be shown after the asynchronous operation completes.
showToast(intl.formatMessage(messages.copying));
try {
const newData = await updateClipboard(usageKey);
clipboardBroadcastChannel.postMessage(newData);
queryClient.setQueryData(['clipboard'], newData);
showToast(intl.formatMessage(messages.done));
} catch (error) {
showToast(intl.formatMessage(messages.error));
}
};
useEffect(() => {
// Handle messages from the broadcast channel
clipboardBroadcastChannel.onmessage = (event) => {
// Note: if this useClipboard() hook is used many times on one page,
// this will result in many separate calls to setQueryData() whenever
// the clipboard contents change, but that is fine and shouldn't actually
// cause any issues. If it did, we could refactor this into a
// <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
// rather than having a separate channel per useClipboard hook.
queryClient.setQueryData(['clipboard'], event.data);
};
// Cleanup function for the BroadcastChannel when the hook is unmounted
return () => {
clipboardBroadcastChannel.close();
};
}, [clipboardBroadcastChannel]);
const isPasteable = canEdit && clipboardData?.content?.status !== CLIPBOARD_STATUS.expired;
const showPasteUnit = isPasteable && clipboardData?.content?.blockType === 'vertical';
const showPasteXBlock = isPasteable
&& clipboardData?.content
&& !STRUCTURAL_XBLOCK_TYPES.includes(clipboardData.content?.blockType);
return {
showPasteUnit,
showPasteXBlock,
sharedClipboardData: clipboardData,
copyToClipboard,
};
};
export default useClipboard;

View File

@@ -1,81 +0,0 @@
// @ts-check
import { useEffect, useState } from 'react';
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';
/**
* Custom React hook for managing clipboard functionality.
*
* @param {boolean} canEdit - Flag indicating whether the clipboard is editable.
* @returns {Object} - An object containing state variables and functions related to clipboard functionality.
* @property {boolean} showPasteUnit - Flag indicating whether the "Paste Unit" button should be visible.
* @property {boolean} showPasteXBlock - Flag indicating whether the "Paste XBlock" button should be visible.
* @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);
const [sharedClipboardData, setSharedClipboardData] = useState({});
const clipboardData = useSelector(getClipboardData);
// Function to refresh the paste button's visibility
const refreshPasteButton = (data) => {
const isPasteable = canEdit && data?.content && data.content.status !== CLIPBOARD_STATUS.expired;
const isPasteableXBlock = isPasteable && !STRUCTURAL_XBLOCK_TYPES.includes(data.content.blockType);
const isPasteableUnit = isPasteable && data.content.blockType === 'vertical';
setShowPasteXBlock(!!isPasteableXBlock);
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) {
refreshPasteButton(clipboardData);
setSharedClipboardData(clipboardData);
clipboardBroadcastChannel.postMessage(clipboardData);
} else {
setShowPasteXBlock(false);
setShowPasteUnit(false);
}
}, [clipboardData, canEdit, clipboardBroadcastChannel]);
useEffect(() => {
// Handle messages from the broadcast channel
clipboardBroadcastChannel.onmessage = (event) => {
setSharedClipboardData(event.data);
refreshPasteButton(event.data);
};
// Cleanup function for the BroadcastChannel when the hook is unmounted
return () => {
clipboardBroadcastChannel.close();
};
}, [clipboardBroadcastChannel]);
return { showPasteUnit, showPasteXBlock, sharedClipboardData };
};
export default useCopyToClipboard;

View File

@@ -1,122 +0,0 @@
import { renderHook, act } from '@testing-library/react-hooks';
import { Provider } from 'react-redux';
import { initializeMockApp } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import initializeStore from '../../../store';
import { executeThunk } from '../../../utils';
import { clipboardUnit, clipboardXBlock } from '../../../__mocks__';
import { copyToClipboard } from '../../data/thunks';
import { getClipboardUrl } from '../../data/api';
import useCopyToClipboard from './useCopyToClipboard';
let axiosMock;
let store;
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
};
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
const wrapper = ({ children }) => (
<Provider store={store}>
<IntlProvider locale="en">
{children}
</IntlProvider>
</Provider>
);
describe('useCopyToClipboard', () => {
beforeEach(async () => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
store = initializeStore();
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('initializes correctly', () => {
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(false);
});
describe('clipboard data update effect', () => {
it('returns falsy flags if canEdit = false', async () => {
const { result } = renderHook(() => useCopyToClipboard(false), { wrapper });
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
await act(async () => {
await executeThunk(copyToClipboard(unitId), store.dispatch);
});
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(false);
});
it('returns flag to display the Paste Unit button', async () => {
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardUnit);
await act(async () => {
await executeThunk(copyToClipboard(unitId), store.dispatch);
});
expect(result.current.showPasteUnit).toBe(true);
expect(result.current.showPasteXBlock).toBe(false);
});
it('returns flag to display the Paste XBlock button', async () => {
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardXBlock);
axiosMock
.onGet(getClipboardUrl())
.reply(200, clipboardXBlock);
await act(async () => {
await executeThunk(copyToClipboard(xblockId), store.dispatch);
});
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(true);
});
});
describe('broadcast channel message handling', () => {
it('updates states correctly on receiving a broadcast message', async () => {
const { result } = renderHook(() => useCopyToClipboard(true), { wrapper });
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
expect(result.current.showPasteUnit).toBe(true);
expect(result.current.showPasteXBlock).toBe(false);
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
expect(result.current.showPasteUnit).toBe(false);
expect(result.current.showPasteXBlock).toBe(true);
});
});
});

View File

@@ -1,2 +1,2 @@
export { default as useCopyToClipboard } from './hooks/useCopyToClipboard';
export { default as useClipboard } from './hooks/useClipboard';
export { default as PasteComponent } from './paste-component';

View File

@@ -1,36 +0,0 @@
import PropsTypes from 'prop-types';
import { useParams } from 'react-router-dom';
import { Button } from '@openedx/paragon';
import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
const PasteButton = ({ onClick, text, className }) => {
const { blockId } = useParams();
const handlePasteXBlockComponent = () => {
onClick({ stagedContent: 'clipboard', parentLocator: blockId });
};
return (
<Button
className={className}
iconBefore={ContentCopyIcon}
variant="outline-primary"
block
onClick={handlePasteXBlockComponent}
>
{text}
</Button>
);
};
PasteButton.propTypes = {
onClick: PropsTypes.func.isRequired,
text: PropsTypes.string.isRequired,
className: PropsTypes.string,
};
PasteButton.defaultProps = {
className: undefined,
};
export default PasteButton;

View File

@@ -0,0 +1,22 @@
import { Button } from '@openedx/paragon';
import { ContentCopy as ContentCopyIcon } from '@openedx/paragon/icons';
interface PasteButtonProps {
onClick: () => void;
text: string;
className?: string;
}
const PasteButton = ({ onClick, text, className }: PasteButtonProps) => (
<Button
className={className}
iconBefore={ContentCopyIcon}
variant="outline-primary"
block
onClick={onClick}
>
{text}
</Button>
);
export default PasteButton;

View File

@@ -1,16 +1,24 @@
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon, Popover, Stack } from '@openedx/paragon';
import { OpenInNew as OpenInNewIcon } from '@openedx/paragon/icons';
import type { ClipboardStatus } from '../../../data/api';
import messages from '../messages';
import { clipboardPropsTypes } from '../constants';
const PopoverContent = ({ clipboardData }) => {
interface PopoverContentProps {
clipboardData: ClipboardStatus,
}
const PopoverContent = ({ clipboardData } : PopoverContentProps) => {
const intl = useIntl();
const { sourceEditUrl, content, sourceContextTitle } = clipboardData;
// istanbul ignore if: this should never happen
if (!content) {
return null;
}
return (
<Popover.Title
className="clipboard-popover-title"
@@ -40,8 +48,4 @@ const PopoverContent = ({ clipboardData }) => {
);
};
PopoverContent.propTypes = {
clipboardData: PropTypes.shape(clipboardPropsTypes).isRequired,
};
export default PopoverContent;

View File

@@ -1,14 +1,19 @@
import { useRef } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Question as QuestionIcon } from '@openedx/paragon/icons';
import messages from '../messages';
interface WhatsInClipboardProps {
handlePopoverToggle: (show: boolean) => void;
togglePopover: (show: boolean) => void;
popoverElementRef: React.RefObject<HTMLDivElement>;
}
const WhatsInClipboard = ({
handlePopoverToggle, togglePopover, popoverElementRef,
}) => {
}: WhatsInClipboardProps) => {
const intl = useIntl();
const triggerElementRef = useRef(null);
@@ -46,13 +51,4 @@ const WhatsInClipboard = ({
);
};
WhatsInClipboard.propTypes = {
handlePopoverToggle: PropTypes.func.isRequired,
togglePopover: PropTypes.func.isRequired,
popoverElementRef: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]).isRequired,
};
export default WhatsInClipboard;

View File

@@ -1,10 +0,0 @@
import PropTypes from 'prop-types';
export const clipboardPropsTypes = {
sourceEditUrl: PropTypes.string.isRequired,
content: PropTypes.shape({
displayName: PropTypes.string.isRequired,
blockTypeDisplay: PropTypes.string.isRequired,
}).isRequired,
sourceContextTitle: PropTypes.string.isRequired,
};

View File

@@ -1,19 +1,25 @@
import { useRef, useState } from 'react';
import PropTypes from 'prop-types';
import { OverlayTrigger, Popover } from '@openedx/paragon';
import { PopoverContent, PasteButton, WhatsInClipboard } from './components';
import { clipboardPropsTypes } from './constants';
import type { ClipboardStatus } from '../../data/api';
interface PasteComponentProps {
onClick: () => void;
clipboardData: ClipboardStatus;
text: string;
className?: string;
}
const PasteComponent = ({
onClick, clipboardData, text, className,
}) => {
}: PasteComponentProps) => {
const [showPopover, togglePopover] = useState(false);
const popoverElementRef = useRef(null);
const handlePopoverToggle = (isOpen) => togglePopover(isOpen);
const renderPopover = (props) => (
const renderPopover = () => (
<div role="link" ref={popoverElementRef} tabIndex={0}>
<Popover
className="clipboard-popover"
@@ -22,11 +28,8 @@ const PasteComponent = ({
onMouseLeave={() => handlePopoverToggle(false)}
onFocus={() => handlePopoverToggle(true)}
onBlur={() => handlePopoverToggle(false)}
{...props}
>
{clipboardData && (
<PopoverContent clipboardData={clipboardData} />
)}
<PopoverContent clipboardData={clipboardData} />
</Popover>
</div>
);
@@ -48,18 +51,4 @@ const PasteComponent = ({
);
};
PasteComponent.propTypes = {
onClick: PropTypes.func.isRequired,
text: PropTypes.string.isRequired,
clipboardData: PropTypes.shape(clipboardPropsTypes),
blockType: PropTypes.string,
className: PropTypes.string,
};
PasteComponent.defaultProps = {
clipboardData: null,
blockType: null,
className: undefined,
};
export default PasteComponent;

View File

@@ -5,4 +5,3 @@ export const getCourseData = (state) => state.generic.createOrRerunCourse.course
export const getCourseRerunData = (state) => state.generic.createOrRerunCourse.courseRerunData;
export const getRedirectUrlObj = (state) => state.generic.createOrRerunCourse.redirectUrlObj;
export const getPostErrors = (state) => state.generic.createOrRerunCourse.postErrors;
export const getClipboardData = (state) => state.generic.clipboardData;

View File

@@ -18,7 +18,6 @@ const slice = createSlice({
redirectUrlObj: {},
postErrors: {},
},
clipboardData: null,
},
reducers: {
fetchOrganizations: (state, { payload }) => {
@@ -42,9 +41,6 @@ const slice = createSlice({
updatePostErrors: (state, { payload }) => {
state.createOrRerunCourse.postErrors = payload;
},
updateClipboardData: (state, { payload }) => {
state.clipboardData = payload;
},
},
});
@@ -56,7 +52,6 @@ export const {
updateSavingStatus,
updateCourseData,
updateRedirectUrlObj,
updateClipboardData,
} = slice.actions;
export const {

View File

@@ -1,10 +1,3 @@
import { logError } from '@edx/frontend-platform/logging';
import { CLIPBOARD_STATUS, NOTIFICATION_MESSAGES } from '../../constants';
import {
hideProcessingNotification,
showProcessingNotification,
} from '../processing-notification/data/slice';
import { RequestStatus } from '../../data/constants';
import {
fetchOrganizations,
@@ -13,14 +6,11 @@ import {
updateRedirectUrlObj,
updateCourseRerunData,
updateSavingStatus,
updateClipboardData,
} from './slice';
import {
createOrRerunCourse,
getOrganizations,
getCourseRerun,
updateClipboard,
getClipboard,
} from './api';
export function fetchOrganizationsQuery() {
@@ -63,33 +53,3 @@ export function updateCreateOrRerunCourseQuery(courseData) {
}
};
}
export function copyToClipboard(usageKey) {
const POLL_INTERVAL_MS = 1000; // Timeout duration for polling in milliseconds
return async (dispatch) => {
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.copying));
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
try {
let clipboardData = await updateClipboard(usageKey);
while (clipboardData.content?.status === CLIPBOARD_STATUS.loading) {
// eslint-disable-next-line no-await-in-loop,no-promise-executor-return
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
clipboardData = await getClipboard(); // eslint-disable-line no-await-in-loop
}
if (clipboardData.content?.status === CLIPBOARD_STATUS.ready) {
dispatch(updateClipboardData(clipboardData));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} else {
throw new Error(`Unexpected clipboard status "${clipboardData.content?.status}" in successful API response.`);
}
} catch (error) {
logError('Error copying to clipboard:', error);
} finally {
dispatch(hideProcessingNotification());
}
};
}