|
| 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}`. |
0 commit comments