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:
Navin Karkera
2025-08-04 22:16:39 +05:30
committed by GitHub
parent 00ce3d7856
commit 915bd559e0
15 changed files with 2008 additions and 485 deletions

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

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

View 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 {};
};

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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