fix: excessive calls to the clipboard API endpoint (#1700)
This commit is contained in:
21
src/generic/clipboard/hooks/messages.ts
Normal file
21
src/generic/clipboard/hooks/messages.ts
Normal 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;
|
||||
119
src/generic/clipboard/hooks/useClipboard.test.tsx
Normal file
119
src/generic/clipboard/hooks/useClipboard.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
82
src/generic/clipboard/hooks/useClipboard.ts
Normal file
82
src/generic/clipboard/hooks/useClipboard.ts
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user