From c0a81703443435d3abc6d17bdf877aeb45d2aab9 Mon Sep 17 00:00:00 2001 From: Gerard Oosterhof Date: Wed, 8 Jul 2026 10:05:20 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(form):=20add=20fs-form=20package=20?= =?UTF-8?q?=E2=80=94=20useFormSubmit=20+=20useValidationErrors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract the form-submit composable pair that BIO and entreezuil independently ran side-by-side into the armory: - useValidationErrors(httpService, {keyMapper?}) — registers a 422-only response-error middleware binding backend field errors into a reactive bag (first message per field), auto-unregisters on unmount. The one axis the two source territories diverged on — raw vs camelCased error keys — is absorbed by an optional keyMapper (default identity). - useFormSubmit(validationErrors) — double-submit guard + submitting flag; swallows a 422 (errors already surfaced, form preserved) and re-throws else. The middleware body is wrapped with fs-http's guarded() so a throwing keyMapper cannot reject a resolved request or mask the real API error — Principle #8 compliant out of the box. Gauntlet: dual ESM+CJS build, 100% coverage (22 tests), 91.89% mutation (3 residual survivors are equivalent mutants the guarded() backstop creates — killing them would mean removing the Principle #8 guard). publint + attw clean; sideEffects:false side-effect-free assertion passes. Docs (docs/packages/form.md + sidebar) and the CLAUDE.md package table updated. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq --- CLAUDE.md | 3 +- docs/.vitepress/config.mts | 1 + docs/packages/form.md | 109 +++++++++ package-lock.json | 71 ++---- packages/form/package.json | 58 +++++ packages/form/src/form-submit.ts | 42 ++++ packages/form/src/index.ts | 3 + packages/form/src/types.ts | 37 ++++ packages/form/src/validation-errors.ts | 64 ++++++ packages/form/stryker.config.mjs | 13 ++ packages/form/tests/form-submit.spec.ts | 114 ++++++++++ packages/form/tests/validation-errors.spec.ts | 206 ++++++++++++++++++ packages/form/tsconfig.json | 1 + packages/form/tsdown.config.ts | 3 + packages/form/vitest.config.ts | 12 + 15 files changed, 688 insertions(+), 49 deletions(-) create mode 100644 docs/packages/form.md create mode 100644 packages/form/package.json create mode 100644 packages/form/src/form-submit.ts create mode 100644 packages/form/src/index.ts create mode 100644 packages/form/src/types.ts create mode 100644 packages/form/src/validation-errors.ts create mode 100644 packages/form/stryker.config.mjs create mode 100644 packages/form/tests/form-submit.spec.ts create mode 100644 packages/form/tests/validation-errors.spec.ts create mode 100644 packages/form/tsconfig.json create mode 100644 packages/form/tsdown.config.ts create mode 100644 packages/form/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3e4f94e..573333f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th **Precedent + latency.** kendo WR-0078 (PR [#1538](https://github.com/script-development/kendo/pull/1538)) independently re-derived this mechanism against fs-http source and guarded **both** kendo central + tenant middleware (try/catch + fail-safe swallow, 100% coverage, 2026-06-15). See the war-room `deferred.md [adr] fs-packages-fs-http-async-aware-middleware-rejection-doctrine` entry (promoted, n=2). **entreezuil / ublgenie / emmie / BIO carry the latent exposure** until their middleware is likewise guarded. -## Packages (11) +## Packages (12) | Package | Vue | Description | | ----------------------- | --- | ---------------------------------------------------------------------------------------------------------------- | @@ -57,6 +57,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th | fs-cached-adapter-store | Yes | Hash-bumping cache wrapper around fs-adapter-store; middleware-driven invalidation with prime() bootstrap; no retrieveAll/retrieveById on the public surface | | fs-toast | Yes | Component-agnostic toast queue (FIFO) | | fs-dialog | Yes | Component-agnostic dialog stack (LIFO) with error middleware | +| fs-form | Yes | Form-submit helpers: double-submit guard + 422 validation-error binding (guarded fs-http middleware); `keyMapper` seam for raw/camel field keys | | fs-translation | Yes | Type-safe reactive i18n with dot-notation keys | | fs-router | Yes | Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router | diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 985d450..a475b57 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -40,6 +40,7 @@ export default defineConfig({ {text: 'fs-loading', link: '/packages/loading'}, {text: 'fs-toast', link: '/packages/toast'}, {text: 'fs-dialog', link: '/packages/dialog'}, + {text: 'fs-form', link: '/packages/form'}, {text: 'fs-translation', link: '/packages/translation'}, ], }, diff --git a/docs/packages/form.md b/docs/packages/form.md new file mode 100644 index 0000000..8692eed --- /dev/null +++ b/docs/packages/form.md @@ -0,0 +1,109 @@ +# fs-form + +Reactive form-submit helpers: a double-submit guard plus 422 validation-error binding for `fs-http`. + +```bash +npm install @script-development/fs-form +``` + +**Peer dependencies:** `vue ^3.5.39`, `@script-development/fs-http ^0.5.0` + +## What It Does + +`fs-form` is the extracted, shared version of a composable pair that two territories independently ran side-by-side: `useValidationErrors` binds backend 422 field errors into a reactive bag, and `useFormSubmit` wraps a submit action with double-submit prevention and validation-aware error handling. The submit half is transport-agnostic (it wraps any `() => Promise`); the validation half hooks `fs-http`'s response-error middleware. + +The two are designed to work together but are independently useful: + +- `useValidationErrors(httpService)` — registers a **422-only** response-error middleware that parses `{errors: {...}}` into a `Partial>` bag (first message per field) and unregisters automatically on unmount. +- `useFormSubmit(validationErrors)` — runs a submit action with a `submitting` flag, ignores re-entrant calls while in flight, clears prior errors before each attempt, **swallows a 422** (the field errors were already surfaced, so the populated form is preserved), and re-throws anything else. + +## Basic Usage + +```vue + + + +``` + +On a 422, `useValidationErrors`' middleware populates `validation.errors.value` and `handleSubmit` swallows the rejection — the form (and its typed input) stays put. On any other failure the rejection propagates to your caller / async error boundary. + +## Key Mapping + +Laravel returns validation keys in the backend's casing (e.g. `first_name`). If your app addresses fields in camelCase, pass a per-key `(key: string) => string` converter: + +```typescript +const camel = (key: string) => key.replace(/_(\w)/g, (_, c: string) => c.toUpperCase()); + +const validation = useValidationErrors(http, {keyMapper: camel}); +// backend `first_name` → bag key `firstName` +``` + +`keyMapper` defaults to identity — keys are used verbatim. + +::: tip Why a keyMapper seam? +The two source territories diverged on exactly one axis: one camelCased the error keys, the other used them raw. `keyMapper` (default identity) is the single injection point that absorbs that divergence, so the package fits both without forking. +::: + +## Middleware Safety (Principle #8) + +`useValidationErrors` wraps its response-error middleware body with `fs-http`'s `guarded()`. A throwing `keyMapper` — or any parse hiccup — is caught and surfaced loudly (via `guarded`'s default `console.error`) **without** rejecting a resolved request or masking the real API error. `fs-form` is a compliant `fs-http` consumer out of the box per the [Middleware Sync Contract](../architecture#middleware-sync-contract). + +## Cleanup + +`useValidationErrors` registers `onUnmounted(unregister)` for you, so a component-scoped instance cleans up its middleware automatically. If you construct one **outside** a component setup, unmount cleanup does not fire — scope it to a component. + +## API Reference + +### `useValidationErrors(httpService, options?)` + +| Parameter | Type | Description | +| ------------------- | ------------------------- | ------------------------------------------------------ | +| `httpService` | `HttpService` | The `fs-http` service whose error responses to observe | +| `options.keyMapper` | `(key: string) => string` | Remaps raw backend field keys (default: identity) | + +**Returns:** + +| Property | Type | Description | +| --------------- | -------------------------- | ----------------------------------------------- | +| `errors` | `Ref>` | Reactive `Partial>` field bag | +| `clearErrors()` | `() => void` | Empty the bag | + +### `useFormSubmit(validationErrors)` + +| Parameter | Type | Description | +| ------------------ | --------------------------- | ---------------------------------------------------------------------------- | +| `validationErrors` | `{clearErrors: () => void}` | Anything exposing `clearErrors` — typically the `useValidationErrors` return | + +**Returns:** + +| Property | Type | Description | +| -------------- | ------------------------------------------------ | ---------------------------------------------- | +| `handleSubmit` | `(action: () => Promise) => Promise` | Runs `action` with double-submit + 422-swallow | +| `submitting` | `Ref` | `true` while an action is in flight | diff --git a/package-lock.json b/package-lock.json index 5b6d282..1a0919d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2183,9 +2183,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2203,9 +2200,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2223,9 +2217,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2243,9 +2234,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2263,9 +2251,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2283,9 +2268,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2303,9 +2285,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2323,9 +2302,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2530,9 +2506,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2550,9 +2523,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2570,9 +2540,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2590,9 +2557,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2610,9 +2574,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2630,9 +2591,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2650,9 +2608,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2670,9 +2625,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3433,6 +3385,10 @@ "resolved": "packages/dialog", "link": true }, + "node_modules/@script-development/fs-form": { + "resolved": "packages/form", + "link": true + }, "node_modules/@script-development/fs-helpers": { "resolved": "packages/helpers", "link": true @@ -10481,6 +10437,25 @@ "vue": "^3.5.39" } }, + "packages/form": { + "name": "@script-development/fs-form", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@script-development/fs-http": "^0.5.0", + "@vue/test-utils": "^2.4.11", + "axios": "^1.18.1", + "happy-dom": "^20.10.3", + "vue": "^3.5.39" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@script-development/fs-http": "^0.5.0", + "vue": "^3.5.39" + } + }, "packages/helpers": { "name": "@script-development/fs-helpers", "version": "0.1.2", diff --git a/packages/form/package.json b/packages/form/package.json new file mode 100644 index 0000000..67f0aa5 --- /dev/null +++ b/packages/form/package.json @@ -0,0 +1,58 @@ +{ + "name": "@script-development/fs-form", + "version": "0.1.0", + "description": "Reactive form-submit helpers: double-submit guard plus 422 validation-error binding for fs-http", + "homepage": "https://packages.script.nl/packages/form", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/script-development/fs-packages.git", + "directory": "packages/form" + }, + "files": [ + "dist" + ], + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "build": "tsdown", + "lint:pkg": "publint && attw --pack", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:mutation": "stryker run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@script-development/fs-http": "^0.5.0", + "@vue/test-utils": "^2.4.11", + "axios": "^1.18.1", + "happy-dom": "^20.10.3", + "vue": "^3.5.39" + }, + "peerDependencies": { + "@script-development/fs-http": "^0.5.0", + "vue": "^3.5.39" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/packages/form/src/form-submit.ts b/packages/form/src/form-submit.ts new file mode 100644 index 0000000..a62487c --- /dev/null +++ b/packages/form/src/form-submit.ts @@ -0,0 +1,42 @@ +import {isAxiosError} from '@script-development/fs-http'; +import {ref} from 'vue'; + +import type {UseFormSubmit} from './types'; + +const HTTP_UNPROCESSABLE_ENTITY = 422; + +/** + * Wrap a form-submit action with double-submit prevention and validation-aware + * error handling. While an action is in flight `submitting` is `true` and + * re-entrant calls are ignored. Before each attempt `clearErrors()` resets the + * previous field errors. + * + * A 422 rejection is swallowed (the field errors were already surfaced by + * `useValidationErrors`' middleware, so the populated form is preserved); every + * other rejection is re-thrown to the caller / async error boundary. + * + * @param validationErrors anything exposing `clearErrors` — typically the object + * returned by `useValidationErrors`. + */ +export const useFormSubmit = (validationErrors: {clearErrors: () => void}): UseFormSubmit => { + const submitting = ref(false); + + const handleSubmit = async (action: () => Promise): Promise => { + if (submitting.value) return; + + submitting.value = true; + validationErrors.clearErrors(); + + try { + await action(); + } catch (error) { + if (isAxiosError(error) && error.response?.status === HTTP_UNPROCESSABLE_ENTITY) return; + + throw error; + } finally { + submitting.value = false; + } + }; + + return {handleSubmit, submitting}; +}; diff --git a/packages/form/src/index.ts b/packages/form/src/index.ts new file mode 100644 index 0000000..7addbfd --- /dev/null +++ b/packages/form/src/index.ts @@ -0,0 +1,3 @@ +export {useFormSubmit} from './form-submit'; +export {useValidationErrors} from './validation-errors'; +export type {UseFormSubmit, UseValidationErrors, UseValidationErrorsOptions, ValidationErrors} from './types'; diff --git a/packages/form/src/types.ts b/packages/form/src/types.ts new file mode 100644 index 0000000..f8f9d2b --- /dev/null +++ b/packages/form/src/types.ts @@ -0,0 +1,37 @@ +import type {Ref} from 'vue'; + +/** Field-error bag: the first backend validation message per field key. */ +export type ValidationErrors = Partial>; + +/** Reactive validation-error state returned by `useValidationErrors`. */ +export type UseValidationErrors = { + /** Current field errors. Populated from a 422 response, cleared on demand. */ + errors: Ref>; + /** Clear all field errors. */ + clearErrors: () => void; +}; + +/** Options for `useValidationErrors`. */ +export type UseValidationErrorsOptions = { + /** + * Maps each raw backend field key to the key stored in the error bag. + * Defaults to identity — keys are used verbatim (e.g. `first_name`). Pass a + * snake→camel converter (such as a per-key wrapper over `fs-helpers`' + * `deepCamelKeys`) when your app addresses fields in camelCase. + * @default (key) => key + */ + keyMapper?: (key: string) => string; +}; + +/** Form-submit helper returned by `useFormSubmit`. */ +export type UseFormSubmit = { + /** + * Run a submit action with double-submit prevention. A 422 (validation) + * rejection is swallowed — the field errors have already been surfaced by + * `useValidationErrors`' response middleware, so the form is preserved. Any + * other rejection is re-thrown to the caller / error boundary. + */ + handleSubmit: (action: () => Promise) => Promise; + /** `true` while a submit action is in flight. */ + submitting: Ref; +}; diff --git a/packages/form/src/validation-errors.ts b/packages/form/src/validation-errors.ts new file mode 100644 index 0000000..5fb4fa0 --- /dev/null +++ b/packages/form/src/validation-errors.ts @@ -0,0 +1,64 @@ +import type {HttpService} from '@script-development/fs-http'; +import type {Ref} from 'vue'; + +import {guarded} from '@script-development/fs-http'; +import {onUnmounted, ref} from 'vue'; + +import type {UseValidationErrors, UseValidationErrorsOptions, ValidationErrors} from './types'; + +const HTTP_UNPROCESSABLE_ENTITY = 422; + +const toFieldErrorMap = (data: unknown): Record => { + const errors = (data as {errors?: unknown} | null | undefined)?.errors; + if (typeof errors !== 'object' || errors === null) return {}; + + return errors as Record; +}; + +const mapFieldErrors = ( + fieldErrors: Record, + keyMapper: (key: string) => string, +): ValidationErrors => + Object.fromEntries( + Object.entries(fieldErrors).map(([key, messages]) => [keyMapper(key), messages[0]]), + ) as ValidationErrors; + +const identity = (key: string): string => key; + +/** + * Register a 422-only response-error middleware on `httpService` that binds + * backend validation errors into a reactive field-error bag, keyed to the first + * message per field. Automatically unregisters on component unmount. + * + * The middleware body is wrapped with fs-http's `guarded()` so a throwing + * `keyMapper` (or any parse hiccup) cannot reject a resolved request nor mask + * the real API error — fs-form is a well-behaved fs-http consumer per the + * Middleware Sync Contract (Architectural Principle #8). + * + * @param httpService the fs-http service whose error responses to observe. + * @param options `keyMapper` remaps raw backend field keys (default identity). + */ +export const useValidationErrors = ( + httpService: HttpService, + options: UseValidationErrorsOptions = {}, +): UseValidationErrors => { + const {keyMapper = identity} = options; + const errors = ref>({}) as Ref>; + + const clearErrors = (): void => { + errors.value = {}; + }; + + const unregister = httpService.registerResponseErrorMiddleware( + guarded((error) => { + const response = error.response; + if (response?.status !== HTTP_UNPROCESSABLE_ENTITY) return; + + errors.value = mapFieldErrors(toFieldErrorMap(response.data), keyMapper); + }), + ); + + onUnmounted(unregister); + + return {errors, clearErrors}; +}; diff --git a/packages/form/stryker.config.mjs b/packages/form/stryker.config.mjs new file mode 100644 index 0000000..8617354 --- /dev/null +++ b/packages/form/stryker.config.mjs @@ -0,0 +1,13 @@ +/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */ +export default { + testRunner: 'vitest', + vitest: {configFile: 'vitest.config.ts'}, + mutate: ['src/**/*.ts', '!src/**/types.ts'], + thresholds: {high: 95, low: 90, break: 90}, + reporters: ['clear-text', 'progress', 'json', 'html'], + jsonReporter: {fileName: 'reports/mutation/mutation.json'}, + htmlReporter: {fileName: 'reports/mutation/mutation.html'}, + incremental: true, + incrementalFile: '.stryker-incremental.json', + cleanTempDir: 'always', +}; diff --git a/packages/form/tests/form-submit.spec.ts b/packages/form/tests/form-submit.spec.ts new file mode 100644 index 0000000..c53571d --- /dev/null +++ b/packages/form/tests/form-submit.spec.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import type {AxiosError} from 'axios'; + +import {describe, expect, it, vi} from 'vitest'; + +import {useFormSubmit} from '../src'; + +const makeAxiosError = (status: number): AxiosError => ({isAxiosError: true, response: {status}}) as AxiosError; + +const deferred = () => { + let resolve!: () => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return {promise, resolve, reject}; +}; + +describe('useFormSubmit', () => { + it('clears prior errors before running the action', async () => { + const clearErrors = vi.fn(); + const {handleSubmit} = useFormSubmit({clearErrors}); + + await handleSubmit(async () => {}); + + expect(clearErrors).toHaveBeenCalledOnce(); + }); + + it('toggles submitting true during the action and false after', async () => { + const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()}); + const gate = deferred(); + + expect(submitting.value).toBe(false); + + const inFlight = handleSubmit(() => gate.promise); + expect(submitting.value).toBe(true); + + gate.resolve(); + await inFlight; + expect(submitting.value).toBe(false); + }); + + it('ignores a re-entrant submit while one is already in flight', async () => { + const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()}); + const gate = deferred(); + const action = vi.fn(() => gate.promise); + + const first = handleSubmit(action); + expect(submitting.value).toBe(true); + + // second call must early-return without invoking the action again + await handleSubmit(action); + expect(action).toHaveBeenCalledOnce(); + + gate.resolve(); + await first; + }); + + it('swallows a 422 rejection so the form is preserved', async () => { + const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()}); + + await expect( + handleSubmit(async () => { + throw makeAxiosError(422); + }), + ).resolves.toBeUndefined(); + expect(submitting.value).toBe(false); + }); + + it('re-throws a non-422 axios rejection', async () => { + const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()}); + const error = makeAxiosError(500); + + await expect( + handleSubmit(async () => { + throw error; + }), + ).rejects.toBe(error); + expect(submitting.value).toBe(false); + }); + + it('re-throws an axios error that carries no response object', async () => { + const {handleSubmit} = useFormSubmit({clearErrors: vi.fn()}); + const error = {isAxiosError: true} as AxiosError; + + await expect( + handleSubmit(async () => { + throw error; + }), + ).rejects.toBe(error); + }); + + it('re-throws a non-axios rejection', async () => { + const {handleSubmit} = useFormSubmit({clearErrors: vi.fn()}); + const error = new Error('network down'); + + await expect( + handleSubmit(async () => { + throw error; + }), + ).rejects.toBe(error); + }); + + it('resets submitting to false even when the action re-throws', async () => { + const {handleSubmit, submitting} = useFormSubmit({clearErrors: vi.fn()}); + + await handleSubmit(async () => { + throw makeAxiosError(500); + }).catch(() => null); + + expect(submitting.value).toBe(false); + }); +}); diff --git a/packages/form/tests/validation-errors.spec.ts b/packages/form/tests/validation-errors.spec.ts new file mode 100644 index 0000000..3e11d94 --- /dev/null +++ b/packages/form/tests/validation-errors.spec.ts @@ -0,0 +1,206 @@ +// @vitest-environment happy-dom +import type {AxiosResponseError, HttpService, ResponseErrorMiddlewareFunc} from '@script-development/fs-http'; +import type {AxiosError} from 'axios'; + +import {mount} from '@vue/test-utils'; +import {afterEach, describe, expect, it, vi} from 'vitest'; +import {defineComponent} from 'vue'; + +import type {UseValidationErrors, UseValidationErrorsOptions} from '../src'; + +import {useValidationErrors} from '../src'; + +type ErrorMiddleware = ResponseErrorMiddlewareFunc; + +const createMockHttpService = () => { + const errorMiddlewares: ErrorMiddleware[] = []; + + const triggerError = (status: number, data: unknown): void => { + const error = {isAxiosError: true, response: {status, data}} as AxiosError; + for (const middleware of errorMiddlewares) middleware(error); + }; + + const triggerBare = (): void => { + const error = {isAxiosError: true, message: 'boom'} as AxiosError; + for (const middleware of errorMiddlewares) middleware(error); + }; + + const registerResponseErrorMiddleware = vi.fn((fn: ErrorMiddleware) => { + errorMiddlewares.push(fn); + return () => { + const index = errorMiddlewares.indexOf(fn); + if (index > -1) errorMiddlewares.splice(index, 1); + }; + }); + + const httpService = { + getRequest: vi.fn(), + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + downloadRequest: vi.fn(), + previewRequest: vi.fn(), + registerRequestMiddleware: vi.fn(() => () => {}), + registerResponseMiddleware: vi.fn(() => () => {}), + registerResponseErrorMiddleware, + } as unknown as HttpService; + + return {httpService, triggerError, triggerBare, registerResponseErrorMiddleware, errorMiddlewares}; +}; + +// Mount useValidationErrors inside a real component so onUnmounted fires. +const mountComposable = (httpService: HttpService, options?: UseValidationErrorsOptions) => { + let result!: UseValidationErrors; + const wrapper = mount( + defineComponent({ + setup() { + result = useValidationErrors(httpService, options); + return () => null; + }, + }), + ); + return {wrapper, result: () => result}; +}; + +const VALIDATION_BODY = {message: 'The given data was invalid.', errors: {first_name: ['Required', 'Too short']}}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('useValidationErrors', () => { + it('registers exactly one response-error middleware', () => { + const {httpService, registerResponseErrorMiddleware} = createMockHttpService(); + mountComposable(httpService); + + expect(registerResponseErrorMiddleware).toHaveBeenCalledOnce(); + }); + + it('binds the first message per field on a 422', () => { + const {httpService, triggerError} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerError(422, VALIDATION_BODY); + + expect(result().errors.value).toEqual({first_name: 'Required'}); + }); + + it('ignores non-422 responses', () => { + const {httpService, triggerError} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerError(500, VALIDATION_BODY); + + expect(result().errors.value).toEqual({}); + }); + + it('yields an empty bag when the 422 body has no errors object', () => { + const {httpService, triggerError} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerError(422, {message: 'nope'}); + + expect(result().errors.value).toEqual({}); + }); + + it('yields an empty bag when errors is null', () => { + const {httpService, triggerError} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerError(422, {errors: null}); + + expect(result().errors.value).toEqual({}); + }); + + it('yields an empty bag when errors is not an object', () => { + const {httpService, triggerError} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerError(422, {errors: 'not-an-object'}); + + expect(result().errors.value).toEqual({}); + }); + + it('yields an empty bag when the 422 body is a non-object', () => { + const {httpService, triggerError} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerError(422, 'plain string body'); + + expect(result().errors.value).toEqual({}); + }); + + it('yields an empty bag when the 422 body is null', () => { + const {httpService, triggerError} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerError(422, null); + + expect(result().errors.value).toEqual({}); + }); + + it('handles an error with no response at all', () => { + const {httpService, triggerBare} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerBare(); + + expect(result().errors.value).toEqual({}); + }); + + it('applies a custom keyMapper to field keys', () => { + const {httpService, triggerError} = createMockHttpService(); + const keyMapper = (key: string) => key.replace(/_(\w)/g, (_, c: string) => c.toUpperCase()); + const {result} = mountComposable(httpService, {keyMapper}); + + triggerError(422, VALIDATION_BODY); + + expect(result().errors.value).toEqual({firstName: 'Required'}); + }); + + it('uses raw keys by default (identity keyMapper)', () => { + const {httpService, triggerError} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerError(422, {errors: {street_name: ['Required']}}); + + expect(result().errors.value).toEqual({street_name: 'Required'}); + }); + + it('clearErrors empties a populated bag', () => { + const {httpService, triggerError} = createMockHttpService(); + const {result} = mountComposable(httpService); + + triggerError(422, VALIDATION_BODY); + expect(result().errors.value).toEqual({first_name: 'Required'}); + + result().clearErrors(); + expect(result().errors.value).toEqual({}); + }); + + it('unregisters the middleware on unmount', () => { + const {httpService, errorMiddlewares} = createMockHttpService(); + const {wrapper} = mountComposable(httpService); + + expect(errorMiddlewares).toHaveLength(1); + + wrapper.unmount(); + + expect(errorMiddlewares).toHaveLength(0); + }); + + it('swallows a throwing keyMapper via guarded() instead of rejecting', () => { + const {httpService, triggerError} = createMockHttpService(); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const keyMapper = () => { + throw new Error('mapper blew up'); + }; + const {result} = mountComposable(httpService, {keyMapper}); + + // The middleware body throws inside guarded(); it must not propagate. + expect(() => triggerError(422, VALIDATION_BODY)).not.toThrow(); + expect(result().errors.value).toEqual({}); + expect(consoleError).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/form/tsconfig.json b/packages/form/tsconfig.json new file mode 100644 index 0000000..850007d --- /dev/null +++ b/packages/form/tsconfig.json @@ -0,0 +1 @@ +{"extends": "../../tsconfig.base.json", "compilerOptions": {"outDir": "dist", "rootDir": "src"}, "include": ["src"]} diff --git a/packages/form/tsdown.config.ts b/packages/form/tsdown.config.ts new file mode 100644 index 0000000..d4ab38f --- /dev/null +++ b/packages/form/tsdown.config.ts @@ -0,0 +1,3 @@ +import {defineConfig} from 'tsdown'; + +export default defineConfig({entry: ['src/index.ts'], format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true}); diff --git a/packages/form/vitest.config.ts b/packages/form/vitest.config.ts new file mode 100644 index 0000000..803b93f --- /dev/null +++ b/packages/form/vitest.config.ts @@ -0,0 +1,12 @@ +import {defineProject} from 'vitest/config'; + +export default defineProject({ + test: { + name: 'form', + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + thresholds: {lines: 100, branches: 100, functions: 100, statements: 100}, + }, + }, +}); From 174f2c657ffdfa669fe5e53b994426339bb235ad Mon Sep 17 00:00:00 2001 From: Gerard Oosterhof Date: Wed, 8 Jul 2026 11:03:06 +0200 Subject: [PATCH 2/3] feat(form): add useForm one-call aggregator over the two primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire useValidationErrors + useFormSubmit into a single useForm(httpService, {keyMapper?}) so a page composes one call, not two — returns {errors, clearErrors, handleSubmit, submitting}. submitting is the form's loading state (true while a submit is in flight); no fs-loading coupling (its global counter is the wrong granularity for per-form state). The primitives stay exported for the one-half cases (validation-less confirm action, etc.). Docs lead with useForm; primitives documented as composition. Gauntlet: 100% coverage (form.ts + form-submit.ts 100% mutation), 92.31% package mutation, lint:pkg clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_011R1MbtAWbFjfmwkFVgVYEq --- CLAUDE.md | 2 +- docs/packages/form.md | 76 +++++++++------- packages/form/src/form.ts | 31 +++++++ packages/form/src/index.ts | 10 +- packages/form/src/types.ts | 12 ++- packages/form/tests/form.spec.ts | 152 +++++++++++++++++++++++++++++++ 6 files changed, 249 insertions(+), 34 deletions(-) create mode 100644 packages/form/src/form.ts create mode 100644 packages/form/tests/form.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 573333f..620d502 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th | fs-cached-adapter-store | Yes | Hash-bumping cache wrapper around fs-adapter-store; middleware-driven invalidation with prime() bootstrap; no retrieveAll/retrieveById on the public surface | | fs-toast | Yes | Component-agnostic toast queue (FIFO) | | fs-dialog | Yes | Component-agnostic dialog stack (LIFO) with error middleware | -| fs-form | Yes | Form-submit helpers: double-submit guard + 422 validation-error binding (guarded fs-http middleware); `keyMapper` seam for raw/camel field keys | +| fs-form | Yes | One-call `useForm`: double-submit guard + `submitting` loading flag + 422 validation-error binding (guarded fs-http middleware); `keyMapper` seam for raw/camel field keys. `useValidationErrors`/`useFormSubmit` primitives still exported | | fs-translation | Yes | Type-safe reactive i18n with dot-notation keys | | fs-router | Yes | Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router | diff --git a/docs/packages/form.md b/docs/packages/form.md index 8692eed..2817a86 100644 --- a/docs/packages/form.md +++ b/docs/packages/form.md @@ -1,6 +1,6 @@ # fs-form -Reactive form-submit helpers: a double-submit guard plus 422 validation-error binding for `fs-http`. +Reactive form-submit helpers: a double-submit guard plus 422 validation-error binding for `fs-http` — from a single composable. ```bash npm install @script-development/fs-form @@ -10,25 +10,25 @@ npm install @script-development/fs-form ## What It Does -`fs-form` is the extracted, shared version of a composable pair that two territories independently ran side-by-side: `useValidationErrors` binds backend 422 field errors into a reactive bag, and `useFormSubmit` wraps a submit action with double-submit prevention and validation-aware error handling. The submit half is transport-agnostic (it wraps any `() => Promise`); the validation half hooks `fs-http`'s response-error middleware. +`fs-form` is the extracted, shared version of a composable pair that two territories independently ran side-by-side. The one-call entry point is **`useForm`**, which wires both halves together: -The two are designed to work together but are independently useful: +- a reactive **field-error bag** populated from backend 422 responses (first message per field), +- a **`submitting`** flag that is the form's loading state (`true` while a submit is in flight), +- a validation-aware **`handleSubmit`** that prevents double-submit, clears prior errors, **swallows a 422** (the errors were already surfaced, so the populated form is preserved), and re-throws everything else. -- `useValidationErrors(httpService)` — registers a **422-only** response-error middleware that parses `{errors: {...}}` into a `Partial>` bag (first message per field) and unregisters automatically on unmount. -- `useFormSubmit(validationErrors)` — runs a submit action with a `submitting` flag, ignores re-entrant calls while in flight, clears prior errors before each attempt, **swallows a 422** (the field errors were already surfaced, so the populated form is preserved), and re-throws anything else. +The two building blocks — `useValidationErrors` and `useFormSubmit` — remain exported for the cases where you want one half without the other. ## Basic Usage ```vue