@maestro-js/agent-skills 1.0.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/choosing-an-npm-package/SKILL.md +72 -0
- package/dist/maestro-cli/SKILL.md +257 -0
- package/dist/maestro-packages/SKILL.md +42 -0
- package/dist/maestro-packages/broadcast.md +306 -0
- package/dist/maestro-packages/cache-control.md +337 -0
- package/dist/maestro-packages/cache.md +397 -0
- package/dist/maestro-packages/config.md +203 -0
- package/dist/maestro-packages/context.md +365 -0
- package/dist/maestro-packages/crypt.md +253 -0
- package/dist/maestro-packages/db.md +391 -0
- package/dist/maestro-packages/events.md +295 -0
- package/dist/maestro-packages/exceptions.md +231 -0
- package/dist/maestro-packages/form.md +391 -0
- package/dist/maestro-packages/hash.md +248 -0
- package/dist/maestro-packages/helpers.md +249 -0
- package/dist/maestro-packages/log.md +321 -0
- package/dist/maestro-packages/mail.md +365 -0
- package/dist/maestro-packages/metrics.md +236 -0
- package/dist/maestro-packages/queue.md +284 -0
- package/dist/maestro-packages/rate-limiting.md +206 -0
- package/dist/maestro-packages/react-router-file-routes.md +337 -0
- package/dist/maestro-packages/recurring-jobs.md +353 -0
- package/dist/maestro-packages/sql.md +316 -0
- package/dist/maestro-packages/tracing.md +240 -0
- package/package.json +33 -0
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: exceptions
|
|
3
|
+
description: "Centralized exception reporting with automatic severity mapping and structured context, backed by @maestro-js/log. Use when working with @maestro-js/exceptions — reporting errors, silencing expected exceptions, attaching tags/extra metadata to error reports, or integrating exception handling into application services. Key capabilities: report exceptions with auto-detected severity (Error maps to 'error', non-Error maps to 'warn'), override log level via context, ignore/unignore specific exception instances, attach structured tags and extra data to reports. Follows the Provider pattern from @maestro-js/service-registry."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @maestro-js/exceptions
|
|
7
|
+
|
|
8
|
+
Centralized exception reporting that forwards exceptions as structured log entries through `@maestro-js/log`. Severity is auto-detected (`Error` instances log at `'error'`, everything else at `'warn'`) but can be overridden per report.
|
|
9
|
+
|
|
10
|
+
## Quick Setup
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import { Log } from '@maestro-js/log'
|
|
14
|
+
import { Exceptions } from '@maestro-js/exceptions'
|
|
15
|
+
|
|
16
|
+
// 1. Create a logger
|
|
17
|
+
const logger = Log.create<[Exceptions.LogMessage]>({
|
|
18
|
+
transports: [{ write(message) { console.log(message) } }]
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
// 2. Create and register the exceptions service
|
|
22
|
+
const service = Exceptions.Provider.create({ logger })
|
|
23
|
+
Exceptions.Provider.register('default', service)
|
|
24
|
+
|
|
25
|
+
// 3. Use the facade directly
|
|
26
|
+
Exceptions.report(new Error('disk full'))
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Provider Pattern
|
|
30
|
+
|
|
31
|
+
Follows the standard Maestro service-registry pattern.
|
|
32
|
+
|
|
33
|
+
| Export | Purpose |
|
|
34
|
+
|---|---|
|
|
35
|
+
| `Exceptions.Provider.create(config)` | Factory — accepts `{ logger }` and returns an `ExceptionsService` |
|
|
36
|
+
| `Exceptions.Provider.register(name, service)` | Register a named instance in the registry |
|
|
37
|
+
| `Exceptions.provider(key)` | Resolve a named instance |
|
|
38
|
+
| `Exceptions.report(...)` / `Exceptions.ignore(...)` / etc. | Facade — delegates to the `'default'` provider |
|
|
39
|
+
|
|
40
|
+
### Config
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
interface ExceptionsServiceConfig {
|
|
44
|
+
logger: Log.LogFunctions<[Exceptions.LogMessage]>
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The `logger` must be a typed `Log` instance parameterized with `[Exceptions.LogMessage]`. Typically created via `Log.create<[Exceptions.LogMessage]>({ transports: [...] })` or by calling `.channel()` on an existing logger.
|
|
49
|
+
|
|
50
|
+
## API Reference
|
|
51
|
+
|
|
52
|
+
### `report(exception: unknown, context?: Exceptions.Context): void`
|
|
53
|
+
|
|
54
|
+
Log an exception through the configured logger. Skips exceptions marked by `ignore()`.
|
|
55
|
+
|
|
56
|
+
- `Error` instances default to `'error'` level.
|
|
57
|
+
- Non-Error values (strings, objects, etc.) default to `'warn'` level.
|
|
58
|
+
- Override level, attach `tags`, or add `extra` metadata via the context parameter.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
// Basic error report
|
|
62
|
+
Exceptions.report(new Error('disk full'))
|
|
63
|
+
|
|
64
|
+
// Non-Error value — logged at 'warn'
|
|
65
|
+
Exceptions.report('something broke')
|
|
66
|
+
|
|
67
|
+
// Override level and attach metadata
|
|
68
|
+
Exceptions.report(err, {
|
|
69
|
+
level: 'emergency',
|
|
70
|
+
tags: { userId: '42', region: 'us-east' },
|
|
71
|
+
extra: { requestBody: body }
|
|
72
|
+
})
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### `ignore<E>(exception: E): E`
|
|
76
|
+
|
|
77
|
+
Mark an exception so future `report()` calls silently skip it. Returns the same reference.
|
|
78
|
+
|
|
79
|
+
Only object-type values (Error instances, plain objects, arrays) are tracked via an internal `WeakMap`. Primitives (string, number, null, undefined) are returned unchanged but not tracked.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const err = new Error('expected')
|
|
83
|
+
Exceptions.ignore(err)
|
|
84
|
+
Exceptions.report(err) // no-op — silently skipped
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### `unignore<E>(exception: E): E`
|
|
88
|
+
|
|
89
|
+
Reverse a previous `ignore()` call, allowing the exception to be reported again. Returns the same reference. No-op if the exception was never ignored or is a primitive.
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
Exceptions.unignore(err)
|
|
93
|
+
Exceptions.report(err) // now logged again
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### `isIgnored(exception: unknown): boolean`
|
|
97
|
+
|
|
98
|
+
Check whether an exception has been previously marked by `ignore()`. Returns `false` for primitives.
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
Exceptions.isIgnored(err) // true if ignore() was called on this reference
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Types
|
|
105
|
+
|
|
106
|
+
### `Exceptions.LogMessage`
|
|
107
|
+
|
|
108
|
+
Structured payload written to the logger on each `report()` call.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
interface LogMessage {
|
|
112
|
+
exception: any
|
|
113
|
+
tags?: { [key: string]: Primitive }
|
|
114
|
+
extra?: Record<string, unknown>
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`Primitive` is `number | string | boolean | bigint | symbol | null | undefined`.
|
|
119
|
+
|
|
120
|
+
### `Exceptions.Context`
|
|
121
|
+
|
|
122
|
+
Optional metadata passed to `report()`.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
type Context = {
|
|
126
|
+
tags?: { [key: string]: Primitive }
|
|
127
|
+
extra?: Record<string, unknown>
|
|
128
|
+
level?: Log.Level
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
When `level` is omitted, severity is auto-detected from the exception type.
|
|
133
|
+
|
|
134
|
+
## Common Patterns
|
|
135
|
+
|
|
136
|
+
### Error Reporting with Context
|
|
137
|
+
|
|
138
|
+
Attach structured tags for filtering and extra data for debugging.
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
try {
|
|
142
|
+
await processOrder(orderId)
|
|
143
|
+
} catch (err) {
|
|
144
|
+
Exceptions.report(err, {
|
|
145
|
+
tags: { orderId, userId: ctx.userId },
|
|
146
|
+
extra: { payload: ctx.body }
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Silencing Expected Exceptions
|
|
152
|
+
|
|
153
|
+
Mark known/expected exceptions to prevent noise in logs.
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
const err = new Error('expected timeout')
|
|
157
|
+
const ignored = Exceptions.ignore(err)
|
|
158
|
+
// ... pass ignored around; any report() call with this reference is a no-op
|
|
159
|
+
Exceptions.report(ignored) // silent
|
|
160
|
+
|
|
161
|
+
// Later, re-enable reporting
|
|
162
|
+
Exceptions.unignore(ignored)
|
|
163
|
+
Exceptions.report(ignored) // now logged
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
### Multiple Named Providers
|
|
167
|
+
|
|
168
|
+
Register separate exception services for different subsystems.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
const apiService = Exceptions.Provider.create({ logger: apiLogger })
|
|
172
|
+
Exceptions.Provider.register('api', apiService)
|
|
173
|
+
|
|
174
|
+
const workerService = Exceptions.Provider.create({ logger: workerLogger })
|
|
175
|
+
Exceptions.Provider.register('worker', workerService)
|
|
176
|
+
|
|
177
|
+
// Use a specific provider
|
|
178
|
+
Exceptions.provider('api').report(err)
|
|
179
|
+
Exceptions.provider('worker').report(err)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Isolation Between Services
|
|
183
|
+
|
|
184
|
+
Each service instance maintains its own ignore state. Ignoring an exception in one service does not affect other services.
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
const a = Exceptions.Provider.create({ logger: loggerA })
|
|
188
|
+
const b = Exceptions.Provider.create({ logger: loggerB })
|
|
189
|
+
|
|
190
|
+
a.ignore(err)
|
|
191
|
+
a.isIgnored(err) // true
|
|
192
|
+
b.isIgnored(err) // false — independent state
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
## Cross-Package Integration
|
|
196
|
+
|
|
197
|
+
### Dependencies
|
|
198
|
+
|
|
199
|
+
- **`@maestro-js/service-registry`** — provides the Provider registry pattern
|
|
200
|
+
- **`@maestro-js/log`** — the logger that receives exception reports; the `logger` config must be a `Log.LogFunctions<[Exceptions.LogMessage]>` instance
|
|
201
|
+
|
|
202
|
+
### Works With
|
|
203
|
+
|
|
204
|
+
- **`@maestro-js/log`** — create typed loggers with transports (console, file, external services) that receive structured `LogMessage` entries
|
|
205
|
+
- **`@maestro-js/context`** — use `AsyncLocalStorage`-based context to pull request-scoped data (user ID, request ID) into exception report tags/extra
|
|
206
|
+
|
|
207
|
+
## Testing
|
|
208
|
+
|
|
209
|
+
Tests are at `packages/exceptions/tests/exceptions.test.ts`.
|
|
210
|
+
|
|
211
|
+
```bash
|
|
212
|
+
pnpm --filter @maestro-js/exceptions test
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Test setup creates an in-memory logger that captures messages for assertion:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
function setup() {
|
|
219
|
+
const messages: Log.Message<[Exceptions.LogMessage]>[] = []
|
|
220
|
+
const logger = Log.create<[Exceptions.LogMessage]>({
|
|
221
|
+
transports: [{ write(message) { messages.push(message) } }]
|
|
222
|
+
})
|
|
223
|
+
const service = Exceptions.Provider.create({ logger })
|
|
224
|
+
return { service, messages }
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
## Source Files
|
|
229
|
+
|
|
230
|
+
- `packages/exceptions/src/index.ts` — single source file containing types, factory, registry, and facade
|
|
231
|
+
- `packages/exceptions/package.json` — package manifest
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: form
|
|
3
|
+
description: "Headless React form primitives with client and server validation, hidden inputs for custom components, controlled/uncontrolled state management, and Standard Schema (Zod) FormData parsing with automatic type coercion. Use when building custom form components that need native constraint validation, server error integration, file upload handling, or type-safe FormData parsing. Provides HeadlessForm root component, useValidation/useWarningState/useControlledState/useReset hooks, HiddenInput/HiddenFileInput/HiddenSelect components, parseFormData/parseFormDataClientSide utilities, and composeValidators."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# @maestro-js/form
|
|
7
|
+
|
|
8
|
+
Headless React form primitives with client and server validation, hidden inputs for custom components, controlled/uncontrolled state management, and Standard Schema (Zod) FormData parsing with automatic type coercion.
|
|
9
|
+
|
|
10
|
+
## Quick Setup
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
14
|
+
|
|
15
|
+
// 1. Wrap your app with GlobalProvider to enable server error integration
|
|
16
|
+
function App() {
|
|
17
|
+
return (
|
|
18
|
+
<HeadlessForm.GlobalProvider useServerErrors={useMyServerErrors}>
|
|
19
|
+
<MyPage />
|
|
20
|
+
</HeadlessForm.GlobalProvider>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// 2. Use HeadlessForm as your form element
|
|
25
|
+
function LoginForm() {
|
|
26
|
+
return (
|
|
27
|
+
<HeadlessForm as="form" method="post" action="/login">
|
|
28
|
+
<input name="email" type="email" required />
|
|
29
|
+
<button type="submit">Log in</button>
|
|
30
|
+
</HeadlessForm>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Core Concepts
|
|
36
|
+
|
|
37
|
+
### Validation Flow
|
|
38
|
+
|
|
39
|
+
Custom validation via `validate` functions runs in realtime, setting `setCustomValidity()` on the referenced native element. Errors are committed (shown to the user) on input `change` or form `submit`. Server errors from `GlobalProvider` are shown immediately and cleared when the user edits the field. The first invalid input auto-focuses on submit.
|
|
40
|
+
|
|
41
|
+
### Hidden Inputs for Custom Components
|
|
42
|
+
|
|
43
|
+
Custom UI components (dropdowns, file pickers, date pickers) can't natively participate in HTML forms. `HiddenInput`, `HiddenFileInput`, and `HiddenSelect` bridge this gap with visually-hidden native elements that carry the value and participate in constraint validation.
|
|
44
|
+
|
|
45
|
+
### FormData Parsing
|
|
46
|
+
|
|
47
|
+
`parseFormData` and `parseFormDataClientSide` convert native FormData into typed objects using a Standard Schema (Zod). They handle type coercion (strings to numbers/booleans), nested objects via dot notation (`address.city`), arrays, and file uploads.
|
|
48
|
+
|
|
49
|
+
## API Reference
|
|
50
|
+
|
|
51
|
+
### HeadlessForm (Component)
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
<HeadlessForm as="form" method="post" id="profile-form">
|
|
55
|
+
{children}
|
|
56
|
+
</HeadlessForm>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Root form component. Wraps a native `<form>` with validation and server error support.
|
|
60
|
+
|
|
61
|
+
- `as` -- Component to render (must resolve to `HTMLFormElement`)
|
|
62
|
+
- `method` -- `'get'` | `'post'` | `'put'` | `'patch'` | `'delete'`
|
|
63
|
+
- `id` -- Optional (auto-generated via `useId()` if omitted)
|
|
64
|
+
- Plus all standard form element props
|
|
65
|
+
|
|
66
|
+
GET forms validate on submit and prevent submission if invalid. Non-GET forms inject a hidden `__formId` input for server-side form identification.
|
|
67
|
+
|
|
68
|
+
### GlobalProvider
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
<HeadlessForm.GlobalProvider useServerErrors={useMyServerErrors}>
|
|
72
|
+
{children}
|
|
73
|
+
</HeadlessForm.GlobalProvider>
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Wraps your app to provide server error resolution to all forms. The `useServerErrors` prop is a hook with signature:
|
|
77
|
+
|
|
78
|
+
```ts
|
|
79
|
+
(formId: string) => { path: string; message: string }[] | null
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### useValidation
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
HeadlessForm.useValidation<T>(
|
|
86
|
+
props: {
|
|
87
|
+
name: string | string[]
|
|
88
|
+
value: T
|
|
89
|
+
form: string
|
|
90
|
+
validate?: ValidationFunction<T>
|
|
91
|
+
isInvalid?: boolean
|
|
92
|
+
isRequired?: boolean
|
|
93
|
+
focus?: () => void
|
|
94
|
+
},
|
|
95
|
+
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
|
96
|
+
): {
|
|
97
|
+
isInvalid: boolean
|
|
98
|
+
validationErrors: string[]
|
|
99
|
+
validationDetails: ValidityState
|
|
100
|
+
validationMessage: string
|
|
101
|
+
commitValidation(): void
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Connects custom validation to the native Constraint Validation API. Use inside custom input components.
|
|
106
|
+
|
|
107
|
+
- Runs `validate(value)` in realtime, sets `setCustomValidity()` on the referenced element
|
|
108
|
+
- Commits errors on `change` event or form submit (`invalid` event)
|
|
109
|
+
- Auto-focuses the first invalid input on submission
|
|
110
|
+
- Integrates server errors from `GlobalProvider` -- clears when user edits
|
|
111
|
+
- Resets on form `reset` event
|
|
112
|
+
|
|
113
|
+
### useWarningState
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
HeadlessForm.useWarningState<T>(
|
|
117
|
+
props: { value: T; validate?: ValidationFunction<T>; isInvalid?: boolean }
|
|
118
|
+
): {
|
|
119
|
+
realtimeValidation: ValidationResult
|
|
120
|
+
displayValidation: ValidationResult
|
|
121
|
+
warningMessage: string
|
|
122
|
+
commitValidation(): void
|
|
123
|
+
resetValidation(): void
|
|
124
|
+
updateValidation(result: ValidationResult): void
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Like `useValidation` but for non-blocking warnings. Does not integrate with native constraint validation or server errors. Use for soft validation hints that don't prevent submission.
|
|
129
|
+
|
|
130
|
+
### useControlledState
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
HeadlessForm.useControlledState<T>(
|
|
134
|
+
value: T | undefined,
|
|
135
|
+
defaultValue: T,
|
|
136
|
+
onChange?: (v: T) => void
|
|
137
|
+
): [T, (value: T) => void]
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Manages controlled/uncontrolled state for custom form components. When `value` is defined, the component is controlled; when `undefined`, it uses `defaultValue` and manages its own state.
|
|
141
|
+
|
|
142
|
+
### useReset
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
HeadlessForm.useReset(
|
|
146
|
+
ref: RefObject<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>,
|
|
147
|
+
initialValue: T,
|
|
148
|
+
onReset: (value: T) => void
|
|
149
|
+
)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Calls `onReset` with the initial value when the parent form fires a `reset` event.
|
|
153
|
+
|
|
154
|
+
### useServerErrors
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
HeadlessForm.useServerErrors(formId?: string): { path: string; message: string }[]
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Returns server errors for the given form ID (or the current form's ID from context if omitted). Requires `GlobalProvider` in the tree.
|
|
161
|
+
|
|
162
|
+
### useTopLevelFormValidation
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
HeadlessForm.useTopLevelFormValidation(
|
|
166
|
+
formElement: HTMLFormElement | string
|
|
167
|
+
): { revalidate(field: string | null): void }
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Programmatically re-triggers validation on form fields. Pass `null` to revalidate all fields, or a field name prefix to revalidate only matching fields.
|
|
171
|
+
|
|
172
|
+
### composeValidators
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
HeadlessForm.composeValidators<T>(
|
|
176
|
+
...fns: (ValidationFunction<T> | undefined | null)[]
|
|
177
|
+
): ValidationFunction<T>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Combines multiple validation functions. Each is called with the value; all returned errors are collected into a single array.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
const required = (v: string) => (!v ? 'Required' : null)
|
|
184
|
+
const minLen = (min: number) => (v: string) => (v.length < min ? `At least ${min} chars` : null)
|
|
185
|
+
|
|
186
|
+
const validate = HeadlessForm.composeValidators(required, minLen(5))
|
|
187
|
+
validate('') // ['Required', 'At least 5 chars']
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
### Hidden Input Components
|
|
191
|
+
|
|
192
|
+
#### HiddenInput
|
|
193
|
+
|
|
194
|
+
Visually hidden `<input>` that participates in form submission. Use inside custom components.
|
|
195
|
+
|
|
196
|
+
```tsx
|
|
197
|
+
<HeadlessForm.HiddenInput ref={inputRef} name="country" value={selectedCountry} form={formId} isRequired />
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Props: `value`, `name`, `form`, `isRequired`, `type`, `min`, `max`
|
|
201
|
+
|
|
202
|
+
#### HiddenFileInput
|
|
203
|
+
|
|
204
|
+
Visually hidden `<input type="file">` with programmatic file assignment via `DataTransfer`.
|
|
205
|
+
|
|
206
|
+
```tsx
|
|
207
|
+
<HeadlessForm.HiddenFileInput ref={fileRef} name="avatar" value={selectedFile} form={formId} isRequired />
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Props: `value` (`File | File[] | null`), `name`, `form`, `isRequired`
|
|
211
|
+
|
|
212
|
+
#### HiddenSelect
|
|
213
|
+
|
|
214
|
+
Visually hidden `<select>` for custom dropdown components.
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
<HeadlessForm.HiddenSelect ref={selectRef} name="role" value={selectedRole} form={formId} isRequired>
|
|
218
|
+
<option value="admin">Admin</option>
|
|
219
|
+
<option value="viewer">Viewer</option>
|
|
220
|
+
</HeadlessForm.HiddenSelect>
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Props: `value`, `name`, `form`, `isRequired`, `children`, `autoComplete`, `onChange`, `label`
|
|
224
|
+
|
|
225
|
+
### FormData Parsing
|
|
226
|
+
|
|
227
|
+
#### parseFormData
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
HeadlessForm.parseFormData<T>(
|
|
231
|
+
schema: StandardSchema<T>,
|
|
232
|
+
formData: FormData | Promise<FormData>,
|
|
233
|
+
options?: {
|
|
234
|
+
handleFile?(file: File, path: string, data: T): Promise<string>
|
|
235
|
+
libraryOptions?: Record<string, unknown>
|
|
236
|
+
}
|
|
237
|
+
): Promise<ParseFormDataResult<T>>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Server-side FormData parser. Converts raw FormData into a typed object validated against a Standard Schema (Zod).
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
const schema = z.object({ name: z.string(), age: z.number(), avatar: z.string() })
|
|
244
|
+
|
|
245
|
+
const result = await HeadlessForm.parseFormData(schema, request.formData(), {
|
|
246
|
+
handleFile: async (file) => await uploadToS3(file)
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
if (result.success) {
|
|
250
|
+
result.data // { name: 'Alice', age: 30, avatar: 'https://cdn.example.com/avatar.png' }
|
|
251
|
+
}
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
#### parseFormDataClientSide
|
|
255
|
+
|
|
256
|
+
```ts
|
|
257
|
+
HeadlessForm.parseFormDataClientSide<T>(
|
|
258
|
+
schema: StandardSchema<T>,
|
|
259
|
+
formData: FormData,
|
|
260
|
+
options?: ParseFormDataOptions<T>
|
|
261
|
+
): ParseFormDataResult<T> & { fileEntries: FoundFile[] }
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
Synchronous client-side variant. Files are replaced with placeholder URLs for validation; actual file entries are returned separately in `fileEntries`.
|
|
265
|
+
|
|
266
|
+
### Coercion Rules
|
|
267
|
+
|
|
268
|
+
The parser uses the schema's JSON Schema representation to coerce FormData string values:
|
|
269
|
+
|
|
270
|
+
| Schema Type | FormData Value | Result |
|
|
271
|
+
| --- | --- | --- |
|
|
272
|
+
| `string` | `'Alice'` | `'Alice'` |
|
|
273
|
+
| `string` (nullable) | `''` | `null` |
|
|
274
|
+
| `number` / `integer` | `'42'` | `42` |
|
|
275
|
+
| `number` (empty) | `''` | `null` |
|
|
276
|
+
| `boolean` | `'true'` / `'false'` | `true` / `false` |
|
|
277
|
+
| `boolean` (absent) | *(missing)* | `false` |
|
|
278
|
+
| `array` | multiple values | `['a', 'b']` |
|
|
279
|
+
| `array` | JSON string `'[1,2]'` | `[1, 2]` |
|
|
280
|
+
| `array` (empty) | `''` | `[]` |
|
|
281
|
+
| nested object | `'addr.city'` key | `{ addr: { city: ... } }` |
|
|
282
|
+
| array of objects | `'items.0.name'` key | `[{ name: ... }]` |
|
|
283
|
+
|
|
284
|
+
### Types
|
|
285
|
+
|
|
286
|
+
```ts
|
|
287
|
+
type ValidationFunction<T> = (value: T) => string | string[] | null | undefined
|
|
288
|
+
|
|
289
|
+
interface ValidationResult {
|
|
290
|
+
isInvalid: boolean
|
|
291
|
+
validationDetails: ValidityState
|
|
292
|
+
validationErrors: string[]
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
type ParseFormDataResult<T> =
|
|
296
|
+
| { success: true; data: T; formData: FormData; formId: string | null; issues: undefined }
|
|
297
|
+
| { success: false; data: any; formData: FormData; formId: string | null; issues: readonly StandardSchemaV1.Issue[] }
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
## Common Patterns
|
|
301
|
+
|
|
302
|
+
### Custom Input with Validation
|
|
303
|
+
|
|
304
|
+
```tsx
|
|
305
|
+
function PhoneInput({ name, form }: { name: string; form: string }) {
|
|
306
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
307
|
+
const [value, setValue] = HeadlessForm.useControlledState<string>(undefined, '')
|
|
308
|
+
|
|
309
|
+
const validation = HeadlessForm.useValidation(
|
|
310
|
+
{
|
|
311
|
+
name,
|
|
312
|
+
form,
|
|
313
|
+
value,
|
|
314
|
+
validate: (v) => (v && !/^\d{10}$/.test(v) ? 'Enter a 10-digit phone number' : null)
|
|
315
|
+
},
|
|
316
|
+
inputRef
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
HeadlessForm.useReset(inputRef, '', setValue)
|
|
320
|
+
|
|
321
|
+
return (
|
|
322
|
+
<div>
|
|
323
|
+
<input value={value} onChange={(e) => setValue(e.target.value)} />
|
|
324
|
+
<HeadlessForm.HiddenInput ref={inputRef} name={name} value={value} form={form} />
|
|
325
|
+
{validation.isInvalid && <span>{validation.validationMessage}</span>}
|
|
326
|
+
</div>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### Server-Side Form Processing
|
|
332
|
+
|
|
333
|
+
```ts
|
|
334
|
+
async function handleProfileUpdate(formData: FormData) {
|
|
335
|
+
const schema = z.object({
|
|
336
|
+
name: z.string().min(1),
|
|
337
|
+
bio: z.string().nullable(),
|
|
338
|
+
age: z.number().min(13),
|
|
339
|
+
avatar: z.string(),
|
|
340
|
+
tags: z.array(z.string())
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
const result = await HeadlessForm.parseFormData(schema, formData, {
|
|
344
|
+
handleFile: async (file) => uploadToStorage(file)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
if (!result.success) {
|
|
348
|
+
return { errors: result.issues.map((i) => ({ path: i.path?.join('.') ?? '', message: i.message })) }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
await db.updateUser(result.data)
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### Custom Select with Hidden Native Element
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
function RoleSelect({ name, form }: { name: string; form: string }) {
|
|
359
|
+
const selectRef = useRef<HTMLSelectElement>(null)
|
|
360
|
+
const [value, setValue] = HeadlessForm.useControlledState<string>(undefined, '')
|
|
361
|
+
|
|
362
|
+
const validation = HeadlessForm.useValidation({ name, form, value, isRequired: true }, selectRef)
|
|
363
|
+
HeadlessForm.useReset(selectRef, '', setValue)
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<div>
|
|
367
|
+
<CustomDropdown value={value} onChange={setValue} options={['admin', 'editor', 'viewer']} />
|
|
368
|
+
<HeadlessForm.HiddenSelect ref={selectRef} name={name} value={value} form={form} isRequired>
|
|
369
|
+
<option value="admin">Admin</option>
|
|
370
|
+
<option value="editor">Editor</option>
|
|
371
|
+
<option value="viewer">Viewer</option>
|
|
372
|
+
</HeadlessForm.HiddenSelect>
|
|
373
|
+
{validation.isInvalid && <span>{validation.validationMessage}</span>}
|
|
374
|
+
</div>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
## Warnings and Gotchas
|
|
380
|
+
|
|
381
|
+
> **Warning:** `GlobalProvider` must wrap your app before any `HeadlessForm` is rendered. Without it, `useServerErrors` throws: `'GlobalContext not found. Please wrap your site with GlobalProvider'`.
|
|
382
|
+
|
|
383
|
+
> **Warning:** Do not use async validation with `parseFormData` / `parseFormDataClientSide` -- they throw `'Async validation not supported'`. All schema validation must be synchronous.
|
|
384
|
+
|
|
385
|
+
> **Note:** Non-GET forms inject a hidden `__formId` field. `parseFormData` strips this automatically. If you parse FormData manually, filter out `__formId`.
|
|
386
|
+
|
|
387
|
+
> **Note:** `parseFormData` requires a `handleFile` option when the form contains file uploads. Without it, it throws. The client-side variant replaces files with placeholder URLs instead.
|
|
388
|
+
|
|
389
|
+
> **Warning:** Unchecked boolean checkboxes are absent from FormData. The parser defaults these to `false` based on the schema. If the boolean field isn't declared in the schema, the absent value won't be coerced.
|
|
390
|
+
|
|
391
|
+
> **Note:** `useControlledState` warns in the console if a component switches between controlled and uncontrolled mode. Always consistently pass either a value or `undefined`.
|