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.
This commit is contained in:
@@ -1767,7 +1767,7 @@ describe('<CourseOutline />', () => {
|
||||
.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('<CourseOutline />', () => {
|
||||
.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('<CourseOutline />', () => {
|
||||
.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('<CourseOutline />', () => {
|
||||
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('<CourseOutline />', () => {
|
||||
.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('<CourseOutline />', () => {
|
||||
.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('<CourseOutline />', () => {
|
||||
.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('<CourseOutline />', () => {
|
||||
.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('<CourseOutline />', () => {
|
||||
.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('<CourseOutline />', () => {
|
||||
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('<CourseOutline />', () => {
|
||||
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);
|
||||
|
||||
117
src/course-outline/drag-helper/CourseItemOverlay.tsx
Normal file
117
src/course-outline/drag-helper/CourseItemOverlay.tsx
Normal file
@@ -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 = () => (
|
||||
<button
|
||||
key="drag-to-reorder-icon"
|
||||
className="btn-icon btn-icon-secondary btn-icon-md"
|
||||
type="button"
|
||||
>
|
||||
<span className="btn-icon__icon-container">
|
||||
<Icon src={DragIndicator} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const SectionCard = ({ status, displayName }: ItemProps) => {
|
||||
const style = {
|
||||
...commonStyle,
|
||||
paddingTop: '2rem',
|
||||
paddingBottom: '5rem',
|
||||
...getItemStatusBorder(status),
|
||||
};
|
||||
|
||||
return (
|
||||
<Row
|
||||
style={style}
|
||||
className="mx-0 bg-white"
|
||||
>
|
||||
<Col className="extend-margin px-0">
|
||||
<div className="item-card-header h3">
|
||||
<Icon src={ArrowRight} className="mr-2" />
|
||||
{displayName}
|
||||
</div>
|
||||
</Col>
|
||||
<DragIndicatorBtn />
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
const SubsectionCard = ({ status, displayName }: ItemProps) => {
|
||||
const style = {
|
||||
...commonStyle,
|
||||
paddingTop: '1rem',
|
||||
paddingBottom: '2.5rem',
|
||||
...getItemStatusBorder(status),
|
||||
};
|
||||
|
||||
return (
|
||||
<Row
|
||||
style={style}
|
||||
className="mx-0 bg-light-200"
|
||||
>
|
||||
<Col className="extend-margin px-0">
|
||||
<div className="item-card-header h4 pt-2">
|
||||
<Icon src={ArrowRight} className="mr-2" />
|
||||
{displayName}
|
||||
</div>
|
||||
</Col>
|
||||
<DragIndicatorBtn />
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
const UnitCard = ({ status, displayName }: ItemProps) => {
|
||||
const style = {
|
||||
...commonStyle,
|
||||
paddingBottom: '1.5rem',
|
||||
...getItemStatusBorder(status),
|
||||
};
|
||||
|
||||
return (
|
||||
<Row
|
||||
style={style}
|
||||
className="mx-0 bg-white"
|
||||
>
|
||||
<Col className="extend-margin px-0">
|
||||
<div className="item-card-header h5 pt-3">
|
||||
{displayName}
|
||||
</div>
|
||||
</Col>
|
||||
<DragIndicatorBtn />
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
const CourseItemOverlay = ({ category, displayName, status }: CourseItemOverlayProps) => {
|
||||
switch (category) {
|
||||
case ContainerType.Chapter:
|
||||
return <SectionCard displayName={displayName} status={status} />;
|
||||
case ContainerType.Sequential:
|
||||
return <SubsectionCard displayName={displayName} status={status} />;
|
||||
case ContainerType.Vertical:
|
||||
return <UnitCard displayName={displayName} status={status} />;
|
||||
default:
|
||||
throw new Error(`Invalid course item type: ${category}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default CourseItemOverlay;
|
||||
@@ -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 (
|
||||
<DragContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
{children}
|
||||
</DragContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
DragContextProvider.defaultProps = {
|
||||
activeId: '',
|
||||
overId: '',
|
||||
};
|
||||
|
||||
DragContextProvider.propTypes = {
|
||||
activeId: PropTypes.string,
|
||||
overId: PropTypes.string,
|
||||
children: PropTypes.node.isRequired,
|
||||
};
|
||||
|
||||
export default DragContextProvider;
|
||||
30
src/course-outline/drag-helper/DragContextProvider.tsx
Normal file
30
src/course-outline/drag-helper/DragContextProvider.tsx
Normal file
@@ -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<DragContextProviderProps>({
|
||||
activeId: null,
|
||||
overId: null,
|
||||
children: null,
|
||||
});
|
||||
|
||||
const DragContextProvider = ({ activeId, overId, children }: DragContextProviderProps) => {
|
||||
const contextValue = React.useMemo(() => ({
|
||||
activeId,
|
||||
overId,
|
||||
}), [activeId, overId]);
|
||||
return (
|
||||
<DragContext.Provider
|
||||
value={contextValue}
|
||||
>
|
||||
{children}
|
||||
</DragContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragContextProvider;
|
||||
@@ -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<React.SetStateAction<XBlock[]>>,
|
||||
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<string | null>();
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
const [activeId, setActiveId] = React.useState();
|
||||
const [currentOverId, setCurrentOverId] = React.useState();
|
||||
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
|
||||
const [draggedItemClone, setDraggedItemClone] = React.useState<React.ReactNode>(null);
|
||||
const [currentOverId, setCurrentOverId] = React.useState<string | null>(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(
|
||||
<CourseItemOverlay
|
||||
displayName={displayName}
|
||||
category={category}
|
||||
status={status}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
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}
|
||||
>
|
||||
<DragContextProvider activeId={activeId} overId={currentOverId}>
|
||||
{children}
|
||||
</DragContextProvider>
|
||||
{createPortal(
|
||||
<DragOverlay>
|
||||
{draggedItemClone && activeId ? draggedItemClone : null}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
@@ -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 {};
|
||||
};
|
||||
1166
src/course-outline/drag-helper/utils.test.ts
Normal file
1166
src/course-outline/drag-helper/utils.test.ts
Normal file
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
432
src/course-outline/drag-helper/utils.ts
Normal file
432
src/course-outline/drag-helper/utils.ts
Normal file
@@ -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 {};
|
||||
};
|
||||
@@ -237,9 +237,13 @@ const SectionCard = ({
|
||||
<>
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
data={{
|
||||
category,
|
||||
displayName,
|
||||
status: sectionStatus,
|
||||
childAddable: actions.childAddable,
|
||||
}}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
componentStyle={{
|
||||
padding: '1.75rem',
|
||||
...borderStyle,
|
||||
|
||||
@@ -86,6 +86,12 @@ const section: XBlock = {
|
||||
id: subsection.id,
|
||||
}],
|
||||
},
|
||||
actions: {
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
} as XBlock;
|
||||
|
||||
const onEditSubectionSubmit = jest.fn();
|
||||
@@ -206,6 +212,30 @@ describe('<SubsectionCard />', () => {
|
||||
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();
|
||||
|
||||
@@ -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 = ({
|
||||
<>
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
data={{
|
||||
category,
|
||||
displayName,
|
||||
childAddable: actions.childAddable,
|
||||
status: subsectionStatus,
|
||||
}}
|
||||
key={id}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
isDroppable={actions.childAddable || section.actions.childAddable}
|
||||
componentStyle={{
|
||||
background: '#f8f7f6',
|
||||
...borderStyle,
|
||||
|
||||
@@ -25,6 +25,12 @@ const section = {
|
||||
visibilityState: 'live',
|
||||
hasChanges: false,
|
||||
highlights: ['highlight 1', 'highlight 2'],
|
||||
actions: {
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
} as XBlock;
|
||||
|
||||
const subsection = {
|
||||
@@ -33,6 +39,12 @@ const subsection = {
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
hasChanges: false,
|
||||
actions: {
|
||||
draggable: true,
|
||||
childAddable: true,
|
||||
deletable: true,
|
||||
duplicable: true,
|
||||
},
|
||||
} as XBlock;
|
||||
|
||||
const unit = {
|
||||
@@ -122,6 +134,30 @@ describe('<UnitCard />', () => {
|
||||
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: {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<SortableItem
|
||||
id={id}
|
||||
category={category}
|
||||
key={id}
|
||||
data={{
|
||||
category,
|
||||
status: unitStatus,
|
||||
displayName,
|
||||
}}
|
||||
isDraggable={isDraggable}
|
||||
isDroppable={actions.childAddable}
|
||||
isDroppable={subsection.actions.childAddable}
|
||||
componentStyle={{
|
||||
background: '#fdfdfd',
|
||||
...borderStyle,
|
||||
|
||||
Reference in New Issue
Block a user