diff --git a/webui/package.json b/webui/package.json index 698ce4d..fe2f219 100644 --- a/webui/package.json +++ b/webui/package.json @@ -21,6 +21,7 @@ "build": "tsc && vite build", "preview": "vite preview --host 127.0.0.1 --port 4173", "audit:i18n-structural": "node scripts/audit-i18n-structural.mjs", + "test:file-drop-zone": "rm -rf .file-drop-test-build && mkdir -p .file-drop-test-build && printf '{\"type\":\"commonjs\"}\\n' > .file-drop-test-build/package.json && tsc -p tsconfig.file-drop-tests.json && node .file-drop-test-build/tests/file-drop-resolver.test.js && node scripts/test-file-drop-zone-structure.mjs", "test:module-capabilities": "rm -rf .module-test-build && mkdir -p .module-test-build && printf '{\"type\":\"commonjs\"}\n' > .module-test-build/package.json && tsc -p tsconfig.module-tests.json && node .module-test-build/tests/module-capabilities.test.js && node .module-test-build/tests/privacy-policy.test.js", "test:module-permutations": "node scripts/test-module-permutations.mjs", "test:mail-components": "rm -rf .component-test-build && mkdir -p .component-test-build && printf '{\"type\":\"commonjs\"}\\n' > .component-test-build/package.json && tsc -p tsconfig.component-tests.json && node .component-test-build/tests/mail-components.test.js" diff --git a/webui/scripts/test-file-drop-zone-structure.mjs b/webui/scripts/test-file-drop-zone-structure.mjs new file mode 100644 index 0000000..1f54660 --- /dev/null +++ b/webui/scripts/test-file-drop-zone-structure.mjs @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; + +const source = readFileSync(new URL("../src/components/FileDropZone.tsx", import.meta.url), "utf8"); +const normalized = source.replace(/\s+/g, " "); + +function assertIncludes(fragment, message) { + if (!normalized.includes(fragment.replace(/\s+/g, " "))) throw new Error(message); +} + +assertIncludes( + "function prepareFileDrag(event: ReactDragEvent) { event.preventDefault(); event.stopPropagation();", + "drag enter/over must prevent the browser default and stop propagation" +); +assertIncludes("onDragEnter={prepareFileDrag}", "drag enter must use the guarded file-drag handler"); +assertIncludes("onDragOver={prepareFileDrag}", "drag over must use the guarded file-drag handler"); +assertIncludes( + "onDragLeave={(event) => { event.preventDefault(); event.stopPropagation();", + "drag leave must prevent the browser default and stop propagation" +); +assertIncludes( + "onDrop={(event) => { event.preventDefault(); event.stopPropagation(); setDragActive(false);", + "drop must prevent the browser default and stop propagation before resolving files" +); +assertIncludes( + "if (interactionDisabled) { onRejectedDrop?.(\"disabled\"); return; }", + "disabled drops must route the disabled rejection reason" +); +assertIncludes( + "void resolveDroppedFiles(event.dataTransfer).then((files) => {", + "drop must route the event DataTransfer through the shared resolver" +); +assertIncludes( + "if (files.length === 0) { onRejectedDrop?.(\"empty\"); return; } void handleFiles(files);", + "resolved files and empty drops must be routed through their explicit callbacks" +); +assertIncludes( + ".catch(() => onRejectedDrop?.(\"unreadable\"))", + "resolver failures must route the unreadable rejection reason" +); + +console.log("FileDropZone event and result routing structure is intact."); diff --git a/webui/src/components/FileDropZone.tsx b/webui/src/components/FileDropZone.tsx index 3a27d70..9fd76d1 100644 --- a/webui/src/components/FileDropZone.tsx +++ b/webui/src/components/FileDropZone.tsx @@ -1,5 +1,8 @@ -import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type ReactNode } from "react"; +import { i18nMessage } from "../i18n/LanguageContext";import { useRef, useState, type CSSProperties, type DragEvent as ReactDragEvent, type ReactNode } from "react"; import { UploadCloud } from "lucide-react"; +import { resolveDroppedFiles } from "./resolveDroppedFiles"; + +type RejectedDropReason = "disabled" | "empty" | "unreadable"; export type FileDropZoneProps = { accept?: string; @@ -14,6 +17,7 @@ export type FileDropZoneProps = { note?: ReactNode; className?: string; inputLabel?: string; + onRejectedDrop?: (reason: RejectedDropReason) => void; onFiles: (files: File[]) => void | Promise; }; @@ -30,6 +34,7 @@ export default function FileDropZone({ note, className = "", inputLabel = "i18n:govoplan-core.drop_files_here_or_click_to_select_files.7eda8608", + onRejectedDrop, onFiles }: FileDropZoneProps) { const inputRef = useRef(null); @@ -52,6 +57,13 @@ export default function FileDropZone({ } } + function prepareFileDrag(event: ReactDragEvent) { + event.preventDefault(); + event.stopPropagation(); + event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy"; + if (!interactionDisabled) setDragActive(true); + } + return ( <>
{ + onDragEnter={prepareFileDrag} + onDragOver={prepareFileDrag} + onDragLeave={(event) => { event.preventDefault(); - if (!interactionDisabled) setDragActive(true); + event.stopPropagation(); + if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget)) return; + setDragActive(false); }} - onDragLeave={() => setDragActive(false)} onDrop={(event) => { event.preventDefault(); + event.stopPropagation(); setDragActive(false); - if (!interactionDisabled) void handleFiles(event.dataTransfer.files); + if (interactionDisabled) { + onRejectedDrop?.("disabled"); + return; + } + void resolveDroppedFiles(event.dataTransfer).then((files) => { + if (files.length === 0) { + onRejectedDrop?.("empty"); + return; + } + void handleFiles(files); + }).catch(() => onRejectedDrop?.("unreadable")); }}> {showProgress ? @@ -110,4 +136,4 @@ export default function FileDropZone({ ); -} \ No newline at end of file +} diff --git a/webui/src/components/resolveDroppedFiles.ts b/webui/src/components/resolveDroppedFiles.ts new file mode 100644 index 0000000..764fb64 --- /dev/null +++ b/webui/src/components/resolveDroppedFiles.ts @@ -0,0 +1,101 @@ +type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike; +type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise }; +type FileSystemDirectoryHandleLike = { + kind: "directory"; + values?: () => AsyncIterable; +}; +type WebKitFileEntryLike = { + isFile: true; + isDirectory: false; + file: ( + success: (file: File) => void, + error?: (error: DOMException) => void + ) => void; +}; +type WebKitDirectoryEntryLike = { isFile: false; isDirectory: true }; +type WebKitEntryLike = WebKitFileEntryLike | WebKitDirectoryEntryLike; +type DropItemWithFileHandles = DataTransferItem & { + getAsFileSystemHandle?: () => Promise; + webkitGetAsEntry?: () => unknown; +}; + +/** + * Resolve a browser file drop exactly once, preferring the standard file list + * before progressively trying item and directory-drag compatibility APIs. + */ +export async function resolveDroppedFiles( + dataTransfer: Pick +): Promise { + const directFiles = Array.from(dataTransfer.files); + if (directFiles.length > 0) return directFiles; + + const items = Array.from(dataTransfer.items).filter( + (item) => item.kind === "file" + ) as DropItemWithFileHandles[]; + const itemFiles = items + .map((item) => item.getAsFile()) + .filter((file): file is File => Boolean(file)); + if (itemFiles.length > 0) return itemFiles; + + const handleFiles = await filesFromDataTransferHandles(items); + if (handleFiles.length > 0) return handleFiles; + + return filesFromWebKitEntries(items); +} + +async function filesFromDataTransferHandles( + items: DropItemWithFileHandles[] +): Promise { + const files: File[] = []; + for (const item of items) { + let handle: FileSystemHandleLike | null | undefined; + try { + handle = await item.getAsFileSystemHandle?.(); + } catch { + handle = null; + } + if (!handle) continue; + files.push(...await filesFromFileSystemHandle(handle)); + } + return files; +} + +async function filesFromFileSystemHandle( + handle: FileSystemHandleLike +): Promise { + if (handle.kind === "file") return [await handle.getFile()]; + const values = handle.values?.(); + if (!values) return []; + const files: File[] = []; + for await (const child of values) { + files.push(...await filesFromFileSystemHandle(child)); + } + return files; +} + +async function filesFromWebKitEntries( + items: DropItemWithFileHandles[] +): Promise { + const entries = items.flatMap((item) => { + const entry = item.webkitGetAsEntry?.() as unknown; + return isWebKitEntryLike(entry) ? [entry] : []; + }); + const files = await Promise.all(entries.map(fileFromWebKitEntry)); + return files.filter((file): file is File => Boolean(file)); +} + +function isWebKitEntryLike(value: unknown): value is WebKitEntryLike { + if (!value || typeof value !== "object") return false; + const entry = value as { isFile?: unknown; isDirectory?: unknown }; + return ( + typeof entry.isFile === "boolean" && + typeof entry.isDirectory === "boolean" + ); +} + +function fileFromWebKitEntry(entry: WebKitEntryLike): Promise { + if (!entry.isFile) return Promise.resolve(null); + return new Promise((resolve, reject) => { + entry.file(resolve, reject); + }); +} diff --git a/webui/tests/file-drop-resolver.test.ts b/webui/tests/file-drop-resolver.test.ts new file mode 100644 index 0000000..a8f402a --- /dev/null +++ b/webui/tests/file-drop-resolver.test.ts @@ -0,0 +1,150 @@ +import { resolveDroppedFiles } from "../src/components/resolveDroppedFiles"; + +type FileDropItem = Omit & { + getAsFileSystemHandle?: () => Promise; + webkitGetAsEntry?: () => unknown; +}; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function assertFiles(actual: File[], expected: File[], message: string): void { + assert(actual.length === expected.length, `${message}: expected ${expected.length} file(s), got ${actual.length}`); + expected.forEach((file, index) => { + assert(actual[index] === file, `${message}: file ${index + 1} did not preserve object identity`); + }); +} + +function transfer(files: File[] = [], items: FileDropItem[] = []): Pick { + return { + files: files as unknown as FileList, + items: items as unknown as DataTransferItemList + }; +} + +function item(overrides: Partial = {}): FileDropItem { + return { + kind: "file", + type: "application/octet-stream", + getAsFile: () => null, + getAsString: () => undefined, + ...overrides + } as FileDropItem; +} + +function namedFile(name: string): File { + return new File([name], name, { type: "text/plain" }); +} + +async function* handles(...entries: unknown[]): AsyncIterable { + for (const entry of entries) yield entry; +} + +async function run(): Promise { + const direct = namedFile("direct.txt"); + let directItemCalls = 0; + const directResult = await resolveDroppedFiles(transfer([direct], [item({ + getAsFile: () => { + directItemCalls += 1; + return direct; + } + })])); + assertFiles(directResult, [direct], "native DataTransfer.files takes precedence"); + assert(directItemCalls === 0, "native files must not also be resolved from items"); + + const itemA = namedFile("item-a.txt"); + const itemB = namedFile("item-b.txt"); + let itemHandleCalls = 0; + const itemResult = await resolveDroppedFiles(transfer([], [ + item({ + getAsFile: () => itemA, + getAsFileSystemHandle: async () => { + itemHandleCalls += 1; + return { kind: "file", getFile: async () => itemA }; + } + }), + item({ getAsFile: () => itemB }), + item({ kind: "string", getAsFile: () => null }) + ])); + assertFiles(itemResult, [itemA, itemB], "DataTransferItem.getAsFile fallback resolves file items"); + assert(itemHandleCalls === 0, "item files must not also be resolved from file-system handles"); + + const topLevelFile = namedFile("top-level.txt"); + const rootFile = namedFile("root.txt"); + const nestedFile = namedFile("nested.txt"); + const deepFile = namedFile("deep.txt"); + const deepDirectory = { + kind: "directory", + values: () => handles({ kind: "file", getFile: async () => deepFile }) + }; + const nestedDirectory = { + kind: "directory", + values: () => handles( + { kind: "file", getFile: async () => nestedFile }, + deepDirectory + ) + }; + const rootDirectory = { + kind: "directory", + values: () => handles( + { kind: "file", getFile: async () => rootFile }, + nestedDirectory + ) + }; + const handleResult = await resolveDroppedFiles(transfer([], [ + item({ + getAsFileSystemHandle: async () => ({ + kind: "file", + getFile: async () => topLevelFile + }) + }), + item({ getAsFileSystemHandle: async () => rootDirectory }) + ])); + assertFiles( + handleResult, + [topLevelFile, rootFile, nestedFile, deepFile], + "File System Access file handles resolve and directory handles recurse in enumeration order" + ); + + const legacy = namedFile("legacy.txt"); + const legacyResult = await resolveDroppedFiles(transfer([], [item({ + getAsFileSystemHandle: async () => { + throw new Error("file-system handles unavailable"); + }, + webkitGetAsEntry: () => ({ + isFile: true, + isDirectory: false, + file: (resolve: (file: File) => void) => resolve(legacy) + }) + })])); + assertFiles(legacyResult, [legacy], "legacy webkit file entry remains a final fallback"); + + const emptyResult = await resolveDroppedFiles(transfer([], [ + item({ kind: "string" }), + item({ + getAsFileSystemHandle: async () => ({ kind: "directory" }), + webkitGetAsEntry: () => ({ isFile: false, isDirectory: true }) + }) + ])); + assertFiles(emptyResult, [], "empty and non-readable directory paths resolve to an empty drop"); + + let unreadableRejected = false; + try { + await resolveDroppedFiles(transfer([], [item({ + getAsFileSystemHandle: async () => ({ + kind: "file", + getFile: async () => { + throw new Error("permission denied"); + } + }) + })])); + } catch (error) { + unreadableRejected = error instanceof Error && error.message === "permission denied"; + } + assert(unreadableRejected, "an unreadable file handle rejects so the component can report the unreadable reason"); +} + +void run().catch((error) => { + throw error; +}); diff --git a/webui/tsconfig.file-drop-tests.json b/webui/tsconfig.file-drop-tests.json new file mode 100644 index 0000000..8ece5ce --- /dev/null +++ b/webui/tsconfig.file-drop-tests.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "module": "CommonJS", + "moduleResolution": "Node", + "noEmit": false, + "outDir": ".file-drop-test-build", + "rootDir": "." + }, + "include": [ + "tests/file-drop-resolver.test.ts", + "src/components/resolveDroppedFiles.ts" + ] +}