feat: implement directory move functionality and add localization for move actions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -1804,6 +1804,8 @@
|
||||
"directoryDeleted": "已删除目录:{name}",
|
||||
"directorySaveFailed": "保存目录失败",
|
||||
"directoryDeleteFailed": "删除目录失败",
|
||||
"directoryMoved": "目录已移动",
|
||||
"directoryMoveFailed": "移动目录失败",
|
||||
"directorySortUpdated": "目录排序已更新",
|
||||
"directorySortUpdateFailed": "更新目录排序失败",
|
||||
"directoryNameRequired": "目录名称不能为空",
|
||||
|
||||
Reference in New Issue
Block a user