Skip to content

Commit b57f6bc

Browse files
authored
Merge pull request #148 from script-development/feat/fs-form-package
feat(form): add fs-form — useFormSubmit + useValidationErrors
2 parents 21d7a85 + 979c54f commit b57f6bc

17 files changed

Lines changed: 909 additions & 49 deletions

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th
4444

4545
**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.
4646

47-
## Packages (11)
47+
## Packages (12)
4848

4949
| Package | Vue | Description |
5050
| ----------------------- | --- | ---------------------------------------------------------------------------------------------------------------- |
@@ -57,6 +57,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th
5757
| 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 |
5858
| fs-toast | Yes | Component-agnostic toast queue (FIFO) |
5959
| fs-dialog | Yes | Component-agnostic dialog stack (LIFO) with error middleware |
60+
| 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 |
6061
| fs-translation | Yes | Type-safe reactive i18n with dot-notation keys |
6162
| fs-router | Yes | Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router |
6263

docs/.vitepress/config.mts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export default defineConfig({
4040
{text: 'fs-loading', link: '/packages/loading'},
4141
{text: 'fs-toast', link: '/packages/toast'},
4242
{text: 'fs-dialog', link: '/packages/dialog'},
43+
{text: 'fs-form', link: '/packages/form'},
4344
{text: 'fs-translation', link: '/packages/translation'},
4445
],
4546
},

docs/packages/form.md

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
# fs-form
2+
3+
Reactive form-submit helpers: a double-submit guard plus 422 validation-error binding for `fs-http` — from a single composable.
4+
5+
```bash
6+
npm install @script-development/fs-form
7+
```
8+
9+
**Peer dependencies:** `vue ^3.5.39`, `@script-development/fs-http ^0.5.0`
10+
11+
## What It Does
12+
13+
`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:
14+
15+
- a reactive **field-error bag** populated from backend 422 responses (first message per field),
16+
- a **`submitting`** flag that is the form's loading state (`true` while a submit is in flight),
17+
- 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.
18+
19+
The two building blocks — `useValidationErrors` and `useFormSubmit` — remain exported for the cases where you want one half without the other.
20+
21+
## Basic Usage
22+
23+
```vue
24+
<script setup lang="ts">
25+
import {useForm} from '@script-development/fs-form';
26+
27+
import {http} from '@/services';
28+
29+
type Field = 'name' | 'email';
30+
31+
const {errors, submitting, handleSubmit} = useForm<Field>(http);
32+
33+
const form = reactive({name: '', email: ''});
34+
35+
const submit = () =>
36+
handleSubmit(async () => {
37+
await http.postRequest('/users', form);
38+
// navigate away, toast success, etc.
39+
});
40+
</script>
41+
42+
<template>
43+
<form @submit.prevent="submit">
44+
<input v-model="form.name" />
45+
<span v-if="errors.value.name">{{ errors.value.name }}</span>
46+
47+
<input v-model="form.email" />
48+
<span v-if="errors.value.email">{{ errors.value.email }}</span>
49+
50+
<button type="submit" :disabled="submitting.value">Save</button>
51+
</form>
52+
</template>
53+
```
54+
55+
One call, everything wired. On a 422 the field errors populate 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.
56+
57+
## Key Mapping
58+
59+
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:
60+
61+
```typescript
62+
const camel = (key: string) => key.replace(/_(\w)/g, (_, c: string) => c.toUpperCase());
63+
64+
const {errors, submitting, handleSubmit} = useForm<Field>(http, {keyMapper: camel});
65+
// backend `first_name` → bag key `firstName`
66+
```
67+
68+
`keyMapper` defaults to identity — keys are used verbatim.
69+
70+
::: tip Why a keyMapper seam?
71+
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.
72+
:::
73+
74+
## Composing the Primitives
75+
76+
`useForm` is `useValidationErrors` + `useFormSubmit` wired together. Reach for the primitives directly when you want one half without the other — e.g. a validation-less confirm action needs the submit guard but no 422 middleware:
77+
78+
```typescript
79+
import {useFormSubmit, useValidationErrors} from '@script-development/fs-form';
80+
81+
// submit guard only — no validation middleware registered
82+
const {handleSubmit, submitting} = useFormSubmit({clearErrors: () => {}});
83+
84+
// or the two, wired by hand (exactly what useForm does internally)
85+
const validation = useValidationErrors<Field>(http);
86+
const {handleSubmit, submitting} = useFormSubmit(validation);
87+
```
88+
89+
## Scoping & Backend Contract
90+
91+
**One error-scope per form.** `useValidationErrors` (and therefore `useForm`) registers a 422 observer on the `HttpService` you pass and keeps its own error bag. If two forms share **one** `HttpService` instance, a 422 from either fills **both** bags — cross-form bleed, with green types. Give each form its own error scope: one form per `HttpService` instance, or don't share a service across concurrently-mounted forms. (The single-form-per-scope shape matches the source territories; a multi-form-per-service layout is the case to watch.)
92+
93+
**Laravel 422 contract.** `fs-form` targets **Laravel**'s validation-error response shape — `{ message?: string, errors: Record<string, string[]> }` — and binds the first message per field. It deliberately does **not** defensively handle other backends' error shapes: a field whose value is not a `string[]` is passed through unguarded (Laravel guarantees arrays, so the cast is safe against every intended consumer). If you point `fs-form` at a non-Laravel backend, revisit the parse in `useValidationErrors` first.
94+
95+
## Middleware Safety (Principle #8)
96+
97+
`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).
98+
99+
## Cleanup
100+
101+
`useValidationErrors` (and therefore `useForm`) 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.
102+
103+
## API Reference
104+
105+
### `useForm(httpService, options?)`
106+
107+
The one-call entry point. Returns everything from both primitives.
108+
109+
| Parameter | Type | Description |
110+
| ------------------- | ------------------------- | ---------------------------------------------------- |
111+
| `httpService` | `HttpService` | The `fs-http` service whose 422 responses to observe |
112+
| `options.keyMapper` | `(key: string) => string` | Remaps raw backend field keys (default: identity) |
113+
114+
**Returns:**
115+
116+
| Property | Type | Description |
117+
| --------------- | ------------------------------------------------ | -------------------------------------------------- |
118+
| `errors` | `Ref<ValidationErrors<T>>` | Reactive `Partial<Record<T, string>>` field bag |
119+
| `clearErrors()` | `() => void` | Empty the bag |
120+
| `handleSubmit` | `(action: () => Promise<void>) => Promise<void>` | Runs `action` with double-submit + 422-swallow |
121+
| `submitting` | `Ref<boolean>` | `true` while a submit is in flight (loading state) |
122+
123+
### `useValidationErrors(httpService, options?)`
124+
125+
Primitive: registers the 422 middleware and owns the error bag. Same `httpService` / `options` as `useForm`; returns `{errors, clearErrors}`.
126+
127+
### `useFormSubmit(validationErrors)`
128+
129+
Primitive: the submit guard. Takes anything exposing `clearErrors` (typically the `useValidationErrors` return); returns `{handleSubmit, submitting}`.

package-lock.json

Lines changed: 23 additions & 48 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)