Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ----------------------- | --- | ---------------------------------------------------------------------------------------------------------------- |
Expand All @@ -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 |

Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
],
},
Expand Down
109 changes: 109 additions & 0 deletions docs/packages/form.md
Original file line number Diff line number Diff line change
@@ -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<void>`); 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<Record<field, message>>` 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
<script setup lang="ts">
import {useFormSubmit, useValidationErrors} from '@script-development/fs-form';

import {http} from '@/services';

type Field = 'name' | 'email';

const validation = useValidationErrors<Field>(http);
const {handleSubmit, submitting} = useFormSubmit(validation);

const form = reactive({name: '', email: ''});

const submit = () =>
handleSubmit(async () => {
await http.postRequest('/users', form);
// navigate away, toast success, etc.
});
</script>

<template>
<form @submit.prevent="submit">
<input v-model="form.name" />
<span v-if="validation.errors.value.name">{{ validation.errors.value.name }}</span>

<input v-model="form.email" />
<span v-if="validation.errors.value.email">{{ validation.errors.value.email }}</span>

<button type="submit" :disabled="submitting.value">Save</button>
</form>
</template>
```

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<Field>(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<ValidationErrors<T>>` | Reactive `Partial<Record<T, string>>` 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<void>) => Promise<void>` | Runs `action` with double-submit + 422-swallow |
| `submitting` | `Ref<boolean>` | `true` while an action is in flight |
71 changes: 23 additions & 48 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

58 changes: 58 additions & 0 deletions packages/form/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading