From 013fe5a521605fff03320354faa50463f0d07bd9 Mon Sep 17 00:00:00 2001 From: Nico <47644445+nicotsx@users.noreply.github.com> Date: Fri, 2 Jan 2026 16:56:13 +0100 Subject: [PATCH] Fix/file tree selection parents (#276) * fix(file-tree): propagate path simplification to parents When all childs of a folder are selected in the tree, the selected path was only simplifiyng one level. But if the reduction was also making its own parent "fully selected" it was not correctly reporting only the parent as selected * test(file-tree): ensure path selection correctness * fix(file-tree): cleanup based on given path not what's visible --- .../components/__test__/file-tree.test.tsx | 166 ++++++++++++++++++ app/client/components/file-tree.tsx | 90 +++++----- app/client/components/volume-file-browser.tsx | 6 +- app/test/setup-client.ts | 4 + bun.lock | 47 ++++- package.json | 7 +- 6 files changed, 274 insertions(+), 46 deletions(-) create mode 100644 app/client/components/__test__/file-tree.test.tsx create mode 100644 app/test/setup-client.ts diff --git a/app/client/components/__test__/file-tree.test.tsx b/app/client/components/__test__/file-tree.test.tsx new file mode 100644 index 00000000..7589fe63 --- /dev/null +++ b/app/client/components/__test__/file-tree.test.tsx @@ -0,0 +1,166 @@ +/** biome-ignore-all lint/style/noNonNullAssertion: Testing file - non-null assertions are acceptable here */ +import { expect, test, describe } from "bun:test"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { FileTree, type FileEntry } from "../file-tree"; + +describe("FileTree Selection Logic", () => { + const testFiles: FileEntry[] = [ + { name: "root", path: "/root", type: "folder" }, + { name: "photos", path: "/root/photos", type: "folder" }, + { name: "backups", path: "/root/photos/backups", type: "folder" }, + { name: "library", path: "/root/photos/library", type: "folder" }, + { name: "profile", path: "/root/photos/profile", type: "folder" }, + { name: "upload", path: "/root/photos/upload", type: "folder" }, + ]; + + test("selecting a folder simplifies to parent if it's the only child", async () => { + let currentSelection = new Set(); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + f.path))} + />, + ); + + const photosCheckbox = screen.getByText("photos").parentElement?.querySelector('button[role="checkbox"]'); + expect(photosCheckbox).toBeTruthy(); + + fireEvent.click(photosCheckbox!); + + expect(currentSelection.has("/root")).toBe(true); + expect(currentSelection.size).toBe(1); + }); + + test("unselecting a child removes the parent from selection", async () => { + let currentSelection = new Set(["/root"]); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + f.path))} + />, + ); + + const libraryCheckbox = screen.getByText("library").parentElement?.querySelector('button[role="checkbox"]'); + fireEvent.click(libraryCheckbox!); + + expect(currentSelection.has("/root")).toBe(false); + expect(currentSelection.has("/root/photos")).toBe(false); + + expect(currentSelection.has("/root/photos/backups")).toBe(true); + expect(currentSelection.has("/root/photos/profile")).toBe(true); + expect(currentSelection.has("/root/photos/upload")).toBe(true); + expect(currentSelection.size).toBe(3); + }); + + test("recursive simplification when all children are selected", async () => { + let currentSelection = new Set(); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + const { rerender } = render( + f.path))} + />, + ); + + const children = ["backups", "library", "profile", "upload"]; + + for (const name of children) { + const checkbox = screen.getByText(name).parentElement?.querySelector('button[role="checkbox"]'); + fireEvent.click(checkbox!); + + rerender( + f.path))} + />, + ); + } + + expect(currentSelection.has("/root")).toBe(true); + expect(currentSelection.size).toBe(1); + }); + + test("does not simplify to parent if not all children are selected", async () => { + const multipleFiles: FileEntry[] = [ + { name: "root", path: "/root", type: "folder" }, + { name: "child1", path: "/root/child1", type: "folder" }, + { name: "child2", path: "/root/child2", type: "folder" }, + ]; + + let currentSelection = new Set(); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + f.path))} + />, + ); + + const child1Checkbox = screen.getByText("child1").parentElement?.querySelector('button[role="checkbox"]'); + fireEvent.click(child1Checkbox!); + + expect(currentSelection.has("/root/child1")).toBe(true); + expect(currentSelection.has("/root")).toBe(false); + 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(["/hello", "/hello_prev", "/service/app/data/upload"]); + const onSelectionChange = (selection: Set) => { + currentSelection = selection; + }; + + render( + , + ); + + 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 + }); +}); diff --git a/app/client/components/file-tree.tsx b/app/client/components/file-tree.tsx index 7789c821..fa9559c2 100644 --- a/app/client/components/file-tree.tsx +++ b/app/client/components/file-tree.tsx @@ -116,13 +116,15 @@ export const FileTree = memo((props: Props) => { // Add new folders to collapsed set when file list changes useEffect(() => { setCollapsedFolders((prevSet) => { + let hasChanges = false; const newSet = new Set(prevSet); for (const item of fileList) { if (item.kind === "folder" && !newSet.has(item.fullPath) && !expandedFolders.has(item.fullPath)) { newSet.add(item.fullPath); + hasChanges = true; } } - return newSet; + return hasChanges ? newSet : prevSet; }); }, [fileList, expandedFolders]); @@ -149,9 +151,9 @@ export const FileTree = memo((props: Props) => { newSelection.add(path); // Remove any descendants from selection since parent now covers them - for (const item of fileList) { - if (item.fullPath.startsWith(`${path}/`)) { - newSelection.delete(item.fullPath); + for (const selectedPath of newSelection) { + if (selectedPath.startsWith(`${path}/`)) { + newSelection.delete(selectedPath); } } } else { @@ -182,7 +184,8 @@ export const FileTree = memo((props: Props) => { if ( item.fullPath.startsWith(`${selectedParentPath}/`) && !item.fullPath.startsWith(`${path}/`) && - item.fullPath !== path + item.fullPath !== path && + !path.startsWith(`${item.fullPath}/`) ) { newSelection.add(item.fullPath); } @@ -190,39 +193,45 @@ export const FileTree = memo((props: Props) => { } } - const childrenByParent = new Map(); - for (const selectedPath of newSelection) { - const lastSlashIndex = selectedPath.lastIndexOf("/"); - if (lastSlashIndex > 0) { - const parentPath = selectedPath.slice(0, lastSlashIndex); - if (!childrenByParent.has(parentPath)) { - childrenByParent.set(parentPath, []); - } - childrenByParent.get(parentPath)?.push(selectedPath); - } - } - - // For each parent, check if all its children are selected - for (const [parentPath, selectedChildren] of childrenByParent.entries()) { - // Get all children of this parent from the file list - const allChildren = fileList.filter((item) => { - const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/")); - return itemParentPath === parentPath; - }); - - // If all children are selected, replace them with the parent - if (allChildren.length > 0 && selectedChildren.length === allChildren.length) { - // Check that we have every child - const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath)); - const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c)); - - if (allChildrenSelected) { - // Remove all children - for (const childPath of selectedChildren) { - newSelection.delete(childPath); + let changed = true; + while (changed) { + changed = false; + const childrenByParent = new Map(); + for (const selectedPath of newSelection) { + const lastSlashIndex = selectedPath.lastIndexOf("/"); + if (lastSlashIndex > 0) { + const parentPath = selectedPath.slice(0, lastSlashIndex); + if (!childrenByParent.has(parentPath)) { + childrenByParent.set(parentPath, []); + } + childrenByParent.get(parentPath)?.push(selectedPath); + } + } + + // For each parent, check if all its children are selected + for (const [parentPath, selectedChildren] of childrenByParent.entries()) { + // Get all children of this parent from the file list + const allChildren = fileList.filter((item) => { + const itemParentPath = item.fullPath.slice(0, item.fullPath.lastIndexOf("/")); + return itemParentPath === parentPath; + }); + + // If all children are selected, replace them with the parent + if (allChildren.length > 0 && selectedChildren.length === allChildren.length) { + // Check that we have every child + const allChildrenPaths = new Set(allChildren.map((c) => c.fullPath)); + const allChildrenSelected = selectedChildren.every((c) => allChildrenPaths.has(c)); + + if (allChildrenSelected) { + // Remove all children + for (const childPath of selectedChildren) { + newSelection.delete(childPath); + } + // Add the parent + newSelection.add(parentPath); + changed = true; + break; } - // Add the parent - newSelection.add(parentPath); } } } @@ -481,8 +490,9 @@ const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, childr const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]); return ( - + ); }); diff --git a/app/client/components/volume-file-browser.tsx b/app/client/components/volume-file-browser.tsx index 84ea09e5..6e9cd53e 100644 --- a/app/client/components/volume-file-browser.tsx +++ b/app/client/components/volume-file-browser.tsx @@ -57,7 +57,7 @@ export const VolumeFileBrowser = ({ if (fileBrowser.isLoading) { return ( -
+

Loading files...

); @@ -65,7 +65,7 @@ export const VolumeFileBrowser = ({ if (error) { return ( -
+

Failed to load files: {(error as Error).message}

); @@ -73,7 +73,7 @@ export const VolumeFileBrowser = ({ if (fileBrowser.isEmpty) { return ( -
+

{emptyMessage}

{emptyDescription &&

{emptyDescription}

} diff --git a/app/test/setup-client.ts b/app/test/setup-client.ts new file mode 100644 index 00000000..f737ee3e --- /dev/null +++ b/app/test/setup-client.ts @@ -0,0 +1,4 @@ +import "./setup.ts"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +GlobalRegistrator.register(); diff --git a/bun.lock b/bun.lock index 73092752..936ed491 100644 --- a/bun.lock +++ b/bun.lock @@ -61,10 +61,13 @@ "devDependencies": { "@biomejs/biome": "^2.3.8", "@faker-js/faker": "^10.1.0", + "@happy-dom/global-registrator": "^20.0.11", "@hey-api/openapi-ts": "^0.88.0", "@react-router/dev": "^7.10.0", "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query-devtools": "^5.91.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.1", "@types/bun": "^1.3.4", "@types/node": "^25.0.3", "@types/react": "^19.2.7", @@ -140,6 +143,8 @@ "@babel/preset-typescript": ["@babel/preset-typescript@7.28.5", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.28.5" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g=="], + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], @@ -244,6 +249,8 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="], "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-oS+5yAdwnK20lSeFO1d53Ku+yaGCsY8PcrmSq2GtSs3bsBfRnHAbpPKSVzQcaxAOrzj5NB+f34WhZglVrNayBA=="], @@ -494,6 +501,12 @@ "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.1", "", { "dependencies": { "@tanstack/query-devtools": "5.91.1" }, "peerDependencies": { "@tanstack/react-query": "^5.90.10", "react": "^18 || ^19" } }, "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/react": ["@testing-library/react@16.3.1", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-gr4KtAWqIOQoucWYD/f6ki+j5chXfcPc74Col/6poTyqTmn7zRmodWahWRCp8tYd+GMqBonw6hstNzqjbs6gjw=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], @@ -528,13 +541,15 @@ "@types/use-sync-external-store": ["@types/use-sync-external-store@0.0.6", "", {}, "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + "accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], @@ -544,6 +559,8 @@ "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + "arkregex": ["arkregex@0.0.5", "", { "dependencies": { "@ark/util": "0.56.0" } }, "sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw=="], "arktype": ["arktype@2.1.29", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-jyfKk4xIOzvYNayqnD8ZJQqOwcrTOUbIU4293yrzAjA3O1dWh61j71ArMQ6tS/u4pD7vabSPe7nG3RCyoXW6RQ=="], @@ -676,6 +693,8 @@ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], @@ -686,6 +705,8 @@ "dither-plugin": ["dither-plugin@1.1.1", "", {}, "sha512-PsgAcSoNVKkwh+Q/OopRn/2qb9HW1LRyGqT1bQe8iooYvVY1FIIqePFN9JkEIVK9rkfZdj7nXn9EUB4B7mNh6g=="], + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "dotenv-cli": ["dotenv-cli@11.0.0", "", { "dependencies": { "cross-spawn": "^7.0.6", "dotenv": "^17.1.0", "dotenv-expand": "^12.0.0", "minimist": "^1.2.6" }, "bin": { "dotenv": "cli.js" } }, "sha512-r5pA8idbk7GFWuHEU7trSTflWcdBpQEK+Aw17UrSHjS6CReuhrrPcyC3zcQBPQvhArRHnBo/h6eLH1fkCvNlww=="], @@ -778,6 +799,8 @@ "h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="], + "happy-dom": ["happy-dom@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "@types/whatwg-mimetype": "^3.0.2", "whatwg-mimetype": "^3.0.0" } }, "sha512-QsCdAUHAmiDeKeaNojb1OHOPF7NjcWPBR7obdu3NwH2a/oyQaLg5d0aaCy/9My6CdPChYF07dvz5chaXBGaD4g=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], @@ -864,6 +887,8 @@ "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="], + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -948,6 +973,8 @@ "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], @@ -1124,6 +1151,8 @@ "vite-tsconfig-paths": ["vite-tsconfig-paths@6.0.3", "", { "dependencies": { "debug": "^4.1.1", "globrex": "^0.1.2", "tsconfck": "^3.0.3" }, "peerDependencies": { "vite": "*" }, "optionalPeers": ["vite"] }, "sha512-7bL7FPX/DSviaZGYUKowWF1AiDVWjMjxNbE8lyaVGDezkedWqfGhlnQ4BZXre0ZN5P4kAgIJfAlgFDVyjrCIyg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "winston": ["winston@3.19.0", "", { "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", "one-time": "^1.0.0", "readable-stream": "^3.4.0", "safe-stable-stringify": "^2.3.1", "stack-trace": "0.0.x", "triple-beam": "^1.3.0", "winston-transport": "^4.9.0" } }, "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA=="], @@ -1150,6 +1179,8 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@happy-dom/global-registrator/@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="], + "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], @@ -1210,6 +1241,8 @@ "giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "happy-dom/@types/node": ["@types/node@20.19.27", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug=="], + "mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "morgan/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -1220,14 +1253,22 @@ "pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pretty-format/react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], "react-router-hono-server/@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + "vite-node/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "compression/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], @@ -1236,6 +1277,8 @@ "finalhandler/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + "happy-dom/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "morgan/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], diff --git a/package.json b/package.json index 4b6861b1..385dfc3c 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,9 @@ "gen:api-client": "openapi-ts", "gen:migrations": "drizzle-kit generate", "studio": "drizzle-kit studio", - "test": "dotenv -e .env.test -- bun test --preload ./app/test/setup.ts" + "test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts", + "test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts", + "test": "bun run test:server && bun run test:client" }, "overrides": { "esbuild": "^0.27.2" @@ -79,10 +81,13 @@ "devDependencies": { "@biomejs/biome": "^2.3.8", "@faker-js/faker": "^10.1.0", + "@happy-dom/global-registrator": "^20.0.11", "@hey-api/openapi-ts": "^0.88.0", "@react-router/dev": "^7.10.0", "@tailwindcss/vite": "^4.1.17", "@tanstack/react-query-devtools": "^5.91.1", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.1", "@types/bun": "^1.3.4", "@types/node": "^25.0.3", "@types/react": "^19.2.7",