From 3b2adc2fc1aaa192d025d4d96252745b4476fdf2 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 16 Apr 2025 19:34:28 +0000 Subject: [PATCH] feat: reorder components in unit page [FC-00083] (#1816) Reorders components in unit page via drag and drop. This PR also refactors and moves draggable list and sortable item components to appropriate location. Course authors will be affected by this change. --- src/course-outline/CourseOutline.jsx | 4 +- src/course-outline/CourseOutline.scss | 1 + src/course-outline/CourseOutline.test.jsx | 2 +- .../drag-helper/DragContextProvider.jsx | 0 .../drag-helper/DraggableList.jsx | 0 .../drag-helper/SortableItem.jsx | 0 .../drag-helper/SortableItem.scss | 0 .../drag-helper}/messages.js | 0 .../drag-helper/utils.js | 0 .../section-card/SectionCard.jsx | 4 +- .../subsection-card/SubsectionCard.jsx | 4 +- src/course-outline/unit-card/UnitCard.jsx | 2 +- src/custom-pages/CustomPages.jsx | 2 +- .../DraggableList/DraggableList.jsx | 32 ++++- .../DraggableList/SortableItem.jsx | 8 ++ .../DraggableList/index.jsx | 0 .../messages.js | 0 src/generic/styles.scss | 1 - src/library-authoring/data/api.test.ts | 11 ++ src/library-authoring/data/api.ts | 14 ++ src/library-authoring/data/apiHooks.test.tsx | 21 +++ src/library-authoring/data/apiHooks.ts | 26 ++++ .../units/LibraryUnitBlocks.tsx | 122 ++++++++++++------ .../units/LibraryUnitPage.test.tsx | 48 ++++++- .../units/LibraryUnitPage.tsx | 2 +- src/library-authoring/units/messages.ts | 10 ++ 26 files changed, 262 insertions(+), 52 deletions(-) rename src/{generic => course-outline}/drag-helper/DragContextProvider.jsx (100%) rename src/{generic => course-outline}/drag-helper/DraggableList.jsx (100%) rename src/{generic => course-outline}/drag-helper/SortableItem.jsx (100%) rename src/{generic => course-outline}/drag-helper/SortableItem.scss (100%) rename src/{editors/sharedComponents/DraggableList => course-outline/drag-helper}/messages.js (100%) rename src/{generic => course-outline}/drag-helper/utils.js (100%) rename src/{editors/sharedComponents => generic}/DraggableList/DraggableList.jsx (70%) rename src/{editors/sharedComponents => generic}/DraggableList/SortableItem.jsx (93%) rename src/{editors/sharedComponents => generic}/DraggableList/index.jsx (100%) rename src/generic/{drag-helper => DraggableList}/messages.js (100%) diff --git a/src/course-outline/CourseOutline.jsx b/src/course-outline/CourseOutline.jsx index b5f3f3b0a..df52bd736 100644 --- a/src/course-outline/CourseOutline.jsx +++ b/src/course-outline/CourseOutline.jsx @@ -45,12 +45,12 @@ import HighlightsModal from './highlights-modal/HighlightsModal'; import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder'; import PublishModal from './publish-modal/PublishModal'; import PageAlerts from './page-alerts/PageAlerts'; -import DraggableList from '../generic/drag-helper/DraggableList'; +import DraggableList from './drag-helper/DraggableList'; import { canMoveSection, possibleUnitMoves, possibleSubsectionMoves, -} from '../generic/drag-helper/utils'; +} from './drag-helper/utils'; import { useCourseOutline } from './hooks'; import messages from './messages'; import { getTagsExportFile } from './data/api'; diff --git a/src/course-outline/CourseOutline.scss b/src/course-outline/CourseOutline.scss index 056b34a8e..101d61ff5 100644 --- a/src/course-outline/CourseOutline.scss +++ b/src/course-outline/CourseOutline.scss @@ -7,3 +7,4 @@ @import "./highlights-modal/HighlightsModal"; @import "./publish-modal/PublishModal"; @import "./xblock-status/XBlockStatus"; +@import "./drag-helper/SortableItem"; diff --git a/src/course-outline/CourseOutline.test.jsx b/src/course-outline/CourseOutline.test.jsx index 05ff96205..2ca68dc44 100644 --- a/src/course-outline/CourseOutline.test.jsx +++ b/src/course-outline/CourseOutline.test.jsx @@ -59,7 +59,7 @@ import { moveUnitOver, moveSubsection, moveUnit, -} from '../generic/drag-helper/utils'; +} from './drag-helper/utils'; let axiosMock; let store; diff --git a/src/generic/drag-helper/DragContextProvider.jsx b/src/course-outline/drag-helper/DragContextProvider.jsx similarity index 100% rename from src/generic/drag-helper/DragContextProvider.jsx rename to src/course-outline/drag-helper/DragContextProvider.jsx diff --git a/src/generic/drag-helper/DraggableList.jsx b/src/course-outline/drag-helper/DraggableList.jsx similarity index 100% rename from src/generic/drag-helper/DraggableList.jsx rename to src/course-outline/drag-helper/DraggableList.jsx diff --git a/src/generic/drag-helper/SortableItem.jsx b/src/course-outline/drag-helper/SortableItem.jsx similarity index 100% rename from src/generic/drag-helper/SortableItem.jsx rename to src/course-outline/drag-helper/SortableItem.jsx diff --git a/src/generic/drag-helper/SortableItem.scss b/src/course-outline/drag-helper/SortableItem.scss similarity index 100% rename from src/generic/drag-helper/SortableItem.scss rename to src/course-outline/drag-helper/SortableItem.scss diff --git a/src/editors/sharedComponents/DraggableList/messages.js b/src/course-outline/drag-helper/messages.js similarity index 100% rename from src/editors/sharedComponents/DraggableList/messages.js rename to src/course-outline/drag-helper/messages.js diff --git a/src/generic/drag-helper/utils.js b/src/course-outline/drag-helper/utils.js similarity index 100% rename from src/generic/drag-helper/utils.js rename to src/course-outline/drag-helper/utils.js diff --git a/src/course-outline/section-card/SectionCard.jsx b/src/course-outline/section-card/SectionCard.jsx index f8d11a4cd..5d3b5fbb5 100644 --- a/src/course-outline/section-card/SectionCard.jsx +++ b/src/course-outline/section-card/SectionCard.jsx @@ -13,8 +13,8 @@ import classNames from 'classnames'; import { setCurrentItem, setCurrentSection } from '../data/slice'; 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 SortableItem from '../drag-helper/SortableItem'; +import { DragContext } from '../drag-helper/DragContextProvider'; import TitleButton from '../card-header/TitleButton'; import XBlockStatus from '../xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; diff --git a/src/course-outline/subsection-card/SubsectionCard.jsx b/src/course-outline/subsection-card/SubsectionCard.jsx index 1d5c7b036..86d35231b 100644 --- a/src/course-outline/subsection-card/SubsectionCard.jsx +++ b/src/course-outline/subsection-card/SubsectionCard.jsx @@ -15,8 +15,8 @@ import CourseOutlineSubsectionCardExtraActionsSlot from '../../plugin-slots/Cour import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; 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 SortableItem from '../drag-helper/SortableItem'; +import { DragContext } from '../drag-helper/DragContextProvider'; import { useClipboard, PasteComponent } from '../../generic/clipboard'; import TitleButton from '../card-header/TitleButton'; import XBlockStatus from '../xblock-status/XBlockStatus'; diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index 7d17d24c5..00ac4cf50 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -10,7 +10,7 @@ import CourseOutlineUnitCardExtraActionsSlot from '../../plugin-slots/CourseOutl import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; import CardHeader from '../card-header/CardHeader'; -import SortableItem from '../../generic/drag-helper/SortableItem'; +import SortableItem from '../drag-helper/SortableItem'; import TitleLink from '../card-header/TitleLink'; import XBlockStatus from '../xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; diff --git a/src/custom-pages/CustomPages.jsx b/src/custom-pages/CustomPages.jsx index ad69e670f..d2c5e7e31 100644 --- a/src/custom-pages/CustomPages.jsx +++ b/src/custom-pages/CustomPages.jsx @@ -21,7 +21,7 @@ import { } from '@openedx/paragon'; import { Add, SpinnerSimple } from '@openedx/paragon/icons'; import Placeholder from '../editors/Placeholder'; -import DraggableList, { SortableItem } from '../editors/sharedComponents/DraggableList'; +import DraggableList, { SortableItem } from '../generic/DraggableList'; import ErrorAlert from '../editors/sharedComponents/ErrorAlerts/ErrorAlert'; import { RequestStatus } from '../data/constants'; diff --git a/src/editors/sharedComponents/DraggableList/DraggableList.jsx b/src/generic/DraggableList/DraggableList.jsx similarity index 70% rename from src/editors/sharedComponents/DraggableList/DraggableList.jsx rename to src/generic/DraggableList/DraggableList.jsx index 6946d1177..1515f29a0 100644 --- a/src/editors/sharedComponents/DraggableList/DraggableList.jsx +++ b/src/generic/DraggableList/DraggableList.jsx @@ -1,5 +1,6 @@ -import React from 'react'; +import React, { useCallback } from 'react'; import PropTypes from 'prop-types'; +import { createPortal } from 'react-dom'; import { DndContext, @@ -8,6 +9,7 @@ import { PointerSensor, useSensor, useSensors, + DragOverlay, } from '@dnd-kit/core'; import { arrayMove, @@ -22,6 +24,9 @@ const DraggableList = ({ setState, updateOrder, children, + renderOverlay, + activeId, + setActiveId, }) => { const sensors = useSensors( useSensor(PointerSensor), @@ -30,7 +35,7 @@ const DraggableList = ({ }), ); - const handleDragEnd = (event) => { + const handleDragEnd = useCallback((event) => { const { active, over } = event; if (active.id !== over.id) { let updatedArray; @@ -44,13 +49,19 @@ const DraggableList = ({ }); updateOrder()(updatedArray); } - }; + setActiveId?.(null); + }, [updateOrder, setActiveId]); + + const handleDragStart = useCallback((event) => { + setActiveId?.(event.active.id); + }, [setActiveId]); return ( {children} + {renderOverlay && createPortal( + + {renderOverlay(activeId)} + , + document.body, + )} ); }; +DraggableList.defaultProps = { + renderOverlay: undefined, + activeId: null, + setActiveId: () => {}, +}; + DraggableList.propTypes = { itemList: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string.isRequired, @@ -70,6 +93,9 @@ DraggableList.propTypes = { setState: PropTypes.func.isRequired, updateOrder: PropTypes.func.isRequired, children: PropTypes.node.isRequired, + renderOverlay: PropTypes.func, + activeId: PropTypes.string, + setActiveId: PropTypes.func, }; export default DraggableList; diff --git a/src/editors/sharedComponents/DraggableList/SortableItem.jsx b/src/generic/DraggableList/SortableItem.jsx similarity index 93% rename from src/editors/sharedComponents/DraggableList/SortableItem.jsx rename to src/generic/DraggableList/SortableItem.jsx index 01816624a..b86563cb9 100644 --- a/src/editors/sharedComponents/DraggableList/SortableItem.jsx +++ b/src/generic/DraggableList/SortableItem.jsx @@ -17,6 +17,7 @@ const SortableItem = ({ children, isClickable, onClick, + disabled, // injected intl, }) => { @@ -31,6 +32,9 @@ const SortableItem = ({ } = useSortable({ id, animateLayoutChanges: () => false, + disabled: { + draggable: disabled, + }, }); const style = { @@ -52,6 +56,7 @@ const SortableItem = ({ > {actions} + {!disabled && ( + )} {children} @@ -76,6 +82,7 @@ SortableItem.defaultProps = { actionStyle: null, isClickable: false, onClick: null, + disabled: false, }; SortableItem.propTypes = { id: PropTypes.string.isRequired, @@ -85,6 +92,7 @@ SortableItem.propTypes = { componentStyle: PropTypes.shape({}), isClickable: PropTypes.bool, onClick: PropTypes.func, + disabled: PropTypes.bool, // injected intl: intlShape.isRequired, }; diff --git a/src/editors/sharedComponents/DraggableList/index.jsx b/src/generic/DraggableList/index.jsx similarity index 100% rename from src/editors/sharedComponents/DraggableList/index.jsx rename to src/generic/DraggableList/index.jsx diff --git a/src/generic/drag-helper/messages.js b/src/generic/DraggableList/messages.js similarity index 100% rename from src/generic/drag-helper/messages.js rename to src/generic/DraggableList/messages.js diff --git a/src/generic/styles.scss b/src/generic/styles.scss index 2ec14a72b..00ef45922 100644 --- a/src/generic/styles.scss +++ b/src/generic/styles.scss @@ -11,6 +11,5 @@ @import "./tag-count/TagCount"; @import "./modal-dropzone/ModalDropzone"; @import "./configure-modal/ConfigureModal"; -@import "./drag-helper/SortableItem"; @import "./block-type-utils"; @import "./modal-iframe" diff --git a/src/library-authoring/data/api.test.ts b/src/library-authoring/data/api.test.ts index e75a2b4b1..37ebc4105 100644 --- a/src/library-authoring/data/api.test.ts +++ b/src/library-authoring/data/api.test.ts @@ -126,4 +126,15 @@ describe('library data API', () => { await api.addComponentsToContainer(containerId, [componentId]); expect(axiosMock.history.post[0].url).toEqual(url); }); + + it('should update container children', async () => { + const { axiosMock } = initializeMocks(); + const containerId = 'lct:org:lib1'; + const url = api.getLibraryContainerChildrenApiUrl(containerId); + + axiosMock.onPatch(url).reply(200); + + await api.updateLibraryContainerChildren(containerId, ['test']); + expect(axiosMock.history.patch[0].url).toEqual(url); + }); }); diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index b4a7bca67..1eabb381e 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -670,3 +670,17 @@ export async function updateContainerCollections(containerId: string, collection collection_keys: collectionKeys, }); } + +/** + * Update library container's children. + */ +export async function updateLibraryContainerChildren( + containerId: string, + children: string[], +): Promise { + const { data } = await getAuthenticatedHttpClient().patch( + getLibraryContainerChildrenApiUrl(containerId), + { usage_keys: children }, + ); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHooks.test.tsx b/src/library-authoring/data/apiHooks.test.tsx index 57e0a8fa7..6a1fd3dc5 100644 --- a/src/library-authoring/data/apiHooks.test.tsx +++ b/src/library-authoring/data/apiHooks.test.tsx @@ -29,6 +29,7 @@ import { useRestoreContainer, useContainerChildren, useAddComponentsToContainer, + useUpdateContainerChildren, } from './apiHooks'; let axiosMock; @@ -266,4 +267,24 @@ describe('library api hooks', () => { expect(axiosMock.history.post[0].url).toEqual(url); }); + + it('should update container children', async () => { + const containerId = 'lct:org:lib1'; + const url = getLibraryContainerChildrenApiUrl(containerId); + + axiosMock.onPatch(url).reply(200); + const { result } = renderHook(() => useUpdateContainerChildren(containerId), { wrapper }); + await result.current.mutateAsync([]); + await waitFor(() => { + expect(axiosMock.history.patch[0].url).toEqual(url); + }); + }); + + it('should not attempt request if containerId is not defined', async () => { + const { result } = renderHook(() => useUpdateContainerChildren(), { wrapper }); + await result.current.mutateAsync([]); + await waitFor(() => { + expect(axiosMock.history.patch.length).toEqual(0); + }); + }); }); diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index e20671a2b..b52b597e3 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -56,6 +56,7 @@ import { restoreContainer, getLibraryContainerChildren, updateContainerCollections, + updateLibraryContainerChildren, } from './api'; import { VersionSpec } from '../LibraryBlock'; @@ -696,3 +697,28 @@ export const useUpdateContainerCollections = (containerId: string) => { }, }); }; + +/** + * Update container children + */ +export const useUpdateContainerChildren = (containerId?: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (usageKeys: string[]) => { + if (!containerId) { + return undefined; + } + return updateLibraryContainerChildren(containerId, usageKeys); + }, + onSettled: () => { + if (!containerId) { + return; + } + // NOTE: We invalidate the library query here because we need to update the library's + // container list. + const libraryId = getLibraryId(containerId); + queryClient.invalidateQueries({ predicate: (query) => libraryQueryPredicate(query, libraryId) }); + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.container(containerId) }); + }, + }); +}; diff --git a/src/library-authoring/units/LibraryUnitBlocks.tsx b/src/library-authoring/units/LibraryUnitBlocks.tsx index f1873fb08..16b0e13f9 100644 --- a/src/library-authoring/units/LibraryUnitBlocks.tsx +++ b/src/library-authoring/units/LibraryUnitBlocks.tsx @@ -1,14 +1,14 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { - ActionRow, Badge, Button, Icon, Stack, useToggle, + ActionRow, Badge, Button, Icon, IconButton, Stack, useToggle, } from '@openedx/paragon'; -import { Add, Description } from '@openedx/paragon/icons'; +import { Add, Description, DragIndicator } from '@openedx/paragon/icons'; import { useQueryClient } from '@tanstack/react-query'; import classNames from 'classnames'; -import { useEffect, useState } from 'react'; +import { useContext, useEffect, useState } from 'react'; import { ContentTagsDrawerSheet } from '../../content-tags-drawer'; import { blockTypes } from '../../editors/data/constants/app'; -import DraggableList, { SortableItem } from '../../editors/sharedComponents/DraggableList'; +import DraggableList, { SortableItem } from '../../generic/DraggableList'; import ErrorAlert from '../../generic/alert-error'; import { getItemIcon } from '../../generic/block-type-utils'; @@ -20,11 +20,12 @@ import { useLibraryContext } from '../common/context/LibraryContext'; import { PickLibraryContentModal } from '../add-content'; import ComponentMenu from '../components'; import { LibraryBlockMetadata } from '../data/api'; -import { libraryAuthoringQueryKeys, useContainerChildren } from '../data/apiHooks'; +import { libraryAuthoringQueryKeys, useContainerChildren, useUpdateContainerChildren } from '../data/apiHooks'; import { LibraryBlock } from '../LibraryBlock'; import { useLibraryRoutes } from '../routes'; import messages from './messages'; import { useSidebarContext } from '../common/context/SidebarContext'; +import { ToastContext } from '../../generic/toast-context'; /** Components that need large min height in preview */ const LARGE_COMPONENTS = [ @@ -35,17 +36,53 @@ const LARGE_COMPONENTS = [ 'lti_consumer', ]; +interface BlockHeaderProps { + block: LibraryBlockMetadata; + onTagClick: () => void; +} + +/** Component header, split out to reuse in drag overlay */ +const BlockHeader = ({ block, onTagClick }: BlockHeaderProps) => ( + <> + + + {block.displayName} + + + + {block.hasUnpublishedChanges && ( + + + + + + + )} + + + + +); + interface LibraryUnitBlocksProps { + /** set to true if it is rendered as preview + * This disables drag and drop + */ preview?: boolean; } -export const LibraryUnitBlocks = ({ preview = false }: LibraryUnitBlocksProps) => { +export const LibraryUnitBlocks = ({ preview }: LibraryUnitBlocksProps) => { const intl = useIntl(); const [orderedBlocks, setOrderedBlocks] = useState([]); const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); + const [hidePreviewFor, setHidePreviewFor] = useState(null); const { navigateTo } = useLibraryRoutes(); + const { showToast } = useContext(ToastContext); const { unitId, @@ -60,6 +97,7 @@ export const LibraryUnitBlocks = ({ preview = false }: LibraryUnitBlocksProps) = } = useSidebarContext(); const queryClient = useQueryClient(); + const orderMutator = useUpdateContainerChildren(unitId); const { data: blocks, isLoading, @@ -78,11 +116,14 @@ export const LibraryUnitBlocks = ({ preview = false }: LibraryUnitBlocksProps) = return ; } - /* istanbul ignore next */ - const handleReorder = () => (newOrder: LibraryBlockMetadata[]) => { - // eslint-disable-next-line no-console - console.log('LibraryUnitBlocks newOrder: ', newOrder); - // TODO: update order of components in unit + const handleReorder = () => async (newOrder: LibraryBlockMetadata[]) => { + const usageKeys = newOrder.map((o) => o.id); + try { + await orderMutator.mutateAsync(usageKeys); + showToast(intl.formatMessage(messages.orderUpdatedMsg)); + } catch (e) { + showToast(intl.formatMessage(messages.failedOrderUpdatedMsg)); + } }; const onTagSidebarClose = () => { @@ -103,44 +144,45 @@ export const LibraryUnitBlocks = ({ preview = false }: LibraryUnitBlocksProps) = return '200px'; }; + const renderOverlay = (activeId: string | null) => { + if (!activeId) { + return null; + } + const block = orderedBlocks?.find((val) => val.id === activeId); + if (!block) { + return null; + } + return ( + + + + + ); + }; + const renderedBlocks = orderedBlocks?.map((block) => ( - - - {block.displayName} - - - - {block.hasUnpublishedChanges && ( - - - - - - - )} - - - - - )} + actions={} actionStyle={{ borderRadius: '8px 8px 0px 0px', padding: '0.5rem 1rem', background: '#FBFAF9', borderBottom: 'solid 1px #E1DDDB', + outline: hidePreviewFor === block.id && '2px dashed gray', }} isClickable onClick={() => handleComponentSelection(block)} + disabled={preview} > + {hidePreviewFor !== block.id && (
+ )}
)); return (
- + {renderedBlocks} { !preview && ( diff --git a/src/library-authoring/units/LibraryUnitPage.test.tsx b/src/library-authoring/units/LibraryUnitPage.test.tsx index db1df1026..daa63b230 100644 --- a/src/library-authoring/units/LibraryUnitPage.test.tsx +++ b/src/library-authoring/units/LibraryUnitPage.test.tsx @@ -1,6 +1,7 @@ import userEvent from '@testing-library/user-event'; import type MockAdapter from 'axios-mock-adapter'; +import { act } from 'react'; import { initializeMocks, fireEvent, @@ -9,7 +10,7 @@ import { waitFor, within, } from '../../testUtils'; -import { getLibraryContainerApiUrl } from '../data/api'; +import { getLibraryContainerApiUrl, getLibraryContainerChildrenApiUrl } from '../data/api'; import { mockContentLibrary, mockXBlockFields, @@ -20,12 +21,13 @@ import { import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock'; import { mockClipboardEmpty } from '../../generic/data/api.mock'; import LibraryLayout from '../LibraryLayout'; +import { ToastActionData } from '../../generic/toast-context'; const path = '/library/:libraryId/*'; const libraryTitle = mockContentLibrary.libraryData.title; let axiosMock: MockAdapter; -let mockShowToast: (message: string) => void; +let mockShowToast: (message: string, action?: ToastActionData | undefined) => void; mockClipboardEmpty.applyMock(); mockGetContainerMetadata.applyMock(); @@ -36,6 +38,16 @@ mockContentLibrary.applyMock(); mockXBlockFields.applyMock(); mockLibraryBlockMetadata.applyMock(); +const closestCenter = jest.fn(); +jest.mock('@dnd-kit/core', () => ({ + ...jest.requireActual('@dnd-kit/core'), + // Since jsdom (used by jest) does not support getBoundingClientRect function + // which is required for drag-n-drop calculations, we mock closestCorners fn + // from dnd-kit to return collided elements as per the test. This allows us to + // test all drag-n-drop handlers. + closestCenter: () => closestCenter(), +})); + describe('', () => { beforeEach(() => { const mocks = initializeMocks(); @@ -187,4 +199,36 @@ describe('', () => { userEvent.click(closeButton); await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); }); + + it('should call update order api on dragging component', async () => { + renderLibraryUnitPage(); + const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0]; + axiosMock + .onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId)) + .reply(200); + closestCenter.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1' }]); + await act(async () => { + fireEvent.keyDown(firstDragHandle, { code: 'Space' }); + }); + await act(async () => { + fireEvent.keyDown(firstDragHandle, { code: 'Space' }); + }); + await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Order updated')); + }); + + it('should show toast error message on update order failure', async () => { + renderLibraryUnitPage(); + const firstDragHandle = (await screen.findAllByRole('button', { name: 'Drag to reorder' }))[0]; + axiosMock + .onPatch(getLibraryContainerChildrenApiUrl(mockGetContainerMetadata.containerId)) + .reply(500); + closestCenter.mockReturnValue([{ id: 'lb:org1:Demo_course:html:text-1' }]); + await act(async () => { + fireEvent.keyDown(firstDragHandle, { code: 'Space' }); + }); + await act(async () => { + fireEvent.keyDown(firstDragHandle, { code: 'Space' }); + }); + await waitFor(() => expect(mockShowToast).toHaveBeenLastCalledWith('Failed to update components order')); + }); }); diff --git a/src/library-authoring/units/LibraryUnitPage.tsx b/src/library-authoring/units/LibraryUnitPage.tsx index fc1eb6716..e362cdb90 100644 --- a/src/library-authoring/units/LibraryUnitPage.tsx +++ b/src/library-authoring/units/LibraryUnitPage.tsx @@ -176,8 +176,8 @@ export const LibraryUnitPage = () => { return ; } + // istanbul ignore if if (isError) { - // istanbul ignore next return ; } diff --git a/src/library-authoring/units/messages.ts b/src/library-authoring/units/messages.ts index e0e288a38..a15eeb850 100644 --- a/src/library-authoring/units/messages.ts +++ b/src/library-authoring/units/messages.ts @@ -41,6 +41,16 @@ const messages = defineMessages({ defaultMessage: 'Failed to update container.', description: 'Message displayed when container update fails', }, + orderUpdatedMsg: { + id: 'course-authoring.library-authoring.unit-component.order-updated-msg.text', + defaultMessage: 'Order updated', + description: 'Toast message displayed when components are successfully reordered in a unit', + }, + failedOrderUpdatedMsg: { + id: 'course-authoring.library-authoring.unit-component.failed-order-updated-msg.text', + defaultMessage: 'Failed to update components order', + description: 'Toast message displayed when components are successfully reordered in a unit', + }, }); export default messages;