Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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 | 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 |

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
129 changes: 129 additions & 0 deletions docs/packages/form.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
# fs-form

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
```

**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. The one-call entry point is **`useForm`**, which wires both halves together:

- 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.

The two building blocks — `useValidationErrors` and `useFormSubmit` — remain exported for the cases where you want one half without the other.

## Basic Usage

```vue
<script setup lang="ts">
import {useForm} from '@script-development/fs-form';

import {http} from '@/services';

type Field = 'name' | 'email';

const {errors, submitting, handleSubmit} = useForm<Field>(http);

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="errors.value.name">{{ errors.value.name }}</span>

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

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

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.

## 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 {errors, submitting, handleSubmit} = useForm<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.
:::

## Composing the Primitives

`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:

```typescript
import {useFormSubmit, useValidationErrors} from '@script-development/fs-form';

// submit guard only — no validation middleware registered
const {handleSubmit, submitting} = useFormSubmit({clearErrors: () => {}});

// or the two, wired by hand (exactly what useForm does internally)
const validation = useValidationErrors<Field>(http);
const {handleSubmit, submitting} = useFormSubmit(validation);
```

## Scoping & Backend Contract

**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.)

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

## 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` (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.

## API Reference

### `useForm(httpService, options?)`

The one-call entry point. Returns everything from both primitives.

| Parameter | Type | Description |
| ------------------- | ------------------------- | ---------------------------------------------------- |
| `httpService` | `HttpService` | The `fs-http` service whose 422 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 |
| `handleSubmit` | `(action: () => Promise<void>) => Promise<void>` | Runs `action` with double-submit + 422-swallow |
| `submitting` | `Ref<boolean>` | `true` while a submit is in flight (loading state) |

### `useValidationErrors(httpService, options?)`

Primitive: registers the 422 middleware and owns the error bag. Same `httpService` / `options` as `useForm`; returns `{errors, clearErrors}`.

### `useFormSubmit(validationErrors)`

Primitive: the submit guard. Takes anything exposing `clearErrors` (typically the `useValidationErrors` return); returns `{handleSubmit, submitting}`.
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.

Loading