feat: implement drag-and-drop sorting for knowledge directories and add localization for sort actions
This commit is contained in:
@@ -84,6 +84,10 @@ func KnowledgeDirectoryPostDelete(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
func KnowledgeDirectoryPostUpdate_sort(ctx *gin.Context) {
|
||||
if _, err := services.AuthService.RequirePermission(ctx, constants.PermissionKnowledgeBaseUpdate); err != nil {
|
||||
httpx.WriteJSON(ctx, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
KnowledgeBaseID int64 `json:"knowledgeBaseId"`
|
||||
ParentID int64 `json:"parentId"`
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -11,7 +28,7 @@ import {
|
||||
PlusIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -39,10 +56,12 @@ import {
|
||||
deleteKnowledgeDirectory,
|
||||
fetchKnowledgeDirectories,
|
||||
updateKnowledgeDirectory,
|
||||
updateKnowledgeDirectorySort,
|
||||
type KnowledgeDirectory,
|
||||
} from "@/lib/api/admin";
|
||||
import { useI18n } from "@/i18n/provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { findDirectoryParentId, moveDirectoryWithinParent } from "./knowledge-directory-sort";
|
||||
|
||||
type KnowledgeDirectoryPanelProps = {
|
||||
knowledgeBaseId: number;
|
||||
@@ -106,6 +125,7 @@ export function KnowledgeDirectoryPanel({
|
||||
const [contextMenuDirectoryId, setContextMenuDirectoryId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [sorting, setSorting] = useState(false);
|
||||
const [dialog, setDialog] = useState<DirectoryDialogState>({
|
||||
open: false,
|
||||
id: null,
|
||||
@@ -120,6 +140,17 @@ export function KnowledgeDirectoryPanel({
|
||||
],
|
||||
[directories, dialog.id, t],
|
||||
);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: { distance: 8 },
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: { delay: 150, tolerance: 8 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const loadDirectories = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -268,6 +299,48 @@ export function KnowledgeDirectoryPanel({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDirectoryDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id || sorting) {
|
||||
return;
|
||||
}
|
||||
const activeId = Number(active.id);
|
||||
const overId = Number(over.id);
|
||||
if (!Number.isFinite(activeId) || !Number.isFinite(overId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeParentId = findDirectoryParentId(directories, activeId);
|
||||
const overParentId = findDirectoryParentId(directories, overId);
|
||||
if (activeParentId === null || overParentId === null || activeParentId !== overParentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousDirectories = directories;
|
||||
const moved = moveDirectoryWithinParent(previousDirectories, activeParentId, activeId, overId);
|
||||
if (!moved.changed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDirectories(moved.items);
|
||||
setSorting(true);
|
||||
try {
|
||||
await updateKnowledgeDirectorySort({
|
||||
knowledgeBaseId,
|
||||
parentId: moved.parentId,
|
||||
ids: moved.orderedIds,
|
||||
});
|
||||
toast.success(t("knowledge.directorySortUpdated"));
|
||||
await loadDirectories();
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
setDirectories(previousDirectories);
|
||||
toast.error(error instanceof Error ? error.message : t("knowledge.directorySortUpdateFailed"));
|
||||
} finally {
|
||||
setSorting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -301,26 +374,37 @@ export function KnowledgeDirectoryPanel({
|
||||
selected={selectedDirectoryId === 0}
|
||||
onClick={() => onSelectDirectory(0)}
|
||||
/>
|
||||
{directories.map((item) => (
|
||||
<DirectoryNode
|
||||
key={item.id}
|
||||
item={item}
|
||||
depth={0}
|
||||
expandedIds={expandedIds}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
contextMenuDirectoryId={contextMenuDirectoryId}
|
||||
saving={saving}
|
||||
onToggle={toggleDirectory}
|
||||
onSelect={onSelectDirectory}
|
||||
onContextMenuOpenChange={(directoryId, open) =>
|
||||
setContextMenuDirectoryId(open ? directoryId : null)
|
||||
}
|
||||
onCreate={openCreate}
|
||||
onEdit={openEdit}
|
||||
onDelete={(directory) => void handleDelete(directory)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={(event) => void handleDirectoryDragEnd(event)}
|
||||
>
|
||||
<SortableContext
|
||||
items={directories.map((item) => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{directories.map((item) => (
|
||||
<DirectoryNode
|
||||
key={item.id}
|
||||
item={item}
|
||||
depth={0}
|
||||
expandedIds={expandedIds}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
contextMenuDirectoryId={contextMenuDirectoryId}
|
||||
saving={saving || sorting}
|
||||
onToggle={toggleDirectory}
|
||||
onSelect={onSelectDirectory}
|
||||
onContextMenuOpenChange={(directoryId, open) =>
|
||||
setContextMenuDirectoryId(open ? directoryId : null)
|
||||
}
|
||||
onCreate={openCreate}
|
||||
onEdit={openEdit}
|
||||
onDelete={(directory) => void handleDelete(directory)}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<div
|
||||
@@ -459,21 +543,39 @@ function DirectoryNode({
|
||||
onDelete,
|
||||
t,
|
||||
}: DirectoryNodeProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: item.id,
|
||||
disabled: saving,
|
||||
});
|
||||
const expanded = expandedIds.has(item.id);
|
||||
const hasChildren = (item.children || []).length > 0;
|
||||
const active = selectedDirectoryId === item.id || contextMenuDirectoryId === item.id;
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<ContextMenu onOpenChange={(open) => onContextMenuOpenChange(item.id, open)}>
|
||||
<ContextMenuTrigger className="block">
|
||||
<div
|
||||
className={cn(
|
||||
"group flex cursor-pointer items-center gap-1 px-2 py-1.5 text-sm hover:bg-accent",
|
||||
active && "bg-accent text-accent-foreground",
|
||||
isDragging && "bg-muted/60 shadow-sm opacity-80",
|
||||
)}
|
||||
style={{ paddingLeft: 8 + depth * 16 }}
|
||||
onClick={() => onSelect(item.id)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -550,24 +652,31 @@ function DirectoryNode({
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
{expanded
|
||||
? (item.children || []).map((child) => (
|
||||
<DirectoryNode
|
||||
key={child.id}
|
||||
item={child}
|
||||
depth={depth + 1}
|
||||
expandedIds={expandedIds}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
contextMenuDirectoryId={contextMenuDirectoryId}
|
||||
saving={saving}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
onCreate={onCreate}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
t={t}
|
||||
/>
|
||||
))
|
||||
? (
|
||||
<SortableContext
|
||||
items={(item.children || []).map((child) => child.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{(item.children || []).map((child) => (
|
||||
<DirectoryNode
|
||||
key={child.id}
|
||||
item={child}
|
||||
depth={depth + 1}
|
||||
expandedIds={expandedIds}
|
||||
selectedDirectoryId={selectedDirectoryId}
|
||||
contextMenuDirectoryId={contextMenuDirectoryId}
|
||||
saving={saving}
|
||||
onToggle={onToggle}
|
||||
onSelect={onSelect}
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
onCreate={onCreate}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { describe, it } from "node:test"
|
||||
import ts from "typescript"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import vm from "node:vm"
|
||||
|
||||
function plain(value) {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
}
|
||||
|
||||
async function loadModule() {
|
||||
const source = await readFile(
|
||||
new URL("./knowledge-directory-sort.ts", import.meta.url),
|
||||
"utf8"
|
||||
)
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2017,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
},
|
||||
fileName: "knowledge-directory-sort.ts",
|
||||
})
|
||||
const sandbox = {
|
||||
exports: {},
|
||||
module: { exports: {} },
|
||||
}
|
||||
sandbox.exports = sandbox.module.exports
|
||||
vm.runInNewContext(compiled.outputText, sandbox)
|
||||
return sandbox.module.exports
|
||||
}
|
||||
|
||||
const tree = [
|
||||
{ id: 1, parentId: 0, name: "A", children: [
|
||||
{ id: 11, parentId: 1, name: "A-1", children: [] },
|
||||
{ id: 12, parentId: 1, name: "A-2", children: [] },
|
||||
] },
|
||||
{ id: 2, parentId: 0, name: "B", children: [] },
|
||||
{ id: 3, parentId: 0, name: "C", children: [] },
|
||||
]
|
||||
|
||||
describe("knowledge directory sorting", () => {
|
||||
it("moves root directories within the same parent", async () => {
|
||||
const { moveDirectoryWithinParent } = await loadModule()
|
||||
|
||||
const next = moveDirectoryWithinParent(tree, 0, 3, 1)
|
||||
|
||||
assert.deepEqual(plain(next.items).map((item) => item.id), [3, 1, 2])
|
||||
assert.equal(next.changed, true)
|
||||
assert.equal(next.parentId, 0)
|
||||
assert.deepEqual(plain(next.orderedIds), [3, 1, 2])
|
||||
})
|
||||
|
||||
it("moves child directories within their parent", async () => {
|
||||
const { moveDirectoryWithinParent } = await loadModule()
|
||||
|
||||
const next = moveDirectoryWithinParent(tree, 1, 12, 11)
|
||||
|
||||
assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12, 11])
|
||||
assert.equal(next.changed, true)
|
||||
assert.equal(next.parentId, 1)
|
||||
assert.deepEqual(plain(next.orderedIds), [12, 11])
|
||||
})
|
||||
|
||||
it("does not move directories across parents", async () => {
|
||||
const { findDirectoryParentId, moveDirectoryWithinParent } = await loadModule()
|
||||
|
||||
assert.equal(findDirectoryParentId(tree, 11), 1)
|
||||
assert.equal(findDirectoryParentId(tree, 2), 0)
|
||||
|
||||
const next = moveDirectoryWithinParent(tree, 1, 11, 2)
|
||||
|
||||
assert.equal(next.changed, false)
|
||||
assert.equal(next.items, tree)
|
||||
assert.deepEqual(plain(next.orderedIds), [])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,98 @@
|
||||
export type SortableKnowledgeDirectory = {
|
||||
id: number
|
||||
parentId: number
|
||||
children?: SortableKnowledgeDirectory[]
|
||||
}
|
||||
|
||||
export type MoveDirectoryResult<T extends SortableKnowledgeDirectory> = {
|
||||
items: T[]
|
||||
changed: boolean
|
||||
parentId: number
|
||||
orderedIds: number[]
|
||||
}
|
||||
|
||||
function arrayMove<T>(items: T[], fromIndex: number, toIndex: number) {
|
||||
const next = [...items]
|
||||
const [item] = next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, item)
|
||||
return next
|
||||
}
|
||||
|
||||
export function findDirectoryParentId<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
id: number,
|
||||
): number | null {
|
||||
for (const item of items) {
|
||||
if (item.id === id) {
|
||||
return item.parentId
|
||||
}
|
||||
const childParentId = findDirectoryParentId(item.children as T[] | undefined ?? [], id)
|
||||
if (childParentId !== null) {
|
||||
return childParentId
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function moveDirectoryWithinParent<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
parentId: number,
|
||||
activeId: number,
|
||||
overId: number,
|
||||
): MoveDirectoryResult<T> {
|
||||
if (activeId === overId) {
|
||||
return { items, changed: false, parentId, orderedIds: [] }
|
||||
}
|
||||
|
||||
if (parentId === 0) {
|
||||
return moveSiblingList(items, parentId, activeId, overId)
|
||||
}
|
||||
|
||||
let changed = false
|
||||
let orderedIds: number[] = []
|
||||
const nextItems = items.map((item) => {
|
||||
if (item.id === parentId) {
|
||||
const moved = moveSiblingList((item.children as T[] | undefined) ?? [], parentId, activeId, overId)
|
||||
changed = moved.changed
|
||||
orderedIds = moved.orderedIds
|
||||
return { ...item, children: moved.items }
|
||||
}
|
||||
if (item.children?.length) {
|
||||
const moved = moveDirectoryWithinParent(item.children as T[], parentId, activeId, overId)
|
||||
if (moved.changed) {
|
||||
changed = true
|
||||
orderedIds = moved.orderedIds
|
||||
return { ...item, children: moved.items }
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
|
||||
return {
|
||||
items: changed ? nextItems : items,
|
||||
changed,
|
||||
parentId,
|
||||
orderedIds,
|
||||
}
|
||||
}
|
||||
|
||||
function moveSiblingList<T extends SortableKnowledgeDirectory>(
|
||||
items: T[],
|
||||
parentId: number,
|
||||
activeId: number,
|
||||
overId: number,
|
||||
): MoveDirectoryResult<T> {
|
||||
const oldIndex = items.findIndex((item) => item.id === activeId)
|
||||
const newIndex = items.findIndex((item) => item.id === overId)
|
||||
if (oldIndex < 0 || newIndex < 0) {
|
||||
return { items, changed: false, parentId, orderedIds: [] }
|
||||
}
|
||||
|
||||
const nextItems = arrayMove(items, oldIndex, newIndex)
|
||||
return {
|
||||
items: nextItems,
|
||||
changed: true,
|
||||
parentId,
|
||||
orderedIds: nextItems.map((item) => item.id),
|
||||
}
|
||||
}
|
||||
@@ -1663,6 +1663,17 @@ export function deleteKnowledgeDirectory(id: number) {
|
||||
})
|
||||
}
|
||||
|
||||
export function updateKnowledgeDirectorySort(payload: {
|
||||
knowledgeBaseId: number
|
||||
parentId: number
|
||||
ids: number[]
|
||||
}) {
|
||||
return request<void>("/api/dashboard/knowledge-directory/update_sort", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function fetchKnowledgeDocuments(
|
||||
query?: Record<string, string | number | undefined>
|
||||
) {
|
||||
|
||||
@@ -1804,6 +1804,8 @@
|
||||
"directoryDeleted": "Directory deleted: {name}",
|
||||
"directorySaveFailed": "Could not save the directory.",
|
||||
"directoryDeleteFailed": "Could not delete the directory.",
|
||||
"directorySortUpdated": "Directory order updated.",
|
||||
"directorySortUpdateFailed": "Could not update directory order.",
|
||||
"directoryNameRequired": "Directory name is required.",
|
||||
"rebuildStarted": "Knowledge base reindex started: {name}",
|
||||
"rebuildFailed": "Could not rebuild the knowledge base index.",
|
||||
|
||||
@@ -1804,6 +1804,8 @@
|
||||
"directoryDeleted": "已删除目录:{name}",
|
||||
"directorySaveFailed": "保存目录失败",
|
||||
"directoryDeleteFailed": "删除目录失败",
|
||||
"directorySortUpdated": "目录排序已更新",
|
||||
"directorySortUpdateFailed": "更新目录排序失败",
|
||||
"directoryNameRequired": "目录名称不能为空",
|
||||
"rebuildStarted": "已开始重建知识库索引:{name}",
|
||||
"rebuildFailed": "重建知识库索引失败",
|
||||
|
||||
Reference in New Issue
Block a user