feat: implement directory move functionality and add localization for move actions
This commit is contained in:
@@ -6,6 +6,7 @@ import {
|
|||||||
MouseSensor,
|
MouseSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
|
useDroppable,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
type DragEndEvent,
|
type DragEndEvent,
|
||||||
@@ -61,7 +62,11 @@ import {
|
|||||||
} from "@/lib/api/admin";
|
} from "@/lib/api/admin";
|
||||||
import { useI18n } from "@/i18n/provider";
|
import { useI18n } from "@/i18n/provider";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { findDirectoryParentId, moveDirectoryWithinParent } from "./knowledge-directory-sort";
|
import {
|
||||||
|
findDirectoryParentId,
|
||||||
|
moveDirectoryToParent,
|
||||||
|
moveDirectoryWithinParent,
|
||||||
|
} from "./knowledge-directory-sort";
|
||||||
|
|
||||||
type KnowledgeDirectoryPanelProps = {
|
type KnowledgeDirectoryPanelProps = {
|
||||||
knowledgeBaseId: number;
|
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_MIN_WIDTH = 180;
|
||||||
const DIRECTORY_PANEL_MAX_WIDTH = 360;
|
const DIRECTORY_PANEL_MAX_WIDTH = 360;
|
||||||
const DIRECTORY_PANEL_DEFAULT_WIDTH = 224;
|
const DIRECTORY_PANEL_DEFAULT_WIDTH = 224;
|
||||||
|
const ROOT_DIRECTORY_DROP_ID = "knowledge-directory-root";
|
||||||
|
|
||||||
function rootDirectoryOptions(items: KnowledgeDirectory[]): DirectoryOption[] {
|
function rootDirectoryOptions(items: KnowledgeDirectory[]): DirectoryOption[] {
|
||||||
return items.map((item) => ({ value: String(item.id), label: item.name }));
|
return items.map((item) => ({ value: String(item.id), label: item.name }));
|
||||||
@@ -305,14 +311,29 @@ export function KnowledgeDirectoryPanel({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const activeId = Number(active.id);
|
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);
|
const overId = Number(over.id);
|
||||||
if (!Number.isFinite(activeId) || !Number.isFinite(overId)) {
|
if (!Number.isFinite(overId)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeParentId = findDirectoryParentId(directories, activeId);
|
const activeParentId = findDirectoryParentId(directories, activeId);
|
||||||
const overParentId = findDirectoryParentId(directories, overId);
|
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;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
@@ -368,17 +425,16 @@ export function KnowledgeDirectoryPanel({
|
|||||||
selected={selectedDirectoryId === null}
|
selected={selectedDirectoryId === null}
|
||||||
onClick={() => onSelectDirectory(null)}
|
onClick={() => onSelectDirectory(null)}
|
||||||
/>
|
/>
|
||||||
<DirectoryStaticRow
|
|
||||||
icon={<FolderIcon className="size-4 text-muted-foreground" />}
|
|
||||||
label={t("knowledge.rootContent")}
|
|
||||||
selected={selectedDirectoryId === 0}
|
|
||||||
onClick={() => onSelectDirectory(0)}
|
|
||||||
/>
|
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCenter}
|
collisionDetection={closestCenter}
|
||||||
onDragEnd={(event) => void handleDirectoryDragEnd(event)}
|
onDragEnd={(event) => void handleDirectoryDragEnd(event)}
|
||||||
>
|
>
|
||||||
|
<RootDirectoryRow
|
||||||
|
selected={selectedDirectoryId === 0}
|
||||||
|
onClick={() => onSelectDirectory(0)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={directories.map((item) => item.id)}
|
items={directories.map((item) => item.id)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
@@ -490,16 +546,18 @@ type DirectoryStaticRowProps = {
|
|||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
draggingOver?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function DirectoryStaticRow({ icon, label, selected, onClick }: DirectoryStaticRowProps) {
|
function DirectoryStaticRow({ icon, label, selected, draggingOver, onClick }: DirectoryStaticRowProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full items-center gap-1 px-2 py-1.5 text-left text-sm hover:bg-accent",
|
"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",
|
selected && "bg-accent text-accent-foreground",
|
||||||
|
draggingOver && "bg-primary/10 text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
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 = {
|
type DirectoryNodeProps = {
|
||||||
item: KnowledgeDirectory;
|
item: KnowledgeDirectory;
|
||||||
depth: number;
|
depth: number;
|
||||||
|
|||||||
@@ -73,4 +73,48 @@ describe("knowledge directory sorting", () => {
|
|||||||
assert.equal(next.items, tree)
|
assert.equal(next.items, tree)
|
||||||
assert.deepEqual(plain(next.orderedIds), [])
|
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[]
|
orderedIds: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MoveDirectoryToParentResult<T extends SortableKnowledgeDirectory> =
|
||||||
|
MoveDirectoryResult<T> & {
|
||||||
|
item: T | null
|
||||||
|
}
|
||||||
|
|
||||||
function arrayMove<T>(items: T[], fromIndex: number, toIndex: number) {
|
function arrayMove<T>(items: T[], fromIndex: number, toIndex: number) {
|
||||||
const next = [...items]
|
const next = [...items]
|
||||||
const [item] = next.splice(fromIndex, 1)
|
const [item] = next.splice(fromIndex, 1)
|
||||||
@@ -34,6 +39,64 @@ export function findDirectoryParentId<T extends SortableKnowledgeDirectory>(
|
|||||||
return null
|
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>(
|
export function moveDirectoryWithinParent<T extends SortableKnowledgeDirectory>(
|
||||||
items: T[],
|
items: T[],
|
||||||
parentId: number,
|
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>(
|
function moveSiblingList<T extends SortableKnowledgeDirectory>(
|
||||||
items: T[],
|
items: T[],
|
||||||
parentId: number,
|
parentId: number,
|
||||||
|
|||||||
@@ -1804,6 +1804,8 @@
|
|||||||
"directoryDeleted": "Directory deleted: {name}",
|
"directoryDeleted": "Directory deleted: {name}",
|
||||||
"directorySaveFailed": "Could not save the directory.",
|
"directorySaveFailed": "Could not save the directory.",
|
||||||
"directoryDeleteFailed": "Could not delete the directory.",
|
"directoryDeleteFailed": "Could not delete the directory.",
|
||||||
|
"directoryMoved": "Directory moved.",
|
||||||
|
"directoryMoveFailed": "Could not move directory.",
|
||||||
"directorySortUpdated": "Directory order updated.",
|
"directorySortUpdated": "Directory order updated.",
|
||||||
"directorySortUpdateFailed": "Could not update directory order.",
|
"directorySortUpdateFailed": "Could not update directory order.",
|
||||||
"directoryNameRequired": "Directory name is required.",
|
"directoryNameRequired": "Directory name is required.",
|
||||||
|
|||||||
@@ -1804,6 +1804,8 @@
|
|||||||
"directoryDeleted": "已删除目录:{name}",
|
"directoryDeleted": "已删除目录:{name}",
|
||||||
"directorySaveFailed": "保存目录失败",
|
"directorySaveFailed": "保存目录失败",
|
||||||
"directoryDeleteFailed": "删除目录失败",
|
"directoryDeleteFailed": "删除目录失败",
|
||||||
|
"directoryMoved": "目录已移动",
|
||||||
|
"directoryMoveFailed": "移动目录失败",
|
||||||
"directorySortUpdated": "目录排序已更新",
|
"directorySortUpdated": "目录排序已更新",
|
||||||
"directorySortUpdateFailed": "更新目录排序失败",
|
"directorySortUpdateFailed": "更新目录排序失败",
|
||||||
"directoryNameRequired": "目录名称不能为空",
|
"directoryNameRequired": "目录名称不能为空",
|
||||||
|
|||||||
Reference in New Issue
Block a user