Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,5 +139,8 @@ export {MuiBaseCustomTheme} from './mui/MuiBaseCustomTheme'
// export {default as MuiSortableTable} from './mui/sortable-table/mui-table-sortable' // react-beautiful-dnd
// export {default as MuiStripePayment} from './mui/StripePayment' // @stripe/react-stripe-js, @stripe/stripe-js
// export {default as MuiAdditionalInput} from './mui/formik-inputs/additional-input/additional-input' // react-beautiful-dnd (via dnd-list)
// export {default as MuiAdditionalInputV2} from './mui/formik-inputs/additional-input/additional-input-v2' // @dnd-kit (via DragNDropList)
// export {default as MuiAdditionalInputList} from './mui/formik-inputs/additional-input/additional-input-list' // react-beautiful-dnd (via dnd-list)
// export {default as MuiAdditionalInputListV2} from './mui/formik-inputs/additional-input/additional-input-list-v2' // @dnd-kit (via DragNDropList)
// export {default as MuiDragNDropList} from './mui/DragNDropList' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
// export {default as MuiTableSortableV2} from './mui/sortable-table-v2/mui-table-sortable-v2' // @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* */

jest.mock("@dnd-kit/core", () => ({
closestCenter: "closestCenter",
KeyboardSensor: function KeyboardSensor() {},
PointerSensor: function PointerSensor() {},
useSensor: jest.fn((sensor) => sensor),
useSensors: jest.fn((...sensors) => sensors)
}));

jest.mock("@dnd-kit/sortable", () => ({
sortableKeyboardCoordinates: jest.fn(),
arrayMove: (arr, from, to) => {
const result = [...arr];
const [item] = result.splice(from, 1);
result.splice(to, 0, item);
return result;
}
}));

import React from "react";
import { render } from "@testing-library/react";
import useDndKitReorder from "../useDndKitReorder";

// @testing-library/react in this repo predates renderHook, so exercise the
// hook through a throwaway host component instead.
const Harness = ({ onReady, ...hookArgs }) => {
onReady(useDndKitReorder(hookArgs));
return null;
};

const setup = (hookArgs) => {
let result;
render(<Harness {...hookArgs} onReady={(r) => { result = r; }} />);
return result;
};

const items = [
{ id: 1, name: "A" },
{ id: 2, name: "B" },
{ id: 3, name: "C" }
];

describe("useDndKitReorder", () => {
test("exposes sensors and the shared collision detection strategy", () => {
const result = setup({ items, getItemId: (i) => String(i.id), onReorder: jest.fn() });

expect(result.sensors).toBeDefined();
expect(result.collisionDetection).toBe("closestCenter");
});

test("computes order as idx + 1 when no orderOffset is given", () => {
const onReorder = jest.fn();
const result = setup({ items, getItemId: (i) => String(i.id), onReorder });

result.handleDragEnd({ active: { id: "1" }, over: { id: "3" } });

expect(onReorder).toHaveBeenCalledWith(
[
{ id: 2, name: "B", order: 1 },
{ id: 3, name: "C", order: 2 },
{ id: 1, name: "A", order: 3 }
],
expect.objectContaining({ oldIndex: 0, newIndex: 2 })
);
});

test("adds orderOffset on top of the base position for paginated consumers", () => {
const onReorder = jest.fn();
const result = setup({
items,
getItemId: (i) => String(i.id),
onReorder,
orderOffset: 10
});

result.handleDragEnd({ active: { id: "1" }, over: { id: "2" } });

const [reordered] = onReorder.mock.calls[0];
expect(reordered[0]).toHaveProperty("order", 11);
expect(reordered[1]).toHaveProperty("order", 12);
});

test("does not call onReorder when there is no drop target", () => {
const onReorder = jest.fn();
const result = setup({ items, getItemId: (i) => String(i.id), onReorder });

result.handleDragEnd({ active: { id: "1" }, over: null });

expect(onReorder).not.toHaveBeenCalled();
});

test("does not call onReorder when dropped on the same item", () => {
const onReorder = jest.fn();
const result = setup({ items, getItemId: (i) => String(i.id), onReorder });

result.handleDragEnd({ active: { id: "1" }, over: { id: "1" } });

expect(onReorder).not.toHaveBeenCalled();
});

test("skips writing an order key when updateOrderKey is falsy", () => {
const onReorder = jest.fn();
const result = setup({
items,
getItemId: (i) => String(i.id),
onReorder,
updateOrderKey: null
});

result.handleDragEnd({ active: { id: "1" }, over: { id: "2" } });

const [reordered] = onReorder.mock.calls[0];
expect(reordered[0]).not.toHaveProperty("order");
});
});
59 changes: 59 additions & 0 deletions src/components/mui/DragNDropList/hooks/useDndKitReorder.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* Copyright 2026 OpenStack Foundation
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* */

import {
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from "@dnd-kit/core";
import { sortableKeyboardCoordinates, arrayMove } from "@dnd-kit/sortable";

// Shared dnd-kit wiring for reorderable lists/tables: sensors, collision
// detection, and the base order computation (orderOffset + idx + 1).
// orderOffset lets a paginated consumer add its page offset on top without
// duplicating the sensor/collision setup or the id-matching logic.
const useDndKitReorder = ({
items,
getItemId,
onReorder,
updateOrderKey = "order",
orderOffset = 0
}) => {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);

const handleDragEnd = ({ active, over }) => {
if (!over || active.id === over.id) return;

const oldIndex = items.findIndex((item, i) => getItemId(item, i) === active.id);
const newIndex = items.findIndex((item, i) => getItemId(item, i) === over.id);

if (oldIndex === -1 || newIndex === -1) return;

const reordered = arrayMove(items, oldIndex, newIndex).map((item, idx) =>
updateOrderKey
? { ...item, [updateOrderKey]: orderOffset + idx + 1 }
: item
);

onReorder(reordered, { active, over, oldIndex, newIndex });
};

return { sensors, collisionDetection: closestCenter, handleDragEnd };
};

export default useDndKitReorder;
49 changes: 10 additions & 39 deletions src/components/mui/DragNDropList/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,12 @@

import React from "react";
import PropTypes from "prop-types";
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors
} from "@dnd-kit/core";
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
arrayMove
} from "@dnd-kit/sortable";
import { DndContext } from "@dnd-kit/core";
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
import { Box } from "@mui/material";

import SortableItem from "./sortable-item";
import useDndKitReorder from "./hooks/useDndKitReorder";

// Items without an idKey value fall back to a positional id (new-${index}).
// Because that id is recomputed from the current index every render, after a
Expand All @@ -51,35 +40,17 @@ const DragNDropList = ({
idKey = "id",
updateOrderKey = "order"
}) => {
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);

const handleDragEnd = ({ active, over }) => {
if (!over || active.id === over.id) return;

const oldIndex = items.findIndex(
(item, i) => getItemId(item, i, idKey) === active.id
);
const newIndex = items.findIndex(
(item, i) => getItemId(item, i, idKey) === over.id
);

if (oldIndex === -1 || newIndex === -1) return;

const reordered = arrayMove(items, oldIndex, newIndex).map((item, i) => ({
...item,
[updateOrderKey]: i + 1
}));

onReorder(reordered, { active, over });
};
const { sensors, collisionDetection, handleDragEnd } = useDndKitReorder({
items,
getItemId: (item, i) => getItemId(item, i, idKey),
updateOrderKey,
onReorder: (reordered, { active, over }) => onReorder(reordered, { active, over })
});

return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
collisionDetection={collisionDetection}
onDragEnd={handleDragEnd}
>
<SortableContext
Expand Down
Loading
Loading