erp-platform/ui/src/views/menu/SortableMenuTree.tsx

237 lines
6.4 KiB
TypeScript

import React, { useEffect, useState } from 'react'
import {
DndContext,
DragOverlay,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragStartEvent,
DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { MenuItemComponent } from './MenuItemComponent'
import { MenuItem } from '@/@types/menu'
import { getPermissionsList } from '@/services/identity.service'
import { PermissionDefinitionRecord } from '@/proxy/admin/models'
import { SelectBoxOption } from '@/shared/types'
interface SortableMenuTreeProps {
items: MenuItem[]
onItemsChange: (items: MenuItem[]) => void
isDesignMode: boolean
refetch: () => void
}
export const SortableMenuTree: React.FC<SortableMenuTreeProps> = ({
items,
onItemsChange,
isDesignMode,
refetch,
}) => {
const [permissions, setPermissions] = useState<SelectBoxOption[]>([])
const [activeItem, setActiveItem] = React.useState<MenuItem | null>(null)
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
)
const flattenItems = (
items: MenuItem[],
parentPath: number[] = [],
): Array<{ item: MenuItem; path: number[] }> => {
return items.flatMap((item, index) => {
const path = [...parentPath, index]
const self = { item, path }
if (item.children?.length) {
return [self, ...flattenItems(item.children, path)]
}
return [self]
})
}
const findItemById = (items: MenuItem[], id: string): MenuItem | null => {
for (const item of items) {
if (item.id === id) return item
if (item.children) {
const found = findItemById(item.children, id)
if (found) return found
}
}
return null
}
const removeItemFromTree = (items: MenuItem[], id: string): MenuItem[] => {
return items.reduce((acc: MenuItem[], item) => {
if (item.id === id) return acc
const newItem = { ...item }
if (newItem.children && newItem.children.length > 0) {
newItem.children = removeItemFromTree(newItem.children, id)
}
acc.push(newItem)
return acc
}, [])
}
const insertItemAtPath = (
items: MenuItem[],
item: MenuItem,
targetPath: number[],
): MenuItem[] => {
if (targetPath.length === 1) {
const newItems = [...items]
newItems.splice(targetPath[0], 0, item)
return newItems
}
const [firstIndex, ...restPath] = targetPath
const newItems = [...items]
if (newItems[firstIndex]) {
newItems[firstIndex] = {
...newItems[firstIndex],
children: insertItemAtPath(newItems[firstIndex].children || [], item, restPath),
}
}
return newItems
}
const getInsertionPath = (
items: MenuItem[],
activeId: string,
overId: string,
): number[] | null => {
const flat = flattenItems(items)
const activeFlat = flat.find((f) => f.item.id === activeId)
const overFlat = flat.find((f) => f.item.id === overId)
if (!activeFlat || !overFlat) return null
const isSameParent = activeFlat.path.slice(0, -1).join() === overFlat.path.slice(0, -1).join()
const insertPath = [...overFlat.path]
if (isSameParent) {
const activeIndex = activeFlat.path[activeFlat.path.length - 2]
const overIndex = overFlat.path[overFlat.path.length - 1]
if (activeIndex < overIndex) {
insertPath[insertPath.length - 1] += 1
}
}
return insertPath
}
const updateOrderNumbers = (items: MenuItem[]): MenuItem[] => {
return items.map((item, index) => ({
...item,
order: index + 1,
children: item.children ? updateOrderNumbers(item.children) : [],
}))
}
const handleDragStart = (event: DragStartEvent) => {
const { active } = event
const activeItem = findItemById(items, active.id as string)
setActiveItem(activeItem)
}
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event
setActiveItem(null)
if (!over || active.id === over.id || !isDesignMode) return
const activeId = active.id as string
const overId = over.id as string
const activeItem = findItemById(items, activeId)
if (!activeItem) return
const insertionPath = getInsertionPath(items, activeId, overId)
if (!insertionPath) return
let newItems = removeItemFromTree(items, activeId)
newItems = insertItemAtPath(newItems, activeItem, insertionPath)
const finalItems = updateOrderNumbers(newItems)
onItemsChange(finalItems)
}
useEffect(() => {
const fetchPermissions = async () => {
const response = await getPermissionsList()
if (response.data) {
setPermissions(
response.data.map((p: PermissionDefinitionRecord) => ({
value: p.name,
label: p.name,
})),
)
}
}
fetchPermissions()
}, [])
const renderMenuItem = (item: MenuItem, depth: number = 0): React.ReactNode => {
return (
<div key={item.id}>
<MenuItemComponent item={item} isDesignMode={isDesignMode} depth={depth} refetch={refetch} permissions={permissions}>
{Array.isArray(item.children) && item.children.length > 0 && (
<SortableContext
items={item.children
.filter((child): child is MenuItem & { id: string } => !!child.id)
.map((child) => child.id)}
strategy={verticalListSortingStrategy}
>
<div className="ml-4">
{item.children.map((child) => renderMenuItem(child, depth + 1))}
</div>
</SortableContext>
)}
</MenuItemComponent>
</div>
)
}
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="space-y-1">{items.map((item) => renderMenuItem(item))}</div>
<DragOverlay>
{activeItem ? (
<MenuItemComponent
item={activeItem}
isDesignMode={isDesignMode}
depth={0}
isDragOverlay={true}
refetch={refetch}
permissions={permissions}
/>
) : null}
</DragOverlay>
</DndContext>
)
}