fix(file-tree): cleanup based on given path not what's visible
This commit is contained in:
parent
745f5ea93a
commit
8a88d2c04f
2 changed files with 68 additions and 35 deletions
|
|
@ -1,15 +1,16 @@
|
||||||
|
/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */
|
||||||
import { expect, test, describe } from "bun:test";
|
import { expect, test, describe } from "bun:test";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { FileTree, type FileEntry } from "../file-tree";
|
import { FileTree, type FileEntry } from "../file-tree";
|
||||||
|
|
||||||
describe("FileTree Selection Logic", () => {
|
describe("FileTree Selection Logic", () => {
|
||||||
const immichFiles: FileEntry[] = [
|
const testFiles: FileEntry[] = [
|
||||||
{ name: "immich", path: "/immich", type: "folder" },
|
{ name: "root", path: "/root", type: "folder" },
|
||||||
{ name: "immich_photos", path: "/immich/immich_photos", type: "folder" },
|
{ name: "photos", path: "/root/photos", type: "folder" },
|
||||||
{ name: "backups", path: "/immich/immich_photos/backups", type: "folder" },
|
{ name: "backups", path: "/root/photos/backups", type: "folder" },
|
||||||
{ name: "library", path: "/immich/immich_photos/library", type: "folder" },
|
{ name: "library", path: "/root/photos/library", type: "folder" },
|
||||||
{ name: "profile", path: "/immich/immich_photos/profile", type: "folder" },
|
{ name: "profile", path: "/root/photos/profile", type: "folder" },
|
||||||
{ name: "upload", path: "/immich/immich_photos/upload", type: "folder" },
|
{ name: "upload", path: "/root/photos/upload", type: "folder" },
|
||||||
];
|
];
|
||||||
|
|
||||||
test("selecting a folder simplifies to parent if it's the only child", async () => {
|
test("selecting a folder simplifies to parent if it's the only child", async () => {
|
||||||
|
|
@ -20,50 +21,48 @@ describe("FileTree Selection Logic", () => {
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<FileTree
|
<FileTree
|
||||||
files={immichFiles}
|
files={testFiles}
|
||||||
withCheckboxes={true}
|
withCheckboxes={true}
|
||||||
selectedPaths={currentSelection}
|
selectedPaths={currentSelection}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
expandedFolders={new Set(immichFiles.map((f) => f.path))}
|
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const immichPhotosCheckbox = screen
|
const photosCheckbox = screen.getByText("photos").parentElement?.querySelector('button[role="checkbox"]');
|
||||||
.getByText("immich_photos")
|
expect(photosCheckbox).toBeTruthy();
|
||||||
.parentElement?.querySelector('button[role="checkbox"]');
|
|
||||||
expect(immichPhotosCheckbox).toBeTruthy();
|
|
||||||
|
|
||||||
fireEvent.click(immichPhotosCheckbox!);
|
fireEvent.click(photosCheckbox!);
|
||||||
|
|
||||||
expect(currentSelection.has("/immich")).toBe(true);
|
expect(currentSelection.has("/root")).toBe(true);
|
||||||
expect(currentSelection.size).toBe(1);
|
expect(currentSelection.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("unselecting a child removes the parent from selection", async () => {
|
test("unselecting a child removes the parent from selection", async () => {
|
||||||
let currentSelection = new Set<string>(["/immich"]);
|
let currentSelection = new Set<string>(["/root"]);
|
||||||
const onSelectionChange = (selection: Set<string>) => {
|
const onSelectionChange = (selection: Set<string>) => {
|
||||||
currentSelection = selection;
|
currentSelection = selection;
|
||||||
};
|
};
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<FileTree
|
<FileTree
|
||||||
files={immichFiles}
|
files={testFiles}
|
||||||
withCheckboxes={true}
|
withCheckboxes={true}
|
||||||
selectedPaths={currentSelection}
|
selectedPaths={currentSelection}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
expandedFolders={new Set(immichFiles.map((f) => f.path))}
|
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]');
|
const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]');
|
||||||
fireEvent.click(libraryCheckbox!);
|
fireEvent.click(libraryCheckbox!);
|
||||||
|
|
||||||
expect(currentSelection.has("/immich")).toBe(false);
|
expect(currentSelection.has("/root")).toBe(false);
|
||||||
expect(currentSelection.has("/immich/immich_photos")).toBe(false);
|
expect(currentSelection.has("/root/photos")).toBe(false);
|
||||||
|
|
||||||
expect(currentSelection.has("/immich/immich_photos/backups")).toBe(true);
|
expect(currentSelection.has("/root/photos/backups")).toBe(true);
|
||||||
expect(currentSelection.has("/immich/immich_photos/profile")).toBe(true);
|
expect(currentSelection.has("/root/photos/profile")).toBe(true);
|
||||||
expect(currentSelection.has("/immich/immich_photos/upload")).toBe(true);
|
expect(currentSelection.has("/root/photos/upload")).toBe(true);
|
||||||
expect(currentSelection.size).toBe(3);
|
expect(currentSelection.size).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -75,11 +74,11 @@ describe("FileTree Selection Logic", () => {
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<FileTree
|
<FileTree
|
||||||
files={immichFiles}
|
files={testFiles}
|
||||||
withCheckboxes={true}
|
withCheckboxes={true}
|
||||||
selectedPaths={currentSelection}
|
selectedPaths={currentSelection}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
expandedFolders={new Set(immichFiles.map((f) => f.path))}
|
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -91,16 +90,16 @@ describe("FileTree Selection Logic", () => {
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<FileTree
|
<FileTree
|
||||||
files={immichFiles}
|
files={testFiles}
|
||||||
withCheckboxes={true}
|
withCheckboxes={true}
|
||||||
selectedPaths={currentSelection}
|
selectedPaths={currentSelection}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
expandedFolders={new Set(immichFiles.map((f) => f.path))}
|
expandedFolders={new Set(testFiles.map((f) => f.path))}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(currentSelection.has("/immich")).toBe(true);
|
expect(currentSelection.has("/root")).toBe(true);
|
||||||
expect(currentSelection.size).toBe(1);
|
expect(currentSelection.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -133,4 +132,35 @@ describe("FileTree Selection Logic", () => {
|
||||||
expect(currentSelection.has("/root")).toBe(false);
|
expect(currentSelection.has("/root")).toBe(false);
|
||||||
expect(currentSelection.size).toBe(1);
|
expect(currentSelection.size).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("simplifies existing deep paths when parent is selected", async () => {
|
||||||
|
const files: FileEntry[] = [
|
||||||
|
{ name: "hello", path: "/hello", type: "folder" },
|
||||||
|
{ name: "hello_prev", path: "/hello_prev", type: "folder" },
|
||||||
|
{ name: "service", path: "/service", type: "folder" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentSelection = new Set<string>(["/hello", "/hello_prev", "/service/app/data/upload"]);
|
||||||
|
const onSelectionChange = (selection: Set<string>) => {
|
||||||
|
currentSelection = selection;
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FileTree
|
||||||
|
files={files}
|
||||||
|
withCheckboxes={true}
|
||||||
|
selectedPaths={currentSelection}
|
||||||
|
onSelectionChange={onSelectionChange}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const serviceCheckbox = screen.getByText("service").parentElement?.querySelector('button[role="checkbox"]');
|
||||||
|
expect(serviceCheckbox).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(serviceCheckbox!);
|
||||||
|
|
||||||
|
expect(currentSelection.has("/service")).toBe(true);
|
||||||
|
expect(currentSelection.has("/service/app/data/upload")).toBe(false);
|
||||||
|
expect(currentSelection.size).toBe(3); // /hello, /hello_prev, /service
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -116,13 +116,15 @@ export const FileTree = memo((props: Props) => {
|
||||||
// Add new folders to collapsed set when file list changes
|
// Add new folders to collapsed set when file list changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCollapsedFolders((prevSet) => {
|
setCollapsedFolders((prevSet) => {
|
||||||
|
let hasChanges = false;
|
||||||
const newSet = new Set(prevSet);
|
const newSet = new Set(prevSet);
|
||||||
for (const item of fileList) {
|
for (const item of fileList) {
|
||||||
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
|
if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) {
|
||||||
newSet.add(item.fullPath);
|
newSet.add(item.fullPath);
|
||||||
|
hasChanges = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return newSet;
|
return hasChanges ? newSet : prevSet;
|
||||||
});
|
});
|
||||||
}, [fileList, expandedFolders]);
|
}, [fileList, expandedFolders]);
|
||||||
|
|
||||||
|
|
@ -149,9 +151,9 @@ export const FileTree = memo((props: Props) => {
|
||||||
newSelection.add(path);
|
newSelection.add(path);
|
||||||
|
|
||||||
// Remove any descendants from selection since parent now covers them
|
// Remove any descendants from selection since parent now covers them
|
||||||
for (const item of fileList) {
|
for (const selectedPath of newSelection) {
|
||||||
if (item.fullPath.startsWith(`${path}/`)) {
|
if (selectedPath.startsWith(`${path}/`)) {
|
||||||
newSelection.delete(item.fullPath);
|
newSelection.delete(selectedPath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -488,8 +490,9 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
||||||
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
|
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
// biome-ignore lint/a11y/noStaticElementInteractions: we handle click and hover manually
|
||||||
type="button"
|
// biome-ignore lint/a11y/useKeyWithClickEvents: we handle click and hover manually
|
||||||
|
<div
|
||||||
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
||||||
style={{ paddingLeft }}
|
style={{ paddingLeft }}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
@ -497,7 +500,7 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue