From dbba4dd2966a8241d63984fb023035a25dc79fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 7 Mar 2025 13:16:39 -0300 Subject: [PATCH] fix: excessive calls to the clipboard API endpoint (#1700) --- src/course-outline/CourseOutline.jsx | 2 - src/course-outline/CourseOutline.test.jsx | 6 - src/course-outline/data/api.js | 1 - src/course-outline/data/thunk.js | 2 - src/course-outline/hooks.jsx | 7 +- .../subsection-card/SubsectionCard.jsx | 6 +- src/course-outline/unit-card/UnitCard.jsx | 7 +- .../unit-card/UnitCard.test.jsx | 9 +- src/course-unit/CourseUnit.test.jsx | 90 +++---------- src/course-unit/data/thunk.js | 4 - src/course-unit/hooks.jsx | 4 +- .../sidebar-footer/ActionButtons.jsx | 8 +- .../sidebar-footer/ActionButtons.test.jsx | 25 ++-- .../hooks/tests/hooks.test.tsx | 52 +++++++- .../hooks/useMessageHandlers.tsx | 53 ++++---- src/generic/clipboard/hooks/messages.ts | 21 +++ .../clipboard/hooks/useClipboard.test.tsx | 119 +++++++++++++++++ src/generic/clipboard/hooks/useClipboard.ts | 82 ++++++++++++ .../clipboard/hooks/useCopyToClipboard.js | 81 ------------ .../hooks/useCopyToClipboard.test.jsx | 122 ------------------ src/generic/clipboard/index.js | 2 +- .../components/PasteButton.jsx | 36 ------ .../components/PasteButton.tsx | 22 ++++ ...{PopoverContent.jsx => PopoverContent.tsx} | 18 ++- ...tsInClipboard.jsx => WhatsInClipboard.tsx} | 18 +-- .../components/{index.js => index.ts} | 0 .../clipboard/paste-component/constants.js | 10 -- .../paste-component/{index.jsx => index.tsx} | 33 ++--- .../{messages.js => messages.ts} | 0 src/generic/data/selectors.js | 1 - src/generic/data/slice.js | 5 - src/generic/data/thunks.js | 40 ------ .../add-content/AddContentContainer.tsx | 14 +- .../components/ComponentCard.test.tsx | 6 +- .../components/ComponentCard.tsx | 14 +- src/library-authoring/components/messages.ts | 10 -- src/testUtils.tsx | 4 +- 37 files changed, 424 insertions(+), 510 deletions(-) create mode 100644 src/generic/clipboard/hooks/messages.ts create mode 100644 src/generic/clipboard/hooks/useClipboard.test.tsx create mode 100644 src/generic/clipboard/hooks/useClipboard.ts delete mode 100644 src/generic/clipboard/hooks/useCopyToClipboard.js delete mode 100644 src/generic/clipboard/hooks/useCopyToClipboard.test.jsx delete mode 100644 src/generic/clipboard/paste-component/components/PasteButton.jsx create mode 100644 src/generic/clipboard/paste-component/components/PasteButton.tsx rename src/generic/clipboard/paste-component/components/{PopoverContent.jsx => PopoverContent.tsx} (81%) rename src/generic/clipboard/paste-component/components/{WhatsInClipboard.jsx => WhatsInClipboard.tsx} (80%) rename src/generic/clipboard/paste-component/components/{index.js => index.ts} (100%) delete mode 100644 src/generic/clipboard/paste-component/constants.js rename src/generic/clipboard/paste-component/{index.jsx => index.tsx} (68%) rename src/generic/clipboard/paste-component/{messages.js => messages.ts} (100%) diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index 21d35831d..8ba7c4d6b 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -105,7 +105,6 @@ const CourseOutline = ({ courseId }) => { handleNewUnitSubmit, getUnitUrl, handleVideoSharingOptionChange, - handleCopyToClipboardClick, handlePasteClipboardClick, notificationDismissUrl, discussionsSettings, @@ -419,7 +418,6 @@ const CourseOutline = ({ courseId }) => { onDuplicateSubmit={handleDuplicateUnitSubmit} getTitleLink={getUnitUrl} onOrderChange={updateUnitOrderByIndex} - onCopyToClipboardClick={handleCopyToClipboardClick} discussionsSettings={discussionsSettings} /> ))} diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index ac8a4d8e1..e71178837 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -2182,9 +2182,6 @@ describe('', () => { .onPost(getClipboardUrl(), { usage_key: unit.id, }).reply(200, clipboardUnit); - // check that initialUserClipboard state is empty - const { initialUserClipboard } = store.getState().courseOutline; - expect(initialUserClipboard).toBeUndefined(); // find menu button and click on it to open menu const menu = await within(unitElement).findByTestId('unit-card-header__menu-button'); @@ -2194,9 +2191,6 @@ describe('', () => { const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage); await act(async () => fireEvent.click(copyButton)); - // check that initialUserClipboard state is updated - expect(store.getState().generic.clipboardData).toEqual(clipboardUnit); - [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); // find clipboard content label const clipboardLabel = await within(subsectionElement).findByText( diff --git a/src/course-outline/data/api.js b/src/course-outline/data/api.js index fc61f3c11..bd2c95254 100644 --- a/src/course-outline/data/api.js +++ b/src/course-outline/data/api.js @@ -28,7 +28,6 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`; export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`; export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`; -export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`; export const exportTags = (courseId) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`; /** diff --git a/src/course-outline/data/thunk.js b/src/course-outline/data/thunk.js index 457d03911..fc5499896 100644 --- a/src/course-outline/data/thunk.js +++ b/src/course-outline/data/thunk.js @@ -1,5 +1,4 @@ import { RequestStatus } from '../../data/constants'; -import { updateClipboardData } from '../../generic/data/slice'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { COURSE_BLOCK_NAMES } from '../constants'; import { @@ -71,7 +70,6 @@ export function fetchCourseOutlineIndexQuery(courseId) { }, } = outlineIndex; dispatch(fetchOutlineIndexSuccess(outlineIndex)); - dispatch(updateClipboardData(outlineIndex.initialUserClipboard)); dispatch(updateStatusBar({ courseReleaseDate, highlightsEnabledForMessaging, diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index 900522656..eaa992e88 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom'; import { useToggle } from '@openedx/paragon'; import { getConfig } from '@edx/frontend-platform'; -import { copyToClipboard } from '../generic/data/thunks'; import { getSavingStatus as getGenericSavingStatus } from '../generic/data/selectors'; import { getWaffleFlags } from '../data/selectors'; import { RequestStatus } from '../data/constants'; @@ -74,6 +73,7 @@ const useCourseOutline = ({ courseId }) => { mfeProctoredExamSettingsUrl, advanceSettingsUrl, } = useSelector(getOutlineIndexData); + const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus); const statusBarData = useSelector(getStatusBarData); const savingStatus = useSelector(getSavingStatus); @@ -97,10 +97,6 @@ const useCourseOutline = ({ courseId }) => { const isSavingStatusFailed = savingStatus === RequestStatus.FAILED || genericSavingStatus === RequestStatus.FAILED; - const handleCopyToClipboardClick = (usageKey) => { - dispatch(copyToClipboard(usageKey)); - }; - const handlePasteClipboardClick = (parentLocator, sectionId) => { dispatch(pasteClipboardContent(parentLocator, sectionId)); }; @@ -342,7 +338,6 @@ const useCourseOutline = ({ courseId }) => { openUnitPage, handleNewUnitSubmit, handleVideoSharingOptionChange, - handleCopyToClipboardClick, handlePasteClipboardClick, notificationDismissUrl, discussionsSettings, diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 9f134fdbe..032178618 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -16,7 +16,7 @@ import { RequestStatus } from '../../data/constants'; import CardHeader from '../card-header/CardHeader'; import SortableItem from '../../generic/drag-helper/SortableItem'; import { DragContext } from '../../generic/drag-helper/DragContextProvider'; -import { useCopyToClipboard, PasteComponent } from '../../generic/clipboard'; +import { useClipboard, PasteComponent } from '../../generic/clipboard'; import TitleButton from '../card-header/TitleButton'; import XBlockStatus from '../xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; @@ -49,7 +49,7 @@ const SubsectionCard = ({ const isScrolledToElement = locatorId === subsection.id; const [isFormOpen, openForm, closeForm] = useToggle(false); const namePrefix = 'subsection'; - const { sharedClipboardData, showPasteUnit } = useCopyToClipboard(); + const { sharedClipboardData, showPasteUnit } = useClipboard(); const { id, @@ -233,7 +233,7 @@ const SubsectionCard = ({ > {intl.formatMessage(messages.newUnitButton)} - {enableCopyPasteUnits && showPasteUnit && ( + {enableCopyPasteUnits && showPasteUnit && sharedClipboardData && ( { const currentRef = useRef(null); @@ -41,6 +41,8 @@ const UnitCard = ({ const [isFormOpen, openForm, closeForm] = useToggle(false); const namePrefix = 'unit'; + const { copyToClipboard } = useClipboard(); + const { id, category, @@ -98,7 +100,7 @@ const UnitCard = ({ }; const handleCopyClick = () => { - onCopyToClipboardClick(unit.id); + copyToClipboard(id); }; const titleComponent = ( @@ -241,7 +243,6 @@ UnitCard.propTypes = { onOrderChange: PropTypes.func.isRequired, isSelfPaced: PropTypes.bool.isRequired, isCustomRelativeDatesActive: PropTypes.bool.isRequired, - onCopyToClipboardClick: PropTypes.func.isRequired, discussionsSettings: PropTypes.shape({ providerType: PropTypes.string, enableGradedUnits: PropTypes.bool, diff --git a/src/course-outline/unit-card/UnitCard.test.jsx b/src/course-outline/unit-card/UnitCard.test.jsx index 192963375..db0ed71aa 100644 --- a/src/course-outline/unit-card/UnitCard.test.jsx +++ b/src/course-outline/unit-card/UnitCard.test.jsx @@ -1,4 +1,3 @@ -import React from 'react'; import { act, render, fireEvent, within, } from '@testing-library/react'; @@ -48,6 +47,13 @@ const unit = { const queryClient = new QueryClient(); +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const renderComponent = (props) => render( @@ -62,7 +68,6 @@ const renderComponent = (props) => render( onOpenPublishModal={jest.fn()} onOpenDeleteModal={jest.fn()} onOpenConfigureModal={jest.fn()} - onCopyToClipboardClick={jest.fn()} savingStatus="" onEditSubmit={jest.fn()} onDuplicateSubmit={jest.fn()} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 121a95c6a..5212d1cd6 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, render, waitFor, within, screen, } from '@testing-library/react'; @@ -67,6 +68,7 @@ import messages from './messages'; let axiosMock; let store; +let queryClient; const courseId = '123'; const blockId = '567890'; const unitDisplayName = courseUnitIndexMock.metadata.display_name; @@ -91,45 +93,6 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockedUsedNavigate, })); -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(({ queryKey }) => { - const taxonomyApiHooksModule = jest.requireActual('../taxonomy/data/apiHooks'); - const actualQueryKeys = taxonomyApiHooksModule.taxonomyQueryKeys; - - if (queryKey[0] === 'contentTaxonomyTags') { - return { - data: { - taxonomies: [], - }, - isSuccess: true, - }; - } if (queryKey[0] === 'contentTagsCount') { - return { - data: 17, - isSuccess: true, - }; - } - if (actualQueryKeys.all.includes(queryKey[0])) { - return { - data: { - results: [], - }, - isSuccess: true, - }; - } - return { - data: {}, - isSuccess: true, - }; - }), - useQueryClient: jest.fn(() => ({ - setQueryData: jest.fn(), - })), - useMutation: jest.fn(() => ({ - mutateAsync: jest.fn(), - })), -})); - const clipboardBroadcastChannelMock = { postMessage: jest.fn(), close: jest.fn(), @@ -157,7 +120,9 @@ const RootWrapper = () => ( - + + + @@ -176,6 +141,13 @@ describe('', () => { window.scrollTo = jest.fn(); global.localStorage.clear(); store = initializeStore(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock .onGet(getClipboardUrl()) @@ -194,7 +166,7 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); axiosMock .onGet(getContentTaxonomyTagsApiUrl(blockId)) - .reply(200, {}); + .reply(200, { taxonomies: [] }); axiosMock .onGet(getContentTaxonomyTagsCountApiUrl(blockId)) .reply(200, 17); @@ -1312,13 +1284,6 @@ describe('', () => { enable_copy_paste_units: true, }); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardUnit, - }); - await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); @@ -1368,11 +1333,9 @@ describe('', () => { }); axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardXBlock, - }); + .onGet(getClipboardUrl()) + .reply(200, clipboardXBlock); + axiosMock .onGet(getCourseUnitApiUrl(courseId)) .reply(200, { @@ -1443,13 +1406,6 @@ describe('', () => { enable_copy_paste_units: true, }); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardUnit, - }); - await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); @@ -1502,13 +1458,6 @@ describe('', () => { enable_copy_paste_units: true, }); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardUnit, - }); - await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); @@ -1563,13 +1512,6 @@ describe('', () => { enable_copy_paste_units: true, }); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardUnit, - }); - await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index c6cdde318..c931bf506 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -8,7 +8,6 @@ import { handleResponseErrors } from '../../generic/saving-error-alert'; import { RequestStatus } from '../../data/constants'; import { NOTIFICATION_MESSAGES } from '../../constants'; import { updateModel, updateModels } from '../../generic/model-store'; -import { updateClipboardData } from '../../generic/data/slice'; import { messageTypes } from '../constants'; import { getCourseUnitData, @@ -77,7 +76,6 @@ export function fetchCourseSectionVerticalData(courseId, sequenceId) { })); dispatch(fetchStaticFileNoticesSuccess(JSON.parse(localStorage.getItem('staticFileNotices')))); localStorage.removeItem('staticFileNotices'); - dispatch(updateClipboardData(courseSectionVerticalData.userClipboard)); dispatch(fetchSequenceSuccess({ sequenceId })); return true; } catch (error) { @@ -230,8 +228,6 @@ export function deleteUnitItemQuery(itemId, xblockId, sendMessageToIframe) { try { await deleteUnitItem(xblockId); sendMessageToIframe(messageTypes.completeXBlockDeleting, { locator: xblockId }); - const { userClipboard } = await getCourseSectionVerticalData(itemId); - dispatch(updateClipboardData(userClipboard)); const courseUnit = await getCourseUnitData(itemId); dispatch(fetchCourseItemSuccess(courseUnit)); dispatch(hideProcessingNotification()); diff --git a/src/course-unit/hooks.jsx b/src/course-unit/hooks.jsx index 915aa9ff0..14045fb0c 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -6,7 +6,7 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import { useToggle } from '@openedx/paragon'; import { RequestStatus } from '../data/constants'; -import { useCopyToClipboard } from '../generic/clipboard'; +import { useClipboard } from '../generic/clipboard'; import { useEventListener } from '../generic/hooks'; import { COURSE_BLOCK_NAMES } from '../constants'; import { messageTypes, PUBLISH_TYPES } from './constants'; @@ -62,7 +62,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const courseOutlineInfo = useSelector(getCourseOutlineInfo); const movedXBlockParams = useSelector(getMovedXBlockParams); const { currentlyVisibleToStudents } = courseUnit; - const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit); + const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit); const { canPasteComponent } = courseVerticalChildren; const { displayName: unitTitle, category: unitCategory } = xblockInfo; const sequenceId = courseUnit.ancestorInfo?.ancestors[0].id; diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx index 5f78ae761..645651a27 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx @@ -1,15 +1,14 @@ import PropTypes from 'prop-types'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { Button } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Divider } from '../../../../generic/divider'; import { getCanEdit, getCourseUnitData } from '../../../data/selectors'; -import { copyToClipboard } from '../../../../generic/data/thunks'; +import { useClipboard } from '../../../../generic/clipboard'; import messages from '../../messages'; const ActionButtons = ({ openDiscardModal, handlePublishing }) => { - const dispatch = useDispatch(); const intl = useIntl(); const { id, @@ -18,6 +17,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => { enableCopyPasteUnits, } = useSelector(getCourseUnitData); const canEdit = useSelector(getCanEdit); + const { copyToClipboard } = useClipboard(); return ( <> @@ -40,7 +40,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => { <> - ); -}; - -PasteButton.propTypes = { - onClick: PropsTypes.func.isRequired, - text: PropsTypes.string.isRequired, - className: PropsTypes.string, -}; - -PasteButton.defaultProps = { - className: undefined, -}; - -export default PasteButton; diff --git a/src/generic/clipboard/paste-component/components/PasteButton.tsx b/src/generic/clipboard/paste-component/components/PasteButton.tsx new file mode 100644 index 000000000..d38f5a45f --- /dev/null +++ b/src/generic/clipboard/paste-component/components/PasteButton.tsx @@ -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) => ( + +); + +export default PasteButton; diff --git a/src/generic/clipboard/paste-component/components/PopoverContent.jsx b/src/generic/clipboard/paste-component/components/PopoverContent.tsx similarity index 81% rename from src/generic/clipboard/paste-component/components/PopoverContent.jsx rename to src/generic/clipboard/paste-component/components/PopoverContent.tsx index 70193c3ea..70ac2808c 100644 --- a/src/generic/clipboard/paste-component/components/PopoverContent.jsx +++ b/src/generic/clipboard/paste-component/components/PopoverContent.tsx @@ -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 ( { ); }; -PopoverContent.propTypes = { - clipboardData: PropTypes.shape(clipboardPropsTypes).isRequired, -}; - export default PopoverContent; diff --git a/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx b/src/generic/clipboard/paste-component/components/WhatsInClipboard.tsx similarity index 80% rename from src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx rename to src/generic/clipboard/paste-component/components/WhatsInClipboard.tsx index 22d4d0ca4..78fd3e17f 100644 --- a/src/generic/clipboard/paste-component/components/WhatsInClipboard.jsx +++ b/src/generic/clipboard/paste-component/components/WhatsInClipboard.tsx @@ -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; +} + 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; diff --git a/src/generic/clipboard/paste-component/components/index.js b/src/generic/clipboard/paste-component/components/index.ts similarity index 100% rename from src/generic/clipboard/paste-component/components/index.js rename to src/generic/clipboard/paste-component/components/index.ts diff --git a/src/generic/clipboard/paste-component/constants.js b/src/generic/clipboard/paste-component/constants.js deleted file mode 100644 index da6797fdd..000000000 --- a/src/generic/clipboard/paste-component/constants.js +++ /dev/null @@ -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, -}; diff --git a/src/generic/clipboard/paste-component/index.jsx b/src/generic/clipboard/paste-component/index.tsx similarity index 68% rename from src/generic/clipboard/paste-component/index.jsx rename to src/generic/clipboard/paste-component/index.tsx index 4d74c771b..0ebc24309 100644 --- a/src/generic/clipboard/paste-component/index.jsx +++ b/src/generic/clipboard/paste-component/index.tsx @@ -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 = () => (
handlePopoverToggle(false)} onFocus={() => handlePopoverToggle(true)} onBlur={() => handlePopoverToggle(false)} - {...props} > - {clipboardData && ( - - )} +
); @@ -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; diff --git a/src/generic/clipboard/paste-component/messages.js b/src/generic/clipboard/paste-component/messages.ts similarity index 100% rename from src/generic/clipboard/paste-component/messages.js rename to src/generic/clipboard/paste-component/messages.ts diff --git a/src/generic/data/selectors.js b/src/generic/data/selectors.js index e111961b1..461e09fe9 100644 --- a/src/generic/data/selectors.js +++ b/src/generic/data/selectors.js @@ -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; diff --git a/src/generic/data/slice.js b/src/generic/data/slice.js index f53ddc610..a25112704 100644 --- a/src/generic/data/slice.js +++ b/src/generic/data/slice.js @@ -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 { diff --git a/src/generic/data/thunks.js b/src/generic/data/thunks.js index f5cc8a955..0e51a8eae 100644 --- a/src/generic/data/thunks.js +++ b/src/generic/data/thunks.js @@ -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()); - } - }; -} diff --git a/src/library-authoring/add-content/AddContentContainer.tsx b/src/library-authoring/add-content/AddContentContainer.tsx index f2d42fc12..94e2692d4 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -23,7 +23,7 @@ import { import { v4 as uuid4 } from 'uuid'; import { ToastContext } from '../../generic/toast-context'; -import { useCopyToClipboard } from '../../generic/clipboard'; +import { useClipboard } from '../../generic/clipboard'; import { getCanEdit } from '../../course-unit/data/selectors'; import { useCreateLibraryBlock, @@ -201,8 +201,7 @@ const AddContentContainer = () => { const pasteClipboardMutation = useLibraryPasteClipboard(); const { showToast } = useContext(ToastContext); const canEdit = useSelector(getCanEdit); - const { sharedClipboardData } = useCopyToClipboard(canEdit); - const { showPasteXBlock } = useCopyToClipboard(canEdit); + const { showPasteXBlock, sharedClipboardData } = useClipboard(canEdit); const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); const [isAdvancedListOpen, showAdvancedList, closeAdvancedList] = useToggle(); @@ -288,7 +287,14 @@ const AddContentContainer = () => { }; const onPaste = () => { - if (!isBlockTypeEnabled(sharedClipboardData.content?.blockType)) { + const clipboardBlockType = sharedClipboardData?.content?.blockType; + + // istanbul ignore if: this should never happen + if (!clipboardBlockType) { + return; + } + + if (!isBlockTypeEnabled(clipboardBlockType)) { showToast(intl.formatMessage(messages.unsupportedBlockPasteClipboardMessage)); return; } diff --git a/src/library-authoring/components/ComponentCard.test.tsx b/src/library-authoring/components/ComponentCard.test.tsx index 2fed43645..c6e5ba5c9 100644 --- a/src/library-authoring/components/ComponentCard.test.tsx +++ b/src/library-authoring/components/ComponentCard.test.tsx @@ -83,7 +83,8 @@ describe('', () => { ); await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith('Component copied to clipboard'); + expect(mockShowToast).toHaveBeenCalledWith('Copying'); + expect(mockShowToast).toHaveBeenCalledWith('Copied to clipboard'); }); }); @@ -106,7 +107,8 @@ describe('', () => { ); await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith('Failed to copy component to clipboard'); + expect(mockShowToast).toHaveBeenCalledWith('Copying'); + expect(mockShowToast).toHaveBeenCalledWith('Error copying to clipboard'); }); }); }); diff --git a/src/library-authoring/components/ComponentCard.tsx b/src/library-authoring/components/ComponentCard.tsx index 682024087..e482cf7d8 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -1,4 +1,4 @@ -import { useCallback, useContext, useState } from 'react'; +import { useCallback, useContext } from 'react'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { ActionRow, @@ -15,8 +15,7 @@ import { MoreVert, } from '@openedx/paragon/icons'; -import { STUDIO_CLIPBOARD_CHANNEL } from '../../constants'; -import { updateClipboard } from '../../generic/data/api'; +import { useClipboard } from '../../generic/clipboard'; import { ToastContext } from '../../generic/toast-context'; import { type ContentHit } from '../../search-manager'; import { useComponentPickerContext } from '../common/context/ComponentPickerContext'; @@ -52,17 +51,12 @@ export const ComponentMenu = ({ usageKey }: { usageKey: string }) => { const canEdit = usageKey && canEditComponent(usageKey); const { showToast } = useContext(ToastContext); - const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL)); const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId); const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false); + const { copyToClipboard } = useClipboard(); const updateClipboardClick = () => { - updateClipboard(usageKey) - .then((clipboardData) => { - clipboardBroadcastChannel.postMessage(clipboardData); - showToast(intl.formatMessage(messages.copyToClipboardSuccess)); - }) - .catch(() => showToast(intl.formatMessage(messages.copyToClipboardError))); + copyToClipboard(usageKey); }; const removeFromCollection = () => { diff --git a/src/library-authoring/components/messages.ts b/src/library-authoring/components/messages.ts index 4a4074683..f1a8a7701 100644 --- a/src/library-authoring/components/messages.ts +++ b/src/library-authoring/components/messages.ts @@ -51,16 +51,6 @@ const messages = defineMessages({ defaultMessage: 'Failed to remove Component', description: 'Message for failure of removal of component from collection.', }, - copyToClipboardSuccess: { - id: 'course-authoring.library-authoring.component.copyToClipboardSuccess', - defaultMessage: 'Component copied to clipboard', - description: 'Message for successful copy component to clipboard.', - }, - copyToClipboardError: { - id: 'course-authoring.library-authoring.component.copyToClipboardError', - defaultMessage: 'Failed to copy component to clipboard', - description: 'Message for failed to copy component to clipboard.', - }, deleteComponentWarningTitle: { id: 'course-authoring.library-authoring.component.delete-confirmation-title', defaultMessage: 'Delete Component', diff --git a/src/testUtils.tsx b/src/testUtils.tsx index b9fdf8e61..b15cd75a6 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -115,7 +115,7 @@ const RouterAndRoute: React.FC = ({ ); }; -function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions) { +function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions = {}) { const AllTheProviders = ({ children }) => ( @@ -195,7 +195,7 @@ export function initializeMocks({ user = defaultUser, initialState = undefined } } export * from '@testing-library/react'; -export { customRender as render }; +export { customRender as render, makeWrapper }; /** Simulate a real Axios error (such as we'd see in response to a 404) */ export function createAxiosError({ code, message, path }: { code: number, message: string, path: string }) {