feat: implement directory move functionality and add localization for move actions

This commit is contained in:
mlogclub
2026-06-07 22:25:27 +08:00
parent 409d04854d
commit 8106b25223
5 changed files with 275 additions and 10 deletions
@@ -6,6 +6,7 @@ import {
MouseSensor,
TouchSensor,
closestCenter,
useDroppable,
useSensor,
useSensors,
type DragEndEvent,
@@ -61,7 +62,11 @@ import {
} from "@/lib/api/admin";
import { useI18n } from "@/i18n/provider";
import { cn } from "@/lib/utils";
import { findDirectoryParentId, moveDirectoryWithinParent } from "./knowledge-directory-sort";
import {
findDirectoryParentId,
moveDirectoryToParent,
moveDirectoryWithinParent,
} from "./knowledge-directory-sort";
type KnowledgeDirectoryPanelProps = {
knowledgeBaseId: number;
@@ -84,6 +89,7 @@ const DIRECTORY_PANEL_WIDTH_STORAGE_KEY = "knowledge-directory-panel-width";
const DIRECTORY_PANEL_MIN_WIDTH = 180;
const DIRECTORY_PANEL_MAX_WIDTH = 360;
const DIRECTORY_PANEL_DEFAULT_WIDTH = 224;
const ROOT_DIRECTORY_DROP_ID = "knowledge-directory-root";
function rootDirectoryOptions(items: KnowledgeDirectory[]): DirectoryOption[] {
return items.map((item) => ({ value: String(item.id), label: item.name }));
@@ -305,14 +311,29 @@ export function KnowledgeDirectoryPanel({
return;
}
const activeId = Number(active.id);
if (!Number.isFinite(activeId)) {
return;
}
if (over.id === ROOT_DIRECTORY_DROP_ID) {
await handleDirectoryMoveToParent(activeId, 0);
return;
}
const overId = Number(over.id);
if (!Number.isFinite(activeId) || !Number.isFinite(overId)) {
if (!Number.isFinite(overId)) {
return;
}
const activeParentId = findDirectoryParentId(directories, activeId);
const overParentId = findDirectoryParentId(directories, overId);
if (activeParentId === null || overParentId === null || activeParentId !== overParentId) {
if (activeParentId === null || overParentId === null) {
return;
}
if (activeParentId !== overParentId) {
await handleDirectoryMoveToParent(activeId, overId);
return;
}
if (activeParentId === 0 && overParentId === 0 && event.delta.x > 24) {
await handleDirectoryMoveToParent(activeId, overId);
return;
}
@@ -341,6 +362,42 @@ export function KnowledgeDirectoryPanel({
}
}
async function handleDirectoryMoveToParent(activeId: number, targetParentId: number) {
const previousDirectories = directories;
const moved = moveDirectoryToParent(previousDirectories, activeId, targetParentId);
if (!moved.changed || !moved.item) {
return;
}
setDirectories(moved.items);
if (moved.parentId > 0) {
setExpandedIds((current) => new Set(current).add(moved.parentId));
}
setSorting(true);
try {
await updateKnowledgeDirectory({
id: moved.item.id,
knowledgeBaseId,
parentId: moved.parentId,
name: moved.item.name,
remark: moved.item.remark || "",
});
await updateKnowledgeDirectorySort({
knowledgeBaseId,
parentId: moved.parentId,
ids: moved.orderedIds,
});
toast.success(t("knowledge.directoryMoved"));
await loadDirectories();
onChanged?.();
} catch (error) {
setDirectories(previousDirectories);
toast.error(error instanceof Error ? error.message : t("knowledge.directoryMoveFailed"));
} finally {
setSorting(false);
}
}
return (
<>
<div
@@ -368,17 +425,16 @@ export function KnowledgeDirectoryPanel({
selected={selectedDirectoryId === null}
onClick={() => onSelectDirectory(null)}
/>
<DirectoryStaticRow
icon={<FolderIcon className="size-4 text-muted-foreground" />}
label={t("knowledge.rootContent")}
selected={selectedDirectoryId === 0}
onClick={() => onSelectDirectory(0)}
/>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(event) => void handleDirectoryDragEnd(event)}
>
<RootDirectoryRow
selected={selectedDirectoryId === 0}
onClick={() => onSelectDirectory(0)}
t={t}
/>
<SortableContext
items={directories.map((item) => item.id)}
strategy={verticalListSortingStrategy}
@@ -490,16 +546,18 @@ type DirectoryStaticRowProps = {
icon: ReactNode;
label: string;
selected: boolean;
draggingOver?: boolean;
onClick: () => void;
};
function DirectoryStaticRow({ icon, label, selected, onClick }: DirectoryStaticRowProps) {
function DirectoryStaticRow({ icon, label, selected, draggingOver, onClick }: DirectoryStaticRowProps) {
return (
<button
type="button"
className={cn(
"flex w-full items-center gap-1 px-2 py-1.5 text-left text-sm hover:bg-accent",
selected && "bg-accent text-accent-foreground",
draggingOver && "bg-primary/10 text-accent-foreground",
)}
onClick={onClick}
>
@@ -510,6 +568,30 @@ function DirectoryStaticRow({ icon, label, selected, onClick }: DirectoryStaticR
);
}
type RootDirectoryRowProps = {
selected: boolean;
onClick: () => void;
t: TFunction;
};
function RootDirectoryRow({ selected, onClick, t }: RootDirectoryRowProps) {
const { isOver, setNodeRef } = useDroppable({
id: ROOT_DIRECTORY_DROP_ID,
});
return (
<div ref={setNodeRef}>
<DirectoryStaticRow
icon={<FolderIcon className="size-4 text-muted-foreground" />}
label={t("knowledge.rootContent")}
selected={selected}
draggingOver={isOver}
onClick={onClick}
/>
</div>
);
}
type DirectoryNodeProps = {
item: KnowledgeDirectory;
depth: number;
@@ -73,4 +73,48 @@ describe("knowledge directory sorting", () => {
assert.equal(next.items, tree)
assert.deepEqual(plain(next.orderedIds), [])
})
it("moves a child directory back to the root level", async () => {
const { moveDirectoryToParent } = await loadModule()
const next = moveDirectoryToParent(tree, 11, 0)
assert.equal(next.changed, true)
assert.equal(next.parentId, 0)
assert.equal(next.item?.parentId, 0)
assert.deepEqual(plain(next.items).map((item) => item.id), [1, 2, 3, 11])
assert.deepEqual(plain(next.items[0].children).map((item) => item.id), [12])
assert.deepEqual(plain(next.orderedIds), [1, 2, 3, 11])
})
it("moves a root directory without children under another root directory", async () => {
const { moveDirectoryToParent } = await loadModule()
const next = moveDirectoryToParent(tree, 3, 2)
assert.equal(next.changed, true)
assert.equal(next.parentId, 2)
assert.equal(next.item?.parentId, 2)
assert.deepEqual(plain(next.items).map((item) => item.id), [1, 2])
assert.deepEqual(plain(next.items[1].children).map((item) => item.id), [3])
assert.deepEqual(plain(next.orderedIds), [3])
})
it("rejects moving a directory with children under another directory", async () => {
const { moveDirectoryToParent } = await loadModule()
const next = moveDirectoryToParent(tree, 1, 2)
assert.equal(next.changed, false)
assert.equal(next.items, tree)
})
it("rejects moving a directory under a child directory", async () => {
const { moveDirectoryToParent } = await loadModule()
const next = moveDirectoryToParent(tree, 3, 11)
assert.equal(next.changed, false)
assert.equal(next.items, tree)
})
})
@@ -11,6 +11,11 @@ export type MoveDirectoryResult<T extends SortableKnowledgeDirectory> = {
orderedIds: number[]
}
export type MoveDirectoryToParentResult<T extends SortableKnowledgeDirectory> =
MoveDirectoryResult<T> & {
item: T | null
}
function arrayMove<T>(items: T[], fromIndex: number, toIndex: number) {
const next = [...items]
const [item] = next.splice(fromIndex, 1)
@@ -34,6 +39,64 @@ export function findDirectoryParentId<T extends SortableKnowledgeDirectory>(
return null
}
export function findDirectory<T extends SortableKnowledgeDirectory>(
items: T[],
id: number,
): T | null {
for (const item of items) {
if (item.id === id) {
return item
}
const child = findDirectory(item.children as T[] | undefined ?? [], id)
if (child !== null) {
return child
}
}
return null
}
export function moveDirectoryToParent<T extends SortableKnowledgeDirectory>(
items: T[],
activeId: number,
targetParentId: number,
): MoveDirectoryToParentResult<T> {
if (activeId === targetParentId) {
return unchangedParentMove(items, targetParentId)
}
const activeItem = findDirectory(items, activeId)
const activeParentId = findDirectoryParentId(items, activeId)
if (!activeItem || activeParentId === null || activeParentId === targetParentId) {
return unchangedParentMove(items, targetParentId)
}
if (targetParentId > 0) {
const targetParent = findDirectory(items, targetParentId)
if (!targetParent || targetParent.parentId !== 0 || (activeItem.children?.length ?? 0) > 0) {
return unchangedParentMove(items, targetParentId)
}
}
const removed = removeDirectory(items, activeId)
if (!removed.item) {
return unchangedParentMove(items, targetParentId)
}
const movedItem = { ...removed.item, parentId: targetParentId } as T
const appended = appendDirectoryToParent(removed.items, targetParentId, movedItem)
if (!appended.changed) {
return unchangedParentMove(items, targetParentId)
}
return {
items: appended.items,
changed: true,
parentId: targetParentId,
orderedIds: appended.siblings.map((item) => item.id),
item: movedItem,
}
}
export function moveDirectoryWithinParent<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
@@ -76,6 +139,78 @@ export function moveDirectoryWithinParent<T extends SortableKnowledgeDirectory>(
}
}
function unchangedParentMove<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
): MoveDirectoryToParentResult<T> {
return { items, changed: false, parentId, orderedIds: [], item: null }
}
function removeDirectory<T extends SortableKnowledgeDirectory>(
items: T[],
id: number,
): { items: T[]; item: T | null } {
let removedItem: T | null = null
const nextItems: T[] = []
for (const item of items) {
if (item.id === id) {
removedItem = item
continue
}
if (item.children?.length) {
const removed = removeDirectory(item.children as T[], id)
if (removed.item) {
removedItem = removed.item
nextItems.push({ ...item, children: removed.items } as T)
continue
}
}
nextItems.push(item)
}
return {
items: removedItem ? nextItems : items,
item: removedItem,
}
}
function appendDirectoryToParent<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
item: T,
): { items: T[]; changed: boolean; siblings: T[] } {
if (parentId === 0) {
const siblings = [...items, item]
return { items: siblings, changed: true, siblings }
}
let changed = false
let siblings: T[] = []
const nextItems = items.map((current) => {
if (current.id === parentId) {
siblings = [...((current.children as T[] | undefined) ?? []), item]
changed = true
return { ...current, children: siblings } as T
}
if (current.children?.length) {
const appended = appendDirectoryToParent(current.children as T[], parentId, item)
if (appended.changed) {
changed = true
siblings = appended.siblings
return { ...current, children: appended.items } as T
}
}
return current
})
return {
items: changed ? nextItems : items,
changed,
siblings,
}
}
function moveSiblingList<T extends SortableKnowledgeDirectory>(
items: T[],
parentId: number,
+2
View File
@@ -1804,6 +1804,8 @@
"directoryDeleted": "Directory deleted: {name}",
"directorySaveFailed": "Could not save the directory.",
"directoryDeleteFailed": "Could not delete the directory.",
"directoryMoved": "Directory moved.",
"directoryMoveFailed": "Could not move directory.",
"directorySortUpdated": "Directory order updated.",
"directorySortUpdateFailed": "Could not update directory order.",
"directoryNameRequired": "Directory name is required.",
+2
View File
@@ -1804,6 +1804,8 @@
"directoryDeleted": "已删除目录:{name}",
"directorySaveFailed": "保存目录失败",
"directoryDeleteFailed": "删除目录失败",
"directoryMoved": "目录已移动",
"directoryMoveFailed": "移动目录失败",
"directorySortUpdated": "目录排序已更新",
"directorySortUpdateFailed": "更新目录排序失败",
"directoryNameRequired": "目录名称不能为空",