fix(files): make dropped-file handling explicit
This commit is contained in:
@@ -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"
|
||||
|
||||
41
webui/scripts/test-file-drop-zone-structure.mjs
Normal file
41
webui/scripts/test-file-drop-zone-structure.mjs
Normal file
@@ -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<HTMLDivElement>) { 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.");
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
@@ -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<HTMLInputElement | null>(null);
|
||||
@@ -52,6 +57,13 @@ export default function FileDropZone({
|
||||
}
|
||||
}
|
||||
|
||||
function prepareFileDrag(event: ReactDragEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = interactionDisabled ? "none" : "copy";
|
||||
if (!interactionDisabled) setDragActive(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -70,15 +82,29 @@ export default function FileDropZone({
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
onDragOver={(event) => {
|
||||
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({
|
||||
|
||||
</>);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
101
webui/src/components/resolveDroppedFiles.ts
Normal file
101
webui/src/components/resolveDroppedFiles.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
type FileSystemHandleLike = FileSystemFileHandleLike | FileSystemDirectoryHandleLike;
|
||||
type FileSystemFileHandleLike = { kind: "file"; getFile: () => Promise<File> };
|
||||
type FileSystemDirectoryHandleLike = {
|
||||
kind: "directory";
|
||||
values?: () => AsyncIterable<FileSystemHandleLike>;
|
||||
};
|
||||
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<FileSystemHandleLike | null>;
|
||||
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<DataTransfer, "files" | "items">
|
||||
): Promise<File[]> {
|
||||
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<File[]> {
|
||||
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<File[]> {
|
||||
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<File[]> {
|
||||
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<File | null> {
|
||||
if (!entry.isFile) return Promise.resolve(null);
|
||||
return new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
}
|
||||
150
webui/tests/file-drop-resolver.test.ts
Normal file
150
webui/tests/file-drop-resolver.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { resolveDroppedFiles } from "../src/components/resolveDroppedFiles";
|
||||
|
||||
type FileDropItem = Omit<DataTransferItem, "getAsFileSystemHandle" | "webkitGetAsEntry"> & {
|
||||
getAsFileSystemHandle?: () => Promise<unknown>;
|
||||
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<DataTransfer, "files" | "items"> {
|
||||
return {
|
||||
files: files as unknown as FileList,
|
||||
items: items as unknown as DataTransferItemList
|
||||
};
|
||||
}
|
||||
|
||||
function item(overrides: Partial<FileDropItem> = {}): 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<unknown> {
|
||||
for (const entry of entries) yield entry;
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
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;
|
||||
});
|
||||
20
webui/tsconfig.file-drop-tests.json
Normal file
20
webui/tsconfig.file-drop-tests.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user