diff --git a/packages/frontend/src/components/drag&drop-item.tsx b/packages/frontend/src/components/drag&drop-item.tsx
deleted file mode 100644
index a2741c61f..000000000
--- a/packages/frontend/src/components/drag&drop-item.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { useSortable } from '@dnd-kit/sortable';
-import { CSS } from '@dnd-kit/utilities';
-import { ChevronRight, GripVertical } from 'lucide-react';
-import React from 'react';
-
-import { cn } from '../helpers/classnames';
-import { Button } from './ui/button';
-
-export const ItemDragAndDrop = ({
- active,
- children,
- childrenLength,
- depth = 0,
- id,
- indentationWidth = 0,
- isDropHere,
- isOpenChildren,
- onCollapse,
- className,
-}: {
- active: boolean;
- children: React.ReactNode;
- childrenLength?: number;
- className?: string;
- depth?: number;
- id: number | string;
- indentationWidth?: number;
- isDropHere: boolean;
- isOpenChildren?: boolean;
- onCollapse: () => void;
-}) => {
- const {
- attributes,
- isDragging,
- listeners,
- setDraggableNodeRef,
- setDroppableNodeRef,
- transform,
- transition,
- } = useSortable({
- id,
- animateLayoutChanges: ({ isSorting, wasDragging }) =>
- !(isSorting || wasDragging),
- });
-
- const allowOpenChildren = !!(childrenLength && onCollapse);
-
- return (
-
-
-
-
-
- {allowOpenChildren && (
-
- )}
-
-
- {children}
-
-
- );
-};
diff --git a/packages/frontend/src/components/drag&drop/sortable-list/flat.ts b/packages/frontend/src/components/drag&drop/sortable-list/flat.ts
new file mode 100644
index 000000000..780de8a12
--- /dev/null
+++ b/packages/frontend/src/components/drag&drop/sortable-list/flat.ts
@@ -0,0 +1,29 @@
+import { FlattenedItem, TreeItem } from './types';
+
+function flatten>(
+ items: T[],
+ parentId: null | number | string = null,
+ depth = 0,
+): FlattenedItem[] {
+ return items.reduce[]>((acc, item, index) => {
+ const newItem: FlattenedItem = {
+ ...item,
+ parentId,
+ depth,
+ index,
+ collapsed: item.collapsed ?? true,
+ };
+
+ return [
+ ...acc,
+ newItem,
+ ...flatten(item.children as T[], item.id, depth + 1),
+ ];
+ }, []);
+}
+
+export function flattenTree>(
+ items: T[],
+): FlattenedItem[] {
+ return flatten(items);
+}
diff --git a/packages/frontend/src/components/drag&drop/sortable-list/item.tsx b/packages/frontend/src/components/drag&drop/sortable-list/item.tsx
new file mode 100644
index 000000000..c9c2cecdd
--- /dev/null
+++ b/packages/frontend/src/components/drag&drop/sortable-list/item.tsx
@@ -0,0 +1,103 @@
+import { Button } from '@/components/ui/button';
+import { cn } from '@/helpers/classnames';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { ChevronRight, GripVertical } from 'lucide-react';
+
+import { TreeItem } from './types';
+
+export function SortableTreeItem>({
+ childCount,
+ clone,
+ depth,
+ indentationWidth,
+ collapsed,
+ className,
+ id,
+ onCollapse,
+ children,
+ ...props
+}: {
+ childCount?: number;
+ children: React.ReactNode;
+ clone?: boolean;
+ collapsed?: boolean;
+ depth: number;
+ id: number | string;
+ indentationWidth: number;
+ onCollapse?: () => void;
+} & Omit, 'id' | 'style'>) {
+ const {
+ attributes,
+ isSorting,
+ listeners,
+ setDraggableNodeRef,
+ setDroppableNodeRef,
+ transform,
+ transition,
+ } = useSortable({
+ id,
+ animateLayoutChanges: ({ isSorting, wasDragging }) =>
+ isSorting || wasDragging ? false : true,
+ });
+
+ return (
+
+
+
+ {onCollapse && (
+
+ )}
+
+ {children}
+ {clone && childCount && childCount > 1 ? (
+
+ {childCount}
+
+ ) : null}
+
+
+ );
+}
diff --git a/packages/frontend/src/components/drag&drop/sortable-list/list.tsx b/packages/frontend/src/components/drag&drop/sortable-list/list.tsx
new file mode 100644
index 000000000..e5782f5a8
--- /dev/null
+++ b/packages/frontend/src/components/drag&drop/sortable-list/list.tsx
@@ -0,0 +1,273 @@
+'use client';
+
+import {
+ closestCenter,
+ defaultDropAnimation,
+ DndContext,
+ DragEndEvent,
+ DragMoveEvent,
+ DragOverEvent,
+ DragOverlay,
+ DragStartEvent,
+ MeasuringStrategy,
+} from '@dnd-kit/core';
+import {
+ arrayMove,
+ SortableContext,
+ verticalListSortingStrategy,
+} from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { useTranslations } from 'next-intl';
+import React, { useEffect, useId, useMemo, useState } from 'react';
+import { createPortal } from 'react-dom';
+
+import { flattenTree } from './flat';
+import { SortableTreeItem } from './item';
+import { FlattenedItem, TreeItem } from './types';
+import {
+ buildTree,
+ getChildCount,
+ getProjection,
+ removeChildrenOf,
+ setProperty,
+} from './utilities';
+
+const indentationWidth = 50;
+
+export function DragAndDropSortableList<
+ T extends TreeItem>,
+>({
+ data,
+ componentItem,
+ onCollapse,
+ maxDepth,
+ onDragEnd,
+}: {
+ componentItem: (item: T, parentId: null | number | string) => React.ReactNode;
+ data: T[];
+ maxDepth?: number;
+ onCollapse?: (props: { id: number | string; isOpen: boolean }) => void;
+ onDragEnd?: (props: {
+ id: number | string;
+ indexToMove: number;
+ parentId: null | number | string;
+ }) => void;
+}) {
+ const t = useTranslations('core');
+ const [isReadyDocument, setIsReadyDocument] = useState(false);
+ const [items, setItems] = useState(data);
+ const [activeId, setActiveId] = useState(null);
+ const [overId, setOverId] = useState(null);
+ const [offsetLeft, setOffsetLeft] = useState(0);
+ const id = useId();
+
+ // Refetch data
+ useEffect(() => {
+ setItems(data);
+ }, [data]);
+
+ const flattenedItems = useMemo(() => {
+ const flattenedTree = flattenTree(items);
+ const collapsedItems = flattenedTree.reduce<(number | string)[]>(
+ (acc, { children, collapsed, id }) =>
+ collapsed && children.length ? [...acc, id] : acc,
+ [],
+ );
+
+ return removeChildrenOf(
+ flattenedTree,
+ activeId ? [activeId, ...collapsedItems] : collapsedItems,
+ );
+ }, [activeId, items]);
+
+ const projected =
+ activeId && overId
+ ? getProjection(
+ flattenedItems,
+ activeId,
+ overId,
+ offsetLeft,
+ indentationWidth,
+ maxDepth,
+ )
+ : null;
+ const activeItem = activeId
+ ? flattenedItems.find(({ id }) => id === activeId)
+ : null;
+
+ const sortedIds = useMemo(
+ () => flattenedItems.map(({ id }) => id),
+ [flattenedItems],
+ );
+
+ function handleDragStart({ active: { id: activeId } }: DragStartEvent) {
+ setActiveId(activeId);
+ setOverId(activeId);
+
+ document.body.style.setProperty('cursor', 'grabbing');
+ }
+
+ function handleDragMove({ delta }: DragMoveEvent) {
+ setOffsetLeft(delta.x);
+ }
+
+ function handleDragOver({ over }: DragOverEvent) {
+ setOverId(over?.id ?? null);
+ }
+
+ function resetState() {
+ setOverId(null);
+ setActiveId(null);
+ setOffsetLeft(0);
+
+ document.body.style.setProperty('cursor', '');
+ }
+
+ function handleDragEnd({ active, over }: DragEndEvent) {
+ resetState();
+ if (!projected || !over) return;
+
+ const { depth, parentId } = projected;
+ const clonedItems: FlattenedItem>[] = JSON.parse(
+ JSON.stringify(flattenTree(items)),
+ );
+ const overIndex = clonedItems.findIndex(({ id }) => id === over.id);
+ const activeIndex = clonedItems.findIndex(({ id }) => id === active.id);
+ const activeTreeItem = clonedItems[activeIndex];
+
+ clonedItems[activeIndex] = { ...activeTreeItem, depth, parentId };
+
+ const sortedItems = arrayMove(clonedItems, activeIndex, overIndex);
+ const newItems = buildTree>(sortedItems);
+
+ setItems(newItems as T[]);
+
+ onDragEnd?.({
+ id: active.id,
+ parentId: parentId,
+ indexToMove: overIndex,
+ });
+ }
+
+ function handleDragCancel() {
+ resetState();
+ }
+
+ function handleCollapse(id: number | string) {
+ const newItems = setProperty>(
+ items,
+ id,
+ 'collapsed',
+ value => {
+ if (value === undefined) {
+ return false;
+ }
+
+ return !value;
+ },
+ );
+
+ onCollapse?.({
+ id,
+ isOpen: newItems.find(item => item.id === id)?.collapsed ?? true,
+ });
+
+ setItems(newItems as T[]);
+ }
+
+ useEffect(() => {
+ setIsReadyDocument(true);
+ }, []);
+
+ if (!data.length) {
+ return (
+ {t('no_results')}
+ );
+ }
+
+ return (
+
+
+ {flattenedItems.map(item => (
+ {
+ handleCollapse(item.id);
+ }
+ : undefined
+ }
+ >
+ {componentItem(item as unknown as T, item.parentId)}
+
+ ))}
+ {isReadyDocument &&
+ createPortal(
+
+ {activeId && activeItem ? (
+
+ {componentItem(
+ activeItem as unknown as T,
+ activeItem.parentId,
+ )}
+
+ ) : null}
+ ,
+ document.body,
+ )}
+
+
+ );
+}
diff --git a/packages/frontend/src/components/drag&drop/sortable-list/types.ts b/packages/frontend/src/components/drag&drop/sortable-list/types.ts
new file mode 100644
index 000000000..a706c49f2
--- /dev/null
+++ b/packages/frontend/src/components/drag&drop/sortable-list/types.ts
@@ -0,0 +1,11 @@
+export interface TreeItem {
+ children: TreeItem[];
+ collapsed?: boolean;
+ id: number | string;
+}
+
+export type FlattenedItem = {
+ depth: number;
+ index: number;
+ parentId: null | number | string;
+} & T;
diff --git a/packages/frontend/src/components/drag&drop/sortable-list/utilities.ts b/packages/frontend/src/components/drag&drop/sortable-list/utilities.ts
new file mode 100644
index 000000000..b35e70275
--- /dev/null
+++ b/packages/frontend/src/components/drag&drop/sortable-list/utilities.ts
@@ -0,0 +1,232 @@
+import { arrayMove } from '@dnd-kit/sortable';
+
+import { FlattenedItem, TreeItem } from './types';
+
+function getDragDepth(offset: number, indentationWidth: number) {
+ return Math.round(offset / indentationWidth);
+}
+
+export function getProjection>(
+ items: FlattenedItem[],
+ activeId: number | string,
+ overId: number | string,
+ dragOffset: number,
+ indentationWidth: number,
+ maxDepthProp?: number,
+) {
+ const overItemIndex = items.findIndex(({ id }) => id === overId);
+ const activeItemIndex = items.findIndex(({ id }) => id === activeId);
+ const activeItem = items[activeItemIndex];
+ const newItems = arrayMove(items, activeItemIndex, overItemIndex);
+ const previousItem: FlattenedItem | undefined =
+ newItems[overItemIndex - 1];
+ const nextItem = newItems[overItemIndex + 1];
+ const dragDepth = getDragDepth(dragOffset, indentationWidth);
+ const projectedDepth = +activeItem.depth + dragDepth;
+ const maxDepth =
+ maxDepthProp ??
+ getMaxDepth({
+ previousItem,
+ });
+ const minDepth = getMinDepth({ nextItem });
+ let depth = projectedDepth;
+
+ if (projectedDepth >= maxDepth) {
+ depth = maxDepth;
+ } else if (projectedDepth < minDepth) {
+ depth = minDepth;
+ }
+
+ return { depth, maxDepth, minDepth, parentId: getParentId() };
+
+ function getParentId() {
+ if (depth === 0 || !previousItem) {
+ return null;
+ }
+
+ if (depth === previousItem.depth) {
+ return previousItem.parentId;
+ }
+
+ if (depth > previousItem.depth) {
+ return previousItem.id;
+ }
+
+ const newParent = newItems
+ .slice(0, overItemIndex)
+ .reverse()
+ .find(item => item.depth === depth)?.parentId;
+
+ return newParent ?? null;
+ }
+}
+
+function getMaxDepth>({
+ previousItem,
+}: {
+ previousItem: FlattenedItem | undefined;
+}) {
+ if (previousItem) {
+ return previousItem.depth + 1;
+ }
+
+ return 0;
+}
+
+function getMinDepth>({
+ nextItem,
+}: {
+ nextItem: FlattenedItem | undefined;
+}) {
+ if (nextItem) {
+ return nextItem.depth;
+ }
+
+ return 0;
+}
+
+export function buildTree>(
+ flattenedItems: FlattenedItem[],
+): T[] {
+ const root: TreeItem = {
+ id: 'root',
+ children: [],
+ collapsed: true,
+ };
+
+ const nodes: Record> = {
+ [root.id]: root,
+ };
+
+ // No need to specify type here; let TypeScript infer it
+ const items = flattenedItems.map(item => ({
+ ...item,
+ children: [],
+ }));
+
+ for (const item of items) {
+ const { id, children } = item;
+ const parentId = item.parentId ?? root.id;
+ const parent = nodes[parentId] ?? findItem(items, parentId);
+
+ nodes[id] = { ...item, children };
+ parent.children.push(item as T);
+ }
+
+ return root.children as T[];
+}
+
+export function findItem>(
+ items: TreeItem[],
+ itemId: number | string,
+) {
+ return items.find(({ id }) => id === itemId);
+}
+
+export function findItemDeep>(
+ items: TreeItem[],
+ itemId: number | string,
+): TreeItem | undefined {
+ for (const item of items) {
+ const { id, children } = item;
+
+ if (id === itemId) {
+ return item;
+ }
+
+ if (children.length) {
+ const child = findItemDeep(children, itemId);
+
+ if (child) {
+ return child;
+ }
+ }
+ }
+
+ return undefined;
+}
+
+export function removeItem>(
+ items: TreeItem[],
+ id: number | string,
+) {
+ const newItems = [];
+
+ for (const item of items) {
+ if (item.id === id) {
+ continue;
+ }
+
+ if (item.children.length) {
+ item.children = removeItem(item.children, id);
+ }
+
+ newItems.push(item as never);
+ }
+
+ return newItems;
+}
+
+export function setProperty<
+ T extends TreeItem,
+ Y extends keyof T = 'collapsed',
+>(
+ items: T[],
+ id: number | string,
+ property: Y,
+ setter: (value: T[Y]) => T[Y],
+): T[] {
+ for (const item of items) {
+ if (item.id === id) {
+ item[property] = setter(item[property]);
+ continue;
+ }
+
+ if (item.children.length) {
+ item.children = setProperty(item.children as T[], id, property, setter);
+ }
+ }
+
+ return [...items];
+}
+
+function countChildren>(
+ items: TreeItem[],
+ count = 0,
+): number {
+ return items.reduce((acc, { children }) => {
+ if (children.length) {
+ return countChildren(children, acc + 1);
+ }
+
+ return acc + 1;
+ }, count);
+}
+
+export function getChildCount>(
+ items: TreeItem[],
+ id: number | string,
+) {
+ const item = findItemDeep(items, id);
+
+ return item ? countChildren(item.children) : 0;
+}
+
+export function removeChildrenOf>(
+ items: FlattenedItem[],
+ ids: (number | string)[],
+) {
+ const excludeParentIds = [...ids];
+
+ return items.filter(item => {
+ if (item.parentId && excludeParentIds.includes(item.parentId)) {
+ if (item.children.length) {
+ excludeParentIds.push(item.id);
+ }
+
+ return false;
+ }
+
+ return true;
+ });
+}
diff --git a/packages/frontend/src/helpers/flatten-tree.ts b/packages/frontend/src/helpers/flatten-tree.ts
deleted file mode 100644
index 5f09e56b0..000000000
--- a/packages/frontend/src/helpers/flatten-tree.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-export type WithChildren = {
- children: WithChildren[];
- id: number | string;
-} & Omit;
-
-export type FlatTree = {
- depth: number;
- parentId: null | number | string;
-} & WithChildren;
-
-export function flattenTree({
- depth = 0,
- parentId = null,
- tree,
-}: {
- depth?: number;
- parentId?: null | number | string;
- tree: WithChildren[];
-}): FlatTree[] {
- return tree.reduce[]>((previousValue, currentValue) => {
- const children =
- currentValue.children.length > 0
- ? flattenTree({
- tree: currentValue.children,
- parentId: currentValue.id,
- depth: depth + 1,
- })
- : [];
-
- return [
- ...previousValue,
- {
- ...currentValue,
- parentId: parentId,
- depth: depth,
- children,
- },
- ...children,
- ];
- }, []);
-}
diff --git a/packages/frontend/src/hooks/drag&drop/use-functions.ts b/packages/frontend/src/hooks/drag&drop/use-functions.ts
deleted file mode 100644
index 07f9c55a3..000000000
--- a/packages/frontend/src/hooks/drag&drop/use-functions.ts
+++ /dev/null
@@ -1,270 +0,0 @@
-import {
- DragEndEvent,
- DragMoveEvent,
- DragOverEvent,
- DragStartEvent,
- UniqueIdentifier,
-} from '@dnd-kit/core';
-import { arrayMove } from '@dnd-kit/sortable';
-import React from 'react';
-
-import {
- flattenTree,
- FlatTree,
- WithChildren,
-} from '../../helpers/flatten-tree';
-import { useProjection } from './use-projection';
-
-function removeChildrenOf({
- ids,
- tree,
-}: {
- ids: UniqueIdentifier[];
- tree: FlatTree[];
-}) {
- const excludeParentIds = [...ids];
-
- return tree.filter(item => {
- if (item.parentId && excludeParentIds.includes(item.parentId)) {
- if (item.children.length > 0) {
- excludeParentIds.push(item.id);
- }
-
- return false;
- }
-
- return true;
- });
-}
-
-export function buildTree({
- flattenedTree,
-}: {
- flattenedTree: FlatTree[];
-}): WithChildren[] {
- const sorted = flattenedTree.sort((a, b) => b.depth - a.depth);
- const maxDepth = sorted[0].depth;
- let tree: FlatTree[] = [];
-
- tree = sorted.map(item => {
- if (item.depth === maxDepth) {
- return item;
- }
-
- return {
- ...item,
- children: [],
- };
- });
-
- tree.forEach(item => {
- const parentIndex = tree.findIndex(({ id }) => id === item.parentId);
- if (parentIndex === -1) return;
- const parent = tree[parentIndex];
-
- tree[parentIndex] = {
- ...parent,
- children: [...parent.children, item],
- };
- });
-
- return tree.filter(item => !item.parentId);
-}
-
-interface OnDragMoveArgs extends DragMoveEvent {
- flattenedItems: FlatTree