From 915bd559e05eea6438ef94b646dc45df54e5c5da Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 4 Aug 2025 22:16:39 +0530 Subject: [PATCH] feat: disable drag handles for children of library imported containers in course outline [FC-0097] (#2311) * Hides/disables drag handles for children components of containers imported from library. * Disables move, delete and duplicate options for children components. * Move up and down option skips containers that cannot accept children (imported from library). * Authors cannot drag and drop xblocks under containers imported from library. * Improves drag-n-drop by creating a representational drag overlay. --- src/course-outline/CourseOutline.test.tsx | 24 +- .../drag-helper/CourseItemOverlay.tsx | 117 ++ .../drag-helper/DragContextProvider.jsx | 31 - .../drag-helper/DragContextProvider.tsx | 30 + .../{DraggableList.jsx => DraggableList.tsx} | 210 +-- .../{SortableItem.jsx => SortableItem.tsx} | 42 +- .../drag-helper/{messages.js => messages.ts} | 0 src/course-outline/drag-helper/utils.js | 331 ----- src/course-outline/drag-helper/utils.test.ts | 1166 +++++++++++++++++ src/course-outline/drag-helper/utils.ts | 432 ++++++ .../section-card/SectionCard.tsx | 8 +- .../subsection-card/SubsectionCard.test.tsx | 30 + .../subsection-card/SubsectionCard.tsx | 16 +- .../unit-card/UnitCard.test.tsx | 36 + src/course-outline/unit-card/UnitCard.tsx | 20 +- 15 files changed, 2008 insertions(+), 485 deletions(-) create mode 100644 src/course-outline/drag-helper/CourseItemOverlay.tsx delete mode 100644 src/course-outline/drag-helper/DragContextProvider.jsx create mode 100644 src/course-outline/drag-helper/DragContextProvider.tsx rename src/course-outline/drag-helper/{DraggableList.jsx => DraggableList.tsx} (63%) rename src/course-outline/drag-helper/{SortableItem.jsx => SortableItem.tsx} (78%) rename src/course-outline/drag-helper/{messages.js => messages.ts} (100%) delete mode 100644 src/course-outline/drag-helper/utils.js create mode 100644 src/course-outline/drag-helper/utils.test.ts create mode 100644 src/course-outline/drag-helper/utils.ts diff --git a/src/course-outline/CourseOutline.test.tsx b/src/course-outline/CourseOutline.test.tsx index 5d3b540f9..5ee8c8fd0 100644 --- a/src/course-outline/CourseOutline.test.tsx +++ b/src/course-outline/CourseOutline.test.tsx @@ -1767,7 +1767,7 @@ describe('', () => { .reply(200, { dummy: 'value' }); const expectedSection = moveSubsection([ ...courseOutlineIndexMock.courseStructure.childInfo.children, - ], 0, 0, 1)[0][0]; + ] as unknown as XBlock[], 0, 0, 1)[0][0]; axiosMock .onGet(getXBlockApiUrl(section.id)) .reply(200, expectedSection); @@ -1805,7 +1805,7 @@ describe('', () => { .reply(200, { dummy: 'value' }); const expectedSections = moveSubsectionOver([ ...courseOutlineIndexMock.courseStructure.childInfo.children, - ], 1, 0, 0, firstSection.childInfo.children.length + 1)[0]; + ] as unknown as XBlock[], 1, 0, 0, firstSection.childInfo.children.length + 1)[0]; axiosMock .onGet(getXBlockApiUrl(firstSection.id)) .reply(200, expectedSections[0]); @@ -1842,7 +1842,7 @@ describe('', () => { .reply(200, { dummy: 'value' }); const expectedSections = moveSubsectionOver([ ...courseOutlineIndexMock.courseStructure.childInfo.children, - ], 0, lastSubsectionIdx, 1, 0)[0]; + ] as unknown as XBlock[], 0, lastSubsectionIdx, 1, 0)[0]; axiosMock .onGet(getXBlockApiUrl(section.id)) .reply(200, expectedSections[0]); @@ -1929,7 +1929,9 @@ describe('', () => { axiosMock .onPut(getCourseItemApiUrl(store.getState().courseOutline.sectionsList[1].childInfo.children[1].id)) .reply(200, { dummy: 'value' }); - const expectedSection = moveUnit([...courseOutlineIndexMock.courseStructure.childInfo.children], 1, 1, 0, 1)[0][1]; + const expectedSection = moveUnit([ + ...courseOutlineIndexMock.courseStructure.childInfo.children, + ] as unknown as XBlock[], 1, 1, 0, 1)[0][1]; axiosMock .onGet(getXBlockApiUrl(section.id)) .reply(200, expectedSection); @@ -1970,7 +1972,7 @@ describe('', () => { .reply(200, { dummy: 'value' }); const expectedSections = moveUnitOver([ ...courseOutlineIndexMock.courseStructure.childInfo.children, - ], 1, 1, 0, 1, 0, firstSubsection.childInfo.children.length)[0]; + ] as unknown as XBlock[], 1, 1, 0, 1, 0, firstSubsection.childInfo.children.length)[0]; axiosMock .onGet(getXBlockApiUrl(section.id)) .reply(200, expectedSections[1]); @@ -2004,7 +2006,7 @@ describe('', () => { .onPut(getCourseItemApiUrl(firstSectionLastSubsection.id)) .reply(200, { dummy: 'value' }); const expectedSections = moveUnitOver( - [...courseOutlineIndexMock.courseStructure.childInfo.children], + [...courseOutlineIndexMock.courseStructure.childInfo.children] as unknown as XBlock[], 1, 0, 0, @@ -2050,7 +2052,7 @@ describe('', () => { .reply(200, { dummy: 'value' }); const expectedSections = moveUnitOver([ ...courseOutlineIndexMock.courseStructure.childInfo.children, - ], 1, 0, lastUnitIdx, 1, 1, 0)[0]; + ] as unknown as XBlock[], 1, 0, lastUnitIdx, 1, 1, 0)[0]; axiosMock .onGet(getXBlockApiUrl(section.id)) .reply(200, expectedSections[1]); @@ -2086,7 +2088,7 @@ describe('', () => { .onPut(getCourseItemApiUrl(thirdSectionFirstSubsection.id)) .reply(200, { dummy: 'value' }); const expectedSections = moveUnitOver( - [...courseOutlineIndexMock.courseStructure.childInfo.children], + [...courseOutlineIndexMock.courseStructure.childInfo.children] as unknown as XBlock[], 1, lastSubIndex, lastUnitIdx, @@ -2173,7 +2175,7 @@ describe('', () => { .reply(200, { dummy: 'value' }); const expectedSection = moveSubsection([ ...courseOutlineIndexMock.courseStructure.childInfo.children, - ], 0, 1, 0)[0][0]; + ] as unknown as XBlock[], 0, 1, 0)[0][0]; axiosMock .onGet(getXBlockApiUrl(section.id)) .reply(200, expectedSection); @@ -2236,7 +2238,7 @@ describe('', () => { axiosMock .onPut(getCourseItemApiUrl(subsection.id)) .reply(200, { dummy: 'value' }); - const expectedSection = moveUnit([...sections], 2, 0, 1, 0)[0][2]; + const expectedSection = moveUnit([...sections] as unknown as XBlock[], 2, 0, 1, 0)[0][2]; axiosMock .onGet(getXBlockApiUrl(section.id)) .reply(200, expectedSection); @@ -2270,7 +2272,7 @@ describe('', () => { axiosMock .onPut(getCourseItemApiUrl(subsection.id)) .reply(500); - const expectedSection = moveUnit([...sections], 2, 0, 1, 0)[0][2]; + const expectedSection = moveUnit([...sections] as unknown as XBlock[], 2, 0, 1, 0)[0][2]; axiosMock .onGet(getXBlockApiUrl(section.id)) .reply(200, expectedSection); diff --git a/src/course-outline/drag-helper/CourseItemOverlay.tsx b/src/course-outline/drag-helper/CourseItemOverlay.tsx new file mode 100644 index 000000000..a61673bf8 --- /dev/null +++ b/src/course-outline/drag-helper/CourseItemOverlay.tsx @@ -0,0 +1,117 @@ +import { Col, Icon, Row } from '@openedx/paragon'; +import { ArrowRight, DragIndicator } from '@openedx/paragon/icons'; +import { ContainerType } from '@src/generic/key-utils'; +import { getItemStatusBorder } from '../utils'; + +interface ItemProps { + displayName: string; + status: string; +} + +interface CourseItemOverlayProps extends ItemProps { + category: string; +} + +const commonStyle = { + padding: '1rem 1.5rem', + marginBottom: '1.5rem', + borderRadius: '0.35rem', + boxShadow: '0 0 .125rem rgba(0, 0, 0, .15), 0 0 .25rem rgba(0, 0, 0, .15)', +}; + +const DragIndicatorBtn = () => ( + +); + +const SectionCard = ({ status, displayName }: ItemProps) => { + const style = { + ...commonStyle, + paddingTop: '2rem', + paddingBottom: '5rem', + ...getItemStatusBorder(status), + }; + + return ( + + +
+ + {displayName} +
+ + +
+ ); +}; + +const SubsectionCard = ({ status, displayName }: ItemProps) => { + const style = { + ...commonStyle, + paddingTop: '1rem', + paddingBottom: '2.5rem', + ...getItemStatusBorder(status), + }; + + return ( + + +
+ + {displayName} +
+ + +
+ ); +}; + +const UnitCard = ({ status, displayName }: ItemProps) => { + const style = { + ...commonStyle, + paddingBottom: '1.5rem', + ...getItemStatusBorder(status), + }; + + return ( + + +
+ {displayName} +
+ + +
+ ); +}; + +const CourseItemOverlay = ({ category, displayName, status }: CourseItemOverlayProps) => { + switch (category) { + case ContainerType.Chapter: + return ; + case ContainerType.Sequential: + return ; + case ContainerType.Vertical: + return ; + default: + throw new Error(`Invalid course item type: ${category}`); + } +}; + +export default CourseItemOverlay; diff --git a/src/course-outline/drag-helper/DragContextProvider.jsx b/src/course-outline/drag-helper/DragContextProvider.jsx deleted file mode 100644 index 48a497d7e..000000000 --- a/src/course-outline/drag-helper/DragContextProvider.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export const DragContext = React.createContext({ activeId: '', overId: '', children: undefined }); - -const DragContextProvider = ({ activeId, overId, children }) => { - const contextValue = React.useMemo(() => ({ - activeId, - overId, - }), [activeId, overId]); - return ( - - {children} - - ); -}; - -DragContextProvider.defaultProps = { - activeId: '', - overId: '', -}; - -DragContextProvider.propTypes = { - activeId: PropTypes.string, - overId: PropTypes.string, - children: PropTypes.node.isRequired, -}; - -export default DragContextProvider; diff --git a/src/course-outline/drag-helper/DragContextProvider.tsx b/src/course-outline/drag-helper/DragContextProvider.tsx new file mode 100644 index 000000000..d4618823c --- /dev/null +++ b/src/course-outline/drag-helper/DragContextProvider.tsx @@ -0,0 +1,30 @@ +import { UniqueIdentifier } from '@dnd-kit/core'; +import React from 'react'; + +interface DragContextProviderProps { + activeId: UniqueIdentifier | null, + overId: UniqueIdentifier | null, + children?: React.ReactNode, +} + +export const DragContext = React.createContext({ + activeId: null, + overId: null, + children: null, +}); + +const DragContextProvider = ({ activeId, overId, children }: DragContextProviderProps) => { + const contextValue = React.useMemo(() => ({ + activeId, + overId, + }), [activeId, overId]); + return ( + + {children} + + ); +}; + +export default DragContextProvider; diff --git a/src/course-outline/drag-helper/DraggableList.jsx b/src/course-outline/drag-helper/DraggableList.tsx similarity index 63% rename from src/course-outline/drag-helper/DraggableList.jsx rename to src/course-outline/drag-helper/DraggableList.tsx index 919f62cf9..61950d03c 100644 --- a/src/course-outline/drag-helper/DraggableList.jsx +++ b/src/course-outline/drag-helper/DraggableList.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { DndContext, @@ -8,6 +7,14 @@ import { PointerSensor, useSensor, useSensors, + DragOverlay, + DragOverEvent, + UniqueIdentifier, + Active, + Over, + DragEndEvent, + DragStartEvent, + CollisionDetection, } from '@dnd-kit/core'; import { arrayMove, @@ -15,8 +22,10 @@ import { } from '@dnd-kit/sortable'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; +import { createPortal } from 'react-dom'; +import { COURSE_BLOCK_NAMES } from '@src/constants'; +import { XBlock } from '@src/data/types'; import DragContextProvider from './DragContextProvider'; -import { COURSE_BLOCK_NAMES } from '../../constants'; import { moveSubsectionOver, moveUnitOver, @@ -24,6 +33,38 @@ import { moveUnit, dragHelpers, } from './utils'; +import CourseItemOverlay from './CourseItemOverlay'; + +interface DraggableListProps { + items: XBlock[], + setSections: React.Dispatch>, + restoreSectionList: () => void, + handleSectionDragAndDrop: (sectionListIds: string[], restoreSectionList: () => void) => void, + handleSubsectionDragAndDrop: ( + sectionId: string, + prevSectionId: string, + subsectionListIds: string[], + restoreSectionList: () => void, + ) => void, + handleUnitDragAndDrop: ( + sectionId: string, + prevSectionId: string, + subsectionId: string, + unitListIds: string[], + restoreSectionList: () => void, + ) => void, + children: React.ReactNode, +} + +interface ItemInfoType { + index: number; + item: XBlock; + category: string; + parent?: XBlock; + parentIndex?: number; + grandParentIndex?: number; + grandParent?: XBlock; +} const DraggableList = ({ items, @@ -33,33 +74,34 @@ const DraggableList = ({ handleSubsectionDragAndDrop, handleUnitDragAndDrop, children, -}) => { - const prevContainerInfo = React.useRef(); +}: DraggableListProps) => { + const prevContainerInfo = React.useRef(); const sensors = useSensors( useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), ); - const [activeId, setActiveId] = React.useState(); - const [currentOverId, setCurrentOverId] = React.useState(); + const [activeId, setActiveId] = React.useState(null); + const [draggedItemClone, setDraggedItemClone] = React.useState(null); + const [currentOverId, setCurrentOverId] = React.useState(null); - const findItemInfo = (id) => { + const findItemInfo = (id: UniqueIdentifier): ItemInfoType | null => { // search id in sections - const sectionIndex = items.findIndex((section) => section.id === id); + const sectionIndex = items.findIndex((section: XBlock) => section.id === id); if (sectionIndex !== -1) { return { index: sectionIndex, item: items[sectionIndex], category: COURSE_BLOCK_NAMES.chapter.id, - parent: 'root', + parent: undefined, }; } // search id in subsections for (let index = 0; index < items.length; index++) { const section = items[index]; - const subsectionIndex = section.childInfo.children.findIndex((subsection) => subsection.id === id); + const subsectionIndex = section.childInfo.children.findIndex((subsection: XBlock) => subsection.id === id); if (subsectionIndex !== -1) { return { index: subsectionIndex, @@ -76,7 +118,7 @@ const DraggableList = ({ const section = items[index]; for (let subIndex = 0; subIndex < section.childInfo.children.length; subIndex++) { const subsection = section.childInfo.children[subIndex]; - const unitIndex = subsection.childInfo.children.findIndex((unit) => unit.id === id); + const unitIndex = subsection.childInfo.children.findIndex((unit: XBlock) => unit.id === id); if (unitIndex !== -1) { return { index: unitIndex, @@ -101,18 +143,22 @@ const DraggableList = ({ // See https://github.com/openedx/frontend-app-course-authoring/pull/859#discussion_r1519199622 // for more details. /* istanbul ignore next */ - const subsectionDragOver = (active, over, activeInfo, overInfo) => { + const subsectionDragOver = ( + active: Active, + over: Over, + activeInfo: ItemInfoType, + overInfo: ItemInfoType, + ) => { if ( - activeInfo.parent.id === overInfo.parent.id - || activeInfo.parent.id === overInfo.item.id - || (activeInfo.category === overInfo.category && !overInfo.parent.actions.childAddable) - || (activeInfo.parent.category === overInfo.category && !overInfo.item.actions.childAddable) + activeInfo.parent?.id === overInfo.parent?.id + || activeInfo.parent?.id === overInfo.item.id + || (activeInfo.category === overInfo.category && !overInfo.parent?.actions.childAddable) ) { return; } // Find the new index for the item - let overSectionIndex; - let newIndex; + let overSectionIndex: number | undefined; + let newIndex: number; if (overInfo.category === COURSE_BLOCK_NAMES.chapter.id) { // We're at the root droppable of a container newIndex = overInfo.item.childInfo.children.length + 1; @@ -122,38 +168,42 @@ const DraggableList = ({ const modifier = dragHelpers.isBelowOverItem(active, over) ? 1 : 0; newIndex = overInfo.index >= 0 ? overInfo.index + modifier : overInfo.item.childInfo.children.length + 1; overSectionIndex = overInfo.parentIndex; - setCurrentOverId(overInfo.parent.id); + setCurrentOverId(overInfo.parent?.id || null); } setSections((prev) => { const [prevCopy] = moveSubsectionOver( [...prev], - activeInfo.parentIndex, + activeInfo.parentIndex!, activeInfo.index, - overSectionIndex, + overSectionIndex!, newIndex, ); return prevCopy; }); if (prevContainerInfo.current === null || prevContainerInfo.current === undefined) { - prevContainerInfo.current = activeInfo.parent.id; + prevContainerInfo.current = activeInfo.parent?.id; } }; /* istanbul ignore next */ - const unitDragOver = (active, over, activeInfo, overInfo) => { + const unitDragOver = ( + active: Active, + over: Over, + activeInfo: ItemInfoType, + overInfo: ItemInfoType, + ) => { if ( - activeInfo.parent.id === overInfo.parent.id - || activeInfo.parent.id === overInfo.item.id - || (activeInfo.category === overInfo.category && !overInfo.parent.actions.childAddable) - || (activeInfo.parent.category === overInfo.category && !overInfo.item.actions.childAddable) + activeInfo.parent?.id === overInfo.parent?.id + || activeInfo.parent?.id === overInfo.item.id + || (activeInfo.parent?.category === overInfo.category && !overInfo.item.actions.childAddable) ) { return; } - let overSubsectionIndex; - let overSectionIndex; + let overSubsectionIndex: number | undefined; + let overSectionIndex: number | undefined; // Find the indexes for the items - let newIndex; + let newIndex: number; if (overInfo.category === COURSE_BLOCK_NAMES.sequential.id) { // We're at the root droppable of a container newIndex = overInfo.item.childInfo.children.length + 1; @@ -165,28 +215,28 @@ const DraggableList = ({ newIndex = overInfo.index >= 0 ? overInfo.index + modifier : overInfo.item.childInfo.children.length + 1; overSubsectionIndex = overInfo.parentIndex; overSectionIndex = overInfo.grandParentIndex; - setCurrentOverId(overInfo.parent.id); + setCurrentOverId(overInfo.parent?.id || null); } - setSections((prev) => { + setSections((prev: XBlock[]) => { const [prevCopy] = moveUnitOver( [...prev], - activeInfo.grandParentIndex, - activeInfo.parentIndex, + activeInfo.grandParentIndex!, + activeInfo.parentIndex!, activeInfo.index, - overSectionIndex, - overSubsectionIndex, + overSectionIndex!, + overSubsectionIndex!, newIndex, ); return prevCopy; }); if (prevContainerInfo.current === null || prevContainerInfo.current === undefined) { - prevContainerInfo.current = activeInfo.grandParent.id; + prevContainerInfo.current = activeInfo.grandParent?.id; } }; /* istanbul ignore next */ - const handleDragOver = (event) => { + const handleDragOver = (event: DragOverEvent) => { const { active, over } = event; if (!active || !over) { return; @@ -212,12 +262,19 @@ const DraggableList = ({ } }; - const handleDragEnd = (event) => { + const handleDragCancel = React.useCallback(() => { + setActiveId?.(null); + setDraggedItemClone(null); + restoreSectionList(); + }, [setActiveId]); + + const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; if (!active || !over) { return; } setActiveId(null); + setDraggedItemClone(null); setCurrentOverId(null); const { id } = active; const { id: overId } = over; @@ -230,7 +287,7 @@ const DraggableList = ({ if ( activeInfo.category !== overInfo.category - || (activeInfo.parent !== 'root' && activeInfo.parentIndex !== overInfo.parentIndex) + || (activeInfo.parent && activeInfo.parentIndex !== overInfo.parentIndex) ) { return; } @@ -248,13 +305,13 @@ const DraggableList = ({ setSections((prev) => { const [prevCopy, result] = moveSubsection( [...prev], - activeInfo.parentIndex, + activeInfo.parentIndex!, activeInfo.index, overInfo.index, ); handleSubsectionDragAndDrop( - activeInfo.parent.id, - prevContainerInfo.current, + activeInfo.parent!.id, + prevContainerInfo.current!, result.map(subsection => subsection.id), restoreSectionList, ); @@ -265,15 +322,15 @@ const DraggableList = ({ setSections((prev) => { const [prevCopy, result] = moveUnit( [...prev], - activeInfo.grandParentIndex, - activeInfo.parentIndex, + activeInfo.grandParentIndex!, + activeInfo.parentIndex!, activeInfo.index, overInfo.index, ); handleUnitDragAndDrop( - activeInfo.grandParent.id, - prevContainerInfo.current, - activeInfo.parent.id, + activeInfo.grandParent!.id, + prevContainerInfo.current!, + activeInfo.parent!.id, result.map(unit => unit.id), restoreSectionList, ); @@ -287,14 +344,25 @@ const DraggableList = ({ } }; - const handleDragStart = (event) => { + const handleDragStart = (event: DragStartEvent) => { const { active } = event; const { id } = active; setActiveId(id); + // @ts-ignore-next-line + // Get the dragged element data + const { displayName, category, status } = active.data.current; + // Create a simple clone of the item to use in the overlay + setDraggedItemClone( + , + ); }; - const customClosestCorners = ({ + const customClosestCorners: CollisionDetection = ({ active, droppableContainers, droppableRects, ...args }) => { const activeCategory = active.data?.current?.category; @@ -304,9 +372,13 @@ const DraggableList = ({ case COURSE_BLOCK_NAMES.chapter.id: return container.data?.current?.category === activeCategory; case COURSE_BLOCK_NAMES.sequential.id: - return [activeCategory, COURSE_BLOCK_NAMES.chapter.id].includes(container.data?.current?.category); + return (container.data?.current?.category === COURSE_BLOCK_NAMES.chapter.id + && container.data?.current?.childAddable) + || (container.data?.current?.category === activeCategory); case COURSE_BLOCK_NAMES.vertical.id: - return [activeCategory, COURSE_BLOCK_NAMES.sequential.id].includes(container.data?.current?.category); + return (container.data?.current?.category === COURSE_BLOCK_NAMES.sequential.id + && container.data?.current?.childAddable) + || (container.data?.current?.category === activeCategory); default: return true; } @@ -325,38 +397,20 @@ const DraggableList = ({ onDragOver={handleDragOver} onDragStart={handleDragStart} onDragEnd={handleDragEnd} + onDragAbort={handleDragCancel} + onDragCancel={handleDragCancel} > {children} + {createPortal( + + {draggedItemClone && activeId ? draggedItemClone : null} + , + document.body, + )} ); }; -DraggableList.propTypes = { - items: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string.isRequired, - childInfo: PropTypes.shape({ - children: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - childInfo: PropTypes.shape({ - children: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - }), - ).isRequired, - }).isRequired, - }), - ).isRequired, - }).isRequired, - })).isRequired, - setSections: PropTypes.func.isRequired, - restoreSectionList: PropTypes.func.isRequired, - handleSectionDragAndDrop: PropTypes.func.isRequired, - handleSubsectionDragAndDrop: PropTypes.func.isRequired, - handleUnitDragAndDrop: PropTypes.func.isRequired, - children: PropTypes.node.isRequired, -}; - export default DraggableList; diff --git a/src/course-outline/drag-helper/SortableItem.jsx b/src/course-outline/drag-helper/SortableItem.tsx similarity index 78% rename from src/course-outline/drag-helper/SortableItem.jsx rename to src/course-outline/drag-helper/SortableItem.tsx index f8acf37d4..8f5306c64 100644 --- a/src/course-outline/drag-helper/SortableItem.jsx +++ b/src/course-outline/drag-helper/SortableItem.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useIntl } from '@edx/frontend-platform/i18n'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; @@ -10,14 +9,28 @@ import { DragIndicator } from '@openedx/paragon/icons'; import messages from './messages'; +interface SortableItemProps { + id: string; + data: { + category: string; + childAddable?: boolean; + displayName: string; + status: string; + } + isDroppable?: boolean; + isDraggable?: boolean; + children: React.ReactNode; + componentStyle?: object; +} + const SortableItem = ({ id, - category, - isDraggable, - isDroppable, + isDraggable = true, + isDroppable = true, componentStyle, + data, children, -}) => { +}: SortableItemProps) => { const intl = useIntl(); const { attributes, @@ -29,9 +42,7 @@ const SortableItem = ({ setActivatorNodeRef, } = useSortable({ id, - data: { - category, - }, + data, disabled: { draggable: !isDraggable, droppable: !isDroppable, @@ -80,19 +91,4 @@ const SortableItem = ({ ); }; -SortableItem.defaultProps = { - componentStyle: null, - isDroppable: true, - isDraggable: true, -}; - -SortableItem.propTypes = { - id: PropTypes.string.isRequired, - category: PropTypes.string.isRequired, - isDroppable: PropTypes.bool, - isDraggable: PropTypes.bool, - children: PropTypes.node.isRequired, - componentStyle: PropTypes.shape({}), -}; - export default SortableItem; diff --git a/src/course-outline/drag-helper/messages.js b/src/course-outline/drag-helper/messages.ts similarity index 100% rename from src/course-outline/drag-helper/messages.js rename to src/course-outline/drag-helper/messages.ts diff --git a/src/course-outline/drag-helper/utils.js b/src/course-outline/drag-helper/utils.js deleted file mode 100644 index efe1d7d95..000000000 --- a/src/course-outline/drag-helper/utils.js +++ /dev/null @@ -1,331 +0,0 @@ -import { arrayMove } from '@dnd-kit/sortable'; - -export const dragHelpers = { - copyBlockChildren: (block) => { - // eslint-disable-next-line no-param-reassign - block.childInfo = { ...block.childInfo }; - // eslint-disable-next-line no-param-reassign - block.childInfo.children = [...block.childInfo.children]; - return block; - }, - setBlockChildren: (block, children) => { - // eslint-disable-next-line no-param-reassign - block.childInfo.children = children; - return block; - }, - setBlockChild: (block, child, id) => { - // eslint-disable-next-line no-param-reassign - block.childInfo.children[id] = child; - return block; - }, - insertChild: (block, child, index) => { - // eslint-disable-next-line no-param-reassign - block.childInfo.children = [ - ...block.childInfo.children.slice(0, index), - child, - ...block.childInfo.children.slice(index, block.childInfo.children.length), - ]; - return block; - }, - isBelowOverItem: (active, over) => over - && active.rect.current.translated - && active.rect.current.translated.top - > over.rect.top + over.rect.height, -}; - -export const moveSubsectionOver = ( - prevCopy, - activeSectionIdx, - activeSubsectionIdx, - overSectionIdx, - newIndex, -) => { - let activeSection = dragHelpers.copyBlockChildren({ ...prevCopy[activeSectionIdx] }); - let overSection = dragHelpers.copyBlockChildren({ ...prevCopy[overSectionIdx] }); - const subsection = activeSection.childInfo.children[activeSubsectionIdx]; - - overSection = dragHelpers.insertChild(overSection, subsection, newIndex); - - activeSection = dragHelpers.setBlockChildren( - activeSection, - activeSection.childInfo.children.filter((item) => item.id !== subsection.id), - ); - - // eslint-disable-next-line no-param-reassign - prevCopy[activeSectionIdx] = activeSection; - // eslint-disable-next-line no-param-reassign - prevCopy[overSectionIdx] = overSection; - return [prevCopy, overSection.childInfo.children]; -}; - -export const moveUnitOver = ( - prevCopy, - activeSectionIdx, - activeSubsectionIdx, - activeUnitIdx, - overSectionIdx, - overSubsectionIdx, - newIndex, -) => { - const activeSection = dragHelpers.copyBlockChildren({ ...prevCopy[activeSectionIdx] }); - let activeSubsection = dragHelpers.copyBlockChildren( - { ...activeSection.childInfo.children[activeSubsectionIdx] }, - ); - - let overSection = { ...prevCopy[overSectionIdx] }; - if (overSection.id === activeSection.id) { - overSection = activeSection; - } - - overSection = dragHelpers.copyBlockChildren(overSection); - let overSubsection = dragHelpers.copyBlockChildren( - { ...overSection.childInfo.children[overSubsectionIdx] }, - ); - - const unit = activeSubsection.childInfo.children[activeUnitIdx]; - overSubsection = dragHelpers.insertChild(overSubsection, unit, newIndex); - overSection = dragHelpers.setBlockChild(overSection, overSubsection, overSubsectionIdx); - - activeSubsection = dragHelpers.setBlockChildren( - activeSubsection, - activeSubsection.childInfo.children.filter((item) => item.id !== unit.id), - ); - - // eslint-disable-next-line no-param-reassign - prevCopy[activeSectionIdx] = dragHelpers.setBlockChild(activeSection, activeSubsection, activeSubsectionIdx); - // eslint-disable-next-line no-param-reassign - prevCopy[overSectionIdx] = overSection; - return [prevCopy, overSubsection.childInfo.children]; -}; - -export const moveSubsection = ( - prevCopy, - sectionIdx, - currentIdx, - newIdx, -) => { - let section = dragHelpers.copyBlockChildren({ ...prevCopy[sectionIdx] }); - - const result = arrayMove(section.childInfo.children, currentIdx, newIdx); - section = dragHelpers.setBlockChildren(section, result); - - // eslint-disable-next-line no-param-reassign - prevCopy[sectionIdx] = section; - return [prevCopy, result]; -}; - -export const moveUnit = ( - prevCopy, - sectionIdx, - subsectionIdx, - currentIdx, - newIdx, -) => { - let section = dragHelpers.copyBlockChildren({ ...prevCopy[sectionIdx] }); - let subsection = dragHelpers.copyBlockChildren({ ...section.childInfo.children[subsectionIdx] }); - - const result = arrayMove(subsection.childInfo.children, currentIdx, newIdx); - subsection = dragHelpers.setBlockChildren(subsection, result); - section = dragHelpers.setBlockChild(section, subsection, subsectionIdx); - - // eslint-disable-next-line no-param-reassign - prevCopy[sectionIdx] = section; - return [prevCopy, result]; -}; - -/** - * Check if section can be moved by given step. - * Inner function returns false if the new index after moving by given step - * is out of bounds of item length. - * If it is within bounds, returns draggable flag of the item in the new index. - * This helps us avoid moving the item to a position of unmovable item. - * @param {Array} items - * @returns {(id, step) => bool} - */ -export const canMoveSection = (sections) => (id, step) => { - const newId = id + step; - const indexCheck = newId >= 0 && newId < sections.length; - if (!indexCheck) { - return false; - } - const newItem = sections[newId]; - return newItem.actions.draggable; -}; - -export const possibleSubsectionMoves = (sections, sectionIndex, section, subsections) => (index, step) => { - if (!subsections[index]?.actions?.draggable) { - return {}; - } - if ((step === -1 && index >= 1) || (step === 1 && subsections.length - index >= 2)) { - // move subsection inside its own parent section - return { - fn: moveSubsection, - args: [ - sections, - sectionIndex, - index, - index + step, - ], - sectionId: section.id, - }; - } if (step === -1 && index === 0 && sectionIndex > 0) { - // move subsection to last position of previous section - if (!sections[sectionIndex + step]?.actions?.childAddable) { - // return if previous section doesn't allow adding subsections - return {}; - } - return { - fn: moveSubsectionOver, - args: [ - sections, - sectionIndex, - index, - sectionIndex + step, - sections[sectionIndex + step].childInfo.children.length + 1, - ], - sectionId: sections[sectionIndex + step].id, - }; - } if (step === 1 && index === subsections.length - 1 && sectionIndex < sections.length - 1) { - // move subsection to first position of next section - if (!sections[sectionIndex + step]?.actions?.childAddable) { - // return if next section doesn't allow adding subsections - return {}; - } - return { - fn: moveSubsectionOver, - args: [ - sections, - sectionIndex, - index, - sectionIndex + step, - 0, - ], - sectionId: sections[sectionIndex + step].id, - }; - } - return {}; -}; - -export const possibleUnitMoves = ( - sections, - sectionIndex, - subsectionIndex, - section, - subsection, - units, -) => (index, step) => { - if (!units[index].actions.draggable) { - return {}; - } - if ((step === -1 && index >= 1) || (step === 1 && units.length - index >= 2)) { - return { - fn: moveUnit, - args: [ - sections, - sectionIndex, - subsectionIndex, - index, - index + step, - ], - sectionId: section.id, - subsectionId: subsection.id, - }; - } if (step === -1 && index === 0) { - if (subsectionIndex > 0) { - // move unit to last position of previous subsection inside same section. - if (!sections[sectionIndex].childInfo.children[subsectionIndex + step]?.actions?.childAddable) { - // return if previous subsection doesn't allow adding subsections - return {}; - } - return { - fn: moveUnitOver, - args: [ - sections, - sectionIndex, - subsectionIndex, - index, - sectionIndex, - subsectionIndex + step, - sections[sectionIndex].childInfo.children[subsectionIndex + step].childInfo.children.length + 1, - ], - sectionId: section.id, - subsectionId: sections[sectionIndex].childInfo.children[subsectionIndex + step].id, - }; - } if (sectionIndex > 0) { - // move unit to last position of previous subsection inside previous section. - const newSectionIndex = sectionIndex + step; - if (sections[newSectionIndex].childInfo.children.length === 0) { - // return if previous section has no subsections. - return {}; - } - const newSubsectionIndex = sections[newSectionIndex].childInfo.children.length - 1; - if (!sections[newSectionIndex].childInfo.children[newSubsectionIndex]?.actions?.childAddable) { - // return if previous subsection doesn't allow adding subsections - return {}; - } - return { - fn: moveUnitOver, - args: [ - sections, - sectionIndex, - subsectionIndex, - index, - newSectionIndex, - newSubsectionIndex, - sections[newSectionIndex].childInfo.children[newSubsectionIndex].childInfo.children.length + 1, - ], - sectionId: sections[newSectionIndex].id, - subsectionId: sections[newSectionIndex].childInfo.children[newSubsectionIndex].id, - }; - } - } else if (step === 1 && index === units.length - 1) { - if (subsectionIndex < sections[sectionIndex].childInfo.children.length - 1) { - // move unit to first position of next subsection inside same section. - if (!sections[sectionIndex].childInfo.children[subsectionIndex + step]?.actions?.childAddable) { - // return if next subsection doesn't allow adding subsections - return {}; - } - return { - fn: moveUnitOver, - args: [ - sections, - sectionIndex, - subsectionIndex, - index, - sectionIndex, - subsectionIndex + step, - 0, - ], - sectionId: section.id, - subsectionId: sections[sectionIndex].childInfo.children[subsectionIndex + step].id, - }; - } if (sectionIndex < sections.length - 1) { - // move unit to first position of next subsection inside next section. - const newSectionIndex = sectionIndex + step; - if (sections[newSectionIndex].childInfo.children.length === 0) { - // return if next section has no subsections. - return {}; - } - const newSubsectionIndex = 0; - if (!sections[newSectionIndex].childInfo.children[newSubsectionIndex]?.actions?.childAddable) { - // return if next subsection doesn't allow adding subsections - return {}; - } - return { - fn: moveUnitOver, - args: [ - sections, - sectionIndex, - subsectionIndex, - index, - newSectionIndex, - newSubsectionIndex, - 0, - ], - sectionId: sections[newSectionIndex].id, - subsectionId: sections[newSectionIndex].childInfo.children[newSubsectionIndex].id, - }; - } - } - return {}; -}; diff --git a/src/course-outline/drag-helper/utils.test.ts b/src/course-outline/drag-helper/utils.test.ts new file mode 100644 index 000000000..86fc6f1a8 --- /dev/null +++ b/src/course-outline/drag-helper/utils.test.ts @@ -0,0 +1,1166 @@ +import { XBlock } from '@src/data/types'; +import { + possibleSubsectionMoves, moveSubsection, moveSubsectionOver, possibleUnitMoves, moveUnit, moveUnitOver, +} from './utils'; + +describe('possibleSubsectionMoves', () => { + const mockSections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + { + id: 'section3', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + ] as unknown as XBlock[]; + + const mockSubsections = [ + { actions: { draggable: true } }, + { actions: { draggable: true } }, + { actions: { draggable: true } }, + ]; + + const createMoveFunction = possibleSubsectionMoves( + mockSections, + 1, + mockSections[1], + mockSubsections, + ); + + test('should return empty object if subsection is not draggable', () => { + const mockNonDraggableSubsections = [ + { actions: { draggable: false } }, + { actions: { draggable: true } }, + ]; + + const createMove = possibleSubsectionMoves( + mockSections, + 1, + mockSections[1], + mockNonDraggableSubsections, + ); + + const result = createMove(0, 1); + expect(result).toEqual({}); + }); + + test('should allow moving subsection down within same section', () => { + const result = createMoveFunction(0, 1); + expect(result).toEqual({ + fn: moveSubsection, + args: [mockSections, 1, 0, 1], + sectionId: 'section2', + }); + }); + + test('should allow moving subsection up within same section', () => { + const result = createMoveFunction(1, -1); + expect(result).toEqual({ + fn: moveSubsection, + args: [mockSections, 1, 1, 0], + sectionId: 'section2', + }); + }); + + test('should move subsection to previous section when at first position', () => { + const result = createMoveFunction(0, -1); + expect(result).toEqual({ + fn: moveSubsectionOver, + args: [mockSections, 1, 0, 0, mockSections[0].childInfo.children.length + 1], + sectionId: 'section1', + }); + }); + + test('should return empty object when moving to previous section not allowed', () => { + const mockRestrictedSections = [ + { id: 'section1', actions: { childAddable: false } }, + { id: 'section2', actions: { childAddable: true } }, + ] as unknown as XBlock[]; + + const createMove = possibleSubsectionMoves( + mockRestrictedSections, + 1, + mockRestrictedSections[1], + mockSubsections, + ); + + const result = createMove(0, -1); + expect(result).toEqual({}); + }); + + test('should move subsection to next section when at last position', () => { + const createMove = possibleSubsectionMoves( + mockSections, + 0, + mockSections[0], + mockSubsections, + ); + + const result = createMove(2, 1); + expect(result).toEqual({ + fn: moveSubsectionOver, + args: [mockSections, 0, 2, 1, 0], + sectionId: 'section2', + }); + }); + + test('should return empty object when moving subsection to next section that does not accept children', () => { + const result = createMoveFunction(2, 1); + expect(result).toEqual({}); + }); + + test('should return empty object when moving to next section not allowed', () => { + const mockRestrictedSections = [ + { id: 'section1', actions: { childAddable: true } }, + { id: 'section2', actions: { childAddable: false } }, + ] as unknown as XBlock[]; + + const createMove = possibleSubsectionMoves( + mockRestrictedSections, + 0, + mockRestrictedSections[0], + mockSubsections, + ); + + const result = createMove(2, 1); + expect(result).toEqual({}); + }); + + test('should return empty object when attempting to move beyond section boundaries', () => { + // Test moving up from first subsection of first section + const firstSectionMove = possibleSubsectionMoves( + mockSections, + 0, + mockSections[0], + mockSubsections, + ); + + const resultUp = firstSectionMove(0, -1); + expect(resultUp).toEqual({}); + + // Test moving down from last subsection of last section + const lastSectionMove = possibleSubsectionMoves( + mockSections, + 2, + mockSections[2], + mockSubsections, + ); + + const resultDown = lastSectionMove(2, 1); + expect(resultDown).toEqual({}); + }); + + test('should handle edge cases with empty sections or subsections', () => { + const emptySections = []; + const emptySubsections = []; + + const createMove = possibleSubsectionMoves( + emptySections, + 0, + {} as unknown as XBlock, + emptySubsections, + ); + + const result = createMove(0, 1); + expect(result).toEqual({}); + }); + + test('should work with different step values', () => { + // Positive step + const resultPositive = createMoveFunction(1, 1); + expect(resultPositive).toEqual({ + fn: moveSubsection, + args: [mockSections, 1, 1, 2], + sectionId: 'section2', + }); + + // Negative step + const resultNegative = createMoveFunction(1, -1); + expect(resultNegative).toEqual({ + fn: moveSubsection, + args: [mockSections, 1, 1, 0], + sectionId: 'section2', + }); + }); +}); + +describe('possibleSubsectionMoves - skipping non-childAddable sections', () => { + test('should move subsection to next childAddable section, skipping non-childAddable sections', () => { + const sectionsWithBlockers = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + { + id: 'section2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'section3', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'section4', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ] as unknown as XBlock[]; + + const subsections = [ + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + // Moving from first section (index 0) to the next available childAddable section + const createMove = possibleSubsectionMoves( + sectionsWithBlockers, + 0, + sectionsWithBlockers[0], + subsections, + ); + + const resultMoveDown = createMove(0, 1); + expect(resultMoveDown).toEqual({ + fn: moveSubsectionOver, + args: [sectionsWithBlockers, 0, 0, 3, 0], + sectionId: 'section4', + }); + }); + + test('should move subsection to previous childAddable section, skipping non-childAddable sections', () => { + const sectionsWithBlockers = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + { + id: 'section2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'section3', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'section4', + actions: { childAddable: true }, + childInfo: { children: ['existing'] }, + }, + ] as unknown as XBlock[]; + + const subsections = [ + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + // Moving from last section (index 4) to the previous available childAddable section + const createMove = possibleSubsectionMoves( + sectionsWithBlockers, + 3, + sectionsWithBlockers[3], + subsections, + ); + + const resultMoveUp = createMove(0, -1); + expect(resultMoveUp).toEqual({ + fn: moveSubsectionOver, + args: [sectionsWithBlockers, 3, 0, 0, sectionsWithBlockers[0].childInfo.children.length + 1], + sectionId: 'section1', + }); + }); +}); + +describe('possibleUnitMoves', () => { + const mockSections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + { + id: 'subsection2', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection3', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const mockUnits = [ + { actions: { draggable: true } }, + { actions: { draggable: true } }, + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + test('should move unit within same units array', () => { + const createMove = possibleUnitMoves( + mockSections, + 0, + 0, + mockSections[0], + mockSections[0].childInfo.children[0], + mockUnits, + ); + + const resultMoveDown = createMove(0, 1); + expect(resultMoveDown).toEqual({ + fn: moveUnit, + args: [mockSections, 0, 0, 0, 1], + sectionId: 'section1', + subsectionId: 'subsection1', + }); + + const resultMoveUp = createMove(1, -1); + expect(resultMoveUp).toEqual({ + fn: moveUnit, + args: [mockSections, 0, 0, 1, 0], + sectionId: 'section1', + subsectionId: 'subsection1', + }); + }); + + test('should return empty object for non-draggable units', () => { + const nonDraggableUnits = [ + { actions: { draggable: false } }, + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + mockSections, + 0, + 0, + mockSections[0], + mockSections[0].childInfo.children[0], + nonDraggableUnits, + ); + + const result = createMove(0, 1); + expect(result).toEqual({}); + }); + + test('should move unit to next subsection within same section', () => { + const createMove = possibleUnitMoves( + mockSections, + 0, + 0, + mockSections[0], + mockSections[0].childInfo.children[0], + mockUnits, + ); + + const result = createMove(2, 1); + expect(result).toEqual({ + fn: moveUnitOver, + args: [mockSections, 0, 0, 2, 0, 1, 0], + sectionId: 'section1', + subsectionId: 'subsection2', + }); + }); + + test('should move unit to previous section and subsection', () => { + const createMove = possibleUnitMoves( + mockSections, + 1, + 0, + mockSections[1], + mockSections[1].childInfo.children[0], + mockUnits, + ); + + const result = createMove(0, -1); + expect(result).toEqual({ + fn: moveUnitOver, + args: [mockSections, 1, 0, 0, 0, 1, 0], + sectionId: 'section1', + subsectionId: 'subsection2', + }); + }); + + test('should move unit to next section and first subsection', () => { + const sectionsWithMultipleSubsections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection2', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + { + id: 'subsection3', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + sectionsWithMultipleSubsections, + 0, + 0, + sectionsWithMultipleSubsections[0], + sectionsWithMultipleSubsections[0].childInfo.children[0], + mockUnits, + ); + + const result = createMove(2, 1); + expect(result).toEqual({ + fn: moveUnitOver, + args: [sectionsWithMultipleSubsections, 0, 0, 2, 1, 0, 0], + sectionId: 'section2', + subsectionId: 'subsection2', + }); + }); + + test('should return empty object when no valid move locations exist', () => { + const restrictedSections = [ + { + id: 'section1', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'section2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + restrictedSections, + 0, + 0, + restrictedSections[0], + { id: 'subsection1', actions: { childAddable: true }, childInfo: { children: [] } } as unknown as XBlock, + mockUnits, + ); + + const resultMoveDown = createMove(2, 1); + expect(resultMoveDown).toEqual({}); + + const resultMoveUp = createMove(0, -1); + expect(resultMoveUp).toEqual({}); + }); + + test('should handle edge cases with single section and subsection', () => { + const emptySections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + emptySections, + 0, + 0, + emptySections[0], + { id: 'subsection1', actions: { childAddable: true }, childInfo: { children: [] } } as unknown as XBlock, + mockUnits, + ); + + const result = createMove(0, -1); + expect(result).toEqual({}); + }); + + test('should skip non-childAddable subsections when moving', () => { + const sectionsWithMixedSubsections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection2', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection3', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + sectionsWithMixedSubsections, + 0, + 0, + sectionsWithMixedSubsections[0], + sectionsWithMixedSubsections[0].childInfo.children[0], + mockUnits, + ); + + const result = createMove(2, 1); + expect(result).toEqual({ + fn: moveUnitOver, + args: [sectionsWithMixedSubsections, 0, 0, 2, 0, 1, 0], + sectionId: 'section1', + subsectionId: 'subsection2', + }); + }); + + test('should move unit to previous section, skipping non-childAddable subsections', () => { + const sectionsWithMixedSubsections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection3', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + sectionsWithMixedSubsections, + 1, + 1, + sectionsWithMixedSubsections[1], + sectionsWithMixedSubsections[1].childInfo.children[1], + mockUnits, + ); + + const result = createMove(0, -1); + expect(result).toEqual({ + fn: moveUnitOver, + args: [sectionsWithMixedSubsections, 1, 1, 0, 0, 0, 0], + sectionId: 'section1', + subsectionId: 'subsection1', + }); + }); + + test('should handle scenarios with multiple non-childAddable sections', () => { + const complexSections = [ + { + id: 'section1', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section3', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'section4', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection2', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + complexSections, + 1, + 0, + complexSections[1], + complexSections[1].childInfo.children[0], + mockUnits, + ); + + const resultMoveDown = createMove(2, 1); + expect(resultMoveDown).toEqual({ + fn: moveUnitOver, + args: [complexSections, 1, 0, 2, 3, 0, 0], + sectionId: 'section4', + subsectionId: 'subsection2', + }); + + const createMoveUp = possibleUnitMoves( + complexSections, + 3, + 0, + complexSections[3], + complexSections[3].childInfo.children[0], + mockUnits, + ); + + const resultMoveUp = createMoveUp(0, -1); + expect(resultMoveUp).toEqual({ + fn: moveUnitOver, + args: [complexSections, 3, 0, 0, 1, 0, 0], + sectionId: 'section2', + subsectionId: 'subsection1', + }); + }); + + test('should return empty object when no valid move locations exist in any direction', () => { + const restrictedSections = [ + { + id: 'section1', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'section2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + restrictedSections, + 0, + 0, + restrictedSections[0], + { id: 'subsection1', actions: { childAddable: true }, childInfo: { children: [] } } as unknown as XBlock, + mockUnits, + ); + + const resultMoveDown = createMove(2, 1); + expect(resultMoveDown).toEqual({}); + + const resultMoveUp = createMove(0, -1); + expect(resultMoveUp).toEqual({}); + }); + + test('should handle scenarios with single unit', () => { + const singleUnitSections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection2', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const singleUnit = [{ actions: { draggable: true } }] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + singleUnitSections, + 0, + 0, + singleUnitSections[0], + singleUnitSections[0].childInfo.children[0], + singleUnit, + ); + + const resultMoveDown = createMove(0, 1); + expect(resultMoveDown).toEqual({ + fn: moveUnitOver, + args: [singleUnitSections, 0, 0, 0, 1, 0, 0], + sectionId: 'section2', + subsectionId: 'subsection2', + }); + }); +}); + +describe('possibleUnitMoves - skipping non-childAddable subsections', () => { + test('should skip non-childAddable subsection when moving within the same section', () => { + const sectionsWithMixedSubsections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + { + id: 'subsection2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection3', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const units = [ + { actions: { draggable: true } }, + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + sectionsWithMixedSubsections, + 0, + 0, + sectionsWithMixedSubsections[0], + sectionsWithMixedSubsections[0].childInfo.children[0], + units, + ); + + const resultMoveDown = createMove(1, 1); + expect(resultMoveDown).toEqual({ + fn: moveUnitOver, + args: [sectionsWithMixedSubsections, 0, 0, 1, 0, 2, 0], + sectionId: 'section1', + subsectionId: 'subsection3', + }); + }); + + test('should skip non-childAddable subsections when moving to next section', () => { + const sectionsWithMixedSubsections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection3', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection4', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const units = [ + { actions: { draggable: true } }, + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + sectionsWithMixedSubsections, + 0, + 0, + sectionsWithMixedSubsections[0], + sectionsWithMixedSubsections[0].childInfo.children[0], + units, + ); + + const resultMoveDown = createMove(1, 1); + expect(resultMoveDown).toEqual({ + fn: moveUnitOver, + args: [sectionsWithMixedSubsections, 0, 0, 1, 1, 2, 0], + sectionId: 'section2', + subsectionId: 'subsection4', + }); + }); + + test('should skip non-childAddable subsections when moving to previous section', () => { + const sectionsWithMixedSubsections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection3', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection4', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const units = [ + { actions: { draggable: true } }, + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + const createMove = possibleUnitMoves( + sectionsWithMixedSubsections, + 1, + 0, + sectionsWithMixedSubsections[1], + sectionsWithMixedSubsections[1].childInfo.children[0], + units, + ); + + const resultMoveUp = createMove(0, -1); + expect(resultMoveUp).toEqual({ + fn: moveUnitOver, + args: [sectionsWithMixedSubsections, 1, 0, 0, 0, 2, 0], + sectionId: 'section1', + subsectionId: 'subsection3', + }); + }); + + test('should handle complex scenario with multiple non-childAddable subsections', () => { + const complexSections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection3', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection4', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection5', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const units = [ + { actions: { draggable: true } }, + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + // Moving from first section to second section, skipping non-childAddable subsections + const createMoveDown = possibleUnitMoves( + complexSections, + 0, + 2, + complexSections[0], + complexSections[0].childInfo.children[2], + units, + ); + + const resultMoveDown = createMoveDown(1, 1); + expect(resultMoveDown).toEqual({ + fn: moveUnitOver, + args: [complexSections, 0, 2, 1, 1, 1, 0], + sectionId: 'section2', + subsectionId: 'subsection5', + }); + + // Moving from second section to first section, skipping non-childAddable subsections + const createMoveUp = possibleUnitMoves( + complexSections, + 1, + 1, + complexSections[1], + complexSections[1].childInfo.children[1], + units, + ); + + const resultMoveUp = createMoveUp(0, -1); + expect(resultMoveUp).toEqual({ + fn: moveUnitOver, + args: [complexSections, 1, 1, 0, 0, 2, 0], + sectionId: 'section1', + subsectionId: 'subsection3', + }); + }); + + test('should return empty object when no childAddable subsections exist', () => { + const sectionsWithNoChildAddable = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection3', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'subsection4', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const units = [ + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + const createMoveDown = possibleUnitMoves( + sectionsWithNoChildAddable, + 0, + 0, + sectionsWithNoChildAddable[0], + sectionsWithNoChildAddable[0].childInfo.children[0], + units, + ); + + const resultMoveDown = createMoveDown(0, 1); + expect(resultMoveDown).toEqual({}); + + const createMoveUp = possibleUnitMoves( + sectionsWithNoChildAddable, + 1, + 0, + sectionsWithNoChildAddable[1], + sectionsWithNoChildAddable[1].childInfo.children[0], + units, + ); + + const resultMoveUp = createMoveUp(0, -1); + expect(resultMoveUp).toEqual({}); + }); + + test('should handle scenarios with multiple sections and mixed childAddable states', () => { + const multipleSections = [ + { + id: 'section1', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection1', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + ], + }, + }, + { + id: 'section2', + actions: { childAddable: false }, + childInfo: { children: [] }, + }, + { + id: 'section3', + actions: { childAddable: true }, + childInfo: { + children: [ + { + id: 'subsection2', + actions: { childAddable: true }, + childInfo: { children: [] }, + }, + ], + }, + }, + ] as unknown as XBlock[]; + + const units = [ + { actions: { draggable: true } }, + ] as unknown as XBlock[]; + + const createMoveDown = possibleUnitMoves( + multipleSections, + 0, + 0, + multipleSections[0], + multipleSections[0].childInfo.children[0], + units, + ); + + const resultMoveDown = createMoveDown(0, 1); + expect(resultMoveDown).toEqual({ + fn: moveUnitOver, + args: [multipleSections, 0, 0, 0, 2, 0, 0], + sectionId: 'section3', + subsectionId: 'subsection2', + }); + }); +}); diff --git a/src/course-outline/drag-helper/utils.ts b/src/course-outline/drag-helper/utils.ts new file mode 100644 index 000000000..d554bd59d --- /dev/null +++ b/src/course-outline/drag-helper/utils.ts @@ -0,0 +1,432 @@ +import { Active, Over } from '@dnd-kit/core'; +import { arrayMove } from '@dnd-kit/sortable'; +import { XBlock } from '@src/data/types'; +import { findIndex, findLastIndex } from 'lodash'; + +export const dragHelpers = { + copyBlockChildren: (block: XBlock) => { + // eslint-disable-next-line no-param-reassign + block.childInfo = { ...block.childInfo }; + // eslint-disable-next-line no-param-reassign + block.childInfo.children = [...block.childInfo.children]; + return block; + }, + setBlockChildren: (block: XBlock, children: XBlock[]) => { + // eslint-disable-next-line no-param-reassign + block.childInfo.children = children; + return block; + }, + setBlockChild: (block: XBlock, child: XBlock, id: number) => { + // eslint-disable-next-line no-param-reassign + block.childInfo.children[id] = child; + return block; + }, + insertChild: (block: XBlock, child: XBlock, index: number) => { + // eslint-disable-next-line no-param-reassign + block.childInfo.children = [ + ...block.childInfo.children.slice(0, index), + child, + ...block.childInfo.children.slice(index, block.childInfo.children.length), + ]; + return block; + }, + isBelowOverItem: (active: Active, over: Over) => over + && active.rect.current.translated + && active.rect.current.translated.top + > over.rect.top + over.rect.height, +}; + +/** + * This function moves a subsection from one section to another in the copy of blocks. + * It updates the copy with the new positions for the sections and their subsections, + * while keeping other sections intact. +*/ +export const moveSubsectionOver = ( + prevCopy: XBlock[], + activeSectionIdx: number, + activeSubsectionIdx: number, + overSectionIdx: number, + newIndex: number, +) => { + let activeSection = dragHelpers.copyBlockChildren({ ...prevCopy[activeSectionIdx] }); + let overSection = dragHelpers.copyBlockChildren({ ...prevCopy[overSectionIdx] }); + const subsection = activeSection.childInfo.children[activeSubsectionIdx]; + + overSection = dragHelpers.insertChild(overSection, subsection, newIndex); + + activeSection = dragHelpers.setBlockChildren( + activeSection, + activeSection.childInfo.children.filter((item) => item.id !== subsection.id), + ); + + // eslint-disable-next-line no-param-reassign + prevCopy[activeSectionIdx] = activeSection; + // eslint-disable-next-line no-param-reassign + prevCopy[overSectionIdx] = overSection; + return [prevCopy, overSection.childInfo.children]; +}; + +export const moveUnitOver = ( + prevCopy: XBlock[], + activeSectionIdx: number, + activeSubsectionIdx: number, + activeUnitIdx: number, + overSectionIdx: number, + overSubsectionIdx: number, + newIndex: number, +) => { + const activeSection = dragHelpers.copyBlockChildren({ ...prevCopy[activeSectionIdx] }); + let activeSubsection = dragHelpers.copyBlockChildren( + { ...activeSection.childInfo.children[activeSubsectionIdx] }, + ); + + let overSection = { ...prevCopy[overSectionIdx] }; + if (overSection.id === activeSection.id) { + overSection = activeSection; + } + + overSection = dragHelpers.copyBlockChildren(overSection); + let overSubsection = dragHelpers.copyBlockChildren( + { ...overSection.childInfo.children[overSubsectionIdx] }, + ); + + const unit = activeSubsection.childInfo.children[activeUnitIdx]; + overSubsection = dragHelpers.insertChild(overSubsection, unit, newIndex); + overSection = dragHelpers.setBlockChild(overSection, overSubsection, overSubsectionIdx); + + activeSubsection = dragHelpers.setBlockChildren( + activeSubsection, + activeSubsection.childInfo.children.filter((item) => item.id !== unit.id), + ); + + // eslint-disable-next-line no-param-reassign + prevCopy[activeSectionIdx] = dragHelpers.setBlockChild(activeSection, activeSubsection, activeSubsectionIdx); + // eslint-disable-next-line no-param-reassign + prevCopy[overSectionIdx] = overSection; + return [prevCopy, overSubsection.childInfo.children]; +}; + +/** + * Handles dragging and dropping a subsection within the same section. +*/ +export const moveSubsection = ( + prevCopy: XBlock[], + sectionIdx: number, + currentIdx: number, + newIdx: number, +) => { + let section = dragHelpers.copyBlockChildren({ ...prevCopy[sectionIdx] }); + + const result = arrayMove(section.childInfo.children, currentIdx, newIdx); + section = dragHelpers.setBlockChildren(section, result); + + // eslint-disable-next-line no-param-reassign + prevCopy[sectionIdx] = section; + return [prevCopy, result]; +}; + +export const moveUnit = ( + prevCopy: XBlock[], + sectionIdx: number, + subsectionIdx: number, + currentIdx: number, + newIdx: number, +) => { + let section = dragHelpers.copyBlockChildren({ ...prevCopy[sectionIdx] }); + let subsection = dragHelpers.copyBlockChildren({ ...section.childInfo.children[subsectionIdx] }); + + const result = arrayMove(subsection.childInfo.children, currentIdx, newIdx); + subsection = dragHelpers.setBlockChildren(subsection, result); + section = dragHelpers.setBlockChild(section, subsection, subsectionIdx); + + // eslint-disable-next-line no-param-reassign + prevCopy[sectionIdx] = section; + return [prevCopy, result]; +}; + +/** + * Check if section can be moved by given step. + * Inner function returns false if the new index after moving by given step + * is out of bounds of item length. + * If it is within bounds, returns draggable flag of the item in the new index. + * This helps us avoid moving the item to a position of unmovable item. + */ +export const canMoveSection = (sections: XBlock[]) => (id: number, step: number) => { + const newId = id + step; + const indexCheck = newId >= 0 && newId < sections.length; + if (!indexCheck) { + return false; + } + const newItem = sections[newId]; + return newItem.actions.draggable; +}; + +/** + * Checks if a user can move a specific subsection within its parent section or other sections. + * It ensures that the new position for the subsection is valid and that it's not + * attempting to drag an unmovable item or beyond the bounds of existing sections. +*/ +export const possibleSubsectionMoves = ( + sections: XBlock[], + sectionIndex: number, + section: XBlock, + subsections: string | any[], +) => (index: number, step: number) => { + if (!subsections[index]?.actions?.draggable) { + return {}; + } + if ((step === -1 && index >= 1) || (step === 1 && subsections.length - index >= 2)) { + // move subsection inside its own parent section + return { + fn: moveSubsection, + args: [ + sections, + sectionIndex, + index, + index + step, + ], + sectionId: section.id, + }; + } if (step === -1 && index === 0 && sectionIndex > 0) { + // find a section that accepts children above/before the current section + const newSectionIndex = findLastIndex(sections, { actions: { childAddable: true } }, sectionIndex + step); + if (newSectionIndex === -1) { + // return if previous section doesn't allow adding subsections + return {}; + } + return { + fn: moveSubsectionOver, + args: [ + sections, + sectionIndex, + index, + newSectionIndex, + sections[newSectionIndex].childInfo.children.length + 1, + ], + sectionId: sections[newSectionIndex].id, + }; + } if (step === 1 && index === subsections.length - 1 && sectionIndex < sections.length + step) { + // find a section that accepts children below/after the current section + const newSectionIndex = findIndex(sections, { actions: { childAddable: true } }, sectionIndex + 1); + // move subsection to first position of next section + if (newSectionIndex === -1) { + // return if below sections don't allow adding subsections + return {}; + } + return { + fn: moveSubsectionOver, + args: [ + sections, + sectionIndex, + index, + newSectionIndex, + 0, + ], + sectionId: sections[newSectionIndex].id, + }; + } + return {}; +}; + +/** + * Function to find the valid subsection index based on the current position and the step. + * It uses the provided find method. +*/ +const findValidSubsectionIndex = ( + sections: XBlock[], + sectionIndex: number, + step: number, + findMethod: typeof findLastIndex | typeof findIndex, +): { + newSectionIndex: number; + newSubsectionIndex: number +} | null => { + if (sectionIndex + step < 0) { + return null; + } + const newSectionIndex = findMethod( + sections, + { actions: { childAddable: true } }, + sectionIndex + step, + ); + + if (newSectionIndex === -1 || sections[newSectionIndex].childInfo.children.length === 0) { + return null; + } + + const newSubsectionIndex = findMethod( + sections[newSectionIndex].childInfo.children, + { actions: { childAddable: true } }, + ); + + return newSubsectionIndex === -1 + ? null + : { newSectionIndex, newSubsectionIndex }; +}; + +/** + * Moves a unit to a previous location within the XBlock structure. This function attempts to move the unit + * to the previous subsection within the same section, and if that fails, it will attempt to move it to the + * previous section. +*/ +const moveToPreviousLocation = ( + sections: XBlock[], + sectionIndex: number, + subsectionIndex: number, + index: number, +) => { + if (subsectionIndex > 0) { + // Find the previous childAddable subsection within the same section + const newSubsectionIndex = findLastIndex( + sections[sectionIndex].childInfo.children, + { actions: { childAddable: true } }, + subsectionIndex - 1, + ); + + // If found a valid subsection within the same section + if (newSubsectionIndex !== -1) { + return { + fn: moveUnitOver, + args: [ + sections, + sectionIndex, + subsectionIndex, + index, + sectionIndex, + newSubsectionIndex, + sections[sectionIndex].childInfo.children[newSubsectionIndex].childInfo.children.length, + ], + sectionId: sections[sectionIndex].id, + subsectionId: sections[sectionIndex].childInfo.children[newSubsectionIndex].id, + }; + } + } + + // Try moving to previous section + const previousLocationResult = findValidSubsectionIndex(sections, sectionIndex, -1, findLastIndex); + + if (!previousLocationResult) { + return {}; + } + + return { + fn: moveUnitOver, + args: [ + sections, + sectionIndex, + subsectionIndex, + index, + previousLocationResult.newSectionIndex, + previousLocationResult.newSubsectionIndex, + sections[previousLocationResult.newSectionIndex] + .childInfo.children[previousLocationResult.newSubsectionIndex] + .childInfo.children.length, + ], + sectionId: sections[previousLocationResult.newSectionIndex].id, + subsectionId: sections[previousLocationResult.newSectionIndex] + .childInfo.children[previousLocationResult.newSubsectionIndex].id, + }; +}; + +/** + * This function attempts to move a unit to the next childAddable subsection within the current section. + * If no such subsection exists, it will attempt to move the unit to the next section. +*/ +const moveToNextLocation = ( + sections: XBlock[], + sectionIndex: number, + subsectionIndex: number, + index: number, +) => { + // Find the next childAddable subsection within the same section + const subsections = sections[sectionIndex].childInfo.children; + if (subsectionIndex < (subsections.length - 1)) { + const newSubsectionIndex = findIndex( + subsections, + { actions: { childAddable: true } }, + subsectionIndex + 1, + ); + + // If found a valid subsection within the same section + if (newSubsectionIndex !== -1) { + return { + fn: moveUnitOver, + args: [ + sections, + sectionIndex, + subsectionIndex, + index, + sectionIndex, + newSubsectionIndex, + 0, + ], + sectionId: sections[sectionIndex].id, + subsectionId: subsections[newSubsectionIndex].id, + }; + } + } + + // Try moving to next section + const nextLocationResult = findValidSubsectionIndex(sections, sectionIndex, 1, findIndex); + + if (!nextLocationResult) { + return {}; + } + + return { + fn: moveUnitOver, + args: [ + sections, + sectionIndex, + subsectionIndex, + index, + nextLocationResult.newSectionIndex, + nextLocationResult.newSubsectionIndex, + 0, + ], + sectionId: sections[nextLocationResult.newSectionIndex].id, + subsectionId: sections[nextLocationResult.newSectionIndex] + .childInfo.children[nextLocationResult.newSubsectionIndex].id, + }; +}; + +/** + * Checks if a user can move a specific unit within all subsections + * It ensures that the new position for the unit is valid and that it's not + * attempting to drag an unmovable item or beyond the bounds of existing subsections and sections. +*/ +export const possibleUnitMoves = ( + sections: XBlock[], + sectionIndex: number, + subsectionIndex: number, + section: XBlock, + subsection: XBlock, + units: XBlock[], +) => (index: number, step: number) => { + // Early return if unit is not draggable + if (!units[index]?.actions?.draggable) { + return {}; + } + + // Move within current subsection + if ((step === -1 && index >= 1) || (step === 1 && units.length - index >= 2)) { + return { + fn: moveUnit, + args: [sections, sectionIndex, subsectionIndex, index, index + step], + sectionId: section.id, + subsectionId: subsection.id, + }; + } + + // Move to previous subsection/section + if (step === -1 && index === 0) { + return moveToPreviousLocation(sections, sectionIndex, subsectionIndex, index); + } + + // Move to next subsection/section + if (step === 1 && index === units.length - 1) { + return moveToNextLocation(sections, sectionIndex, subsectionIndex, index); + } + + return {}; +}; diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 7d0abade1..2e4bfac07 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -237,9 +237,13 @@ const SectionCard = ({ <> ', () => { expect(screen.queryByRole('button', { name: 'New unit' })).not.toBeInTheDocument(); }); + it('hides move, duplicate & delete options if parent was imported from library', async () => { + renderComponent({ + section: { + ...section, + upstreamInfo: { + readyToSync: true, + upstreamRef: 'lct:org1:lib1:section:1', + versionSynced: 1, + }, + }, + }); + const element = await screen.findByTestId('subsection-card'); + const menu = await within(element).findByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + expect(within(element).queryByTestId('subsection-card-header__menu-duplicate-button')).not.toBeInTheDocument(); + expect(within(element).queryByTestId('subsection-card-header__menu-delete-button')).not.toBeInTheDocument(); + expect( + await within(element).findByTestId('subsection-card-header__menu-move-up-button'), + ).toHaveAttribute('aria-disabled', 'true'); + expect( + await within(element).findByTestId('subsection-card-header__menu-move-down-button'), + ).toHaveAttribute('aria-disabled', 'true'); + }); + it('renders live status', async () => { renderComponent(); expect(await screen.findByText(cardHeaderMessages.statusBadgeLive.defaultMessage)).toBeInTheDocument(); diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 5d6e04b94..2ede79e35 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -112,8 +112,10 @@ const SubsectionCard = ({ // add actions to control display of move up & down menu button. const moveUpDetails = getPossibleMoves(index, -1); const moveDownDetails = getPossibleMoves(index, 1); - actions.allowMoveUp = !isEmpty(moveUpDetails); - actions.allowMoveDown = !isEmpty(moveDownDetails); + actions.allowMoveUp = !isEmpty(moveUpDetails) && !section.upstreamInfo?.upstreamRef; + actions.allowMoveDown = !isEmpty(moveDownDetails) && !section.upstreamInfo?.upstreamRef; + actions.deletable = actions.deletable && !section.upstreamInfo?.upstreamRef; + actions.duplicable = actions.duplicable && !section.upstreamInfo?.upstreamRef; // Expand the subsection if a search result should be shown/scrolled to const containsSearchResult = () => { @@ -218,6 +220,7 @@ const SubsectionCard = ({ actions.draggable && (actions.allowMoveUp || actions.allowMoveDown) && !(isHeaderVisible === false) + && !section.upstreamInfo?.upstreamRef ); const handleSelectLibraryUnit = useCallback((selectedUnit: SelectedComponent) => { @@ -234,10 +237,15 @@ const SubsectionCard = ({ <> ', () => { expect(within(element).queryByTestId('unit-card-header__menu-delete-button')).not.toBeInTheDocument(); }); + it('hides move, duplicate & delete options if parent was imported from library', async () => { + const { findByTestId } = renderComponent({ + subsection: { + ...subsection, + upstreamInfo: { + readyToSync: true, + upstreamRef: 'lct:org1:lib1:subsection:1', + versionSynced: 1, + }, + }, + }); + const element = await findByTestId('unit-card'); + const menu = await within(element).findByTestId('unit-card-header__menu-button'); + await act(async () => fireEvent.click(menu)); + expect(within(element).queryByTestId('unit-card-header__menu-duplicate-button')).not.toBeInTheDocument(); + expect(within(element).queryByTestId('unit-card-header__menu-delete-button')).not.toBeInTheDocument(); + expect( + await within(element).findByTestId('unit-card-header__menu-move-up-button'), + ).toHaveAttribute('aria-disabled', 'true'); + expect( + await within(element).findByTestId('unit-card-header__menu-move-down-button'), + ).toHaveAttribute('aria-disabled', 'true'); + }); + it('shows copy option based on enableCopyPasteUnits flag', async () => { const { findByTestId } = renderComponent({ unit: { diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index d602ca4f4..62a5d307f 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -109,8 +109,10 @@ const UnitCard = ({ // add actions to control display of move up & down menu buton. const moveUpDetails = getPossibleMoves(index, -1); const moveDownDetails = getPossibleMoves(index, 1); - actions.allowMoveUp = !isEmpty(moveUpDetails); - actions.allowMoveDown = !isEmpty(moveDownDetails); + actions.allowMoveUp = !isEmpty(moveUpDetails) && !subsection.upstreamInfo?.upstreamRef; + actions.allowMoveDown = !isEmpty(moveDownDetails) && !subsection.upstreamInfo?.upstreamRef; + actions.deletable = actions.deletable && !subsection.upstreamInfo?.upstreamRef; + actions.duplicable = actions.duplicable && !subsection.upstreamInfo?.upstreamRef; const parentInfo = { graded: subsection.graded, @@ -193,16 +195,24 @@ const UnitCard = ({ return null; } - const isDraggable = actions.draggable && (actions.allowMoveUp || actions.allowMoveDown); + const isDraggable = ( + actions.draggable + && (actions.allowMoveUp || actions.allowMoveDown) + && !subsection.upstreamInfo?.upstreamRef + ); return ( <>