From a38438ecc0629558941f0fbe2920319814c3bc26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 7 Mar 2025 13:15:26 -0300 Subject: [PATCH] fix: excessive calls to the clipboard API endpoint [sumac] --- 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.jsx | 4 +- src/course-unit/CourseUnit.test.jsx | 102 ++++----------- .../course-xblock/CourseXBlock.jsx | 9 +- .../course-xblock/CourseXBlock.test.jsx | 35 +++-- src/course-unit/data/thunk.js | 4 - src/course-unit/hooks.jsx | 5 +- .../sidebar-footer/ActionButtons.jsx | 8 +- .../sidebar-footer/ActionButtons.test.jsx | 25 ++-- 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 | 11 -- .../paste-component/{index.jsx => index.tsx} | 30 ++--- .../{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 | 13 +- .../components/ComponentCard.test.tsx | 6 +- .../components/ComponentCard.tsx | 14 +- src/library-authoring/components/messages.ts | 10 -- src/testUtils.tsx | 4 +- 38 files changed, 388 insertions(+), 499 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} (71%) 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 fe428c1c74..28e8dd4b25 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -104,7 +104,6 @@ const CourseOutline = ({ courseId }) => { handleNewUnitSubmit, getUnitUrl, handleVideoSharingOptionChange, - handleCopyToClipboardClick, handlePasteClipboardClick, notificationDismissUrl, discussionsSettings, @@ -397,7 +396,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 b7f8332eeb..cdb4739c3a 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 fc61f3c117..bd2c95254a 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 315c5846c0..c4cebdc44c 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 { API_ERROR_TYPES, COURSE_BLOCK_NAMES } from '../constants'; import { @@ -88,7 +87,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 25b9d8bedd..51e0037619 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 { RequestStatus } from '../data/constants'; import { COURSE_BLOCK_NAMES } from './constants'; @@ -72,6 +71,7 @@ const useCourseOutline = ({ courseId }) => { mfeProctoredExamSettingsUrl, advanceSettingsUrl, } = useSelector(getOutlineIndexData); + const { outlineIndexLoadingStatus, reIndexLoadingStatus } = useSelector(getLoadingStatus); const statusBarData = useSelector(getStatusBarData); const savingStatus = useSelector(getSavingStatus); @@ -95,10 +95,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)); }; @@ -339,7 +335,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 9f134fdbe1..032178618f 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 192963375f..db0ed71aa8 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.jsx b/src/course-unit/CourseUnit.jsx index 2b54f876e6..e6fb60dd8d 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -185,7 +185,9 @@ const CourseUnit = ({ courseId }) => { {showPasteXBlock && canPasteComponent && ( handleCreateNewCourseXBlock({ stagedContent: 'clipboard', parentLocator: blockId }) + } text={intl.formatMessage(messages.pasteButtonText)} /> )} diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index d6b00a385d..85115bd992 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, fireEvent, within, screen, } from '@testing-library/react'; @@ -38,10 +39,7 @@ import { courseVerticalChildrenMock, clipboardMockResponse, } from './__mocks__'; -import { - clipboardUnit, - clipboardXBlock, -} from '../__mocks__'; +import { clipboardUnit, clipboardXBlock } from '../__mocks__'; import { executeThunk } from '../utils'; import deleteModalMessages from '../generic/delete-modal/messages'; import pasteComponentMessages from '../generic/clipboard/paste-component/messages'; @@ -54,6 +52,7 @@ import { extractCourseUnitId } from './sidebar/utils'; import CourseUnit from './CourseUnit'; import configureModalMessages from '../generic/configure-modal/messages'; +import { getClipboardUrl } from '../generic/data/api'; import courseXBlockMessages from './course-xblock/messages'; import addComponentMessages from './add-component/messages'; import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from './constants'; @@ -63,6 +62,7 @@ import { RequestStatus } from '../data/constants'; let axiosMock; let store; +let queryClient; const courseId = '123'; const blockId = '567890'; const unitDisplayName = courseUnitIndexMock.metadata.display_name; @@ -80,31 +80,6 @@ jest.mock('react-router-dom', () => ({ useNavigate: () => mockedUsedNavigate, })); -jest.mock('@tanstack/react-query', () => ({ - useQuery: jest.fn(({ queryKey }) => { - if (queryKey[0] === 'contentTaxonomyTags') { - return { - data: { - taxonomies: [], - }, - isSuccess: true, - }; - } if (queryKey[0] === 'contentTagsCount') { - return { - data: 17, - isSuccess: true, - }; - } - return { - data: {}, - isSuccess: true, - }; - }), - useQueryClient: jest.fn(() => ({ - setQueryData: jest.fn(), - })), -})); - const clipboardBroadcastChannelMock = { postMessage: jest.fn(), close: jest.fn(), @@ -115,7 +90,9 @@ global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); const RootWrapper = () => ( - + + + ); @@ -132,7 +109,17 @@ describe('', () => { }); global.localStorage.clear(); store = initializeStore(); + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardUnit); axiosMock .onGet(getCourseUnitApiUrl(courseId)) .reply(200, courseUnitIndexMock); @@ -147,7 +134,7 @@ describe('', () => { await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); axiosMock .onGet(getContentTaxonomyTagsApiUrl(blockId)) - .reply(200, {}); + .reply(200, { taxonomies: [] }); axiosMock .onGet(getContentTaxonomyTagsCountApiUrl(blockId)) .reply(200, 17); @@ -1087,19 +1074,16 @@ describe('', () => { queryByTestId, getByRole, getAllByLabelText, getByText, } = render(); + axiosMock + .onGet(getClipboardUrl()) + .reply(200, clipboardXBlock); + await waitFor(() => { const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage); userEvent.click(xblockActionBtn); userEvent.click(getByRole('button', { name: courseXBlockMessages.blockLabelButtonCopyToClipboard.defaultMessage })); }); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardXBlock, - }); - await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); expect(getByRole('button', { name: messages.pasteButtonText.defaultMessage })).toBeInTheDocument(); @@ -1141,11 +1125,8 @@ describe('', () => { }); axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardXBlock, - }); + .onGet(getClipboardUrl()) + .reply(200, clipboardXBlock); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1197,11 +1178,8 @@ describe('', () => { }); axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - user_clipboard: clipboardXBlock, - }); + .onGet(getClipboardUrl()) + .reply(200, clipboardXBlock); axiosMock .onGet(getCourseUnitApiUrl(courseId)) @@ -1228,13 +1206,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); @@ -1288,13 +1259,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); @@ -1349,13 +1313,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); @@ -1410,13 +1367,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/course-xblock/CourseXBlock.jsx b/src/course-unit/course-xblock/CourseXBlock.jsx index 89a13ece7a..ddba7397e0 100644 --- a/src/course-unit/course-xblock/CourseXBlock.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.jsx @@ -1,7 +1,7 @@ import { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { ActionRow, Card, Dropdown, Icon, IconButton, useToggle, } from '@openedx/paragon'; @@ -15,7 +15,7 @@ import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; import SortableItem from '../../generic/drag-helper/SortableItem'; import { scrollToElement } from '../../course-outline/utils'; import { COURSE_BLOCK_NAMES } from '../../constants'; -import { copyToClipboard } from '../../generic/data/thunks'; +import { useClipboard } from '../../generic/clipboard'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import XBlockMessages from './xblock-messages/XBlockMessages'; import messages from './messages'; @@ -28,7 +28,6 @@ const CourseXBlock = ({ const courseXBlockElementRef = useRef(null); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); - const dispatch = useDispatch(); const canEdit = useSelector(getCanEdit); const courseId = useSelector(getCourseId); const intl = useIntl(); @@ -48,6 +47,8 @@ const CourseXBlock = ({ showCorrectness: 'always', }; + const { copyToClipboard } = useClipboard(canEdit); + const onDeleteSubmit = () => { unitXBlockActions.handleDelete(id); closeDeleteModal(); @@ -120,7 +121,7 @@ const CourseXBlock = ({ {intl.formatMessage(messages.blockLabelButtonMove)} {canEdit && ( - dispatch(copyToClipboard(id))}> + copyToClipboard(id)}> {intl.formatMessage(messages.blockLabelButtonCopyToClipboard)} )} diff --git a/src/course-unit/course-xblock/CourseXBlock.test.jsx b/src/course-unit/course-xblock/CourseXBlock.test.jsx index 95482d098e..f72a9ef740 100644 --- a/src/course-unit/course-xblock/CourseXBlock.test.jsx +++ b/src/course-unit/course-xblock/CourseXBlock.test.jsx @@ -1,3 +1,4 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, waitFor, within, } from '@testing-library/react'; @@ -24,6 +25,15 @@ import messages from './messages'; let axiosMock; let store; +let queryClient; + +const clipboardBroadcastChannelMock = { + postMessage: jest.fn(), + close: jest.fn(), +}; + +global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock); + const courseId = '1234'; const blockId = '567890'; const handleDeleteMock = jest.fn(); @@ -49,17 +59,19 @@ jest.mock('react-redux', () => ({ const renderComponent = (props) => render( - + + + , ); @@ -98,6 +110,7 @@ describe('', () => { .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); + queryClient = new QueryClient(); }); it('render CourseXBlock component correctly', async () => { diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index c2ac2be7c8..496c4127a3 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 { getCourseUnitData, editUnitDisplayName, @@ -75,7 +74,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) { @@ -214,8 +212,6 @@ export function deleteUnitItemQuery(itemId, xblockId) { try { await deleteUnitItem(xblockId); dispatch(deleteXBlock(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 66182ef1fd..14bdd4190a 100644 --- a/src/course-unit/hooks.jsx +++ b/src/course-unit/hooks.jsx @@ -3,6 +3,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { RequestStatus } from '../data/constants'; +import { useClipboard } from '../generic/clipboard'; import { createNewCourseXBlock, fetchCourseUnitQuery, @@ -28,8 +29,6 @@ import { import { changeEditTitleFormOpen, updateQueryPendingStatus } from './data/slice'; import { PUBLISH_TYPES } from './constants'; -import { useCopyToClipboard } from '../generic/clipboard'; - // eslint-disable-next-line import/prefer-default-export export const useCourseUnit = ({ courseId, blockId }) => { const dispatch = useDispatch(); @@ -47,7 +46,7 @@ export const useCourseUnit = ({ courseId, blockId }) => { const isTitleEditFormOpen = useSelector(state => state.courseUnit.isTitleEditFormOpen); const canEdit = useSelector(getCanEdit); const { currentlyVisibleToStudents } = courseUnit; - const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useCopyToClipboard(canEdit); + const { sharedClipboardData, showPasteXBlock, showPasteUnit } = useClipboard(canEdit); const { canPasteComponent } = courseVerticalChildren; const unitTitle = courseUnit.metadata?.displayName || ''; diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx index 5f78ae7617..645651a271 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 0000000000..d38f5a45f0 --- /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 70193c3eae..70ac2808c9 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 22d4d0ca4c..78fd3e17f8 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 1dc7a1c526..0000000000 --- a/src/generic/clipboard/paste-component/constants.js +++ /dev/null @@ -1,11 +0,0 @@ -import PropTypes from 'prop-types'; - -/* eslint-disable import/prefer-default-export */ -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 71% rename from src/generic/clipboard/paste-component/index.jsx rename to src/generic/clipboard/paste-component/index.tsx index 4d74c771b6..55e986a2ac 100644 --- a/src/generic/clipboard/paste-component/index.jsx +++ b/src/generic/clipboard/paste-component/index.tsx @@ -1,13 +1,19 @@ 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); @@ -24,9 +30,7 @@ const PasteComponent = ({ onBlur={() => handlePopoverToggle(false)} {...props} > - {clipboardData && ( - - )} + ); @@ -48,18 +52,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 e111961b15..461e09fe98 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 f53ddc610e..a25112704e 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 f5cc8a9557..0e51a8eaef 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 0afcd43309..0a69deb696 100644 --- a/src/library-authoring/add-content/AddContentContainer.tsx +++ b/src/library-authoring/add-content/AddContentContainer.tsx @@ -22,7 +22,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, useLibraryPasteClipboard, useAddComponentsToCollection } from '../data/apiHooks'; import { useLibraryContext } from '../common/context'; @@ -77,7 +77,7 @@ const AddContentContainer = () => { const pasteClipboardMutation = useLibraryPasteClipboard(); const { showToast } = useContext(ToastContext); const canEdit = useSelector(getCanEdit); - const { showPasteXBlock, sharedClipboardData } = useCopyToClipboard(canEdit); + const { showPasteXBlock, sharedClipboardData } = useClipboard(canEdit); const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); @@ -172,7 +172,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 fb5fb9685d..b9f6a5dba8 100644 --- a/src/library-authoring/components/ComponentCard.test.tsx +++ b/src/library-authoring/components/ComponentCard.test.tsx @@ -77,7 +77,8 @@ describe('', () => { ); await waitFor(() => { - expect(mockShowToast).toHaveBeenCalledWith('Component copied to clipboard'); + expect(mockShowToast).toHaveBeenCalledWith('Copying'); + expect(mockShowToast).toHaveBeenCalledWith('Copied to clipboard'); }); }); @@ -100,7 +101,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 c21f15467e..46a814a621 100644 --- a/src/library-authoring/components/ComponentCard.tsx +++ b/src/library-authoring/components/ComponentCard.tsx @@ -1,4 +1,4 @@ -import { useContext, useState } from 'react'; +import { 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 { SidebarAdditionalActions, useLibraryContext } from '../common/context'; @@ -43,17 +42,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 b591d956f5..a9e36d1a16 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 dc84448bde..4f7e4dd527 100644 --- a/src/testUtils.tsx +++ b/src/testUtils.tsx @@ -116,7 +116,7 @@ const RouterAndRoute: React.FC = ({ ); }; -function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions) { +function makeWrapper({ extraWrapper, ...routeArgs }: WrapperOptions & RouteOptions = {}) { const AllTheProviders = ({ children }) => ( @@ -192,7 +192,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 }) {