diff --git a/.changeset/fi-file-fields-phase-a.md b/.changeset/fi-file-fields-phase-a.md new file mode 100644 index 000000000..94aae601b --- /dev/null +++ b/.changeset/fi-file-fields-phase-a.md @@ -0,0 +1,5 @@ +--- +"@jayoncode/form-intelligence": minor +--- + +File fields: Phase A foundation (`File[]` DOM read/clear, non-persistent omit from drafts/history/offline), Phase B ergonomics (`accept` / `maxSize` / `minSize` / `maxFiles` / `minFiles`, `form.toFormData()` / `form.payload()`, file-shaped `bind()`), and Phase C opt-in `@jayoncode/form-intelligence/upload` (`uploadTransport` multipart progress + abort). Raise `core-login` entry-chunk gzip budget 27→28 KB (ADR-013) for file orchestration on the createForm graph; upload XHR remains `/upload`-only. diff --git a/apps/browser-session-playground/package.json b/apps/browser-session-playground/package.json index 679db05c0..2b7ae2768 100644 --- a/apps/browser-session-playground/package.json +++ b/apps/browser-session-playground/package.json @@ -20,7 +20,7 @@ "@jayoncode/browser-lifecycle": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1" + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/apps/docs/docs/.vitepress/form-intelligence-sidebar.ts b/apps/docs/docs/.vitepress/form-intelligence-sidebar.ts index 502a1ee63..25fc1003b 100644 --- a/apps/docs/docs/.vitepress/form-intelligence-sidebar.ts +++ b/apps/docs/docs/.vitepress/form-intelligence-sidebar.ts @@ -47,6 +47,7 @@ export function createFormIntelligenceSidebar( { text: "Validation", link: `${base}/modules/validation` }, { text: "Submission", link: `${base}/modules/submission` }, { text: "CAPTCHA", link: `${base}/modules/captcha` }, + { text: "Upload transport", link: `${base}/modules/upload` }, { text: "State", link: `${base}/modules/state` }, { text: "Workflow", link: `${base}/modules/workflow` }, { text: "Rules", link: `${base}/modules/rules` }, diff --git a/apps/docs/docs/packages/form-intelligence/overview.md b/apps/docs/docs/packages/form-intelligence/overview.md index 357da92ae..2830ce726 100644 --- a/apps/docs/docs/packages/form-intelligence/overview.md +++ b/apps/docs/docs/packages/form-intelligence/overview.md @@ -38,6 +38,7 @@ Form Intelligence makes those workflows **declarative** on one `createForm()` in - Sync + async validation (including multiple async checks per field) - **HTML constraints** on DOM-backed forms (`required`, `minlength`, `type="email"`, … → validators on attach) +- **File inputs** — `File[]` from `input.files`, file validators, `toFormData()` / `payload()`; optional [`/upload`](/packages/form-intelligence/modules/upload) transport; excluded from drafts/history (not an upload framework) - Declarative `when()` rules (show / require / populate / gate submit) - Autosave, draft restore, wizard steps, offline submit queue - Headless `bind()` + optional framework/schema adapters diff --git a/apps/form-intelligence-playground/package.json b/apps/form-intelligence-playground/package.json index 0ecc3e878..2713ec709 100644 --- a/apps/form-intelligence-playground/package.json +++ b/apps/form-intelligence-playground/package.json @@ -22,7 +22,7 @@ "@jayoncode/object-diff": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1" + "react-router-dom": "^7.18.1" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/apps/object-diff-playground/package.json b/apps/object-diff-playground/package.json index 4cb555883..8524d68eb 100644 --- a/apps/object-diff-playground/package.json +++ b/apps/object-diff-playground/package.json @@ -21,7 +21,7 @@ "@jayoncode/storage": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1" + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/apps/storage-playground/package.json b/apps/storage-playground/package.json index 0c4e47d69..9a2180eb3 100644 --- a/apps/storage-playground/package.json +++ b/apps/storage-playground/package.json @@ -20,7 +20,7 @@ "@jayoncode/storage": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1", - "react-router-dom": "^6.30.1" + "react-router-dom": "^7.18.1" }, "devDependencies": { "@types/react": "^18.3.12", diff --git a/packages/form-intelligence/docs/adapters.md b/packages/form-intelligence/docs/adapters.md index 9d0f6222a..aa9ccff9e 100644 --- a/packages/form-intelligence/docs/adapters.md +++ b/packages/form-intelligence/docs/adapters.md @@ -64,6 +64,51 @@ Merge precedence for the same validator **kind**: **Field > Schema > HTML**. Cus Deferred: `min` / `max` / `step` / `multiple` / date-time constraints, MutationObserver re-extraction. +## File inputs (Phase A + B) + +Form Intelligence **orchestrates** native file fields; it does **not** manage uploads. + +| Rule | Behavior | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Read | `input.files` → canonical `File[]` in form values | +| Never read | `input.value` (fake path string) | +| State → DOM | Never restore a file selection; clear only | +| Persistence | File fields are browser-owned ephemeral values — omitted from drafts, autosave, offline queue, and history | +| Validation | `required` (presence) plus `accept`, `maxSize`, `minSize`, `maxFiles`, `minFiles` | +| Submit | `onSubmit` values, or `form.toFormData()` / `form.payload()` — apps own the network transport (optional helper: [`uploadTransport`](/packages/form-intelligence/modules/upload)) | + +```ts +import { createForm, required, accept, maxSize, maxFiles } from "@jayoncode/form-intelligence"; + +const form = createForm({ + target: "#profile", + schema: { + name: { required: true }, + avatar: "file", + }, + validators: { + avatar: [required, accept("image/*"), maxSize("5MB"), maxFiles(1)], + }, + async onSubmit() { + await fetch("/api/profile", { method: "POST", body: form.toFormData() }); + }, +}); +``` + +```html +
+ + + +
+``` + +`form.payload()` returns `{ kind: "json", values }` when no files are selected, or `{ kind: "multipart", formData }` when any file selection is non-empty. + +`field().bind()` returns a file-shaped binding (`kind: "file"`, `files`, no controlled `value`) for file fields — do not spread it onto the input as controlled props. + +For abortable multipart upload with progress, use the opt-in [`@jayoncode/form-intelligence/upload`](/packages/form-intelligence/modules/upload) plugin. Form Intelligence still does **not** ship cloud SDKs, chunking, or resumable protocols. + ```ts createForm({ target: "#register", diff --git a/packages/form-intelligence/docs/entrypoints.md b/packages/form-intelligence/docs/entrypoints.md index ef7452823..b74f159f9 100644 --- a/packages/form-intelligence/docs/entrypoints.md +++ b/packages/form-intelligence/docs/entrypoints.md @@ -17,6 +17,7 @@ Tree-shakeable imports for `@jayoncode/form-intelligence`. Prefer the **narrowes | DevTools inspector | `@jayoncode/form-intelligence/devtools` | | Derived UI projection (`showError`, `canSubmit`, `status`, …) | `@jayoncode/form-intelligence/ui` | | CAPTCHA Security Stage (`captcha`, providers) | `@jayoncode/form-intelligence/captcha` | +| Opt-in multipart upload (`uploadTransport`) | `@jayoncode/form-intelligence/upload` | | Browser-lifecycle / keyboard plugins | `@jayoncode/form-intelligence/plugins` | | Error helpers (`toNormalizedErrors`, …) | `@jayoncode/form-intelligence/validation` | @@ -44,6 +45,7 @@ Formatter masks use a `format*` prefix on `/format` only (`formatPhone`, `format | `…/devtools` | `enableFormDevTools`, `getFormDevTools` | **DevTools: this path only** | | `…/ui` | Derived UI projection: `ui()`, `createUiProjection`, `showError`, `canSubmit`, `status`, `explain()` — [UI projection → free helpers](/packages/form-intelligence/modules/ui-projection#free-helpers--policy-store-advanced) | **Projection: this path** | | `…/captcha` | CAPTCHA Security Stage: `captcha()`, `turnstile()`, `recaptcha()`, `hcaptcha()`, `mockCaptcha()` | **CAPTCHA: this path only** | +| `…/upload` | Opt-in multipart upload transport: `uploadTransport()` — [Upload transport](/packages/form-intelligence/modules/upload) | **Upload: this path only** | | `…/analytics` | `FormAnalyticsTracker`, `createAnalyticsPlugin` — [Integrations → analytics module API](/packages/form-intelligence/modules/integrations#analytics-module-api) | Prefer `workflow.analytics` | | `…/adapters` | Adapter types, `createFormController` | Controller also on main | | `…/dependency` | Dependency engine, `detectDependencyCycles` | `dependencies` also on main | diff --git a/packages/form-intelligence/docs/migration.md b/packages/form-intelligence/docs/migration.md index ff6bb1065..05bcaf106 100644 --- a/packages/form-intelligence/docs/migration.md +++ b/packages/form-intelligence/docs/migration.md @@ -69,7 +69,7 @@ import { createBrowserLifecyclePlugin } from "@jayoncode/form-intelligence/plugi ### Bundle budget (`core-login`) -Entry-chunk gzip budget is **27 KB** (ADR-013). Measure with `pnpm --filter @jayoncode/form-intelligence check:size` after `tsc -b`. See [Performance](/packages/form-intelligence/modules/performance). +Entry-chunk gzip budget is **28 KB** (ADR-013). Measure with `pnpm --filter @jayoncode/form-intelligence check:size` after `tsc -b`. See [Performance](/packages/form-intelligence/modules/performance). ### Controllers / accessibility diff --git a/packages/form-intelligence/docs/overview.md b/packages/form-intelligence/docs/overview.md index 69ab83ae0..41c94ef96 100644 --- a/packages/form-intelligence/docs/overview.md +++ b/packages/form-intelligence/docs/overview.md @@ -33,6 +33,7 @@ Form Intelligence makes those workflows **declarative** on one `createForm()` in - Sync + async validation (including multiple async checks per field) - **HTML constraints** on DOM-backed forms (`required`, `minlength`, `type="email"`, … → validators on attach) +- **File inputs** — `File[]` from `input.files`, file validators, `toFormData()` / `payload()`; optional [`/upload`](/packages/form-intelligence/modules/upload) transport; excluded from drafts/history (not an upload framework) - Declarative `when()` rules (show / require / populate / gate submit) - Autosave, draft restore, wizard steps, offline submit queue - Headless `bind()` + optional framework/schema adapters diff --git a/packages/form-intelligence/docs/performance.md b/packages/form-intelligence/docs/performance.md index 57a00e134..8aea700b5 100644 --- a/packages/form-intelligence/docs/performance.md +++ b/packages/form-intelligence/docs/performance.md @@ -18,11 +18,11 @@ Budgets below measure **entry chunks**, not “all features in one bundle.” Measured as **entry chunk** gzip (esbuild minify + `splitting: true`) so dynamic `import()` of offline/analytics/object-diff/integrations stays out of the login graph. -| Fixture | maxGzipKb | Notes | -| ---------------- | --------- | ----------------------------------------------------------------------------------- | -| `core-login` | 27 | `createForm` login graph; forbids DevTools / offline queue class / analytics module | -| `workflow-rules` | 3 | Rules-only subpath | -| `format-only` | 2 | Format subpath | +| Fixture | maxGzipKb | Notes | +| ---------------- | --------- | ----------------------------------------------------------------------------------------- | +| `core-login` | 28 | `createForm` login graph; forbids DevTools / offline queue class / analytics / upload XHR | +| `workflow-rules` | 3 | Rules-only subpath | +| `format-only` | 2 | Format subpath | ### Ratcheting (ADR-013) @@ -49,6 +49,13 @@ Measured as **entry chunk** gzip (esbuild minify + `splitting: true`) so dynamic - core-login entry ≈ **26.1 KB** gzip with merge + extract on the always-on graph - Raised budget **26 → 27**; DevTools / offline / captcha forbid list unchanged +### File fields + upload stage (2026-07) + +- Phase A/B file orchestration (`File[]`, `toFormData` / `payload`, ephemeral omit) lives on the main `createForm` graph +- Opt-in `/upload` XHR stays out of core (`xhrMultipartUpload` / `uploadTransport` forbid needles); submit override WeakMap mirrors Security Stage under `/submission` +- core-login entry ≈ **27.8 KB** gzip +- Raised budget **27 → 28**; DevTools / offline / captcha / upload XHR forbid list unchanged + ## Timing budgets (Vitest) See `tests/performance/performance-budgets.test.ts`. diff --git a/packages/form-intelligence/docs/plugins.md b/packages/form-intelligence/docs/plugins.md index 389cef94d..0eea14397 100644 --- a/packages/form-intelligence/docs/plugins.md +++ b/packages/form-intelligence/docs/plugins.md @@ -17,6 +17,7 @@ Add cross-cutting behavior — analytics, guards, or integrations — without fo | Middleware stage maps | `@jayoncode/form-intelligence/middleware` | | UI policies (`ui()`) | `@jayoncode/form-intelligence/ui` | | CAPTCHA (`captcha()`, `turnstile()`) | `@jayoncode/form-intelligence/captcha` | +| Upload transport (`uploadTransport()`) | `@jayoncode/form-intelligence/upload` | | DevTools inspector | `@jayoncode/form-intelligence/devtools` | Full map: [Entrypoints](/packages/form-intelligence/modules/entrypoints). diff --git a/packages/form-intelligence/docs/upload.md b/packages/form-intelligence/docs/upload.md new file mode 100644 index 000000000..b3d712768 --- /dev/null +++ b/packages/form-intelligence/docs/upload.md @@ -0,0 +1,102 @@ +# Upload transport + +Opt-in multipart upload with progress and abort — without turning Form Intelligence into an upload framework. + +**Related:** [Adapters → File inputs](/packages/form-intelligence/modules/adapters#file-inputs) · [Submission](/packages/form-intelligence/modules/submission) · [Entrypoints](/packages/form-intelligence/modules/entrypoints) + +## Import path + +```ts +import { uploadTransport } from "@jayoncode/form-intelligence/upload"; +``` + +| Need | Import | +| ------------------------ | ------------------------------------- | +| Plugin + XHR helper | `@jayoncode/form-intelligence/upload` | +| File values / validators | main package (see Adapters) | + +Core stays free of upload networking unless this plugin is registered. + +## Basic usage + +```ts +import { createForm } from "@jayoncode/form-intelligence"; +import { uploadTransport } from "@jayoncode/form-intelligence/upload"; + +createForm({ + target: "#profile", + initialValues: { name: "", avatar: [] }, + schema: { avatar: "file" }, + plugins: [ + uploadTransport({ + url: "/api/upload", + onProgress: (p) => { + // p.loaded / p.total / p.percent (percent may be null) + }, + }), + ], + onSubmit(values, meta) { + // Optional: runs after a successful upload when files were sent. + // meta.upload → { status, responseText, response } + }, +}); +``` + +By default (`whenFilesOnly: true`), JSON-only submits call `onSubmit` as usual. Submits that include files POST multipart `FormData` via XHR (so upload progress works). + +Cancel with `form.cancelSubmit()` — the transport honors the submit `AbortSignal`. A late XHR `onload` after cancel is treated as abort (no `onComplete` / `onSubmit`). + +## Options + +| Option | Default | Description | +| --------------- | -------- | ----------------------------------------------------------------------------------------------------- | +| `url` | — | Target URL for the built-in XHR transport (required unless `transport` / `buildRequest` supplies one) | +| `method` | `"POST"` | HTTP method | +| `headers` | — | Request headers (`Content-Type` is omitted so the browser sets the multipart boundary) | +| `buildRequest` | — | Per-submit `{ url, method?, headers? }` from values + FormData | +| `transport` | — | Custom uploader `(formData, { values, signal, onProgress })` — use for presigned / direct-to-cloud | +| `whenFilesOnly` | `true` | Only intercept multipart payloads; otherwise always upload `FormData` | +| `onProgress` | — | `{ loaded, total, percent }` | +| `onComplete` | — | Called with `{ status, responseText, response }` | +| `onError` | — | Called before the error is rethrown into the submit pipeline | + +## Events + +| Event | Payload | +| ----------------- | ---------------- | +| `upload:progress` | `UploadProgress` | +| `upload:complete` | `UploadResult` | +| `upload:error` | `unknown` | + +```ts +form.on("upload:progress", (progress) => { + /* … */ +}); +``` + +## Custom transport + +Presigned URLs and cloud SDKs stay **app-owned**: + +```ts +uploadTransport({ + transport: async (formData, { signal, onProgress }) => { + // Your PUT/POST to a presigned URL, S3 SDK, etc. + // Call onProgress when you can; honor signal. + return { status: 200, responseText: "", response: null }; + }, +}); +``` + +## Explicit non-goals + +- Chunking / resumable / tus +- Built-in S3 / Azure / GCS clients +- Background uploads / service workers +- Persisting file selections in drafts (see Phase A ephemeral rules) + +## Related + +- [Adapters → File inputs](/packages/form-intelligence/modules/adapters#file-inputs) +- [Submission](/packages/form-intelligence/modules/submission) +- [Plugins](/packages/form-intelligence/modules/plugins) diff --git a/packages/form-intelligence/engineering/003-modular-packages.md b/packages/form-intelligence/engineering/003-modular-packages.md index c9755fc05..4c1ae182d 100644 --- a/packages/form-intelligence/engineering/003-modular-packages.md +++ b/packages/form-intelligence/engineering/003-modular-packages.md @@ -272,7 +272,7 @@ Fixtures: | Fixture | Import surface | Budget (gzip) | | ---------------- | ----------------------------------- | ------------- | -| `core-login` | `createForm` + validators from main | 27 KB | +| `core-login` | `createForm` + validators from main | 28 KB | | `workflow-rules` | `when` from `/rules` | 3 KB | | `format-only` | formatters from `/format` | 2 KB | diff --git a/packages/form-intelligence/package.json b/packages/form-intelligence/package.json index eb7e566b8..b2fba1a35 100644 --- a/packages/form-intelligence/package.json +++ b/packages/form-intelligence/package.json @@ -106,6 +106,10 @@ "./captcha": { "types": "./dist/captcha/index.d.ts", "import": "./dist/captcha/index.js" + }, + "./upload": { + "types": "./dist/upload/index.d.ts", + "import": "./dist/upload/index.js" } }, "repository": { diff --git a/packages/form-intelligence/scripts/bundle-budgets.json b/packages/form-intelligence/scripts/bundle-budgets.json index 2ec9811a2..48226779c 100644 --- a/packages/form-intelligence/scripts/bundle-budgets.json +++ b/packages/form-intelligence/scripts/bundle-budgets.json @@ -3,7 +3,7 @@ { "id": "core-login", "file": "core-login.mjs", - "maxGzipKb": 27, + "maxGzipKb": 28, "forbid": [ "OfflineSubmitQueue", "createDevToolsPlugin", @@ -14,7 +14,9 @@ "js.hcaptcha.com", "turnstile", "grecaptcha", - "hcaptcha" + "hcaptcha", + "xhrMultipartUpload", + "uploadTransport" ] }, { diff --git a/packages/form-intelligence/scripts/check-entry-sizes.mjs b/packages/form-intelligence/scripts/check-entry-sizes.mjs index aef52243b..075412e44 100644 --- a/packages/form-intelligence/scripts/check-entry-sizes.mjs +++ b/packages/form-intelligence/scripts/check-entry-sizes.mjs @@ -35,6 +35,7 @@ const entries = [ "history/index.js", "ui/index.js", "captcha/index.js", + "upload/index.js", ]; console.log("@jayoncode/form-intelligence dist entry sizes (raw / gzip):\n"); diff --git a/packages/form-intelligence/src/core/create-form.ts b/packages/form-intelligence/src/core/create-form.ts index 54a618e7c..9d43a0255 100644 --- a/packages/form-intelligence/src/core/create-form.ts +++ b/packages/form-intelligence/src/core/create-form.ts @@ -46,6 +46,14 @@ import { import { WhenRuleBuilder } from "../engines/workflow/when.js"; import { ConfigurationError } from "../errors/index.js"; import { createFieldHandle } from "../fields/field-handle.js"; +import { + coerceToCanonicalFileValue, + emptyFileValue, + isCanonicalFileValue, + mergePreservingNonPersistent, + omitPaths, +} from "../fields/file.js"; +import { buildFormPayload, valuesToFormData } from "../fields/form-data.js"; import { formatFieldValue as runFieldFormatPipeline } from "../format/pipeline.js"; import { registerConfiguredModules } from "../modules/register-configured.js"; import { resolveHookResult } from "../plugins/hooks.js"; @@ -57,6 +65,7 @@ import { FormStateStore } from "../state/store.js"; import { evaluateSubmissionGuard } from "../submission/guard.js"; import { bindSecurityStageNotify, runSecurityStage } from "../submission/security-stage.js"; import { SubmissionOrchestrator } from "../submission/submit.js"; +import { resolveSubmitHandler } from "../submission/upload-stage.js"; import { ASYNC_VALIDATOR_OPTION_DEFAULTS } from "../types/async-validation.js"; import { explainDisabled } from "../ui/explain-disabled.js"; import { projectFieldUi } from "../ui/field-projection.js"; @@ -94,6 +103,7 @@ import type { FieldOption, FieldUiState, } from "../engines/workflow/types.js"; +import type { ToFormDataOptions } from "../fields/form-data.js"; import type { MiddlewareInput } from "../plugins/middleware.js"; import type { FormCoreState } from "../state/store.js"; import type { SubmissionGuardResult } from "../submission/guard.js"; @@ -144,6 +154,8 @@ class FormInstanceImpl> implements FormI /** Kind-merged Schema + Field + HTML used by the validation pipeline. */ private effectiveValidators: FormConfig["validators"]; private readonly fieldOptions = new Map>(); + /** Browser-owned ephemeral paths (file fields) — omitted from drafts/history/offline. */ + private readonly nonPersistentPaths = new Set(); private readonly store: FormStateStore; private readonly moduleHost: FormModuleHost; private readonly undoRedo = new UndoRedoController(); @@ -231,6 +243,23 @@ class FormInstanceImpl> implements FormI : options.fieldPaths; this.requiredBaseline = new Set(options.requiredBaseline ?? []); + for (const [path, value] of Object.entries(this.config.initialValues)) { + if (Array.isArray(value) && value.length === 0) { + const schemaDef = + config.schema && !isSchemaAdapter(config.schema) + ? (config.schema as Record)[path] + : undefined; + const isFileSchema = + schemaDef === "file" || + (typeof schemaDef === "object" && + schemaDef !== null && + (schemaDef as { type?: string }).type === "file"); + if (isFileSchema) { + this.nonPersistentPaths.add(path); + } + } + } + const draftKey = this.config.workflow?.draft?.storageKey ?? `${this.id}:draft`; const draftConfig = this.config.workflow?.draft; this.draftManager = new DraftManager(draftConfig, draftKey, { formId: this.id }); @@ -566,6 +595,7 @@ class FormInstanceImpl> implements FormI } void this.validate({ paths: [path], mode: "onBlur" }); }, + isFileField: () => this.nonPersistentPaths.has(path), }); } @@ -694,7 +724,7 @@ class FormInstanceImpl> implements FormI if (this.config.workflow?.offlineQueue?.enabled && isNavigatorOffline()) { const offline = await this.ensureOfflineService(); try { - offline.ensure().enqueue(cloneValue(this.core.values)); + offline.ensure().enqueue(cloneValue(this.valuesForPersistence())); } catch (error) { this.config.onSubmitError?.(error); this.setSubmitPhase("error"); @@ -743,11 +773,12 @@ class FormInstanceImpl> implements FormI this.events.emit("submit"); try { + const onSubmit = resolveSubmitHandler(this as FormInstance, this.config.onSubmit); const result = await this.submission.execute({ values: this.core.values, submitCount: this.core.submitCount, ...(meta ? { meta } : {}), - ...(this.config.onSubmit ? { onSubmit: this.config.onSubmit } : {}), + ...(onSubmit ? { onSubmit } : {}), ...(this.config.onSubmitError ? { onSubmitError: this.config.onSubmitError } : {}), ...(options ? { options } : {}), }); @@ -1064,6 +1095,18 @@ class FormInstanceImpl> implements FormI this.patchValues(path, value, options); } + public markNonPersistent(path: FieldPath): void { + this.nonPersistentPaths.add(path); + } + + public toFormData(options?: ToFormDataOptions): FormData { + return valuesToFormData(this.core.values as Record, options); + } + + public payload(options?: ToFormDataOptions) { + return buildFormPayload(this.core.values, options); + } + public setError(path: FieldPath, message: string): void { this.patchState({ errors: { ...this.core.errors, [path]: message } }); } @@ -1180,7 +1223,7 @@ class FormInstanceImpl> implements FormI version: 1, kind: "checkpoint", capturedAt: Date.now(), - values: cloneValue(this.core.values), + values: this.valuesForPersistence(), }; const withMeta = { @@ -1205,7 +1248,11 @@ class FormInstanceImpl> implements FormI } const restoreMeta = options.restoreMeta !== false; - const nextValues = cloneValue(checkpoint.values); + const nextValues = mergePreservingNonPersistent( + cloneValue(checkpoint.values), + this.core.values, + this.nonPersistentPaths, + ); this.store.replaceValues(nextValues); this.store.patchCore({ @@ -1221,7 +1268,7 @@ class FormInstanceImpl> implements FormI } if (options.recordHistory) { - this.undoRedo.record(cloneValue(nextValues)); + this.undoRedo.record(cloneValue(omitPaths(nextValues, this.nonPersistentPaths))); } this.recomputeFieldUi(); @@ -1370,7 +1417,7 @@ class FormInstanceImpl> implements FormI public saveDraft(): void { const persistStep = this.config.workflow?.wizard?.persistStepInDraft === true; this.draftManager.save( - this.core.values, + this.valuesForPersistence(), persistStep ? { currentStep: this.core.currentStep, persistWorkflow: true } : {}, ); this.events.emit("draft"); @@ -1400,7 +1447,13 @@ class FormInstanceImpl> implements FormI return false; } - this.store.replaceValues(cloneValue(result.values)); + this.store.replaceValues( + mergePreservingNonPersistent( + cloneValue(result.values), + this.core.values, + this.nonPersistentPaths, + ), + ); if (result.workflow?.currentStep !== undefined) { this.store.patchCore({ currentStep: result.workflow.currentStep }); } @@ -1420,7 +1473,12 @@ class FormInstanceImpl> implements FormI return false; } - this.store.replaceValues(cloneValue(previous)); + const restored = mergePreservingNonPersistent( + previous, + this.core.values, + this.nonPersistentPaths, + ); + this.store.replaceValues(cloneValue(restored)); this.recomputeFieldUi(); this.notify(); this.events.emit("change"); @@ -1433,7 +1491,8 @@ class FormInstanceImpl> implements FormI return false; } - this.store.replaceValues(cloneValue(next)); + const restored = mergePreservingNonPersistent(next, this.core.values, this.nonPersistentPaths); + this.store.replaceValues(cloneValue(restored)); this.recomputeFieldUi(); this.notify(); this.events.emit("change"); @@ -1518,10 +1577,14 @@ class FormInstanceImpl> implements FormI return this.store.subscribe(listener); } - public on(event: FormEvent, listener: () => void): () => void { + public on(event: FormEvent, listener: (payload?: unknown) => void): () => void { return this.events.on(event, listener); } + public emit(event: FormEvent, payload?: unknown): void { + this.events.emit(event, payload); + } + public destroy(): void { if (this.store.isDestroyed()) { return; @@ -1607,7 +1670,7 @@ class FormInstanceImpl> implements FormI } private setupAutosave(): void { - this.autosave.configure(this.config.workflow?.autosave, () => cloneValue(this.core.values), { + this.autosave.configure(this.config.workflow?.autosave, () => this.valuesForPersistence(), { onStart: () => { this.patchState({ isAutosaving: true }); this.events.emit("autosave"); @@ -1615,7 +1678,7 @@ class FormInstanceImpl> implements FormI onSuccess: async (savedAt) => { this.patchState({ isAutosaving: false, lastAutosaveAt: savedAt }); await this.pluginRegistry.hookBus.runOnAutosave({ - values: cloneValue(this.core.values), + values: this.valuesForPersistence(), savedAt, }); }, @@ -1626,7 +1689,7 @@ class FormInstanceImpl> implements FormI if (this.config.workflow?.draft?.enabled) { const persistStep = this.config.workflow?.wizard?.persistStepInDraft === true; this.draftManager.save( - this.core.values, + this.valuesForPersistence(), persistStep ? { currentStep: this.core.currentStep, persistWorkflow: true } : {}, ); this.events.emit("draft"); @@ -1864,6 +1927,10 @@ class FormInstanceImpl> implements FormI path: FieldPath, fieldOptions?: FieldOptions, ): unknown { + if (this.nonPersistentPaths.has(path) && isCanonicalFileValue(value)) { + return value; + } + const ctx = { path, values: this.core.values, @@ -1881,9 +1948,30 @@ class FormInstanceImpl> implements FormI return runFieldFormatPipeline(next, fieldOptions); } + private resolvePatchValue(path: FieldPath, value: unknown): unknown { + const coerced = coerceToCanonicalFileValue(value); + if (coerced !== null) { + this.nonPersistentPaths.add(path); + return coerced; + } + + if (this.nonPersistentPaths.has(path)) { + if (value === null || value === undefined || value === "") { + return emptyFileValue(); + } + } + + return value; + } + + private valuesForPersistence(): TValues { + return cloneValue(omitPaths(this.core.values, this.nonPersistentPaths)); + } + private patchValuesSilent(path: FieldPath, value: unknown, options?: SetValueOptions): void { + const resolved = this.resolvePatchValue(path, value); const fieldOptions = this.fieldOptions.get(path); - const formatted = this.applyFieldFormatting(value, path, fieldOptions); + const formatted = this.applyFieldFormatting(resolved, path, fieldOptions); const previous = getIn(this.core.values, path); if (previous === formatted) { return; @@ -1904,15 +1992,16 @@ class FormInstanceImpl> implements FormI } private patchValues(path: FieldPath, value: unknown, options?: SetValueOptions): void { + const resolved = this.resolvePatchValue(path, value); const fieldOpts = this.fieldOptions.get(path); - const formatted = this.applyFieldFormatting(value, path, fieldOpts); + const formatted = this.applyFieldFormatting(resolved, path, fieldOpts); const previous = getIn(this.core.values, path); if (previous === formatted) { return; } if (options?.recordHistory !== false) { - this.undoRedo.record(cloneValue(this.core.values)); + this.undoRedo.record(cloneValue(this.valuesForPersistence())); } this.store.setValueAt(path, formatted); @@ -2047,7 +2136,7 @@ class FormInstanceImpl> implements FormI nextArray: unknown[], mutation?: ArrayFieldMutation, ): void { - this.undoRedo.record(cloneValue(this.core.values)); + this.undoRedo.record(cloneValue(this.valuesForPersistence())); if (mutation) { this.store.patchCore({ diff --git a/packages/form-intelligence/src/core/events.ts b/packages/form-intelligence/src/core/events.ts index f6154737d..5b10fb80b 100644 --- a/packages/form-intelligence/src/core/events.ts +++ b/packages/form-intelligence/src/core/events.ts @@ -1,6 +1,6 @@ import type { FormEvent } from "../types/index.js"; -type EventListener = () => void; +type EventListener = (payload?: unknown) => void; export class FormEventBus { private readonly listeners = new Map>(); @@ -15,14 +15,14 @@ export class FormEventBus { }; } - public emit(event: FormEvent): void { + public emit(event: FormEvent, payload?: unknown): void { const bucket = this.listeners.get(event); if (!bucket) { return; } for (const listener of bucket) { - listener(); + listener(payload); } } diff --git a/packages/form-intelligence/src/dom/enhance-form.ts b/packages/form-intelligence/src/dom/enhance-form.ts index b93b7cfba..93be88767 100644 --- a/packages/form-intelligence/src/dom/enhance-form.ts +++ b/packages/form-intelligence/src/dom/enhance-form.ts @@ -1,5 +1,5 @@ import { findFieldContainer, findFieldControl, findFieldControls } from "./discover-fields.js"; -import { readControlValue, writeControlValue } from "./field-value.js"; +import { isFileInputElement, readControlValue, writeControlValue } from "./field-value.js"; import { shouldShowErrorWithPolicies } from "../ui/show-error.js"; import { hasUiPoliciesRegistered } from "../ui/store.js"; import { resolvePoliciesForForm } from "../ui/store.js"; @@ -211,6 +211,13 @@ export function attachDomEnhancer>( continue; } + // File inputs: never restore selection from state; clear-only via writeControlValue. + if (isFileInputElement(control)) { + const nextValue = form.get(path); + writeControlValue(control, nextValue); + continue; + } + const nextValue = form.get(path); const currentValue = readControlValue(control); if (currentValue !== nextValue) { @@ -267,6 +274,14 @@ export function attachDomEnhancer>( form.field(path); + if (isFileInputElement(control)) { + form.markNonPersistent(path); + const current = form.get(path); + if (current === "" || current === undefined || current === null) { + form.setValue(path, [], { markDirty: false, recordHistory: false }); + } + } + const handleInput = (): void => { form.setValue(path, readControlValue(control)); }; diff --git a/packages/form-intelligence/src/dom/field-value.ts b/packages/form-intelligence/src/dom/field-value.ts index fd73858f6..04cf795cf 100644 --- a/packages/form-intelligence/src/dom/field-value.ts +++ b/packages/form-intelligence/src/dom/field-value.ts @@ -1,5 +1,18 @@ +import { + clearFileInput, + coerceToCanonicalFileValue, + emptyFileValue, + isEmptyFileValue, + isFileInputElement, + readFileInputValue, +} from "../fields/file.js"; + export function readControlValue(control: HTMLElement): unknown { if (control instanceof HTMLInputElement) { + if (isFileInputElement(control)) { + return readFileInputValue(control); + } + if (control.type === "checkbox") { return control.checked; } @@ -28,6 +41,14 @@ export function readControlValue(control: HTMLElement): unknown { export function writeControlValue(control: HTMLElement, value: unknown): void { if (control instanceof HTMLInputElement) { + if (isFileInputElement(control)) { + // State → DOM: never restore a file selection; clear only. + if (isEmptyFileValue(value)) { + clearFileInput(control); + } + return; + } + if (control.type === "checkbox") { control.checked = Boolean(value); return; @@ -78,3 +99,5 @@ export function readNamedFieldValue(form: HTMLFormElement, name: string): unknow return ""; } + +export { coerceToCanonicalFileValue, emptyFileValue, isFileInputElement }; diff --git a/packages/form-intelligence/src/fields/field-handle.ts b/packages/form-intelligence/src/fields/field-handle.ts index 06c1eb51e..46d30d86a 100644 --- a/packages/form-intelligence/src/fields/field-handle.ts +++ b/packages/form-intelligence/src/fields/field-handle.ts @@ -1,3 +1,4 @@ +import { coerceToCanonicalFileValue, emptyFileValue, isCanonicalFileValue } from "./file.js"; import { computeFieldAria } from "../engines/accessibility/compute-aria.js"; import type { FieldAriaIds, FieldAriaResult } from "../engines/accessibility/types.js"; @@ -29,6 +30,8 @@ export interface FieldHandleContext { emitBlur(): void; emitFocus(): void; validateOnBlur(): void; + /** True for browser-owned ephemeral file fields. */ + isFileField?: () => boolean; } function resolveAria(context: FieldHandleContext): FieldAriaResult { @@ -102,7 +105,31 @@ export function createFieldHandle>( return context.validateField(); }, bind(): FieldBinding { + const useFile = context.isFileField?.() === true || isCanonicalFileValue(context.getValue()); + + if (useFile) { + return { + kind: "file", + name: context.path, + get files() { + const current = context.getValue(); + return isCanonicalFileValue(current) ? current : emptyFileValue(); + }, + onChange: (files) => { + if (files === null || files === undefined) { + context.setValue(emptyFileValue()); + return; + } + const coerced = coerceToCanonicalFileValue(files); + context.setValue(coerced ?? emptyFileValue()); + }, + onBlur, + onFocus, + }; + } + return { + kind: "value", name: context.path, get value() { return context.getValue(); diff --git a/packages/form-intelligence/src/fields/file.ts b/packages/form-intelligence/src/fields/file.ts new file mode 100644 index 000000000..675bba9c2 --- /dev/null +++ b/packages/form-intelligence/src/fields/file.ts @@ -0,0 +1,137 @@ +import { getIn, parsePath, setIn } from "../utils/index.js"; + +import type { FieldPath } from "../types/index.js"; + +/** Canonical in-memory representation for file fields (ADR-FILE-001 Phase A). */ +export type CanonicalFileValue = File[]; + +export function isFileInputElement( + control: HTMLElement, +): control is HTMLInputElement & { type: "file" } { + return control instanceof HTMLInputElement && control.type === "file"; +} + +export function emptyFileValue(): CanonicalFileValue { + return []; +} + +export function isCanonicalFileValue(value: unknown): value is CanonicalFileValue { + if (!Array.isArray(value)) { + return false; + } + if (value.length === 0) { + return true; + } + if (typeof File === "undefined") { + return false; + } + return value.every((entry) => entry instanceof File); +} + +export function isEmptyFileValue(value: unknown): boolean { + if (value === null || value === undefined || value === "") { + return true; + } + if (Array.isArray(value)) { + return value.length === 0; + } + if (typeof FileList !== "undefined" && value instanceof FileList) { + return value.length === 0; + } + return false; +} + +/** + * Coerce DOM / bind payloads into the canonical `File[]` representation. + * Returns `null` when the value is not file-shaped. + */ +export function coerceToCanonicalFileValue(value: unknown): CanonicalFileValue | null { + if (typeof FileList !== "undefined" && value instanceof FileList) { + return Array.from(value); + } + if (typeof File !== "undefined" && value instanceof File) { + return [value]; + } + if (Array.isArray(value)) { + if (value.length === 0) { + return []; + } + if (typeof File !== "undefined" && value.every((entry) => entry instanceof File)) { + return value as File[]; + } + } + return null; +} + +export function readFileInputValue(control: HTMLInputElement): CanonicalFileValue { + return control.files ? Array.from(control.files) : []; +} + +/** Clear selection only — never restore files from state (ADR-FILE-001). */ +export function clearFileInput(control: HTMLInputElement): void { + control.value = ""; +} + +function deleteIn(values: Record, path: FieldPath): Record { + const segments = parsePath(path); + if (segments.length === 0) { + return values; + } + + if (segments.length === 1) { + const key = segments[0]; + if (key === undefined) { + return values; + } + const { [key]: _removed, ...rest } = values; + return rest; + } + + const parentPath = segments.slice(0, -1).join("."); + const last = segments[segments.length - 1]; + if (last === undefined) { + return values; + } + const parent = getIn(values, parentPath); + if (!parent || typeof parent !== "object" || Array.isArray(parent)) { + return values; + } + const { [last]: _removed, ...rest } = parent as Record; + return setIn(values, parentPath, rest); +} + +/** Drop non-persistent paths from a values snapshot (drafts / offline / history). */ +export function omitPaths>( + values: TValues, + paths: ReadonlySet, +): TValues { + if (paths.size === 0) { + return values; + } + + let next: Record = { ...values }; + for (const path of paths) { + next = deleteIn(next, path); + } + return next as TValues; +} + +/** + * Apply a persistence/history snapshot while keeping current browser-owned + * ephemeral values for registered non-persistent paths. + */ +export function mergePreservingNonPersistent>( + snapshot: TValues, + current: TValues, + nonPersistentPaths: ReadonlySet, +): TValues { + if (nonPersistentPaths.size === 0) { + return snapshot; + } + + let next: Record = { ...snapshot }; + for (const path of nonPersistentPaths) { + next = setIn(next, path, getIn(current, path)); + } + return next as TValues; +} diff --git a/packages/form-intelligence/src/fields/form-data.ts b/packages/form-intelligence/src/fields/form-data.ts new file mode 100644 index 000000000..c712fbc05 --- /dev/null +++ b/packages/form-intelligence/src/fields/form-data.ts @@ -0,0 +1,122 @@ +import { isCanonicalFileValue } from "./file.js"; +import { asFileList } from "../validation/validators/file-utils.js"; + +import type { FieldPath } from "../types/index.js"; + +export type FormPayloadKind = "json" | "multipart"; + +export interface JsonFormPayload> { + readonly kind: "json"; + readonly values: TValues; +} + +export interface MultipartFormPayload { + readonly kind: "multipart"; + readonly formData: FormData; +} + +export type FormPayload> = + JsonFormPayload | MultipartFormPayload; + +export interface ToFormDataOptions { + /** Skip empty strings, empty file arrays, null, and undefined. Default true. */ + readonly omitEmpty?: boolean; +} + +function hasFilePayload(values: Record): boolean { + for (const value of Object.values(values)) { + if (typeof File !== "undefined" && value instanceof File) { + return true; + } + if (isCanonicalFileValue(value) && value.length > 0) { + return true; + } + if (typeof FileList !== "undefined" && value instanceof FileList && value.length > 0) { + return true; + } + } + return false; +} + +function appendValue(formData: FormData, key: string, value: unknown, omitEmpty: boolean): void { + if (value === null || value === undefined) { + return; + } + + if (typeof File !== "undefined" && value instanceof File) { + formData.append(key, value); + return; + } + + if ( + isCanonicalFileValue(value) || + (typeof FileList !== "undefined" && value instanceof FileList) + ) { + const files = asFileList(value); + if (files.length === 0) { + if (!omitEmpty) { + formData.append(key, ""); + } + return; + } + for (const file of files) { + formData.append(key, file); + } + return; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + if (!omitEmpty) { + formData.append(key, ""); + } + return; + } + for (const entry of value) { + appendValue(formData, key, entry, omitEmpty); + } + return; + } + + if (typeof value === "boolean" || typeof value === "number") { + formData.append(key, String(value)); + return; + } + + if (typeof value === "string") { + if (omitEmpty && value === "") { + return; + } + formData.append(key, value); + return; + } + + if (typeof value === "object") { + formData.append(key, JSON.stringify(value)); + } +} + +/** Build `FormData` from form values (top-level keys). Files append as binary parts. */ +export function valuesToFormData( + values: Record, + options: ToFormDataOptions = {}, +): FormData { + const omitEmpty = options.omitEmpty !== false; + const formData = new FormData(); + for (const [key, value] of Object.entries(values)) { + appendValue(formData, key, value, omitEmpty); + } + return formData; +} + +export function buildFormPayload>( + values: TValues, + options?: ToFormDataOptions, +): FormPayload { + if (hasFilePayload(values)) { + return { kind: "multipart", formData: valuesToFormData(values, options) }; + } + return { kind: "json", values }; +} + +export type { FieldPath }; diff --git a/packages/form-intelligence/src/index.ts b/packages/form-intelligence/src/index.ts index 92f570a67..05ccfd415 100644 --- a/packages/form-intelligence/src/index.ts +++ b/packages/form-intelligence/src/index.ts @@ -65,6 +65,12 @@ export { regex, required, url, + accept, + maxSize, + minSize, + maxFiles, + minFiles, + parseByteSize, } from "./validation/validators/index.js"; export { parseTtl } from "./validation/async/parse-ttl.js"; export { clearSharedValidationCaches } from "./validation/async/memory-cache.js"; @@ -129,6 +135,8 @@ export type { RestoreDraftOptions, RestorePromptResult, FieldBinding, + FileFieldBinding, + ValueFieldBinding, FieldHandle, FieldMetaState, FieldOption, @@ -189,6 +197,18 @@ export type { WorkflowState, } from "./types/index.js"; export { ASYNC_VALIDATOR_OPTION_DEFAULTS } from "./types/index.js"; -export type { AsyncValidator, AsyncValidatorWithOptions } from "./validation/validators/index.js"; +export type { + AsyncValidator, + AsyncValidatorWithOptions, + FileSizeInput, +} from "./validation/validators/index.js"; +export type { + FormPayload, + FormPayloadKind, + JsonFormPayload, + MultipartFormPayload, + ToFormDataOptions, +} from "./fields/form-data.js"; +export type { CanonicalFileValue } from "./fields/file.js"; export type { FormModule, FormModuleContext } from "./core/module-types.js"; export type { FormatPreset } from "./engines/formatter/presets.js"; diff --git a/packages/form-intelligence/src/schema/compiler.ts b/packages/form-intelligence/src/schema/compiler.ts index 648eef2e9..c6d5de0a2 100644 --- a/packages/form-intelligence/src/schema/compiler.ts +++ b/packages/form-intelligence/src/schema/compiler.ts @@ -51,6 +51,8 @@ function validatorsForBuiltinType(type: BuiltInFieldType): Validator[] { return [required, password()]; case "url": return [required, url]; + case "file": + return []; case "text": default: return []; @@ -125,7 +127,10 @@ export function compileSchema( continue; } - initialValues[path] = ""; + const isFile = + definition === "file" || + (typeof definition === "object" && definition !== null && definition.type === "file"); + initialValues[path] = isFile ? [] : ""; const compiled = compileFieldSchema(definition); if (compiled.length > 0) { validators[path] = compiled; diff --git a/packages/form-intelligence/src/submission/index.ts b/packages/form-intelligence/src/submission/index.ts index d1b0a44a6..07960efb5 100644 --- a/packages/form-intelligence/src/submission/index.ts +++ b/packages/form-intelligence/src/submission/index.ts @@ -38,3 +38,5 @@ export { runSecurityStage, } from "./security-stage.js"; export type { SecurityStageHandler, SecurityStageResult } from "./security-stage.js"; +export { registerUploadTransport, resolveSubmitHandler } from "./upload-stage.js"; +export type { UploadSubmitHandler } from "./upload-stage.js"; diff --git a/packages/form-intelligence/src/submission/upload-stage.ts b/packages/form-intelligence/src/submission/upload-stage.ts new file mode 100644 index 000000000..71a91ee0a --- /dev/null +++ b/packages/form-intelligence/src/submission/upload-stage.ts @@ -0,0 +1,57 @@ +import type { FormInstance, SubmitMeta } from "../types/index.js"; + +/** + * Upload submit override — pipeline slot that wraps `onSubmit` when + * `@jayoncode/form-intelligence/upload` registers (ADR-FILE-002). + * Lives under `/submission` so core never imports the `/upload` XHR module. + */ + +export type UploadSubmitHandler> = ( + values: TValues, + meta: SubmitMeta | undefined, + originalOnSubmit: ((values: TValues, meta?: SubmitMeta) => void | Promise) | undefined, +) => Promise; + +interface UploadTransportRegistration> { + readonly submit: UploadSubmitHandler; +} + +const registrations = new WeakMap< + FormInstance>, + UploadTransportRegistration> +>(); + +export function registerUploadTransport>( + form: FormInstance, + submit: UploadSubmitHandler, +): () => void { + const formLike = form as FormInstance>; + const registration: UploadTransportRegistration> = { + submit: submit as UploadSubmitHandler>, + }; + registrations.set(formLike, registration); + return () => { + if (registrations.get(formLike) === registration) { + registrations.delete(formLike); + } + }; +} + +export function resolveSubmitHandler>( + form: FormInstance, + originalOnSubmit: ((values: TValues, meta?: SubmitMeta) => void | Promise) | undefined, +): ((values: TValues, meta?: SubmitMeta) => void | Promise) | undefined { + const registration = registrations.get(form as FormInstance>); + if (!registration) { + return originalOnSubmit; + } + + return async (values, meta) => { + await registration.submit( + values as Record, + meta, + originalOnSubmit as + ((values: Record, meta?: SubmitMeta) => void | Promise) | undefined, + ); + }; +} diff --git a/packages/form-intelligence/src/types/index.ts b/packages/form-intelligence/src/types/index.ts index 5f5156908..060c4b624 100644 --- a/packages/form-intelligence/src/types/index.ts +++ b/packages/form-intelligence/src/types/index.ts @@ -62,7 +62,10 @@ export type FormEvent = | "validate" | "validated" | "autosave" - | "draft"; + | "draft" + | "upload:progress" + | "upload:complete" + | "upload:error"; export type ValidatorResult = true | false | string | undefined; @@ -96,7 +99,7 @@ export type { DependencyNode, } from "../engines/dependency/types.js"; -export type BuiltInFieldType = "text" | "email" | "password" | "url"; +export type BuiltInFieldType = "text" | "email" | "password" | "url" | "file"; export interface FieldValidateRules { readonly required?: boolean; @@ -187,7 +190,8 @@ export interface FieldHandle<_TValues extends Record> { bind(): FieldBinding; } -export interface FieldBinding { +export interface ValueFieldBinding { + readonly kind?: "value"; readonly name: string; readonly value: unknown; readonly onChange: (value: unknown) => void; @@ -195,6 +199,19 @@ export interface FieldBinding { readonly onFocus: () => void; } +export interface FileFieldBinding { + readonly kind: "file"; + readonly name: string; + /** Canonical file selection (`File[]`). */ + readonly files: File[]; + readonly onChange: (files: File[] | FileList | null | undefined) => void; + readonly onBlur: () => void; + readonly onFocus: () => void; +} + +/** Headless binding — file fields omit controlled `value` (ADR-FILE-001). */ +export type FieldBinding = ValueFieldBinding | FileFieldBinding; + export type { Formatter, Parser } from "../format/types.js"; export type { FormatPreset } from "../format/presets.js"; export type { DraftStorageAdapter, DraftStorageKind } from "../engines/draft/storage-adapter.js"; @@ -391,12 +408,21 @@ export interface SubmitSecurityMeta { readonly captcha?: SubmitSecurityCaptcha; } +/** Populated by the opt-in upload transport plugin (`@jayoncode/form-intelligence/upload`). */ +export interface SubmitUploadMeta { + readonly status: number; + readonly responseText: string; + readonly response: unknown; +} + export interface SubmitMeta { readonly changedFields?: readonly FieldPath[]; readonly diff?: FormDiffResult; readonly signal?: AbortSignal; /** Populated by the Security Stage (e.g. CAPTCHA plugin). */ readonly security?: SubmitSecurityMeta; + /** Populated after a successful upload-transport submit. */ + readonly upload?: SubmitUploadMeta; } export interface ValidateOptions { @@ -568,6 +594,23 @@ export interface FormInstance> { get(path: FieldPath): unknown; errors(path?: FieldPath): string | undefined | Readonly>; setValue(path: FieldPath, value: unknown, options?: SetValueOptions): void; + /** + * Mark a path as browser-owned ephemeral (non-persistent). + * File fields are registered automatically from DOM `type="file"` or file-shaped values. + * Drafts, autosave, offline queue, and history omit these paths (ADR-FILE-001). + */ + markNonPersistent(path: FieldPath): void; + /** + * Build `FormData` from current values (files as binary parts). + * Applications remain responsible for the network transport (ADR-FILE-001). + */ + toFormData(options?: import("../fields/form-data.js").ToFormDataOptions): FormData; + /** + * JSON when no files are present; multipart `FormData` when any file selection is non-empty. + */ + payload( + options?: import("../fields/form-data.js").ToFormDataOptions, + ): import("../fields/form-data.js").FormPayload; setError(path: FieldPath, message: string): void; clearErrors(path?: FieldPath): void; getFieldState(path: FieldPath): FieldState; @@ -641,7 +684,9 @@ export interface FormInstance> { * For declarative create-time listeners, prefer `createForm({ subscribe })`. */ subscribe(listener: () => void): () => void; - on(event: FormEvent, listener: () => void): () => void; + on(event: FormEvent, listener: (payload?: unknown) => void): () => void; + /** Emit a form lifecycle event (plugins may use for `upload:*`). */ + emit(event: FormEvent, payload?: unknown): void; destroy(): void; registerPlugin(plugin: FormPlugin): void; workflow: { diff --git a/packages/form-intelligence/src/upload/index.ts b/packages/form-intelligence/src/upload/index.ts new file mode 100644 index 000000000..58fa2a625 --- /dev/null +++ b/packages/form-intelligence/src/upload/index.ts @@ -0,0 +1,16 @@ +/** + * Upload transport — opt-in multipart progress/abort (ADR-FILE-002). + * + * @packageDocumentation + */ + +export { uploadTransport } from "./plugin.js"; +export { xhrMultipartUpload, UploadTransportError } from "./xhr-multipart.js"; +export { getLastUploadProgress } from "./registry.js"; +export type { + UploadProgress, + UploadResult, + UploadTransportContext, + UploadTransportFn, + UploadTransportOptions, +} from "./types.js"; diff --git a/packages/form-intelligence/src/upload/plugin.ts b/packages/form-intelligence/src/upload/plugin.ts new file mode 100644 index 000000000..80172c061 --- /dev/null +++ b/packages/form-intelligence/src/upload/plugin.ts @@ -0,0 +1,150 @@ +import { setLastUploadProgress } from "./registry.js"; +import { xhrMultipartUpload } from "./xhr-multipart.js"; +import { SubmitError } from "../errors/index.js"; +import { registerUploadTransport } from "../submission/upload-stage.js"; + +import type { UploadProgress, UploadResult, UploadTransportOptions } from "./types.js"; +import type { FormInstance, FormPlugin, SubmitMeta } from "../types/index.js"; + +function normalizeResult(result: UploadResult | void | undefined): UploadResult { + if (result && typeof result === "object" && "status" in result) { + return result; + } + return { + status: 200, + responseText: "", + response: null, + }; +} + +function abortError(): Error { + const error = new Error("Upload aborted."); + error.name = "AbortError"; + return error; +} + +/** + * Opt-in multipart upload transport with progress and abort (ADR-FILE-002). + * + * @example + * ```ts + * import { uploadTransport } from "@jayoncode/form-intelligence/upload"; + * + * createForm({ + * plugins: [ + * uploadTransport({ + * url: "/api/upload", + * onProgress: (p) => setPct(p.percent), + * }), + * ], + * }); + * ``` + */ +export function uploadTransport = Record>( + options: UploadTransportOptions, +): FormPlugin { + if (!options.transport && !options.url && !options.buildRequest) { + throw new Error("uploadTransport requires `url`, `buildRequest`, or `transport`."); + } + + const whenFilesOnly = options.whenFilesOnly !== false; + + return { + name: "upload-transport", + setup(form) { + const formLike = form as FormInstance>; + + const unregister = registerUploadTransport( + formLike, + async (values, meta, originalOnSubmit) => { + const payload = formLike.payload(); + const shouldUpload = !whenFilesOnly || payload.kind === "multipart"; + + if (!shouldUpload) { + if (!originalOnSubmit) { + throw new SubmitError("Form does not define an onSubmit handler."); + } + await originalOnSubmit(values as TValues, meta); + return; + } + + const formData = payload.kind === "multipart" ? payload.formData : formLike.toFormData(); + const signal = meta?.signal ?? new AbortController().signal; + + const reportProgress = (progress: UploadProgress): void => { + if (signal.aborted) { + return; + } + setLastUploadProgress(formLike, progress); + options.onProgress?.(progress); + formLike.emit("upload:progress", progress); + }; + + try { + let result: UploadResult; + + if (options.transport) { + result = normalizeResult( + await options.transport(formData, { + values: values as TValues, + signal, + onProgress: reportProgress, + }), + ); + } else { + const built = options.buildRequest?.({ + values: values as TValues, + formData, + }); + const url = built?.url ?? options.url; + if (!url) { + throw new Error( + "uploadTransport requires `url` or `buildRequest` returning a url.", + ); + } + const method = built?.method ?? options.method; + const headers = built?.headers ?? options.headers; + result = await xhrMultipartUpload({ + url, + formData, + signal, + onProgress: reportProgress, + ...(method !== undefined ? { method } : {}), + ...(headers !== undefined ? { headers } : {}), + }); + } + + if (signal.aborted) { + throw abortError(); + } + + options.onComplete?.(result); + formLike.emit("upload:complete", result); + + const submitMeta: SubmitMeta = { + ...(meta ?? {}), + upload: { + status: result.status, + responseText: result.responseText, + response: result.response, + }, + }; + + if (originalOnSubmit) { + if (signal.aborted) { + throw abortError(); + } + await originalOnSubmit(values as TValues, submitMeta); + } + } catch (error) { + options.onError?.(error); + formLike.emit("upload:error", error); + throw error; + } + }, + ); + + return unregister; + }, + }; +} diff --git a/packages/form-intelligence/src/upload/registry.ts b/packages/form-intelligence/src/upload/registry.ts new file mode 100644 index 000000000..cc5b2a3e5 --- /dev/null +++ b/packages/form-intelligence/src/upload/registry.ts @@ -0,0 +1,19 @@ +import type { UploadProgress, UploadResult, UploadTransportFn } from "./types.js"; +import type { FormInstance } from "../types/index.js"; + +const lastProgress = new WeakMap>, UploadProgress>(); + +export function setLastUploadProgress( + form: FormInstance>, + progress: UploadProgress, +): void { + lastProgress.set(form, progress); +} + +export function getLastUploadProgress( + form: FormInstance>, +): UploadProgress | undefined { + return lastProgress.get(form); +} + +export type { UploadTransportFn, UploadResult, UploadProgress }; diff --git a/packages/form-intelligence/src/upload/types.ts b/packages/form-intelligence/src/upload/types.ts new file mode 100644 index 000000000..9f6a46800 --- /dev/null +++ b/packages/form-intelligence/src/upload/types.ts @@ -0,0 +1,50 @@ +export interface UploadProgress { + readonly loaded: number; + readonly total: number; + /** 0–100 when `total` is known; otherwise `null`. */ + readonly percent: number | null; +} + +export interface UploadResult { + readonly status: number; + readonly responseText: string; + readonly response: unknown; +} + +export interface UploadTransportContext< + TValues extends Record = Record, +> { + readonly values: TValues; + readonly signal: AbortSignal; + readonly onProgress: (progress: UploadProgress) => void; +} + +export type UploadTransportFn = Record> = ( + formData: FormData, + context: UploadTransportContext, +) => Promise | UploadResult | void; + +export interface UploadTransportOptions< + TValues extends Record = Record, +> { + /** Target URL for the built-in XHR multipart transport. Required unless `transport` is set. */ + readonly url?: string; + readonly method?: string; + readonly headers?: Readonly>; + /** Customize URL/method/headers per submit. */ + readonly buildRequest?: (input: { readonly values: TValues; readonly formData: FormData }) => { + readonly url: string; + readonly method?: string; + readonly headers?: Readonly>; + }; + /** Replace the built-in XHR transport (e.g. presigned PUT). */ + readonly transport?: UploadTransportFn; + /** + * When true (default), only intercept submits that include files. + * JSON-only submits call `onSubmit` as usual. + */ + readonly whenFilesOnly?: boolean; + readonly onProgress?: (progress: UploadProgress) => void; + readonly onComplete?: (result: UploadResult) => void; + readonly onError?: (error: unknown) => void; +} diff --git a/packages/form-intelligence/src/upload/xhr-multipart.ts b/packages/form-intelligence/src/upload/xhr-multipart.ts new file mode 100644 index 000000000..f72590bcd --- /dev/null +++ b/packages/form-intelligence/src/upload/xhr-multipart.ts @@ -0,0 +1,149 @@ +import type { UploadProgress, UploadResult } from "./types.js"; + +export class UploadTransportError extends Error { + readonly status?: number; + readonly responseText?: string; + + constructor( + message: string, + options: { + readonly status?: number; + readonly responseText?: string; + readonly cause?: unknown; + } = {}, + ) { + super(message, options.cause !== undefined ? { cause: options.cause } : undefined); + this.name = "UploadTransportError"; + if (options.status !== undefined) { + this.status = options.status; + } + if (options.responseText !== undefined) { + this.responseText = options.responseText; + } + } +} + +function parseResponseBody(responseText: string): unknown { + const trimmed = responseText.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed) as unknown; + } catch { + return responseText; + } +} + +function abortError(): Error { + const error = new Error("Upload aborted."); + error.name = "AbortError"; + return error; +} + +/** + * Multipart POST/PUT via XHR so upload progress is available. + * Honors `AbortSignal` (wired from `form.cancelSubmit()`). + */ +export function xhrMultipartUpload(input: { + readonly url: string; + readonly method?: string; + readonly headers?: Readonly>; + readonly formData: FormData; + readonly signal?: AbortSignal; + readonly onProgress?: (progress: UploadProgress) => void; +}): Promise { + const method = input.method ?? "POST"; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + let settled = false; + + const finishReject = (error: Error): void => { + if (settled) { + return; + } + settled = true; + input.signal?.removeEventListener("abort", onAbort); + reject(error); + }; + + const finishResolve = (result: UploadResult): void => { + if (settled) { + return; + } + if (input.signal?.aborted) { + finishReject(abortError()); + return; + } + settled = true; + input.signal?.removeEventListener("abort", onAbort); + resolve(result); + }; + + const onAbort = (): void => { + xhr.abort(); + }; + + xhr.open(method, input.url, true); + + if (input.headers) { + for (const [key, value] of Object.entries(input.headers)) { + // Let the browser set multipart boundary. + if (key.toLowerCase() === "content-type") { + continue; + } + xhr.setRequestHeader(key, value); + } + } + + if (input.signal) { + if (input.signal.aborted) { + finishReject(abortError()); + return; + } + input.signal.addEventListener("abort", onAbort, { once: true }); + } + + xhr.upload.onprogress = (event) => { + if (!input.onProgress || input.signal?.aborted || settled) { + return; + } + const total = event.lengthComputable ? event.total : 0; + input.onProgress({ + loaded: event.loaded, + total, + percent: + event.lengthComputable && total > 0 ? Math.round((event.loaded / total) * 100) : null, + }); + }; + + xhr.onload = () => { + const result: UploadResult = { + status: xhr.status, + responseText: xhr.responseText, + response: parseResponseBody(xhr.responseText), + }; + if (xhr.status >= 200 && xhr.status < 300) { + finishResolve(result); + return; + } + finishReject( + new UploadTransportError(`Upload failed with status ${String(xhr.status)}.`, { + status: xhr.status, + responseText: xhr.responseText, + }), + ); + }; + + xhr.onerror = () => { + finishReject(new UploadTransportError("Upload network error.")); + }; + + xhr.onabort = () => { + finishReject(abortError()); + }; + + xhr.send(input.formData); + }); +} diff --git a/packages/form-intelligence/src/utils/index.ts b/packages/form-intelligence/src/utils/index.ts index 8df103910..99fe56791 100644 --- a/packages/form-intelligence/src/utils/index.ts +++ b/packages/form-intelligence/src/utils/index.ts @@ -14,11 +14,36 @@ export function cloneValue(value: T): T { return value; } + // Browser-owned opaque values — keep by reference (ADR-FILE-001). + if (typeof File !== "undefined" && value instanceof File) { + return value; + } + + if (Array.isArray(value)) { + return value.map((entry) => cloneValue(entry)) as T; + } + + if (isPlainObject(value)) { + const output: Record = {}; + for (const [key, entry] of Object.entries(value)) { + output[key] = cloneValue(entry); + } + return output as T; + } + if (typeof structuredClone === "function") { - return structuredClone(value); + try { + return structuredClone(value); + } catch { + return value; + } } - return JSON.parse(JSON.stringify(value)) as T; + try { + return JSON.parse(JSON.stringify(value)) as T; + } catch { + return value; + } } export function parsePath(path: FieldPath): string[] { diff --git a/packages/form-intelligence/src/validation/index.ts b/packages/form-intelligence/src/validation/index.ts index a6e39381a..41a1663d8 100644 --- a/packages/form-intelligence/src/validation/index.ts +++ b/packages/form-intelligence/src/validation/index.ts @@ -12,6 +12,12 @@ export { phone, currency, password, + accept, + maxSize, + minSize, + maxFiles, + minFiles, + parseByteSize, custom, asyncValidator, isAsyncValidator, @@ -28,6 +34,7 @@ export type { DateValidatorOptions, CurrencyValidatorOptions, PasswordValidatorOptions, + FileSizeInput, } from "./validators/index.js"; export { validatePaths, diff --git a/packages/form-intelligence/src/validation/validator-kind.ts b/packages/form-intelligence/src/validation/validator-kind.ts index 73ae75dbe..ca0dfa00e 100644 --- a/packages/form-intelligence/src/validation/validator-kind.ts +++ b/packages/form-intelligence/src/validation/validator-kind.ts @@ -1,7 +1,18 @@ import type { Validator } from "../types/index.js"; -/** Phase 1 kind registry for HTML / schema / field merge (ADR-VAL-002). */ -export type ValidatorKind = "required" | "email" | "url" | "minLength" | "maxLength" | "regex"; +/** Kind registry for mergeable validators (ADR-VAL-002 + ADR-FILE-001 Phase B). */ +export type ValidatorKind = + | "required" + | "email" + | "url" + | "minLength" + | "maxLength" + | "regex" + | "accept" + | "maxSize" + | "minSize" + | "maxFiles" + | "minFiles"; export const VALIDATOR_KIND_ORDER: readonly ValidatorKind[] = [ "required", @@ -10,6 +21,11 @@ export const VALIDATOR_KIND_ORDER: readonly ValidatorKind[] = [ "minLength", "maxLength", "regex", + "accept", + "maxSize", + "minSize", + "maxFiles", + "minFiles", ] as const; const VALIDATOR_KIND = Symbol.for("@jayoncode/form-intelligence/validatorKind"); diff --git a/packages/form-intelligence/src/validation/validators/file-utils.ts b/packages/form-intelligence/src/validation/validators/file-utils.ts new file mode 100644 index 000000000..072a8d7ed --- /dev/null +++ b/packages/form-intelligence/src/validation/validators/file-utils.ts @@ -0,0 +1,82 @@ +/** + * Parse a byte size as a number or human string (`"5MB"`, `"500kb"`, `"1 GiB"`). + */ +export function parseByteSize(input: number | string): number { + if (typeof input === "number") { + if (!Number.isFinite(input) || input < 0) { + throw new RangeError(`Invalid byte size: ${String(input)}`); + } + return Math.floor(input); + } + + const trimmed = input.trim(); + const match = /^(\d+(?:\.\d+)?)\s*(b|kb|kib|mb|mib|gb|gib)?$/i.exec(trimmed); + if (!match) { + throw new RangeError(`Invalid byte size: ${input}`); + } + + const amount = Number(match[1]); + const unit = (match[2] ?? "b").toLowerCase(); + const multipliers: Record = { + b: 1, + kb: 1000, + kib: 1024, + mb: 1000 ** 2, + mib: 1024 ** 2, + gb: 1000 ** 3, + gib: 1024 ** 3, + }; + const factor = multipliers[unit]; + if (factor === undefined || !Number.isFinite(amount)) { + throw new RangeError(`Invalid byte size: ${input}`); + } + return Math.floor(amount * factor); +} + +export function asFileList(value: unknown): File[] { + if (typeof FileList !== "undefined" && value instanceof FileList) { + return Array.from(value); + } + if (typeof File !== "undefined" && value instanceof File) { + return [value]; + } + if (Array.isArray(value)) { + if (typeof File === "undefined") { + return []; + } + return value.filter((entry): entry is File => entry instanceof File); + } + return []; +} + +/** Whether a single file matches an HTML-like `accept` token. */ +export function fileMatchesAcceptToken(file: File, token: string): boolean { + const normalized = token.trim().toLowerCase(); + if (!normalized) { + return true; + } + + const mime = file.type.toLowerCase(); + const name = file.name.toLowerCase(); + + if (normalized.startsWith(".")) { + return name.endsWith(normalized); + } + + if (normalized.endsWith("/*")) { + const prefix = normalized.slice(0, -1); // keep trailing "/" + return mime.startsWith(prefix); + } + + return mime === normalized; +} + +export function fileMatchesAccept(file: File, accept: string | readonly string[]): boolean { + const tokens = (typeof accept === "string" ? accept.split(",") : [...accept]) + .map((token) => token.trim()) + .filter(Boolean); + if (tokens.length === 0) { + return true; + } + return tokens.some((token) => fileMatchesAcceptToken(file, token)); +} diff --git a/packages/form-intelligence/src/validation/validators/file.ts b/packages/form-intelligence/src/validation/validators/file.ts new file mode 100644 index 000000000..d9d9e65c5 --- /dev/null +++ b/packages/form-intelligence/src/validation/validators/file.ts @@ -0,0 +1,81 @@ +import { tagValidator } from "../validator-kind.js"; +import { asFileList, fileMatchesAccept, parseByteSize } from "./file-utils.js"; + +import type { Validator } from "../../types/index.js"; + +export type FileSizeInput = number | string; + +/** + * Require every selected file to match HTML-like `accept` tokens + * (extensions `.png`, MIME `image/png`, or wildcards `image/*`). + */ +export const accept = (pattern: string | readonly string[]): Validator => + tagValidator((value) => { + const files = asFileList(value); + if (files.length === 0) { + return true; + } + for (const file of files) { + if (!fileMatchesAccept(file, pattern)) { + return `File type not allowed (${file.name}).`; + } + } + return true; + }, "accept"); + +/** Maximum size per file (bytes or `"5MB"`). */ +export const maxSize = (limit: FileSizeInput): Validator => { + const maxBytes = parseByteSize(limit); + return tagValidator((value) => { + const files = asFileList(value); + for (const file of files) { + if (file.size > maxBytes) { + return `File is too large (${file.name}).`; + } + } + return true; + }, "maxSize"); +}; + +/** Minimum size per file (bytes or `"1KB"`). Empty selection passes. */ +export const minSize = (limit: FileSizeInput): Validator => { + const minBytes = parseByteSize(limit); + return tagValidator((value) => { + const files = asFileList(value); + if (files.length === 0) { + return true; + } + for (const file of files) { + if (file.size < minBytes) { + return `File is too small (${file.name}).`; + } + } + return true; + }, "minSize"); +}; + +/** Maximum number of selected files. */ +export const maxFiles = (limit: number): Validator => + tagValidator((value) => { + if (!Number.isFinite(limit) || limit < 0) { + return true; + } + const files = asFileList(value); + if (files.length > limit) { + return `Select at most ${String(limit)} file${limit === 1 ? "" : "s"}.`; + } + return true; + }, "maxFiles"); + +/** Minimum number of selected files. Empty still fails when limit > 0. */ +export const minFiles = (limit: number): Validator => + tagValidator((value) => { + if (!Number.isFinite(limit) || limit <= 0) { + return true; + } + const files = asFileList(value); + if (files.length < limit) { + return `Select at least ${String(limit)} file${limit === 1 ? "" : "s"}.`; + } + return true; + }, "minFiles"); diff --git a/packages/form-intelligence/src/validation/validators/index.ts b/packages/form-intelligence/src/validation/validators/index.ts index 27e58d7be..e2849b68a 100644 --- a/packages/form-intelligence/src/validation/validators/index.ts +++ b/packages/form-intelligence/src/validation/validators/index.ts @@ -9,6 +9,9 @@ export { date } from "./date.js"; export { phone } from "./phone.js"; export { currency } from "./currency.js"; export { password } from "./password.js"; +export { accept, maxSize, minSize, maxFiles, minFiles } from "./file.js"; +export type { FileSizeInput } from "./file.js"; +export { parseByteSize, fileMatchesAccept, asFileList } from "./file-utils.js"; export { custom, asyncValidator, diff --git a/packages/form-intelligence/tests/browser/file-fields-phase-b.browser.test.ts b/packages/form-intelligence/tests/browser/file-fields-phase-b.browser.test.ts new file mode 100644 index 000000000..fb6b5d5bb --- /dev/null +++ b/packages/form-intelligence/tests/browser/file-fields-phase-b.browser.test.ts @@ -0,0 +1,132 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from "vitest"; + +import { + accept, + createForm, + maxFiles, + maxSize, + minFiles, + minSize, + parseByteSize, + required, +} from "../../src/index.js"; + +describe("ADR-FILE-001 Phase B — file validators and payload", () => { + it("parses human byte sizes", () => { + expect(parseByteSize(1024)).toBe(1024); + expect(parseByteSize("5MB")).toBe(5_000_000); + expect(parseByteSize("1 KiB")).toBe(1024); + }); + + it("validates accept, size, and file count", async () => { + const png = new File(["x"], "a.png", { type: "image/png" }); + const txt = new File(["x"], "a.txt", { type: "text/plain" }); + const big = new File([new Uint8Array(2_000)], "big.png", { type: "image/png" }); + + const form = createForm({ + initialValues: { avatar: [] as File[] }, + schema: { avatar: "file" }, + validators: { + avatar: [required, accept("image/*"), maxSize(1000), maxFiles(1)], + }, + onSubmit: () => undefined, + }); + + expect(await form.validate()).toBe(false); + + form.setValue("avatar", [txt]); + expect(await form.validate()).toBe(false); + expect(String(form.errors("avatar"))).toMatch(/not allowed/i); + + form.setValue("avatar", [big]); + expect(await form.validate()).toBe(false); + expect(String(form.errors("avatar"))).toMatch(/too large/i); + + form.setValue("avatar", [png, png]); + expect(await form.validate()).toBe(false); + expect(String(form.errors("avatar"))).toMatch(/at most/i); + + form.setValue("avatar", [png]); + expect(await form.validate()).toBe(true); + + form.destroy(); + }); + + it("supports minSize and minFiles", async () => { + const tiny = new File(["x"], "a.bin", { type: "application/octet-stream" }); + const form = createForm({ + initialValues: { docs: [] as File[] }, + schema: { docs: "file" }, + validators: { + docs: [minFiles(2), minSize(10)], + }, + onSubmit: () => undefined, + }); + + form.setValue("docs", [tiny]); + expect(await form.validate()).toBe(false); + + const larger = new File([new Uint8Array(20)], "b.bin", { type: "application/octet-stream" }); + form.setValue("docs", [larger, larger]); + expect(await form.validate()).toBe(true); + + form.destroy(); + }); + + it("builds FormData and payload()", () => { + const file = new File(["hello"], "a.txt", { type: "text/plain" }); + const form = createForm({ + initialValues: { title: "Hi", avatar: [] as File[] }, + schema: { title: "text", avatar: "file" }, + onSubmit: () => undefined, + }); + + expect(form.payload().kind).toBe("json"); + + form.setValue("avatar", [file]); + const payload = form.payload(); + expect(payload.kind).toBe("multipart"); + if (payload.kind !== "multipart") { + throw new Error("expected multipart"); + } + expect(payload.formData.get("title")).toBe("Hi"); + expect(payload.formData.get("avatar")).toBeInstanceOf(File); + + const fd = form.toFormData(); + expect(fd.get("avatar")).toBeInstanceOf(File); + + form.destroy(); + }); + + it("returns file-shaped bind() without value", () => { + const form = createForm({ + initialValues: { avatar: [] as File[] }, + schema: { avatar: "file" }, + onSubmit: () => undefined, + }); + + const binding = form.field("avatar").bind(); + expect(binding.kind).toBe("file"); + if (binding.kind !== "file") { + throw new Error("expected file binding"); + } + expect(binding.files).toEqual([]); + expect("value" in binding).toBe(false); + + const file = new File(["x"], "a.txt"); + binding.onChange([file]); + expect(form.get("avatar")).toEqual([file]); + + const titleForm = createForm({ + initialValues: { title: "" }, + onSubmit: () => undefined, + }); + const text = titleForm.field("title").bind(); + expect(text.kind === undefined || text.kind === "value").toBe(true); + titleForm.destroy(); + + form.destroy(); + }); +}); diff --git a/packages/form-intelligence/tests/browser/file-fields.browser.test.ts b/packages/form-intelligence/tests/browser/file-fields.browser.test.ts new file mode 100644 index 000000000..2a9663fbe --- /dev/null +++ b/packages/form-intelligence/tests/browser/file-fields.browser.test.ts @@ -0,0 +1,152 @@ +// @vitest-environment jsdom + +import { describe, expect, it, vi } from "vitest"; + +import { createForm, required } from "../../src/index.js"; + +function mountUploadForm(): HTMLFormElement { + document.body.innerHTML = ` +
+ + + +
+ `; + return document.querySelector("#upload") as HTMLFormElement; +} + +describe("ADR-FILE-001 Phase A — file fields", () => { + it("reads input.files as canonical File[] and submits them", async () => { + const formElement = mountUploadForm(); + const onSubmit = vi.fn(); + const file = new File(["hello"], "avatar.png", { type: "image/png" }); + + const form = createForm({ + target: formElement, + schema: { + title: "text", + avatar: "file", + }, + onSubmit, + }); + + expect(form.get("avatar")).toEqual([]); + + const avatar = formElement.querySelector('input[name="avatar"]') as HTMLInputElement; + Object.defineProperty(avatar, "files", { + configurable: true, + get: () => { + const list = { + 0: file, + length: 1, + item: (index: number) => (index === 0 ? file : null), + [Symbol.iterator]: function* () { + yield file; + }, + }; + return list as unknown as FileList; + }, + }); + avatar.dispatchEvent(new Event("change", { bubbles: true })); + + expect(form.get("avatar")).toEqual([file]); + + const title = formElement.querySelector('input[name="title"]') as HTMLInputElement; + title.value = "Profile"; + title.dispatchEvent(new Event("input", { bubbles: true })); + + formElement.requestSubmit(); + + await vi.waitFor(() => { + expect(onSubmit).toHaveBeenCalled(); + }); + + const [values] = onSubmit.mock.calls[0] as [Record]; + expect(values.title).toBe("Profile"); + expect(values.avatar).toEqual([file]); + + form.destroy(); + }); + + it("never restores a file selection from state; clears only", () => { + const formElement = mountUploadForm(); + const file = new File(["x"], "a.txt", { type: "text/plain" }); + const form = createForm({ + target: formElement, + schema: { avatar: "file" }, + onSubmit: vi.fn(), + }); + + const avatar = formElement.querySelector('input[name="avatar"]') as HTMLInputElement; + form.setValue("avatar", [file]); + // State has files; DOM cannot be restored — value stays "". + expect(avatar.value).toBe(""); + + form.setValue("avatar", []); + expect(avatar.value).toBe(""); + + form.destroy(); + }); + + it("treats empty File[] as failing required presence", async () => { + const form = createForm({ + initialValues: { avatar: [] as File[] }, + validators: { avatar: [required] }, + onSubmit: vi.fn(), + }); + form.markNonPersistent("avatar"); + + const ok = await form.validate(); + expect(ok).toBe(false); + expect(form.errors("avatar")).toBe("This field is required."); + + form.setValue("avatar", [new File(["x"], "a.txt")]); + expect(await form.validate()).toBe(true); + + form.destroy(); + }); + + it("omits file fields from drafts", () => { + const storageKey = `fi-file-draft-${Math.random().toString(36).slice(2)}`; + const file = new File(["x"], "a.txt"); + const form = createForm({ + initialValues: { title: "", avatar: [] as File[] }, + schema: { title: "text", avatar: "file" }, + workflow: { + draft: { enabled: true, storageKey }, + }, + onSubmit: vi.fn(), + }); + + form.setValue("title", "Saved"); + form.setValue("avatar", [file]); + form.saveDraft(); + + const raw = window.localStorage.getItem(storageKey); + expect(raw).toBeTruthy(); + expect(raw).not.toContain("a.txt"); + expect(JSON.parse(raw as string)).not.toHaveProperty("avatar"); + + form.destroy(); + window.localStorage.removeItem(storageKey); + }); + + it("does not replay file values through undo", () => { + const file = new File(["x"], "a.txt"); + const form = createForm({ + initialValues: { title: "", avatar: [] as File[] }, + schema: { title: "text", avatar: "file" }, + onSubmit: vi.fn(), + }); + + form.setValue("title", "one"); + form.setValue("avatar", [file]); + form.setValue("title", "two"); + + expect(form.undo()).toBe(true); + expect(form.get("title")).toBe("one"); + expect(form.get("avatar")).toEqual([file]); + + form.destroy(); + }); +}); diff --git a/packages/form-intelligence/tests/unit/upload-transport.test.ts b/packages/form-intelligence/tests/unit/upload-transport.test.ts new file mode 100644 index 000000000..d04c8ddca --- /dev/null +++ b/packages/form-intelligence/tests/unit/upload-transport.test.ts @@ -0,0 +1,298 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createForm } from "../../src/index.js"; +import { + getLastUploadProgress, + uploadTransport, + UploadTransportError, + xhrMultipartUpload, +} from "../../src/upload/index.js"; + +function mockXhr(options: { + status?: number; + responseText?: string; + progress?: Array<{ loaded: number; total: number }>; + abortOnSend?: boolean; +}): void { + const status = options.status ?? 200; + const responseText = options.responseText ?? '{"ok":true}'; + const progress = options.progress ?? [{ loaded: 50, total: 100 }]; + + class FakeXHR { + static readonly UNSENT = 0; + static readonly OPENED = 1; + static readonly HEADERS_RECEIVED = 2; + static readonly LOADING = 3; + static readonly DONE = 4; + + upload = { + onprogress: null as ((event: ProgressEvent) => void) | null, + }; + status = 0; + responseText = ""; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + + open(): void {} + setRequestHeader(): void {} + abort(): void { + this.onabort?.(); + } + send(): void { + if (options.abortOnSend) { + queueMicrotask(() => this.onabort?.()); + return; + } + queueMicrotask(() => { + for (const step of progress) { + this.upload.onprogress?.({ + lengthComputable: true, + loaded: step.loaded, + total: step.total, + } as ProgressEvent); + } + this.status = status; + this.responseText = responseText; + this.onload?.(); + }); + } + } + + vi.stubGlobal("XMLHttpRequest", FakeXHR); +} + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("xhrMultipartUpload", () => { + it("reports progress and resolves on 2xx", async () => { + mockXhr({ + progress: [ + { loaded: 25, total: 100 }, + { loaded: 100, total: 100 }, + ], + }); + const onProgress = vi.fn(); + + const result = await xhrMultipartUpload({ + url: "/api/upload", + formData: new FormData(), + onProgress, + }); + + expect(result.status).toBe(200); + expect(result.response).toEqual({ ok: true }); + expect(onProgress).toHaveBeenCalledWith({ loaded: 25, total: 100, percent: 25 }); + expect(onProgress).toHaveBeenCalledWith({ loaded: 100, total: 100, percent: 100 }); + }); + + it("rejects with UploadTransportError on non-2xx", async () => { + mockXhr({ status: 500, responseText: "nope" }); + + await expect( + xhrMultipartUpload({ url: "/api/upload", formData: new FormData() }), + ).rejects.toBeInstanceOf(UploadTransportError); + }); + + it("aborts when signal aborts", async () => { + mockXhr({}); + const controller = new AbortController(); + const promise = xhrMultipartUpload({ + url: "/api/upload", + formData: new FormData(), + signal: controller.signal, + }); + controller.abort(); + + await expect(promise).rejects.toMatchObject({ name: "AbortError" }); + }); +}); + +describe("uploadTransport plugin", () => { + it("uploads multipart when files are present and passes meta.upload to onSubmit", async () => { + mockXhr({ responseText: '{"id":"1"}' }); + const file = new File(["hello"], "hello.txt", { type: "text/plain" }); + const onProgress = vi.fn(); + const onComplete = vi.fn(); + const onSubmit = vi.fn(); + const progressEvents: unknown[] = []; + + const form = createForm({ + initialValues: { note: "hi", avatar: [file] }, + plugins: [ + uploadTransport({ + url: "/api/upload", + onProgress, + onComplete, + }), + ], + onSubmit, + }); + form.on("upload:progress", (payload) => { + progressEvents.push(payload); + }); + + await expect(form.submit()).resolves.toBe(true); + + expect(onProgress).toHaveBeenCalled(); + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ status: 200, response: { id: "1" } }), + ); + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ note: "hi" }), + expect.objectContaining({ + upload: expect.objectContaining({ status: 200, response: { id: "1" } }), + }), + ); + expect(progressEvents.length).toBeGreaterThan(0); + expect(getLastUploadProgress(form)?.percent).toBe(50); + form.destroy(); + }); + + it("uses onSubmit only when whenFilesOnly and no files", async () => { + const transport = vi.fn(); + const onSubmit = vi.fn(); + + const form = createForm({ + initialValues: { note: "hi", avatar: [] as File[] }, + plugins: [uploadTransport({ url: "/api/upload", transport })], + onSubmit, + }); + + await expect(form.submit()).resolves.toBe(true); + expect(transport).not.toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalledTimes(1); + form.destroy(); + }); + + it("supports custom transport without url", async () => { + const file = new File(["x"], "x.bin"); + const onSubmit = vi.fn(); + + const form = createForm({ + initialValues: { file: [file] }, + plugins: [ + uploadTransport({ + transport: async (formData, ctx) => { + expect(formData).toBeInstanceOf(FormData); + ctx.onProgress({ loaded: 1, total: 1, percent: 100 }); + return { status: 201, responseText: "", response: { created: true } }; + }, + }), + ], + onSubmit, + }); + + await expect(form.submit()).resolves.toBe(true); + expect(onSubmit).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + upload: expect.objectContaining({ status: 201 }), + }), + ); + form.destroy(); + }); + + it("cancels in-flight upload via form.cancelSubmit()", async () => { + let resolveSent!: () => void; + const sent = new Promise((resolve) => { + resolveSent = resolve; + }); + + class SlowXHR { + upload = { onprogress: null as ((event: ProgressEvent) => void) | null }; + status = 0; + responseText = ""; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + open(): void {} + setRequestHeader(): void {} + abort(): void { + this.onabort?.(); + } + send(): void { + resolveSent(); + } + } + vi.stubGlobal("XMLHttpRequest", SlowXHR); + + const file = new File(["x"], "x.bin"); + const onError = vi.fn(); + const form = createForm({ + initialValues: { file: [file] }, + plugins: [uploadTransport({ url: "/api/upload", onError })], + }); + + const submitPromise = form.submit(); + await sent; + form.cancelSubmit(); + + await expect(submitPromise).resolves.toBe(false); + expect(onError).toHaveBeenCalled(); + form.destroy(); + }); + + it("ignores successful onload after cancelSubmit (abort race)", async () => { + let sent = false; + let triggerLateOnload: (() => void) | null = null; + + class RaceXHR { + upload = { onprogress: null as ((event: ProgressEvent) => void) | null }; + status = 0; + responseText = ""; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + onabort: (() => void) | null = null; + open(): void {} + setRequestHeader(): void {} + abort(): void { + // Simulate late onload winning the race before abort settles. + this.status = 200; + this.responseText = '{"ok":true}'; + this.onload?.(); + this.onabort?.(); + } + send(): void { + sent = true; + triggerLateOnload = () => { + this.status = 200; + this.responseText = '{"ok":true}'; + this.onload?.(); + }; + } + } + vi.stubGlobal("XMLHttpRequest", RaceXHR); + + const file = new File(["x"], "x.bin"); + const onComplete = vi.fn(); + const onSubmit = vi.fn(); + const form = createForm({ + initialValues: { file: [file] }, + plugins: [uploadTransport({ url: "/api/upload", onComplete })], + onSubmit, + }); + + const submitPromise = form.submit(); + await vi.waitFor(() => { + expect(sent).toBe(true); + }); + // Keep triggerLateOnload referenced so the mock shape stays intentional. + void triggerLateOnload; + form.cancelSubmit(); + + await expect(submitPromise).resolves.toBe(false); + expect(onComplete).not.toHaveBeenCalled(); + expect(onSubmit).not.toHaveBeenCalled(); + form.destroy(); + }); + + it("throws when configured without url/transport/buildRequest", () => { + expect(() => uploadTransport({} as never)).toThrow(/url/); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 257ea8081..eec3ebac2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,8 +74,8 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-router-dom: - specifier: ^6.30.1 - version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^7.18.1 + version: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/react': specifier: ^18.3.12 @@ -129,8 +129,8 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-router-dom: - specifier: ^6.30.1 - version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^7.18.1 + version: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@testing-library/jest-dom': specifier: ^6.9.1 @@ -175,8 +175,8 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-router-dom: - specifier: ^6.30.1 - version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^7.18.1 + version: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/react': specifier: ^18.3.12 @@ -240,8 +240,8 @@ importers: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) react-router-dom: - specifier: ^6.30.1 - version: 6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + specifier: ^7.18.1 + version: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) devDependencies: '@types/react': specifier: ^18.3.12 @@ -1064,10 +1064,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@remix-run/router@1.23.3': - resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==} - engines: {node: '>=14.0.0'} - '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -1804,6 +1800,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -2913,18 +2913,22 @@ packages: resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} engines: {node: '>=0.10.0'} - react-router-dom@6.30.4: - resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==} - engines: {node: '>=14.0.0'} + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} peerDependencies: - react: '>=16.8' - react-dom: '>=16.8' + react: '>=18' + react-dom: '>=18' - react-router@6.30.4: - resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==} - engines: {node: '>=14.0.0'} + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} peerDependencies: - react: '>=16.8' + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true react@18.3.1: resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} @@ -3037,6 +3041,9 @@ packages: resolution: {integrity: sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==} engines: {node: '>=10'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -4264,8 +4271,6 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@remix-run/router@1.23.3': {} - '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.62.2': @@ -5053,6 +5058,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 @@ -6262,17 +6269,19 @@ snapshots: react-refresh@0.17.0: {} - react-router-dom@6.30.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + react-router-dom@7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@remix-run/router': 1.23.3 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) - react-router: 6.30.4(react@18.3.1) + react-router: 7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react-router@6.30.4(react@18.3.1): + react-router@7.18.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: - '@remix-run/router': 1.23.3 + cookie: 1.1.1 react: 18.3.1 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) react@18.3.1: dependencies: @@ -6421,6 +6430,8 @@ snapshots: seroval@1.5.5: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4