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.
This commit is contained in:
Navin Karkera
2025-04-16 19:34:28 +00:00
committed by GitHub
parent 4bd2c3b29a
commit 3b2adc2fc1
26 changed files with 262 additions and 52 deletions

View File

@@ -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';

View File

@@ -7,3 +7,4 @@
@import "./highlights-modal/HighlightsModal";
@import "./publish-modal/PublishModal";
@import "./xblock-status/XBlockStatus";
@import "./drag-helper/SortableItem";

View File

@@ -59,7 +59,7 @@ import {
moveUnitOver,
moveSubsection,
moveUnit,
} from '../generic/drag-helper/utils';
} from './drag-helper/utils';
let axiosMock;
let store;

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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 (
<DndContext
sensors={sensors}
modifiers={[restrictToVerticalAxis]}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
@@ -59,10 +70,22 @@ const DraggableList = ({
>
{children}
</SortableContext>
{renderOverlay && createPortal(
<DragOverlay>
{renderOverlay(activeId)}
</DragOverlay>,
document.body,
)}
</DndContext>
);
};
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;

View File

@@ -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 = ({
>
<ActionRow style={actionStyle}>
{actions}
{!disabled && (
<IconButtonWithTooltip
key="drag-to-reorder-icon"
ref={setActivatorNodeRef}
@@ -64,6 +69,7 @@ const SortableItem = ({
{...attributes}
{...listeners}
/>
)}
</ActionRow>
{children}
</Card>
@@ -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,
};

View File

@@ -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"

View File

@@ -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);
});
});

View File

@@ -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<LibraryBlockMetadata[]> {
const { data } = await getAuthenticatedHttpClient().patch(
getLibraryContainerChildrenApiUrl(containerId),
{ usage_keys: children },
);
return camelCaseObject(data);
}

View File

@@ -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);
});
});
});

View File

@@ -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) });
},
});
};

View File

@@ -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) => (
<>
<Stack direction="horizontal" gap={2} className="font-weight-bold">
<Icon src={getItemIcon(block.blockType)} />
{block.displayName}
</Stack>
<ActionRow.Spacer />
<Stack direction="horizontal" gap={3}>
{block.hasUnpublishedChanges && (
<Badge
className="px-2 pt-1"
variant="warning"
>
<Stack direction="horizontal" gap={1}>
<Icon className="mb-1" size="xs" src={Description} />
<FormattedMessage {...messages.draftChipText} />
</Stack>
</Badge>
)}
<TagCount size="sm" count={block.tagsCount} onClick={onTagClick} />
<ComponentMenu usageKey={block.id} />
</Stack>
</>
);
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<LibraryBlockMetadata[]>([]);
const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false);
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [hidePreviewFor, setHidePreviewFor] = useState<string | null>(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 <ErrorAlert error={error} />;
}
/* 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 (
<ActionRow className="bg-light-200 border border-light-500 p-2 rounded">
<BlockHeader block={block} onTagClick={openManageTagsDrawer} />
<IconButton
src={DragIndicator}
variant="light"
iconAs={Icon}
alt=""
/>
</ActionRow>
);
};
const renderedBlocks = orderedBlocks?.map((block) => (
<IframeProvider key={block.id}>
<SortableItem
id={block.id}
componentStyle={null}
actions={(
<>
<Stack direction="horizontal" gap={2} className="font-weight-bold">
<Icon src={getItemIcon(block.blockType)} />
{block.displayName}
</Stack>
<ActionRow.Spacer />
<Stack direction="horizontal" gap={3}>
{block.hasUnpublishedChanges && (
<Badge
className="px-2 pt-1"
variant="warning"
>
<Stack direction="horizontal" gap={1}>
<Icon className="mb-1" size="xs" src={Description} />
<FormattedMessage {...messages.draftChipText} />
</Stack>
</Badge>
)}
<TagCount size="sm" count={block.tagsCount} onClick={openManageTagsDrawer} />
<ComponentMenu usageKey={block.id} />
</Stack>
</>
)}
actions={<BlockHeader block={block} onTagClick={openManageTagsDrawer} />}
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 && (
<div className={classNames('p-3', {
'container-mw-md': block.blockType === blockTypes.video,
})}
@@ -151,13 +193,21 @@ export const LibraryUnitBlocks = ({ preview = false }: LibraryUnitBlocksProps) =
minHeight={calculateMinHeight(block)}
/>
</div>
)}
</SortableItem>
</IframeProvider>
));
return (
<div className="library-unit-page">
<DraggableList itemList={orderedBlocks} setState={setOrderedBlocks} updateOrder={handleReorder}>
<DraggableList
itemList={orderedBlocks}
setState={setOrderedBlocks}
updateOrder={handleReorder}
renderOverlay={renderOverlay}
activeId={hidePreviewFor}
setActiveId={setHidePreviewFor}
>
{renderedBlocks}
</DraggableList>
{ !preview && (

View File

@@ -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('<LibraryUnitPage />', () => {
beforeEach(() => {
const mocks = initializeMocks();
@@ -187,4 +199,36 @@ describe('<LibraryUnitPage />', () => {
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'));
});
});

View File

@@ -176,8 +176,8 @@ export const LibraryUnitPage = () => {
return <NotFoundAlert />;
}
// istanbul ignore if
if (isError) {
// istanbul ignore next
return <ErrorAlert error={error} />;
}

View File

@@ -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;