@thomasfosterau/effect-forms 0.0.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/LICENSE +21 -0
- package/README.md +100 -0
- package/dist/index.d.ts +878 -0
- package/dist/index.js +1768 -0
- package/dist/index.js.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/package.json +53 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1768 @@
|
|
|
1
|
+
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
+
import * as Schema from "effect/Schema";
|
|
3
|
+
import * as SchemaAST from "effect/SchemaAST";
|
|
4
|
+
import * as Effect from "effect/Effect";
|
|
5
|
+
import * as SchemaParser from "effect/SchemaParser";
|
|
6
|
+
import "effect/SchemaIssue";
|
|
7
|
+
import * as Duration from "effect/Duration";
|
|
8
|
+
//#region src/Coerce.ts
|
|
9
|
+
var Coerce_exports = /* @__PURE__ */ __exportAll({
|
|
10
|
+
booleanFromCheckbox: () => booleanFromCheckbox,
|
|
11
|
+
emptyToNull: () => emptyToNull,
|
|
12
|
+
emptyToUndefined: () => emptyToUndefined,
|
|
13
|
+
numberFromField: () => numberFromField,
|
|
14
|
+
trimToNull: () => trimToNull,
|
|
15
|
+
trimToUndefined: () => trimToUndefined
|
|
16
|
+
});
|
|
17
|
+
/**
|
|
18
|
+
* @copyright Thomas Foster
|
|
19
|
+
*
|
|
20
|
+
* Tri-state coercers for the HTML-form boundary.
|
|
21
|
+
*
|
|
22
|
+
* An HTML form submits every value as a string, and an absent optional field
|
|
23
|
+
* arrives as `undefined` (or an empty string). Mapping that onto a
|
|
24
|
+
* value / `null` / `undefined` update contract — where a value **sets**, `null`
|
|
25
|
+
* **clears** a nullable field, and `undefined` **skips** (carry-forward) — is
|
|
26
|
+
* the same boundary discipline the host app applies, lifted here so any
|
|
27
|
+
* consumer of the core gets it without a framework dependency.
|
|
28
|
+
*
|
|
29
|
+
* These are plain, total functions: they never throw and never reach for a
|
|
30
|
+
* framework's `error()` helper (validation failures belong on the schema, not
|
|
31
|
+
* in a coercer).
|
|
32
|
+
*
|
|
33
|
+
* @module effect-forms/Coerce
|
|
34
|
+
*/
|
|
35
|
+
/** Empty string / `undefined` → `undefined`; otherwise the value (skip semantics). */
|
|
36
|
+
function emptyToUndefined(value) {
|
|
37
|
+
return value === "" || value === void 0 ? void 0 : value;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `undefined` stays `undefined` (skip); empty string / `null` → `null`
|
|
41
|
+
* (clear a nullable field); otherwise the value (set). The three-state coercer
|
|
42
|
+
* for nullable update fields.
|
|
43
|
+
*/
|
|
44
|
+
function emptyToNull(value) {
|
|
45
|
+
if (value === void 0) return void 0;
|
|
46
|
+
if (value === "" || value === null) return null;
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
/** Trim, then {@link emptyToUndefined}: whitespace-only collapses to `undefined` (skip). */
|
|
50
|
+
function trimToUndefined(value) {
|
|
51
|
+
return emptyToUndefined(value?.trim());
|
|
52
|
+
}
|
|
53
|
+
/** Trim, then {@link emptyToNull}: whitespace-only / blank clears (`null`). */
|
|
54
|
+
function trimToNull(value) {
|
|
55
|
+
return emptyToNull(value?.trim());
|
|
56
|
+
}
|
|
57
|
+
function numberFromField(raw, { nullable }) {
|
|
58
|
+
const coerced = nullable ? emptyToNull(raw) : emptyToUndefined(raw);
|
|
59
|
+
return typeof coerced === "string" ? Number(coerced) : coerced;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Interpret a checkbox field. An HTML checkbox submits its `value` (default
|
|
63
|
+
* `"on"`) only when checked and omits the field entirely when unchecked, so a
|
|
64
|
+
* present field is `true` and an absent one is `false`. The truthy strings
|
|
65
|
+
* `"on"` / `"true"` / `"1"` / `"yes"` all read as `true`; an explicit
|
|
66
|
+
* `"false"` / `"0"` / `"off"` / `"no"` reads as `false`.
|
|
67
|
+
*/
|
|
68
|
+
function booleanFromCheckbox(raw) {
|
|
69
|
+
if (raw === void 0) return false;
|
|
70
|
+
const normalised = raw.trim().toLowerCase();
|
|
71
|
+
return !(normalised === "" || normalised === "false" || normalised === "0" || normalised === "off" || normalised === "no");
|
|
72
|
+
}
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region src/Command.ts
|
|
75
|
+
/**
|
|
76
|
+
* @copyright Thomas Foster
|
|
77
|
+
*
|
|
78
|
+
* Command-struct introspection and form derivation.
|
|
79
|
+
*
|
|
80
|
+
* A host app's write surface is a pair of flat command `Schema.Struct`s per
|
|
81
|
+
* entity — a create struct (required fields bare, the rest `optional`) and an
|
|
82
|
+
* update struct (keyed by a required `id`, every field `optional`, clearable
|
|
83
|
+
* fields `optional(NullOr(...))` carrying the value / `null` / `undefined`
|
|
84
|
+
* tri-state). This module reads that pair into a plain-data {@link CommandPlan}
|
|
85
|
+
* and derives the three per-entity form artefacts that were previously
|
|
86
|
+
* hand-written per entity: the all-string Form-Input schema fields, the blank
|
|
87
|
+
* defaults, and the tri-state adapters that map a validated multipart input
|
|
88
|
+
* onto the encoded command payload.
|
|
89
|
+
*
|
|
90
|
+
* Classification leads with behavioural decode probes — date codecs are
|
|
91
|
+
* transformations and branded ids erase to `String`, so "what does this
|
|
92
|
+
* schema accept" is the only robust signal for those (the same approach the
|
|
93
|
+
* host app's model walkers use). Two kinds fall back to AST matching instead,
|
|
94
|
+
* because no decode probe can name what they are: `enum`'s literal option set
|
|
95
|
+
* (needed anyway for the renderer, not just the classification) and
|
|
96
|
+
* `structured`'s "this is a nested object, not a scalar" shape. Neither
|
|
97
|
+
* shadows a probe-classified kind — see {@link classify}.
|
|
98
|
+
*
|
|
99
|
+
* The derived adapters emit the **encoded** command payload (dates stay
|
|
100
|
+
* `YYYY-MM-DD` strings, ids stay plain strings) — the command's canonical
|
|
101
|
+
* decode downstream owns branding and validation. Genuinely irregular fields
|
|
102
|
+
* (a create-time default, an asymmetric coercion) stay expressible via the
|
|
103
|
+
* per-field {@link AdaptOverrides} escape hatch instead of forcing ejection.
|
|
104
|
+
*
|
|
105
|
+
* @module effect-forms/Command
|
|
106
|
+
*/
|
|
107
|
+
var Command_exports = /* @__PURE__ */ __exportAll({
|
|
108
|
+
blankDefaults: () => blankDefaults,
|
|
109
|
+
createAdapter: () => createAdapter,
|
|
110
|
+
formInputFields: () => formInputFields,
|
|
111
|
+
jsonRowsFromField: () => jsonRowsFromField,
|
|
112
|
+
plan: () => plan,
|
|
113
|
+
updateAdapter: () => updateAdapter
|
|
114
|
+
});
|
|
115
|
+
/**
|
|
116
|
+
* Run one decode probe: whether the schema accepts the input, plus the decoded
|
|
117
|
+
* value for instance checks (the date probe needs to see a `Date` come out,
|
|
118
|
+
* not just a successful decode).
|
|
119
|
+
*/
|
|
120
|
+
const probe = (schema, input) => {
|
|
121
|
+
try {
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
value: Schema.decodeUnknownSync(schema)(input)
|
|
125
|
+
};
|
|
126
|
+
} catch {
|
|
127
|
+
return { ok: false };
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
/** True when a schema decodes the given input without throwing. */
|
|
131
|
+
const decodes = (schema, input) => probe(schema, input).ok;
|
|
132
|
+
/** A syntactically valid UUID no other field kind accepts as non-text. */
|
|
133
|
+
const PROBE_UUID = "9f8c1c2e-4b3a-4c5d-8e6f-0a1b2c3d4e5f";
|
|
134
|
+
/**
|
|
135
|
+
* Peel exactly the `optional` / `NullOr` wrapper layers off a property's type
|
|
136
|
+
* AST, stopping at the first union that isn't itself a pure wrapper. A wrapper
|
|
137
|
+
* union carries exactly one non-`null`/`undefined` member (the wrapped type);
|
|
138
|
+
* a union with zero or several such members is a real value-level union (an
|
|
139
|
+
* `enum` field's own literal set, say) rather than another layer to peel, so
|
|
140
|
+
* the loop stops there. Unlike {@link module:effect-forms/Constraints}'s
|
|
141
|
+
* `unwrapOptional` (which only ever needs the *first* real member), this
|
|
142
|
+
* walks every wrapper layer and preserves a multi-member union intact —
|
|
143
|
+
* `enumOptions` below needs the full literal set, not just one branch of it.
|
|
144
|
+
*/
|
|
145
|
+
function unwrapWrapperUnion(ast) {
|
|
146
|
+
let current = ast;
|
|
147
|
+
while (SchemaAST.isUnion(current)) {
|
|
148
|
+
const real = current.types.filter((t) => !SchemaAST.isUndefined(t) && !SchemaAST.isNull(t));
|
|
149
|
+
if (real.length !== 1 || real.length === current.types.length) break;
|
|
150
|
+
current = real[0];
|
|
151
|
+
}
|
|
152
|
+
return current;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* The literal option values of a field shaped like `Schema.Literal(...)` or
|
|
156
|
+
* `Schema.Literals([...])` (optionally `optional` / `NullOr`-wrapped) —
|
|
157
|
+
* `undefined` when the field isn't literal-shaped. Stringified: a `<select>`'s
|
|
158
|
+
* option values are strings at the form boundary regardless of the literal's
|
|
159
|
+
* own type.
|
|
160
|
+
*/
|
|
161
|
+
function enumOptions(ast) {
|
|
162
|
+
const inner = unwrapWrapperUnion(ast);
|
|
163
|
+
if (SchemaAST.isLiteral(inner)) return [String(inner.literal)];
|
|
164
|
+
if (SchemaAST.isUnion(inner)) {
|
|
165
|
+
const literals = inner.types.filter(SchemaAST.isLiteral);
|
|
166
|
+
if (literals.length === inner.types.length && literals.length > 0) return literals.map((literal) => String(literal.literal));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Whether a field is itself an object/struct schema (optionally `optional` /
|
|
171
|
+
* `NullOr`-wrapped) — the `structured` kind's shape (inline / block ProseKit
|
|
172
|
+
* JSON: `{ type, content }`). Mirrors {@link module:effect-forms/Field}'s and
|
|
173
|
+
* {@link module:effect-forms/Constraints}'s own `SchemaAST.isObjects` struct
|
|
174
|
+
* check. A `Schema.declare`d type (`UploadedDocument`, `Image`) has no
|
|
175
|
+
* `Objects` AST node, so it is never caught here — the default-throw the
|
|
176
|
+
* derivation relies on stays intact.
|
|
177
|
+
*/
|
|
178
|
+
function isStructured(ast) {
|
|
179
|
+
return SchemaAST.isObjects(unwrapWrapperUnion(ast));
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The scalar kinds classified purely by what a schema decodes, with no AST
|
|
183
|
+
* inspection: array → boolean → number → date → id → text. `undefined` when
|
|
184
|
+
* the schema accepts none of them, so {@link classify} can fall through to
|
|
185
|
+
* its AST-based checks (`enum`, `structured`).
|
|
186
|
+
*/
|
|
187
|
+
const probeKind = (schema) => {
|
|
188
|
+
if (decodes(schema, [])) return "jsonArray";
|
|
189
|
+
if (decodes(schema, true)) return "boolean";
|
|
190
|
+
if (decodes(schema, 42)) return "number";
|
|
191
|
+
const date = probe(schema, "2024-01-02T03:04:05.000Z");
|
|
192
|
+
if (date.ok && date.value instanceof Date) return "date";
|
|
193
|
+
if (decodes(schema, PROBE_UUID) && !decodes(schema, "not-a-uuid")) return "id";
|
|
194
|
+
if (decodes(schema, "plain text")) return "text";
|
|
195
|
+
};
|
|
196
|
+
/**
|
|
197
|
+
* Classify one field schema: by its AST shape first, then by what it accepts.
|
|
198
|
+
* `enum` is checked before {@link probeKind} rather than after, since a
|
|
199
|
+
* decode probe can't tell "the literal set an enum accepts" from any other
|
|
200
|
+
* shape (worse, a numeric-literal enum whose set happens to include the `42`
|
|
201
|
+
* probe value would otherwise misclassify as `number`) — reading the literal
|
|
202
|
+
* set off the AST is unambiguous, and it's needed anyway for the renderer.
|
|
203
|
+
* `structured` is checked last, after every probe has failed: an object
|
|
204
|
+
* schema never decodes any of `probeKind`'s scalar probes, so there is no
|
|
205
|
+
* real ordering hazard, but putting the broadest, AST-only check last keeps
|
|
206
|
+
* every more specific kind first in line. A field that matches nothing
|
|
207
|
+
* (`Schema.declare`d — `UploadedDocument`, `Image`) throws at module load so
|
|
208
|
+
* the gap surfaces when the plan is declared, not on a live submission.
|
|
209
|
+
*/
|
|
210
|
+
const classify = (key, schema) => {
|
|
211
|
+
const options = enumOptions(schema.ast);
|
|
212
|
+
if (options !== void 0) return {
|
|
213
|
+
kind: "enum",
|
|
214
|
+
options
|
|
215
|
+
};
|
|
216
|
+
const probed = probeKind(schema);
|
|
217
|
+
if (probed !== void 0) return { kind: probed };
|
|
218
|
+
if (isStructured(schema.ast)) return { kind: "structured" };
|
|
219
|
+
throw new Error(`Command.plan: field "${key}" matches no known kind (text/id/number/date/jsonArray/boolean/enum/structured) — a Schema.declare'd type needs an explicit form-level treatment before it can derive`);
|
|
220
|
+
};
|
|
221
|
+
/**
|
|
222
|
+
* Read a create/update command struct pair into a {@link CommandPlan}.
|
|
223
|
+
* Runs once at module load — probe cost is a handful of tiny decodes per
|
|
224
|
+
* field, never per render or per request.
|
|
225
|
+
*/
|
|
226
|
+
const plan = (create, update) => {
|
|
227
|
+
const createFields = create.fields;
|
|
228
|
+
const updateFields = update.fields;
|
|
229
|
+
const keys = [];
|
|
230
|
+
for (const key in updateFields) if (key !== "id") keys.push(key);
|
|
231
|
+
for (const key in createFields) if (key !== "id" && !(key in updateFields)) keys.push(key);
|
|
232
|
+
return {
|
|
233
|
+
fields: keys.map((key) => {
|
|
234
|
+
const createField = createFields[key];
|
|
235
|
+
const updateField = updateFields[key];
|
|
236
|
+
const { kind, options } = classify(key, createField ?? updateField);
|
|
237
|
+
return {
|
|
238
|
+
key,
|
|
239
|
+
kind,
|
|
240
|
+
inCreate: createField !== void 0,
|
|
241
|
+
createRequired: createField !== void 0 && !decodes(createField, void 0),
|
|
242
|
+
inUpdate: updateField !== void 0,
|
|
243
|
+
updateClearable: updateField !== void 0 && decodes(updateField, null),
|
|
244
|
+
...options !== void 0 ? { options } : {}
|
|
245
|
+
};
|
|
246
|
+
}),
|
|
247
|
+
clientId: "id" in createFields
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
/**
|
|
251
|
+
* Derive the all-string Form-Input schema fields for one mode — every value is
|
|
252
|
+
* a string at the multipart boundary. Create-required fields get a min-length
|
|
253
|
+
* check so blankness surfaces as an inline field error; the update `id` gets
|
|
254
|
+
* the UUID check. Spread into `Schema.Struct({ ...Command.formInputFields(plan,
|
|
255
|
+
* "create") })`, with `overrides` spread last for irregular fields.
|
|
256
|
+
*/
|
|
257
|
+
const formInputFields = (commandPlan, mode, overrides = {}) => {
|
|
258
|
+
const fields = {};
|
|
259
|
+
if (mode === "create" && commandPlan.clientId) fields.id = Schema.optional(Schema.String);
|
|
260
|
+
if (mode === "update") fields.id = Schema.String.check(Schema.isUUID());
|
|
261
|
+
for (const field of commandPlan.fields) {
|
|
262
|
+
if (mode === "create" ? !field.inCreate : !field.inUpdate) continue;
|
|
263
|
+
fields[field.key] = mode === "create" && field.createRequired ? Schema.String.check(Schema.isMinLength(1)) : Schema.optional(Schema.String);
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
...fields,
|
|
267
|
+
...overrides
|
|
268
|
+
};
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* Blank string defaults for one mode's Form-Input shape. Client-id entities
|
|
272
|
+
* override `id` at the call site (`{ ...blankDefaults(plan, "create"), id:
|
|
273
|
+
* crypto.randomUUID() }`) so retries stay idempotent.
|
|
274
|
+
*/
|
|
275
|
+
const blankDefaults = (commandPlan, mode) => {
|
|
276
|
+
const defaults = {};
|
|
277
|
+
if (mode === "update" || commandPlan.clientId) defaults.id = "";
|
|
278
|
+
for (const field of commandPlan.fields) {
|
|
279
|
+
if (mode === "create" ? !field.inCreate : !field.inUpdate) continue;
|
|
280
|
+
defaults[field.key] = "";
|
|
281
|
+
}
|
|
282
|
+
return defaults;
|
|
283
|
+
};
|
|
284
|
+
/**
|
|
285
|
+
* Parse a JSON-encoded row list (a contributor list) from a multipart form
|
|
286
|
+
* field. A blank or absent field yields `undefined` (skip — leave the existing
|
|
287
|
+
* list untouched); an empty JSON array (`"[]"`) clears the list. Rows keep
|
|
288
|
+
* `id` / `creditedName` when present; `orderingPosition` falls back to the
|
|
289
|
+
* row's array index. Throws a plain `Error` on a malformed payload — mapping
|
|
290
|
+
* that onto a framework response is the caller's concern.
|
|
291
|
+
*/
|
|
292
|
+
const jsonRowsFromField = (raw, label) => {
|
|
293
|
+
if (raw === void 0 || raw.trim() === "") return void 0;
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(raw);
|
|
297
|
+
} catch {
|
|
298
|
+
throw new Error(`Invalid ${label} payload — expected JSON array`);
|
|
299
|
+
}
|
|
300
|
+
if (!Array.isArray(parsed)) throw new Error(`Invalid ${label} payload — expected array`);
|
|
301
|
+
return parsed.map((row, index) => {
|
|
302
|
+
if (typeof row !== "object" || row === null) throw new Error(`Invalid ${label} entry at index ${index}`);
|
|
303
|
+
const r = row;
|
|
304
|
+
if (typeof r.agentId !== "string") throw new Error(`${label} entry at index ${index} missing agentId`);
|
|
305
|
+
return {
|
|
306
|
+
id: typeof r.id === "string" ? r.id : void 0,
|
|
307
|
+
agentId: r.agentId,
|
|
308
|
+
creditedName: typeof r.creditedName === "string" ? r.creditedName : void 0,
|
|
309
|
+
orderingPosition: typeof r.orderingPosition === "number" ? r.orderingPosition : index
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
/** Text fields: trim always; required create fields never skip. */
|
|
314
|
+
const coerceText = (ctx, raw) => ctx.required ? (raw ?? "").trim() : ctx.clearable ? trimToNull(raw) : trimToUndefined(raw);
|
|
315
|
+
/** Id fields: no trimming (pickers emit `""` or a UUID); required create FKs pass through raw. */
|
|
316
|
+
const coerceId = (ctx, raw) => ctx.required ? raw : ctx.clearable ? emptyToNull(raw) : emptyToUndefined(raw);
|
|
317
|
+
/** Numeric text fields: blank clears on the clearable path, skips otherwise. */
|
|
318
|
+
const coerceNumber = (ctx, raw) => ctx.clearable ? numberFromField(raw, { nullable: true }) : numberFromField(raw, { nullable: false });
|
|
319
|
+
/** Date fields stay encoded (`YYYY-MM-DD` strings); create trims, update passes through. */
|
|
320
|
+
const coerceDate = (ctx, raw) => {
|
|
321
|
+
const value = ctx.trimCreate ? raw?.trim() : raw;
|
|
322
|
+
return ctx.clearable ? emptyToNull(value) : emptyToUndefined(value);
|
|
323
|
+
};
|
|
324
|
+
/**
|
|
325
|
+
* Boolean fields: a `<select>`'s value set, not a checkbox — the only truthy
|
|
326
|
+
* raw value is the literal string `"true"`, everything else (`"false"`,
|
|
327
|
+
* blank, absent) reads as `false`. Create defaults an absent/blank field to
|
|
328
|
+
* `false` (never skips); update skips absent/blank unless clearable, in which
|
|
329
|
+
* case it clears (`null`) instead — the create-defaults / update-skips
|
|
330
|
+
* asymmetry the Content family already hand-writes.
|
|
331
|
+
*/
|
|
332
|
+
const coerceBoolean = (ctx, raw) => {
|
|
333
|
+
const trimmed = raw?.trim();
|
|
334
|
+
if (trimmed === void 0 || trimmed === "") return ctx.required ? false : ctx.clearable ? null : void 0;
|
|
335
|
+
return trimmed === "true";
|
|
336
|
+
};
|
|
337
|
+
/**
|
|
338
|
+
* Parse a JSON-encoded structured document (inline / block ProseKit JSON)
|
|
339
|
+
* from a multipart field. Blank or absent skips (`undefined`) or, on the
|
|
340
|
+
* clearable path, clears (`null`); a present value is `JSON.parse`d and
|
|
341
|
+
* passed through as-is — shape validation is the command's own decode's job,
|
|
342
|
+
* exactly as {@link jsonRowsFromField} leaves row-shape validation to itself
|
|
343
|
+
* rather than duplicating it here. `JSON.parse` throws a `SyntaxError` on
|
|
344
|
+
* malformed JSON, same as {@link jsonRowsFromField} does on a malformed row
|
|
345
|
+
* list — mapping that onto a framework response is the caller's concern.
|
|
346
|
+
*/
|
|
347
|
+
const structuredFromField = (raw, clearable) => {
|
|
348
|
+
const trimmed = raw?.trim();
|
|
349
|
+
if (trimmed === void 0 || trimmed === "") return clearable ? null : void 0;
|
|
350
|
+
return JSON.parse(trimmed);
|
|
351
|
+
};
|
|
352
|
+
/** Build one field's {@link CoerceContext} for one mode. */
|
|
353
|
+
const coerceContextFor = (field, mode) => ({
|
|
354
|
+
required: mode === "create" && field.createRequired,
|
|
355
|
+
clearable: mode === "update" && field.updateClearable,
|
|
356
|
+
trimCreate: mode === "create"
|
|
357
|
+
});
|
|
358
|
+
/** The default coercion for one field in one mode. */
|
|
359
|
+
const coerce = (field, mode, raw) => {
|
|
360
|
+
const ctx = coerceContextFor(field, mode);
|
|
361
|
+
switch (field.kind) {
|
|
362
|
+
case "text":
|
|
363
|
+
case "enum": return coerceText(ctx, raw);
|
|
364
|
+
case "id": return coerceId(ctx, raw);
|
|
365
|
+
case "number": return coerceNumber(ctx, raw);
|
|
366
|
+
case "date": return coerceDate(ctx, raw);
|
|
367
|
+
case "jsonArray": return jsonRowsFromField(raw, field.key);
|
|
368
|
+
case "boolean": return coerceBoolean(ctx, raw);
|
|
369
|
+
case "structured": return structuredFromField(raw, ctx.clearable);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
/**
|
|
373
|
+
* Derive the create adapter: validated all-string input → encoded create
|
|
374
|
+
* command payload. `overrides` replace the default per-field coercion for
|
|
375
|
+
* genuinely irregular fields (e.g. a create-time fallback default).
|
|
376
|
+
*/
|
|
377
|
+
const createAdapter = (commandPlan, overrides = {}) => (input) => {
|
|
378
|
+
const out = {};
|
|
379
|
+
if (commandPlan.clientId) out.id = trimToUndefined(input.id);
|
|
380
|
+
for (const field of commandPlan.fields) {
|
|
381
|
+
if (!field.inCreate) continue;
|
|
382
|
+
const override = overrides[field.key];
|
|
383
|
+
out[field.key] = override ? override(input[field.key]) : coerce(field, "create", input[field.key]);
|
|
384
|
+
}
|
|
385
|
+
return out;
|
|
386
|
+
};
|
|
387
|
+
/**
|
|
388
|
+
* Derive the update adapter: validated all-string input → encoded update
|
|
389
|
+
* command payload (value sets, `null` clears a clearable field, `undefined`
|
|
390
|
+
* skips). The required `id` passes through as-is — the command's canonical
|
|
391
|
+
* decode brands it.
|
|
392
|
+
*/
|
|
393
|
+
const updateAdapter = (commandPlan, overrides = {}) => (input) => {
|
|
394
|
+
const out = { id: input.id };
|
|
395
|
+
for (const field of commandPlan.fields) {
|
|
396
|
+
if (!field.inUpdate) continue;
|
|
397
|
+
const override = overrides[field.key];
|
|
398
|
+
out[field.key] = override ? override(input[field.key]) : coerce(field, "update", input[field.key]);
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
};
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/Constraints.ts
|
|
404
|
+
var Constraints_exports = /* @__PURE__ */ __exportAll({
|
|
405
|
+
constraintAt: () => constraintAt,
|
|
406
|
+
constraintsFromSchema: () => constraintsFromSchema,
|
|
407
|
+
isLeafConstraint: () => isLeafConstraint
|
|
408
|
+
});
|
|
409
|
+
/** Whether a node in an {@link InputConstraints} tree is a leaf ({@link InputConstraint}) rather than a branch. */
|
|
410
|
+
const isLeafConstraint = (value) => "required" in value || "min" in value || "max" in value || "minlength" in value || "maxlength" in value || "pattern" in value || "step" in value;
|
|
411
|
+
/** Whether a property's type AST is optional (so the field is not required). */
|
|
412
|
+
const isOptional = (type) => type.context?.isOptional === true;
|
|
413
|
+
/**
|
|
414
|
+
* Unwrap an `optional` / `NullOr` union the way {@link module:effect-forms/Field}'s
|
|
415
|
+
* `fromAst` does: recurse into the first member that isn't a bare `undefined`
|
|
416
|
+
* / `null` literal, so a wrapped struct / array / refined leaf still yields
|
|
417
|
+
* its own shape instead of falling through to an empty constraint.
|
|
418
|
+
*/
|
|
419
|
+
function unwrapOptional(type) {
|
|
420
|
+
if (!SchemaAST.isUnion(type)) return type;
|
|
421
|
+
for (const member of type.types) {
|
|
422
|
+
if (SchemaAST.isUndefined(member) || SchemaAST.isNull(member)) continue;
|
|
423
|
+
return unwrapOptional(member);
|
|
424
|
+
}
|
|
425
|
+
return type;
|
|
426
|
+
}
|
|
427
|
+
/** Flatten a `Checks` array (recursing into `FilterGroup`s) into individual filters. */
|
|
428
|
+
function flattenChecks(checks, out) {
|
|
429
|
+
if (!checks) return;
|
|
430
|
+
for (const check of checks) if (check._tag === "FilterGroup") flattenChecks(check.checks, out);
|
|
431
|
+
else out.push(check);
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* One constraint patch per recognised representation id — the built-in
|
|
435
|
+
* checks that map onto a native HTML attribute. An id with no entry here
|
|
436
|
+
* (an unannotated custom filter, or a built-in check with no HTML
|
|
437
|
+
* equivalent) derives nothing. `isInt` isn't a plain patch: it only sets
|
|
438
|
+
* `step` as a fallback when nothing more specific already did, so it's
|
|
439
|
+
* handled separately in {@link constraintFromChecks}.
|
|
440
|
+
*/
|
|
441
|
+
const payloadPatches = {
|
|
442
|
+
"effect/schema/isMinLength": (p) => ({ minlength: p.minLength }),
|
|
443
|
+
"effect/schema/isMaxLength": (p) => ({ maxlength: p.maxLength }),
|
|
444
|
+
"effect/schema/isLengthBetween": (p) => ({
|
|
445
|
+
minlength: p.minimum,
|
|
446
|
+
maxlength: p.maximum
|
|
447
|
+
}),
|
|
448
|
+
"effect/schema/isPattern": (p) => ({ pattern: p.source }),
|
|
449
|
+
"effect/schema/isGreaterThan": (p) => ({ min: p.exclusiveMinimum }),
|
|
450
|
+
"effect/schema/isGreaterThanOrEqualTo": (p) => ({ min: p.minimum }),
|
|
451
|
+
"effect/schema/isLessThan": (p) => ({ max: p.exclusiveMaximum }),
|
|
452
|
+
"effect/schema/isLessThanOrEqualTo": (p) => ({ max: p.maximum }),
|
|
453
|
+
"effect/schema/isBetween": (p) => ({
|
|
454
|
+
min: p.minimum,
|
|
455
|
+
max: p.maximum
|
|
456
|
+
}),
|
|
457
|
+
"effect/schema/isMultipleOf": (p) => ({ step: p.divisor })
|
|
458
|
+
};
|
|
459
|
+
/**
|
|
460
|
+
* Look up and apply `payloadPatches[representation.id]`. The cast is sound:
|
|
461
|
+
* each payload is authored by the same built-in check constructor its id
|
|
462
|
+
* names, so an entry is never called with a mismatched payload shape.
|
|
463
|
+
*/
|
|
464
|
+
function applyPayloadPatch(representation) {
|
|
465
|
+
const patch = payloadPatches[representation.id];
|
|
466
|
+
return patch?.(representation.payload);
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* Derive an {@link InputConstraint}'s refinement attributes from the checks
|
|
470
|
+
* attached to `outer` (the property's own type, e.g. the union wrapping an
|
|
471
|
+
* optional field) and `inner` (the unwrapped leaf) — `inner`'s checks win on
|
|
472
|
+
* conflict since they're the more specific, unwrapped node. A check whose
|
|
473
|
+
* `representation` annotation has no recognised id (an unannotated custom
|
|
474
|
+
* filter) is skipped: there is no attribute to derive from it.
|
|
475
|
+
*/
|
|
476
|
+
function constraintFromChecks(outer, inner) {
|
|
477
|
+
const filters = [];
|
|
478
|
+
flattenChecks(outer.checks, filters);
|
|
479
|
+
if (inner !== outer) flattenChecks(inner.checks, filters);
|
|
480
|
+
let out = {};
|
|
481
|
+
let hasIntCheck = false;
|
|
482
|
+
for (const filter of filters) {
|
|
483
|
+
const representation = filter.annotations?.representation;
|
|
484
|
+
if (!representation) continue;
|
|
485
|
+
if (representation.id === "effect/schema/isInt") {
|
|
486
|
+
hasIntCheck = true;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
out = {
|
|
490
|
+
...out,
|
|
491
|
+
...applyPayloadPatch(representation)
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (hasIntCheck && out.step === void 0) out.step = 1;
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Whether `ast` is a homogeneous array/list — `Schema.Array(item)` or
|
|
499
|
+
* `Schema.NonEmptyArray(item)` — as opposed to a fixed-shape tuple with
|
|
500
|
+
* differently-typed positional elements. Only a homogeneous array has a
|
|
501
|
+
* single item schema to descend into; a tuple's elements each need their own
|
|
502
|
+
* constraint, which this leaf-per-key tree can't represent, so it's left to
|
|
503
|
+
* `overrides`.
|
|
504
|
+
*/
|
|
505
|
+
function homogeneousItem(ast) {
|
|
506
|
+
if (ast.rest.length !== 1) return void 0;
|
|
507
|
+
const [item] = ast.rest;
|
|
508
|
+
if (ast.elements.length === 0) return item;
|
|
509
|
+
if (ast.elements.length === 1 && ast.elements[0] === item) return item;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Derive the constraint (or nested tree) for one property/item type: a
|
|
513
|
+
* struct descends into its properties, a homogeneous array descends into its
|
|
514
|
+
* item type (the array's own length checks have no native equivalent for a
|
|
515
|
+
* repeated field, so they're not derived) while folding the array's own
|
|
516
|
+
* optionality into the item's `required`, and anything else is a leaf whose
|
|
517
|
+
* `required` comes from optionality and whose refinement attributes come
|
|
518
|
+
* from {@link constraintFromChecks}.
|
|
519
|
+
*/
|
|
520
|
+
function constraintsForType(type) {
|
|
521
|
+
const required = !isOptional(type);
|
|
522
|
+
const inner = unwrapOptional(type);
|
|
523
|
+
if (SchemaAST.isObjects(inner)) return constraintsForObjects(inner);
|
|
524
|
+
if (SchemaAST.isArrays(inner)) {
|
|
525
|
+
const item = homogeneousItem(inner);
|
|
526
|
+
if (!item) return { required };
|
|
527
|
+
const itemConstraints = constraintsForType(item);
|
|
528
|
+
return isLeafConstraint(itemConstraints) ? {
|
|
529
|
+
...itemConstraints,
|
|
530
|
+
required: required && itemConstraints.required !== false
|
|
531
|
+
} : itemConstraints;
|
|
532
|
+
}
|
|
533
|
+
return {
|
|
534
|
+
required,
|
|
535
|
+
...constraintFromChecks(type, inner)
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
/** Build the constraint tree for one object AST node. */
|
|
539
|
+
function constraintsForObjects(ast) {
|
|
540
|
+
const out = {};
|
|
541
|
+
for (const ps of ast.propertySignatures) out[String(ps.name)] = constraintsForType(ps.type);
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
/** Deep-merge constraint overrides over a derived tree. */
|
|
545
|
+
function mergeConstraints(base, overrides) {
|
|
546
|
+
const out = { ...base };
|
|
547
|
+
for (const key of Object.keys(overrides)) {
|
|
548
|
+
const override = overrides[key];
|
|
549
|
+
if (override === void 0) continue;
|
|
550
|
+
const existing = out[key];
|
|
551
|
+
if (existing !== void 0 && !isLeafConstraint(existing) && !isLeafConstraint(override)) out[key] = mergeConstraints(existing, override);
|
|
552
|
+
else if (existing !== void 0 && isLeafConstraint(existing) && isLeafConstraint(override)) out[key] = {
|
|
553
|
+
...existing,
|
|
554
|
+
...override
|
|
555
|
+
};
|
|
556
|
+
else out[key] = override;
|
|
557
|
+
}
|
|
558
|
+
return out;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Derive the {@link InputConstraints} for a struct schema. Non-struct schemas
|
|
562
|
+
* yield an empty tree. `overrides` (deep-merged over the derived tree, and
|
|
563
|
+
* winning on conflict) is the seam for anything the AST walk does not infer.
|
|
564
|
+
*/
|
|
565
|
+
function constraintsFromSchema(schema, overrides = {}) {
|
|
566
|
+
const ast = schema.ast;
|
|
567
|
+
return mergeConstraints(SchemaAST.isObjects(ast) ? constraintsForObjects(ast) : {}, overrides);
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Read the {@link InputConstraint} leaf at `path` in an {@link InputConstraints}
|
|
571
|
+
* tree, defaulting to `{}` when `path` has no derived constraints. Array
|
|
572
|
+
* indices in `path` are skipped rather than descended into — the tree has no
|
|
573
|
+
* per-item nodes to index into, since {@link constraintsForType} derives one
|
|
574
|
+
* constraint (or branch) per *field*, shared by every item at that field. A
|
|
575
|
+
* bare item path (`tags[0]`) resolves to that shared leaf; a path that
|
|
576
|
+
* continues past the index into a struct item's own field (`items[0].label`)
|
|
577
|
+
* skips the index and resolves straight to `label`'s derived constraint.
|
|
578
|
+
*/
|
|
579
|
+
function constraintAt(constraints, path) {
|
|
580
|
+
let node = constraints;
|
|
581
|
+
for (const segment of path) {
|
|
582
|
+
if (typeof segment === "number") continue;
|
|
583
|
+
if (node === void 0 || isLeafConstraint(node)) return {};
|
|
584
|
+
node = node[segment];
|
|
585
|
+
}
|
|
586
|
+
return node !== void 0 && isLeafConstraint(node) ? node : {};
|
|
587
|
+
}
|
|
588
|
+
//#endregion
|
|
589
|
+
//#region src/FormData.ts
|
|
590
|
+
var FormData_exports = /* @__PURE__ */ __exportAll({
|
|
591
|
+
formDataToObject: () => formDataToObject,
|
|
592
|
+
parseFieldPath: () => parseFieldPath,
|
|
593
|
+
requestToObject: () => requestToObject
|
|
594
|
+
});
|
|
595
|
+
/**
|
|
596
|
+
* Split a flat field name into its path tokens. Supports dot notation
|
|
597
|
+
* (`a.b`), bracket notation (`a[b]`, `a[0]`), and the trailing append form
|
|
598
|
+
* (`a[]`, represented by an empty-string token). `"a[b][0]"` → `["a","b","0"]`;
|
|
599
|
+
* `"a.b[]"` → `["a","b",""]`.
|
|
600
|
+
*/
|
|
601
|
+
function parseFieldPath(name) {
|
|
602
|
+
const tokens = [];
|
|
603
|
+
let buffer = "";
|
|
604
|
+
let inBracket = false;
|
|
605
|
+
for (const char of name) if (char === "[") {
|
|
606
|
+
if (buffer !== "") tokens.push(buffer);
|
|
607
|
+
buffer = "";
|
|
608
|
+
inBracket = true;
|
|
609
|
+
} else if (char === "]") {
|
|
610
|
+
tokens.push(buffer);
|
|
611
|
+
buffer = "";
|
|
612
|
+
inBracket = false;
|
|
613
|
+
} else if (char === "." && !inBracket) {
|
|
614
|
+
if (buffer !== "") tokens.push(buffer);
|
|
615
|
+
buffer = "";
|
|
616
|
+
} else buffer += char;
|
|
617
|
+
if (buffer !== "") tokens.push(buffer);
|
|
618
|
+
return tokens.length > 1 && tokens[0] === "" ? tokens.slice(1) : tokens;
|
|
619
|
+
}
|
|
620
|
+
const isIndexToken = (token) => token === "" || /^\d+$/.test(token);
|
|
621
|
+
/** Create the container a token should live in: array for index/append tokens. */
|
|
622
|
+
const makeContainer = (nextToken) => isIndexToken(nextToken) ? [] : {};
|
|
623
|
+
const setOnContainer = (container, token, value) => {
|
|
624
|
+
if (Array.isArray(container)) if (token === "") container.push(value);
|
|
625
|
+
else container[Number(token)] = value;
|
|
626
|
+
else container[token] = value;
|
|
627
|
+
};
|
|
628
|
+
const getFromContainer = (container, token) => Array.isArray(container) ? container[Number(token)] : container[token];
|
|
629
|
+
/** Assign `value` deep into `root` along `tokens`, creating containers as needed. */
|
|
630
|
+
function assign(root, tokens, value) {
|
|
631
|
+
let node = root;
|
|
632
|
+
for (let i = 0; i < tokens.length - 1; i++) {
|
|
633
|
+
const token = tokens[i];
|
|
634
|
+
const nextToken = tokens[i + 1];
|
|
635
|
+
let child = token === "" ? void 0 : getFromContainer(node, token);
|
|
636
|
+
if (child === void 0 || typeof child !== "object" || child === null) {
|
|
637
|
+
child = makeContainer(nextToken);
|
|
638
|
+
setOnContainer(node, token, child);
|
|
639
|
+
}
|
|
640
|
+
node = child;
|
|
641
|
+
}
|
|
642
|
+
const last = tokens[tokens.length - 1];
|
|
643
|
+
if (last !== "" && !Array.isArray(node)) {
|
|
644
|
+
const existing = getFromContainer(node, last);
|
|
645
|
+
if (existing !== void 0) {
|
|
646
|
+
const arr = Array.isArray(existing) ? existing : [existing];
|
|
647
|
+
arr.push(value);
|
|
648
|
+
setOnContainer(node, last, arr);
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
setOnContainer(node, last, value);
|
|
653
|
+
}
|
|
654
|
+
/** Normalise a source into `[name, value]` entries. */
|
|
655
|
+
function* entriesOf(source) {
|
|
656
|
+
if (typeof source[Symbol.iterator] === "function" && !(source instanceof FormData) && !(source instanceof URLSearchParams)) {
|
|
657
|
+
yield* source;
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
for (const entry of source.entries()) yield entry;
|
|
661
|
+
}
|
|
662
|
+
/**
|
|
663
|
+
* Parse a form body into a nested object / array tree. Field names use dot or
|
|
664
|
+
* bracket path notation (see {@link parseFieldPath}); repeated bare keys
|
|
665
|
+
* become arrays; `File` values pass through. The result is ready to hand to
|
|
666
|
+
* `Schema.decodeUnknown` with a schema whose leaves coerce the string inputs.
|
|
667
|
+
*/
|
|
668
|
+
function formDataToObject(source) {
|
|
669
|
+
const root = {};
|
|
670
|
+
for (const [name, value] of entriesOf(source)) {
|
|
671
|
+
const tokens = parseFieldPath(name);
|
|
672
|
+
if (tokens.length === 0) continue;
|
|
673
|
+
assign(root, tokens, value);
|
|
674
|
+
}
|
|
675
|
+
return root;
|
|
676
|
+
}
|
|
677
|
+
/** Read and parse a `Request`'s form body. Errors (e.g. wrong content-type) reject. */
|
|
678
|
+
async function requestToObject(request) {
|
|
679
|
+
return formDataToObject(await request.formData());
|
|
680
|
+
}
|
|
681
|
+
//#endregion
|
|
682
|
+
//#region src/Path.ts
|
|
683
|
+
/**
|
|
684
|
+
* @copyright Thomas Foster
|
|
685
|
+
*
|
|
686
|
+
* Nested get / set along a {@link FormPath}.
|
|
687
|
+
*
|
|
688
|
+
* A form's data is a tree, and the reactive bindings address it by path:
|
|
689
|
+
* `["author", "name"]`, `["tags", 0]`. This module reads and writes a value
|
|
690
|
+
* at such a path — {@link getAt} descends, {@link setAt} returns a new tree
|
|
691
|
+
* with the value replaced (structural sharing, never a mutation) so a rune /
|
|
692
|
+
* atom store can diff cheaply. It is the framework-neutral half of the
|
|
693
|
+
* reactive field bindings (the Svelte store leans on it for
|
|
694
|
+
* `setValue` and the array operations).
|
|
695
|
+
*
|
|
696
|
+
* Paths are {@link FormPath}s — the same array-of-segments the {@link
|
|
697
|
+
* module:effect-forms/Errors} tree and {@link module:effect-forms/Issues}
|
|
698
|
+
* walker already speak — so string keys address object properties and number
|
|
699
|
+
* segments address array indices. {@link toString} / {@link fromString}
|
|
700
|
+
* convert to and from the flat `author.name` / `tags[0]` notation the HTML
|
|
701
|
+
* body parser ({@link module:effect-forms/FormData}) uses.
|
|
702
|
+
*
|
|
703
|
+
* @module effect-forms/Path
|
|
704
|
+
*/
|
|
705
|
+
var Path_exports = /* @__PURE__ */ __exportAll({
|
|
706
|
+
clearAll: () => clearAll,
|
|
707
|
+
fromString: () => fromString,
|
|
708
|
+
getAt: () => getAt,
|
|
709
|
+
insertAt: () => insertAt,
|
|
710
|
+
isUnder: () => isUnder,
|
|
711
|
+
moveAt: () => moveAt,
|
|
712
|
+
remapIndex: () => remapIndex,
|
|
713
|
+
remapKeyedRecord: () => remapKeyedRecord,
|
|
714
|
+
removeAt: () => removeAt,
|
|
715
|
+
replaceAt: () => replaceAt$1,
|
|
716
|
+
setAt: () => setAt,
|
|
717
|
+
swapAt: () => swapAt,
|
|
718
|
+
toString: () => toString
|
|
719
|
+
});
|
|
720
|
+
/**
|
|
721
|
+
* Read the value at `path`. Returns `undefined` if any segment is missing or
|
|
722
|
+
* traverses a non-object. The empty path returns the object itself.
|
|
723
|
+
*/
|
|
724
|
+
function getAt(obj, path) {
|
|
725
|
+
let current = obj;
|
|
726
|
+
for (const segment of path) {
|
|
727
|
+
if (current === null || typeof current !== "object") return void 0;
|
|
728
|
+
current = current[segment];
|
|
729
|
+
}
|
|
730
|
+
return current;
|
|
731
|
+
}
|
|
732
|
+
/**
|
|
733
|
+
* Return a new tree with the value at `path` replaced by `value`. Objects and
|
|
734
|
+
* arrays along the path are cloned (structural sharing elsewhere); missing
|
|
735
|
+
* containers are created as arrays for numeric segments and objects otherwise.
|
|
736
|
+
* The empty path returns `value`. Pure — `obj` is never mutated.
|
|
737
|
+
*/
|
|
738
|
+
function setAt(obj, path, value) {
|
|
739
|
+
if (path.length === 0) return value;
|
|
740
|
+
const [head, ...rest] = path;
|
|
741
|
+
const clone = Array.isArray(obj) ? [...obj] : { ...obj };
|
|
742
|
+
const child = clone[head];
|
|
743
|
+
clone[head] = rest.length === 0 ? value : setAt(child === null || typeof child !== "object" ? typeof rest[0] === "number" ? [] : {} : child, rest, value);
|
|
744
|
+
return clone;
|
|
745
|
+
}
|
|
746
|
+
/** Whether `path` is `root` or nested beneath it. */
|
|
747
|
+
function isUnder(path, root) {
|
|
748
|
+
if (path.length < root.length) return false;
|
|
749
|
+
for (let i = 0; i < root.length; i++) if (path[i] !== root[i]) return false;
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Render a {@link FormPath} as flat field-name notation: string segments join
|
|
754
|
+
* with `.` and number segments become `[n]` (`["tags", 0]` → `"tags[0]"`).
|
|
755
|
+
* The inverse of {@link fromString}.
|
|
756
|
+
*/
|
|
757
|
+
function toString(path) {
|
|
758
|
+
let out = "";
|
|
759
|
+
for (const segment of path) if (typeof segment === "number") out += `[${segment}]`;
|
|
760
|
+
else out += out === "" ? segment : `.${segment}`;
|
|
761
|
+
return out;
|
|
762
|
+
}
|
|
763
|
+
/**
|
|
764
|
+
* Parse flat field-name notation into a {@link FormPath}: integer tokens become
|
|
765
|
+
* number segments (array indices), everything else stays a string. The inverse
|
|
766
|
+
* of {@link toString}.
|
|
767
|
+
*/
|
|
768
|
+
function fromString(name) {
|
|
769
|
+
return parseFieldPath(name).map((token) => /^\d+$/.test(token) ? Number(token) : token);
|
|
770
|
+
}
|
|
771
|
+
/** The {@link IndexMap} for removing the item at `index`: shifts later indices down by one. */
|
|
772
|
+
function removeAt(index) {
|
|
773
|
+
return (i) => i === index ? null : i > index ? i - 1 : i;
|
|
774
|
+
}
|
|
775
|
+
/** The {@link IndexMap} for inserting an item at `index`: shifts `index` and later up by one. */
|
|
776
|
+
function insertAt(index) {
|
|
777
|
+
return (i) => i >= index ? i + 1 : i;
|
|
778
|
+
}
|
|
779
|
+
/** The {@link IndexMap} for swapping the items at `a` and `b`. */
|
|
780
|
+
function swapAt(a, b) {
|
|
781
|
+
return (i) => i === a ? b : i === b ? a : i;
|
|
782
|
+
}
|
|
783
|
+
/** The {@link IndexMap} for moving the item at `from` to `to`, shifting the items between. */
|
|
784
|
+
function moveAt(from, to) {
|
|
785
|
+
return (i) => {
|
|
786
|
+
if (i === from) return to;
|
|
787
|
+
if (from < to) return i > from && i <= to ? i - 1 : i;
|
|
788
|
+
return i >= to && i < from ? i + 1 : i;
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/** The {@link IndexMap} for replacing the item at `index` in place: drops only that index's state. */
|
|
792
|
+
function replaceAt$1(index) {
|
|
793
|
+
return (i) => i === index ? null : i;
|
|
794
|
+
}
|
|
795
|
+
/** The {@link IndexMap} for clearing every item in the array: drops all per-index state. */
|
|
796
|
+
const clearAll = () => null;
|
|
797
|
+
/**
|
|
798
|
+
* Remap `path`'s index segment immediately under `arrayPath` through `map`.
|
|
799
|
+
* Paths outside `arrayPath`, and `arrayPath` itself (a whole-array message),
|
|
800
|
+
* pass through unchanged; a path whose index segment maps to `null` is
|
|
801
|
+
* dropped (returns `null`) — its item no longer exists. Pure.
|
|
802
|
+
*/
|
|
803
|
+
function remapIndex(path, arrayPath, map) {
|
|
804
|
+
if (!isUnder(path, arrayPath) || path.length === arrayPath.length) return path;
|
|
805
|
+
const segment = path[arrayPath.length];
|
|
806
|
+
if (typeof segment !== "number") return path;
|
|
807
|
+
const mapped = map(segment);
|
|
808
|
+
if (mapped === null) return null;
|
|
809
|
+
if (mapped === segment) return path;
|
|
810
|
+
return [
|
|
811
|
+
...path.slice(0, arrayPath.length),
|
|
812
|
+
mapped,
|
|
813
|
+
...path.slice(arrayPath.length + 1)
|
|
814
|
+
];
|
|
815
|
+
}
|
|
816
|
+
/**
|
|
817
|
+
* Remap every entry of a flat, path-keyed record (a {@link toString} key per
|
|
818
|
+
* entry — e.g. touched flags) through {@link remapIndex}. Entries whose path
|
|
819
|
+
* maps to `null` are dropped. Pure.
|
|
820
|
+
*/
|
|
821
|
+
function remapKeyedRecord(record, arrayPath, map) {
|
|
822
|
+
const out = {};
|
|
823
|
+
for (const key of Object.keys(record)) {
|
|
824
|
+
const remapped = remapIndex(fromString(key), arrayPath, map);
|
|
825
|
+
if (remapped !== null) out[toString(remapped)] = record[key];
|
|
826
|
+
}
|
|
827
|
+
return out;
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region src/Errors.ts
|
|
831
|
+
var Errors_exports = /* @__PURE__ */ __exportAll({
|
|
832
|
+
ROOT_ERROR_KEY: () => ROOT_ERROR_KEY,
|
|
833
|
+
clearSourceAt: () => clearSourceAt,
|
|
834
|
+
emptySourced: () => emptySourced,
|
|
835
|
+
flattenErrors: () => flattenErrors,
|
|
836
|
+
getError: () => getError,
|
|
837
|
+
hasErrors: () => hasErrors,
|
|
838
|
+
issuesToErrors: () => issuesToErrors,
|
|
839
|
+
mergeErrors: () => mergeErrors,
|
|
840
|
+
mergeSourced: () => mergeSourced,
|
|
841
|
+
noErrors: () => noErrors,
|
|
842
|
+
remapIndices: () => remapIndices,
|
|
843
|
+
replaceAt: () => replaceAt,
|
|
844
|
+
setError: () => setError,
|
|
845
|
+
setSourceAt: () => setSourceAt
|
|
846
|
+
});
|
|
847
|
+
/** The root key under which a branch's own (non-field) messages are stored. */
|
|
848
|
+
const ROOT_ERROR_KEY = "_errors";
|
|
849
|
+
const isMessageList = (value) => Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
850
|
+
const ownErrors = (node) => {
|
|
851
|
+
const existing = node[ROOT_ERROR_KEY];
|
|
852
|
+
if (isMessageList(existing)) return existing;
|
|
853
|
+
const created = [];
|
|
854
|
+
node[ROOT_ERROR_KEY] = created;
|
|
855
|
+
return created;
|
|
856
|
+
};
|
|
857
|
+
/**
|
|
858
|
+
* If `node[key]` is a leaf's message list, convert it in place into a branch
|
|
859
|
+
* carrying those same messages as its own {@link ROOT_ERROR_KEY} (a path used
|
|
860
|
+
* as a leaf is now needed as a branch too) — a no-op when `node[key]` is
|
|
861
|
+
* already a branch or absent. Shared by {@link insert} and {@link setLeaf}'s
|
|
862
|
+
* path-descent, the one piece of their branch-walking that's identical.
|
|
863
|
+
*/
|
|
864
|
+
const promoteLeafToBranch = (node, key) => {
|
|
865
|
+
const existing = node[key];
|
|
866
|
+
if (isMessageList(existing)) node[key] = { [ROOT_ERROR_KEY]: existing };
|
|
867
|
+
};
|
|
868
|
+
/**
|
|
869
|
+
* Insert one message at `path` into a mutable error tree. A leaf becomes a
|
|
870
|
+
* `string[]`; if the same path is later used as a branch the existing list is
|
|
871
|
+
* preserved under {@link ROOT_ERROR_KEY}; if a branch later receives its own
|
|
872
|
+
* message it lands under {@link ROOT_ERROR_KEY}. The empty path targets the
|
|
873
|
+
* tree root's {@link ROOT_ERROR_KEY}.
|
|
874
|
+
*/
|
|
875
|
+
const insert = (root, path, message) => {
|
|
876
|
+
if (path.length === 0) {
|
|
877
|
+
ownErrors(root).push(message);
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
let node = root;
|
|
881
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
882
|
+
const key = String(path[i]);
|
|
883
|
+
if (node[key] === void 0) node[key] = {};
|
|
884
|
+
else promoteLeafToBranch(node, key);
|
|
885
|
+
node = node[key];
|
|
886
|
+
}
|
|
887
|
+
const last = String(path[path.length - 1]);
|
|
888
|
+
const existing = node[last];
|
|
889
|
+
if (existing === void 0) node[last] = [message];
|
|
890
|
+
else if (isMessageList(existing)) existing.push(message);
|
|
891
|
+
else ownErrors(existing).push(message);
|
|
892
|
+
};
|
|
893
|
+
/** Build a nested {@link FormErrors} tree from a flat list of {@link FormIssue}s. */
|
|
894
|
+
function issuesToErrors(issues) {
|
|
895
|
+
const root = {};
|
|
896
|
+
for (const issue of issues) insert(root, issue.path, issue.message);
|
|
897
|
+
return root;
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Flatten a {@link FormErrors} tree back into a list of {@link FormIssue}s, in
|
|
901
|
+
* depth-first order. The inverse of {@link issuesToErrors} (modulo message
|
|
902
|
+
* ordering within a single field).
|
|
903
|
+
*/
|
|
904
|
+
function flattenErrors(errors) {
|
|
905
|
+
const out = [];
|
|
906
|
+
const walk = (node, path) => {
|
|
907
|
+
for (const key of Object.keys(node)) {
|
|
908
|
+
const value = node[key];
|
|
909
|
+
if (value === void 0) continue;
|
|
910
|
+
if (key === "_errors") {
|
|
911
|
+
for (const message of value) out.push({
|
|
912
|
+
path,
|
|
913
|
+
message
|
|
914
|
+
});
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
const segment = /^\d+$/.test(key) ? Number(key) : key;
|
|
918
|
+
if (isMessageList(value)) for (const message of value) out.push({
|
|
919
|
+
path: [...path, segment],
|
|
920
|
+
message
|
|
921
|
+
});
|
|
922
|
+
else walk(value, [...path, segment]);
|
|
923
|
+
}
|
|
924
|
+
};
|
|
925
|
+
walk(errors, []);
|
|
926
|
+
return out;
|
|
927
|
+
}
|
|
928
|
+
/** Whether the tree carries any message anywhere. */
|
|
929
|
+
function hasErrors(errors) {
|
|
930
|
+
return flattenErrors(errors).length > 0;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Read the messages attached directly to `path` (a leaf's list, or a branch's
|
|
934
|
+
* own {@link ROOT_ERROR_KEY}). Returns `undefined` when the path has no direct
|
|
935
|
+
* messages.
|
|
936
|
+
*/
|
|
937
|
+
function getError(errors, path) {
|
|
938
|
+
let node = errors;
|
|
939
|
+
for (const segment of path) {
|
|
940
|
+
if (node === void 0 || isMessageList(node)) return void 0;
|
|
941
|
+
node = node[String(segment)];
|
|
942
|
+
}
|
|
943
|
+
if (node === void 0) return void 0;
|
|
944
|
+
if (isMessageList(node)) return node;
|
|
945
|
+
return node[ROOT_ERROR_KEY];
|
|
946
|
+
}
|
|
947
|
+
/** Return a new tree with `message` added at `path`. Pure. */
|
|
948
|
+
function setError(errors, path, message) {
|
|
949
|
+
const next = structuredClone(errors);
|
|
950
|
+
insert(next, path, message);
|
|
951
|
+
return next;
|
|
952
|
+
}
|
|
953
|
+
/** Combine two error trees into one (left then right). Pure. */
|
|
954
|
+
function mergeErrors(a, b) {
|
|
955
|
+
return issuesToErrors([...flattenErrors(a), ...flattenErrors(b)]);
|
|
956
|
+
}
|
|
957
|
+
/** The empty error tree. */
|
|
958
|
+
const noErrors = {};
|
|
959
|
+
/**
|
|
960
|
+
* Remap the array-index segments of every message under `arrayPath` through
|
|
961
|
+
* `map` (see {@link module:effect-forms/Path.IndexMap}), so an error recorded
|
|
962
|
+
* against a row follows that row through a remove / insert / swap / move.
|
|
963
|
+
* Messages whose index maps to `null` are dropped. Pure.
|
|
964
|
+
*/
|
|
965
|
+
function remapIndices(errors, arrayPath, map) {
|
|
966
|
+
const remapped = [];
|
|
967
|
+
for (const issue of flattenErrors(errors)) {
|
|
968
|
+
const path = remapIndex(issue.path, arrayPath, map);
|
|
969
|
+
if (path !== null) remapped.push({
|
|
970
|
+
path,
|
|
971
|
+
message: issue.message
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
return issuesToErrors(remapped);
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Return a new tree with the messages *directly at* `path` replaced by
|
|
978
|
+
* `messages` (removing the key when `messages` is empty), leaving every other
|
|
979
|
+
* path — including children of a branch `path` — untouched. Unlike
|
|
980
|
+
* {@link setError} (which appends one message), this is the "this field's
|
|
981
|
+
* validator just ran, here is its complete result" operation a per-field
|
|
982
|
+
* validator needs: it can clear a field back to valid without disturbing
|
|
983
|
+
* sibling or nested messages. Pure.
|
|
984
|
+
*/
|
|
985
|
+
function replaceAt(errors, path, messages) {
|
|
986
|
+
const next = structuredClone(errors);
|
|
987
|
+
setLeaf(next, path, messages);
|
|
988
|
+
return next;
|
|
989
|
+
}
|
|
990
|
+
const setLeaf = (root, path, messages) => {
|
|
991
|
+
if (path.length === 0) {
|
|
992
|
+
setOwnMessages(root, messages);
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
995
|
+
const parent = descendClearing(root, path, messages);
|
|
996
|
+
if (parent === void 0) return;
|
|
997
|
+
setLeafMessages(parent, String(path[path.length - 1]), messages);
|
|
998
|
+
};
|
|
999
|
+
/** Set or clear a node's own {@link ROOT_ERROR_KEY} messages. */
|
|
1000
|
+
const setOwnMessages = (node, messages) => {
|
|
1001
|
+
if (messages.length === 0) delete node[ROOT_ERROR_KEY];
|
|
1002
|
+
else node[ROOT_ERROR_KEY] = [...messages];
|
|
1003
|
+
};
|
|
1004
|
+
/**
|
|
1005
|
+
* Walk to `path`'s parent branch, creating branches as needed. When
|
|
1006
|
+
* `messages` is empty (a clear, not a set) a missing branch means there is
|
|
1007
|
+
* nothing to clear — descent stops early and returns `undefined` rather than
|
|
1008
|
+
* creating empty branches just to immediately no-op inside them.
|
|
1009
|
+
*/
|
|
1010
|
+
const descendClearing = (root, path, messages) => {
|
|
1011
|
+
let node = root;
|
|
1012
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
1013
|
+
const key = String(path[i]);
|
|
1014
|
+
if (node[key] === void 0) {
|
|
1015
|
+
if (messages.length === 0) return void 0;
|
|
1016
|
+
node[key] = {};
|
|
1017
|
+
} else promoteLeafToBranch(node, key);
|
|
1018
|
+
node = node[key];
|
|
1019
|
+
}
|
|
1020
|
+
return node;
|
|
1021
|
+
};
|
|
1022
|
+
/** Set or clear `node[key]`'s messages — a leaf's list, or (if it's already a branch) its own {@link ROOT_ERROR_KEY}. */
|
|
1023
|
+
const setLeafMessages = (node, key, messages) => {
|
|
1024
|
+
const existing = node[key];
|
|
1025
|
+
if (existing === void 0) {
|
|
1026
|
+
if (messages.length > 0) node[key] = [...messages];
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
if (isMessageList(existing)) {
|
|
1030
|
+
if (messages.length === 0) delete node[key];
|
|
1031
|
+
else node[key] = [...messages];
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
if (messages.length === 0) delete existing[ROOT_ERROR_KEY];
|
|
1035
|
+
else existing[ROOT_ERROR_KEY] = [...messages];
|
|
1036
|
+
};
|
|
1037
|
+
/** Both source trees empty. */
|
|
1038
|
+
const emptySourced = {
|
|
1039
|
+
client: noErrors,
|
|
1040
|
+
server: noErrors
|
|
1041
|
+
};
|
|
1042
|
+
/** Combine both source trees into the single tree a UI reads (server first, then client). */
|
|
1043
|
+
function mergeSourced(sourced) {
|
|
1044
|
+
return mergeErrors(sourced.server, sourced.client);
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Return new {@link SourcedErrors} with `source`'s tree updated at `path` via
|
|
1048
|
+
* {@link replaceAt} (the other source's tree is untouched). Pure.
|
|
1049
|
+
*/
|
|
1050
|
+
function setSourceAt(sourced, source, path, messages) {
|
|
1051
|
+
return {
|
|
1052
|
+
...sourced,
|
|
1053
|
+
[source]: replaceAt(sourced[source], path, messages)
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
/** Clear `source`'s messages at `path` (and nowhere else). Shorthand for {@link setSourceAt} with `[]`. */
|
|
1057
|
+
function clearSourceAt(sourced, source, path) {
|
|
1058
|
+
return setSourceAt(sourced, source, path, []);
|
|
1059
|
+
}
|
|
1060
|
+
//#endregion
|
|
1061
|
+
//#region src/Field.ts
|
|
1062
|
+
var Field_exports = /* @__PURE__ */ __exportAll({
|
|
1063
|
+
defaultEncodedValues: () => defaultEncodedValues,
|
|
1064
|
+
defaultItemAt: () => defaultItemAt,
|
|
1065
|
+
defaultValue: () => defaultValue,
|
|
1066
|
+
isArrayFieldDef: () => isArrayFieldDef,
|
|
1067
|
+
isFieldDef: () => isFieldDef,
|
|
1068
|
+
makeArrayField: () => makeArrayField,
|
|
1069
|
+
makeField: () => makeField,
|
|
1070
|
+
schemaAt: () => schemaAt
|
|
1071
|
+
});
|
|
1072
|
+
/** Whether `def` is an {@link ArrayFieldDef}. */
|
|
1073
|
+
const isArrayFieldDef = (def) => def._tag === "array";
|
|
1074
|
+
/** Whether `def` is a scalar {@link FieldDef}. */
|
|
1075
|
+
const isFieldDef = (def) => def._tag === "field";
|
|
1076
|
+
/** Construct a scalar {@link FieldDef}. */
|
|
1077
|
+
const makeField = (key, schema) => ({
|
|
1078
|
+
_tag: "field",
|
|
1079
|
+
key,
|
|
1080
|
+
schema
|
|
1081
|
+
});
|
|
1082
|
+
/** Construct an {@link ArrayFieldDef}. */
|
|
1083
|
+
const makeArrayField = (key, itemSchema) => ({
|
|
1084
|
+
_tag: "array",
|
|
1085
|
+
key,
|
|
1086
|
+
itemSchema
|
|
1087
|
+
});
|
|
1088
|
+
/**
|
|
1089
|
+
* Derive a blank *encoded* default from a schema AST: a struct recurses into
|
|
1090
|
+
* its properties, an array seeds to `[]`, and any scalar seeds to `""` (an
|
|
1091
|
+
* HTML input's empty value). Conservative by design — a value that will not
|
|
1092
|
+
* decode cleanly is still a valid *starting* value the user edits.
|
|
1093
|
+
*/
|
|
1094
|
+
function defaultValue(schema) {
|
|
1095
|
+
return fromAst(schema.ast);
|
|
1096
|
+
}
|
|
1097
|
+
/**
|
|
1098
|
+
* Unwrap a union to its first structured (struct / array) member — an
|
|
1099
|
+
* `optional` / `NullOr` wrapper (`Struct | undefined` / `Struct | null`)
|
|
1100
|
+
* still resolves to the wrapped struct or array's AST rather than a scalar
|
|
1101
|
+
* fallback. Non-unions, and unions with no structured member, pass through.
|
|
1102
|
+
*/
|
|
1103
|
+
function unwrapToStructured(ast) {
|
|
1104
|
+
if (!SchemaAST.isUnion(ast)) return ast;
|
|
1105
|
+
for (const member of ast.types) {
|
|
1106
|
+
const inner = unwrapToStructured(member);
|
|
1107
|
+
if (SchemaAST.isObjects(inner) || SchemaAST.isArrays(inner)) return inner;
|
|
1108
|
+
}
|
|
1109
|
+
return ast;
|
|
1110
|
+
}
|
|
1111
|
+
function fromAst(ast) {
|
|
1112
|
+
const unwrapped = unwrapToStructured(ast);
|
|
1113
|
+
if (SchemaAST.isObjects(unwrapped)) {
|
|
1114
|
+
const out = {};
|
|
1115
|
+
for (const ps of unwrapped.propertySignatures) out[String(ps.name)] = fromAst(ps.type);
|
|
1116
|
+
return out;
|
|
1117
|
+
}
|
|
1118
|
+
if (SchemaAST.isArrays(unwrapped)) return [];
|
|
1119
|
+
return "";
|
|
1120
|
+
}
|
|
1121
|
+
/** The item AST of an {@link SchemaAST.Arrays} node: the rest (spread) type, or the first tuple element. */
|
|
1122
|
+
function arrayElementAst(ast) {
|
|
1123
|
+
return ast.rest[0] ?? ast.elements[0];
|
|
1124
|
+
}
|
|
1125
|
+
/** Descend one array-index path segment from an (already-unwrapped) array AST. */
|
|
1126
|
+
function stepIntoArray(ast) {
|
|
1127
|
+
if (!SchemaAST.isArrays(ast)) return void 0;
|
|
1128
|
+
const item = arrayElementAst(ast);
|
|
1129
|
+
return item === void 0 ? void 0 : unwrapToStructured(item);
|
|
1130
|
+
}
|
|
1131
|
+
/** Descend one struct-property path segment from an (already-unwrapped) struct AST. */
|
|
1132
|
+
function stepIntoStruct(ast, key) {
|
|
1133
|
+
if (!SchemaAST.isObjects(ast)) return void 0;
|
|
1134
|
+
const ps = ast.propertySignatures.find((p) => String(p.name) === key);
|
|
1135
|
+
return ps === void 0 ? void 0 : unwrapToStructured(ps.type);
|
|
1136
|
+
}
|
|
1137
|
+
/**
|
|
1138
|
+
* Walk `schema`'s AST along `path` — through struct properties and array
|
|
1139
|
+
* elements alike — and return the AST found there, or `undefined` if `path`
|
|
1140
|
+
* doesn't resolve (a missing property, or a non-array indexed). Unlike a
|
|
1141
|
+
* {@link FieldsRecord} lookup, this works for any nesting depth and for
|
|
1142
|
+
* schemas built directly with `Form.make` (no builder involved).
|
|
1143
|
+
*/
|
|
1144
|
+
function schemaAt(schema, path) {
|
|
1145
|
+
let ast = unwrapToStructured(schema.ast);
|
|
1146
|
+
for (const segment of path) {
|
|
1147
|
+
if (ast === void 0) return void 0;
|
|
1148
|
+
ast = typeof segment === "number" ? stepIntoArray(ast) : stepIntoStruct(ast, segment);
|
|
1149
|
+
}
|
|
1150
|
+
return ast;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Derive a blank encoded item for the array field at `path` (any nesting
|
|
1154
|
+
* depth) by walking `schema`'s AST there — the nested-array counterpart to
|
|
1155
|
+
* {@link defaultValue}. Returns `""` when `path` doesn't resolve to an array.
|
|
1156
|
+
*/
|
|
1157
|
+
function defaultItemAt(schema, path) {
|
|
1158
|
+
const ast = schemaAt(schema, path);
|
|
1159
|
+
if (ast === void 0 || !SchemaAST.isArrays(ast)) return "";
|
|
1160
|
+
const item = arrayElementAst(ast);
|
|
1161
|
+
return item === void 0 ? "" : fromAst(item);
|
|
1162
|
+
}
|
|
1163
|
+
/** Seed a blank encoded value for every field: `[]` for arrays, `""` otherwise. */
|
|
1164
|
+
function defaultEncodedValues(fields) {
|
|
1165
|
+
const out = {};
|
|
1166
|
+
for (const [key, def] of Object.entries(fields)) out[key] = isArrayFieldDef(def) ? [] : defaultValue(def.schema);
|
|
1167
|
+
return out;
|
|
1168
|
+
}
|
|
1169
|
+
//#endregion
|
|
1170
|
+
//#region src/Issues.ts
|
|
1171
|
+
var Issues_exports = /* @__PURE__ */ __exportAll({
|
|
1172
|
+
defaultLeafFormatter: () => defaultLeafFormatter,
|
|
1173
|
+
filterOutputToFormIssues: () => filterOutputToFormIssues,
|
|
1174
|
+
schemaIssuesToFormIssues: () => schemaIssuesToFormIssues
|
|
1175
|
+
});
|
|
1176
|
+
/** The default leaf formatter — Effect Schema's own rendering of the leaf. */
|
|
1177
|
+
const defaultLeafFormatter = (issue) => String(issue);
|
|
1178
|
+
/**
|
|
1179
|
+
* Normalise a `Pointer.path` segment into a {@link FormPath} segment: integer
|
|
1180
|
+
* keys become numbers (array indices), everything else becomes a string.
|
|
1181
|
+
*/
|
|
1182
|
+
const normaliseSegment = (segment) => {
|
|
1183
|
+
if (typeof segment === "number") return segment;
|
|
1184
|
+
if (typeof segment === "symbol") return segment.toString();
|
|
1185
|
+
return /^\d+$/.test(segment) ? Number(segment) : segment;
|
|
1186
|
+
};
|
|
1187
|
+
/**
|
|
1188
|
+
* Walk an Effect Schema issue tree, emitting one {@link FormIssue} per leaf
|
|
1189
|
+
* failure. Path segments accumulate through `Pointer` nodes; `Composite` and
|
|
1190
|
+
* `AnyOf` fan out to siblings; `Filter` / `Encoding` are transparent
|
|
1191
|
+
* wrappers. An `AnyOf` with no recorded member failures (a bare union
|
|
1192
|
+
* mismatch) is itself a leaf.
|
|
1193
|
+
*/
|
|
1194
|
+
function schemaIssuesToFormIssues(issue, format = defaultLeafFormatter) {
|
|
1195
|
+
const out = [];
|
|
1196
|
+
const walk = (current, path) => {
|
|
1197
|
+
switch (current._tag) {
|
|
1198
|
+
case "Pointer":
|
|
1199
|
+
walk(current.issue, [...path, ...current.path.map(normaliseSegment)]);
|
|
1200
|
+
return;
|
|
1201
|
+
case "Composite":
|
|
1202
|
+
for (const child of current.issues) walk(child, path);
|
|
1203
|
+
return;
|
|
1204
|
+
case "AnyOf":
|
|
1205
|
+
if (current.issues.length === 0) {
|
|
1206
|
+
out.push({
|
|
1207
|
+
path,
|
|
1208
|
+
message: format(current)
|
|
1209
|
+
});
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
for (const child of current.issues) walk(child, path);
|
|
1213
|
+
return;
|
|
1214
|
+
case "Filter":
|
|
1215
|
+
case "Encoding":
|
|
1216
|
+
walk(current.issue, path);
|
|
1217
|
+
return;
|
|
1218
|
+
default:
|
|
1219
|
+
out.push({
|
|
1220
|
+
path,
|
|
1221
|
+
message: format(current)
|
|
1222
|
+
});
|
|
1223
|
+
return;
|
|
1224
|
+
}
|
|
1225
|
+
};
|
|
1226
|
+
walk(issue, []);
|
|
1227
|
+
return out;
|
|
1228
|
+
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Normalise a filter predicate's {@link Schema.FilterOutput} into a flat list of
|
|
1231
|
+
* {@link FormIssue}s (empty on success). Shared by whole-form refinements
|
|
1232
|
+
* ({@link module:effect-forms/FormBuilder}) and per-field validators
|
|
1233
|
+
* ({@link module:effect-forms/Validate}) so both fold into the same nested
|
|
1234
|
+
* error tree as schema-decode failures.
|
|
1235
|
+
*/
|
|
1236
|
+
function filterOutputToFormIssues(output) {
|
|
1237
|
+
if (output === void 0 || output === true) return [];
|
|
1238
|
+
if (output === false) return [{
|
|
1239
|
+
path: [],
|
|
1240
|
+
message: "Invalid value"
|
|
1241
|
+
}];
|
|
1242
|
+
if (Array.isArray(output)) return output.flatMap(filterIssueToFormIssues);
|
|
1243
|
+
return filterIssueToFormIssues(output);
|
|
1244
|
+
}
|
|
1245
|
+
function filterIssueToFormIssues(issue) {
|
|
1246
|
+
if (typeof issue === "string") return [{
|
|
1247
|
+
path: [],
|
|
1248
|
+
message: issue
|
|
1249
|
+
}];
|
|
1250
|
+
if ("path" in issue && "issue" in issue) {
|
|
1251
|
+
const prefix = issue.path.map((s) => typeof s === "symbol" ? s.toString() : s);
|
|
1252
|
+
return (typeof issue.issue === "string" ? [{
|
|
1253
|
+
path: [],
|
|
1254
|
+
message: issue.issue
|
|
1255
|
+
}] : schemaIssuesToFormIssues(issue.issue)).map((i) => ({
|
|
1256
|
+
path: [...prefix, ...i.path],
|
|
1257
|
+
message: i.message
|
|
1258
|
+
}));
|
|
1259
|
+
}
|
|
1260
|
+
return schemaIssuesToFormIssues(issue);
|
|
1261
|
+
}
|
|
1262
|
+
//#endregion
|
|
1263
|
+
//#region src/FormBuilder.ts
|
|
1264
|
+
/**
|
|
1265
|
+
* @copyright Thomas Foster
|
|
1266
|
+
*
|
|
1267
|
+
* Compose a form schema one field at a time, with cross-field refinements.
|
|
1268
|
+
*
|
|
1269
|
+
* {@link make} (aliased {@link empty}) starts an empty builder;
|
|
1270
|
+
* {@link FormBuilder.addField} adds a scalar or array field, {@link
|
|
1271
|
+
* FormBuilder.merge} folds another builder in, and {@link FormBuilder.refine} /
|
|
1272
|
+
* {@link FormBuilder.refineEffect} attach cross-field validations. The point is
|
|
1273
|
+
* incremental composition — assemble a large form from small, reusable
|
|
1274
|
+
* field-group builders — plus a home for whole-form rules (`password ===
|
|
1275
|
+
* confirmPassword`) that a single field schema can't express.
|
|
1276
|
+
*
|
|
1277
|
+
* {@link buildSchema} collapses the accumulated fields into an ordinary
|
|
1278
|
+
* `Schema.Struct` (array fields become `Schema.Array(item)`) and applies every
|
|
1279
|
+
* *synchronous* refinement as a struct-level `check`, so the result decodes,
|
|
1280
|
+
* derives constraints, and validates exactly like a hand-written schema — hand
|
|
1281
|
+
* it straight to {@link module:effect-forms/Form}'s `make`. *Asynchronous*
|
|
1282
|
+
* refinements can't be schema checks (Effect Schema has no effectful filter),
|
|
1283
|
+
* so they're carried on the builder and run by the Effect-returning validate
|
|
1284
|
+
* path — {@link asyncRefinements} exposes them and {@link runAsyncRefinements}
|
|
1285
|
+
* runs them into a {@link FormIssue} list.
|
|
1286
|
+
*
|
|
1287
|
+
* @module effect-forms/FormBuilder
|
|
1288
|
+
*/
|
|
1289
|
+
var FormBuilder_exports = /* @__PURE__ */ __exportAll({
|
|
1290
|
+
TypeId: () => TypeId,
|
|
1291
|
+
asyncRefinements: () => asyncRefinements,
|
|
1292
|
+
buildSchema: () => buildSchema,
|
|
1293
|
+
empty: () => empty,
|
|
1294
|
+
filterOutputToFormIssues: () => filterOutputToFormIssues,
|
|
1295
|
+
isFormBuilder: () => isFormBuilder,
|
|
1296
|
+
make: () => make$2,
|
|
1297
|
+
runAsyncRefinements: () => runAsyncRefinements
|
|
1298
|
+
});
|
|
1299
|
+
/** Brand for a {@link FormBuilder}. */
|
|
1300
|
+
const TypeId = Symbol.for("@thomasfosterau/effect-forms/FormBuilder");
|
|
1301
|
+
const proto = {
|
|
1302
|
+
[TypeId]: TypeId,
|
|
1303
|
+
addField(keyOrField, schema) {
|
|
1304
|
+
const field = typeof keyOrField === "string" ? makeField(keyOrField, schema) : keyOrField;
|
|
1305
|
+
return build({
|
|
1306
|
+
...this.fields,
|
|
1307
|
+
[field.key]: field
|
|
1308
|
+
}, this.refinements);
|
|
1309
|
+
},
|
|
1310
|
+
merge(other) {
|
|
1311
|
+
return build({
|
|
1312
|
+
...this.fields,
|
|
1313
|
+
...other.fields
|
|
1314
|
+
}, [...this.refinements, ...other.refinements]);
|
|
1315
|
+
},
|
|
1316
|
+
refine(predicate) {
|
|
1317
|
+
return build(this.fields, [...this.refinements, {
|
|
1318
|
+
_tag: "sync",
|
|
1319
|
+
fn: predicate
|
|
1320
|
+
}]);
|
|
1321
|
+
},
|
|
1322
|
+
refineEffect(predicate) {
|
|
1323
|
+
return build(this.fields, [...this.refinements, {
|
|
1324
|
+
_tag: "async",
|
|
1325
|
+
fn: predicate
|
|
1326
|
+
}]);
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
function build(fields, refinements) {
|
|
1330
|
+
const self = Object.create(proto);
|
|
1331
|
+
self.fields = fields;
|
|
1332
|
+
self.refinements = refinements;
|
|
1333
|
+
return self;
|
|
1334
|
+
}
|
|
1335
|
+
/** Whether `u` is a {@link FormBuilder}. */
|
|
1336
|
+
const isFormBuilder = (u) => typeof u === "object" && u !== null && TypeId in u;
|
|
1337
|
+
/** The empty builder — the starting point for {@link FormBuilder.addField}. */
|
|
1338
|
+
const make$2 = build({}, []);
|
|
1339
|
+
/** Alias of {@link make} — the empty builder. */
|
|
1340
|
+
const empty = make$2;
|
|
1341
|
+
/**
|
|
1342
|
+
* Collapse a builder into an Effect Schema: a `Schema.Struct` of the fields
|
|
1343
|
+
* (array fields as `Schema.Array(item)`) with every synchronous refinement
|
|
1344
|
+
* applied as a struct-level check. Ready for {@link module:effect-forms/Form}.
|
|
1345
|
+
*/
|
|
1346
|
+
function buildSchema(self) {
|
|
1347
|
+
const structFields = {};
|
|
1348
|
+
for (const [key, def] of Object.entries(self.fields)) if (isArrayFieldDef(def)) structFields[key] = Schema.Array(def.itemSchema);
|
|
1349
|
+
else if (isFieldDef(def)) structFields[key] = def.schema;
|
|
1350
|
+
let schema = Schema.Struct(structFields);
|
|
1351
|
+
for (const refinement of self.refinements) if (refinement._tag === "sync") schema = schema.check(Schema.makeFilter(refinement.fn));
|
|
1352
|
+
return schema;
|
|
1353
|
+
}
|
|
1354
|
+
/** The asynchronous refinements carried on a builder, as bare predicates. */
|
|
1355
|
+
function asyncRefinements(self) {
|
|
1356
|
+
return self.refinements.filter((r) => r._tag === "async").map((r) => r.fn);
|
|
1357
|
+
}
|
|
1358
|
+
/**
|
|
1359
|
+
* Run every asynchronous refinement on `self` against `values` and collect
|
|
1360
|
+
* their failures as {@link FormIssue}s (empty when all pass). Runs them
|
|
1361
|
+
* concurrently; requirements propagate on the `R` channel.
|
|
1362
|
+
*/
|
|
1363
|
+
function runAsyncRefinements(self, values) {
|
|
1364
|
+
const refinements = asyncRefinements(self);
|
|
1365
|
+
if (refinements.length === 0) return Effect.succeed([]);
|
|
1366
|
+
return Effect.forEach(refinements, (fn) => fn(values), { concurrency: "unbounded" }).pipe(Effect.map((outputs) => outputs.flatMap(filterOutputToFormIssues)));
|
|
1367
|
+
}
|
|
1368
|
+
//#endregion
|
|
1369
|
+
//#region src/State.ts
|
|
1370
|
+
/**
|
|
1371
|
+
* @copyright Thomas Foster
|
|
1372
|
+
*
|
|
1373
|
+
* {@link FormState} — the serialisable snapshot of a form.
|
|
1374
|
+
*
|
|
1375
|
+
* This is the single object a
|
|
1376
|
+
* server produces (validate a submission, or seed an empty form) and hands to
|
|
1377
|
+
* the client to hydrate from. It is plain JSON so it survives the SSR → client
|
|
1378
|
+
* boundary, and {@link FormStateSchema} gives it an Effect Schema so it can be
|
|
1379
|
+
* decoded back on arrival.
|
|
1380
|
+
*
|
|
1381
|
+
* @module effect-forms/State
|
|
1382
|
+
*/
|
|
1383
|
+
var State_exports = /* @__PURE__ */ __exportAll({
|
|
1384
|
+
FormStateSchema: () => FormStateSchema,
|
|
1385
|
+
isValid: () => isValid,
|
|
1386
|
+
make: () => make$1,
|
|
1387
|
+
withError: () => withError,
|
|
1388
|
+
withMessage: () => withMessage
|
|
1389
|
+
});
|
|
1390
|
+
/** Build a {@link FormState} from data plus overrides. `valid` defaults to "no errors". */
|
|
1391
|
+
function make$1(data, options = {}) {
|
|
1392
|
+
const errors = options.errors ?? noErrors;
|
|
1393
|
+
return {
|
|
1394
|
+
id: options.id ?? "default",
|
|
1395
|
+
valid: options.valid ?? !hasErrors(errors),
|
|
1396
|
+
posted: options.posted ?? false,
|
|
1397
|
+
data,
|
|
1398
|
+
errors,
|
|
1399
|
+
...options.message !== void 0 ? { message: options.message } : {},
|
|
1400
|
+
...options.constraints !== void 0 ? { constraints: options.constraints } : {}
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
/** Return a new state carrying `message` (and not otherwise changed). */
|
|
1404
|
+
function withMessage(state, message) {
|
|
1405
|
+
return {
|
|
1406
|
+
...state,
|
|
1407
|
+
message
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
/**
|
|
1411
|
+
* Return a new state with `message` added at `path` and `valid` forced to
|
|
1412
|
+
* `false` — for attaching a
|
|
1413
|
+
* server-side validation failure to a specific field after the schema passed.
|
|
1414
|
+
*/
|
|
1415
|
+
function withError(state, path, message) {
|
|
1416
|
+
return {
|
|
1417
|
+
...state,
|
|
1418
|
+
valid: false,
|
|
1419
|
+
errors: setError(state.errors, path, message)
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1422
|
+
/** Whether the state is valid. */
|
|
1423
|
+
const isValid = (state) => state.valid;
|
|
1424
|
+
/** Effect Schema describing the JSON shape of {@link FormState} for transport. */
|
|
1425
|
+
function FormStateSchema(data) {
|
|
1426
|
+
return Schema.Struct({
|
|
1427
|
+
id: Schema.String,
|
|
1428
|
+
valid: Schema.Boolean,
|
|
1429
|
+
posted: Schema.Boolean,
|
|
1430
|
+
data,
|
|
1431
|
+
errors: Schema.Record(Schema.String, Schema.Unknown),
|
|
1432
|
+
message: Schema.optional(Schema.Unknown),
|
|
1433
|
+
constraints: Schema.optional(Schema.Record(Schema.String, Schema.Unknown))
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
//#endregion
|
|
1437
|
+
//#region src/Form.ts
|
|
1438
|
+
/**
|
|
1439
|
+
* @copyright Thomas Foster
|
|
1440
|
+
*
|
|
1441
|
+
* {@link make} — the core entry point: bind an Effect Schema to a form and
|
|
1442
|
+
* get back a validator and an empty-state factory.
|
|
1443
|
+
*
|
|
1444
|
+
* This is the framework-neutral validate-and-seed entry point,
|
|
1445
|
+
* expressed as a small object bound to one schema:
|
|
1446
|
+
*
|
|
1447
|
+
* - `validate(input)` parses an HTML form body (or a plain object) and decodes
|
|
1448
|
+
* it through the schema, returning a {@link FormState} — valid with the
|
|
1449
|
+
* decoded data, or invalid with the nested {@link FormErrors} and the posted
|
|
1450
|
+
* values preserved so the form re-renders what the user typed. It is an
|
|
1451
|
+
* `Effect`, so a schema requiring decoding services composes naturally and
|
|
1452
|
+
* the caller stays on the typed error channel — there is none; validation
|
|
1453
|
+
* failures live in the returned state, not the error channel.
|
|
1454
|
+
* - `empty()` seeds an unposted, invalid-by-default form from the supplied
|
|
1455
|
+
* defaults — what a `load` returns to render a blank create form.
|
|
1456
|
+
*
|
|
1457
|
+
* Coercion (strings → numbers / booleans / dates) is the schema's job: build
|
|
1458
|
+
* the schema from coercing leaves (`Schema.FiniteFromString`, the
|
|
1459
|
+
* {@link module:effect-forms/Coerce} helpers, …) so a multipart body decodes
|
|
1460
|
+
* cleanly.
|
|
1461
|
+
*
|
|
1462
|
+
* @module effect-forms/Form
|
|
1463
|
+
*/
|
|
1464
|
+
var Form_exports = /* @__PURE__ */ __exportAll({
|
|
1465
|
+
fromBuilder: () => fromBuilder,
|
|
1466
|
+
make: () => make
|
|
1467
|
+
});
|
|
1468
|
+
const toStructured = (input) => {
|
|
1469
|
+
if (input === null || input === void 0) return Effect.succeed(null);
|
|
1470
|
+
if (input instanceof Request) return Effect.promise(() => requestToObject(input));
|
|
1471
|
+
if (input instanceof FormData || input instanceof URLSearchParams) return Effect.succeed(formDataToObject(input));
|
|
1472
|
+
return Effect.succeed(input);
|
|
1473
|
+
};
|
|
1474
|
+
/**
|
|
1475
|
+
* Bind `schema` to a form. See the module docs for the contract. The returned
|
|
1476
|
+
* {@link Form} is stateless and reusable across requests.
|
|
1477
|
+
*/
|
|
1478
|
+
function make(schema, options) {
|
|
1479
|
+
const id = options.id ?? "default";
|
|
1480
|
+
const { defaults, leafFormatter } = options;
|
|
1481
|
+
const constraints = options.deriveConstraints === false ? options.constraints ?? {} : constraintsFromSchema(schema, options.constraints ?? {});
|
|
1482
|
+
const decode = SchemaParser.decodeUnknownEffect(schema, { errors: "all" });
|
|
1483
|
+
const empty = (overrides = {}) => make$1(defaults, {
|
|
1484
|
+
id,
|
|
1485
|
+
valid: false,
|
|
1486
|
+
posted: false,
|
|
1487
|
+
constraints,
|
|
1488
|
+
...overrides
|
|
1489
|
+
});
|
|
1490
|
+
const parse = (input) => input instanceof FormData || input instanceof URLSearchParams ? formDataToObject(input) : input;
|
|
1491
|
+
const validate = (input) => toStructured(input).pipe(Effect.flatMap((object) => {
|
|
1492
|
+
if (object === null) return Effect.succeed(empty());
|
|
1493
|
+
return decode(object).pipe(Effect.matchEffect({
|
|
1494
|
+
onSuccess: (data) => Effect.succeed(make$1(data, {
|
|
1495
|
+
id,
|
|
1496
|
+
valid: true,
|
|
1497
|
+
posted: true,
|
|
1498
|
+
constraints
|
|
1499
|
+
})),
|
|
1500
|
+
onFailure: (issue) => Effect.succeed(make$1({
|
|
1501
|
+
...defaults,
|
|
1502
|
+
...object
|
|
1503
|
+
}, {
|
|
1504
|
+
id,
|
|
1505
|
+
valid: false,
|
|
1506
|
+
posted: true,
|
|
1507
|
+
constraints,
|
|
1508
|
+
errors: issuesToErrors(schemaIssuesToFormIssues(issue, leafFormatter))
|
|
1509
|
+
}))
|
|
1510
|
+
}));
|
|
1511
|
+
}));
|
|
1512
|
+
return {
|
|
1513
|
+
schema,
|
|
1514
|
+
id,
|
|
1515
|
+
constraints,
|
|
1516
|
+
empty,
|
|
1517
|
+
parse,
|
|
1518
|
+
validate
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
/**
|
|
1522
|
+
* Bind a {@link FormBuilder} to a {@link Form}. Collapses the builder to a
|
|
1523
|
+
* schema (see {@link buildSchema}), then behaves like {@link make} — except its
|
|
1524
|
+
* `validate` also runs the builder's *asynchronous* refinements after a
|
|
1525
|
+
* successful decode, folding any failures into the same nested error tree. When
|
|
1526
|
+
* `defaults` is omitted it is derived from the fields (blank encoded values).
|
|
1527
|
+
*/
|
|
1528
|
+
function fromBuilder(builder, options = {}) {
|
|
1529
|
+
const schema = buildSchema(builder);
|
|
1530
|
+
const defaults = options.defaults ?? defaultEncodedValues(builder.fields);
|
|
1531
|
+
const base = make(schema, {
|
|
1532
|
+
...options,
|
|
1533
|
+
defaults
|
|
1534
|
+
});
|
|
1535
|
+
const decode = SchemaParser.decodeUnknownEffect(schema, { errors: "all" });
|
|
1536
|
+
const validate = (input) => toStructured(input).pipe(Effect.flatMap((object) => {
|
|
1537
|
+
if (object === null) return Effect.succeed(base.empty());
|
|
1538
|
+
const posted = {
|
|
1539
|
+
...defaults,
|
|
1540
|
+
...object
|
|
1541
|
+
};
|
|
1542
|
+
return decode(object).pipe(Effect.matchEffect({
|
|
1543
|
+
onSuccess: (data) => runAsyncRefinements(builder, data).pipe(Effect.map((issues) => issues.length === 0 ? make$1(data, {
|
|
1544
|
+
id: base.id,
|
|
1545
|
+
valid: true,
|
|
1546
|
+
posted: true,
|
|
1547
|
+
constraints: base.constraints
|
|
1548
|
+
}) : make$1(posted, {
|
|
1549
|
+
id: base.id,
|
|
1550
|
+
valid: false,
|
|
1551
|
+
posted: true,
|
|
1552
|
+
constraints: base.constraints,
|
|
1553
|
+
errors: issuesToErrors(issues)
|
|
1554
|
+
}))),
|
|
1555
|
+
onFailure: (issue) => Effect.succeed(make$1(posted, {
|
|
1556
|
+
id: base.id,
|
|
1557
|
+
valid: false,
|
|
1558
|
+
posted: true,
|
|
1559
|
+
constraints: base.constraints,
|
|
1560
|
+
errors: issuesToErrors(schemaIssuesToFormIssues(issue, options.leafFormatter))
|
|
1561
|
+
}))
|
|
1562
|
+
}));
|
|
1563
|
+
}));
|
|
1564
|
+
return {
|
|
1565
|
+
...base,
|
|
1566
|
+
validate
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
//#endregion
|
|
1570
|
+
//#region src/Mode.ts
|
|
1571
|
+
/**
|
|
1572
|
+
* @copyright Thomas Foster
|
|
1573
|
+
*
|
|
1574
|
+
* When a form validates (and, optionally, auto-submits).
|
|
1575
|
+
*
|
|
1576
|
+
* A reactive form has to decide *when* to run validation as the user types.
|
|
1577
|
+
* The three modes mirror the common form-library vocabulary:
|
|
1578
|
+
*
|
|
1579
|
+
* - **`onSubmit`** (the default) — validate only when the form is submitted (or
|
|
1580
|
+
* validation is triggered explicitly). The quietest mode.
|
|
1581
|
+
* - **`onBlur`** — validate a field when it loses focus.
|
|
1582
|
+
* - **`onChange`** — validate on every change, optionally `debounce`d.
|
|
1583
|
+
*
|
|
1584
|
+
* `onBlur` / `onChange` may also `autoSubmit`, turning the form into a
|
|
1585
|
+
* live-saving surface (a settings panel, a filter bar). This module is the
|
|
1586
|
+
* framework-neutral half: {@link parse} normalises the loose {@link FormMode}
|
|
1587
|
+
* a caller supplies into a fully-resolved {@link ParsedMode} the reactive
|
|
1588
|
+
* store drives from. The store owns the actual scheduling.
|
|
1589
|
+
*
|
|
1590
|
+
* A separate `revalidate` dimension governs what happens *after a submit*: an
|
|
1591
|
+
* `onSubmit`-mode form is otherwise silent until the next submit, which
|
|
1592
|
+
* leaves a failed post's errors frozen even after the user fixes the
|
|
1593
|
+
* offending field. `revalidate` says which trigger (`"onChange"` /
|
|
1594
|
+
* `"onBlur"` — independent of the primary `validation` trigger) re-validates
|
|
1595
|
+
* once the form has been posted at least once; `"never"` keeps the classic
|
|
1596
|
+
* frozen-until-resubmit behaviour.
|
|
1597
|
+
*
|
|
1598
|
+
* @module effect-forms/Mode
|
|
1599
|
+
*/
|
|
1600
|
+
var Mode_exports = /* @__PURE__ */ __exportAll({ parse: () => parse });
|
|
1601
|
+
/**
|
|
1602
|
+
* Normalise a {@link FormMode} into a {@link ParsedMode}. An absent mode is
|
|
1603
|
+
* `onSubmit`. `debounce` is resolved to milliseconds and only applies in
|
|
1604
|
+
* `onChange`; `autoSubmit` is only honoured for `onBlur` / `onChange`;
|
|
1605
|
+
* `revalidate` defaults to `"onChange"` (TanStack's / RHF's default
|
|
1606
|
+
* post-submit behaviour) regardless of the primary `validation` mode.
|
|
1607
|
+
*/
|
|
1608
|
+
function parse(mode) {
|
|
1609
|
+
const validation = mode?.validation ?? "onSubmit";
|
|
1610
|
+
const revalidate = mode?.revalidate ?? "onChange";
|
|
1611
|
+
if (validation === "onBlur") return {
|
|
1612
|
+
validation: "onBlur",
|
|
1613
|
+
debounce: null,
|
|
1614
|
+
autoSubmit: mode?.autoSubmit === true,
|
|
1615
|
+
revalidate
|
|
1616
|
+
};
|
|
1617
|
+
if (validation === "onChange") return {
|
|
1618
|
+
validation: "onChange",
|
|
1619
|
+
debounce: mode?.debounce === void 0 ? null : Duration.toMillis(mode.debounce),
|
|
1620
|
+
autoSubmit: mode?.autoSubmit === true,
|
|
1621
|
+
revalidate
|
|
1622
|
+
};
|
|
1623
|
+
return {
|
|
1624
|
+
validation: "onSubmit",
|
|
1625
|
+
debounce: null,
|
|
1626
|
+
autoSubmit: false,
|
|
1627
|
+
revalidate
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
//#endregion
|
|
1631
|
+
//#region src/Result.ts
|
|
1632
|
+
var Result_exports = /* @__PURE__ */ __exportAll({
|
|
1633
|
+
errorResult: () => errorResult,
|
|
1634
|
+
failure: () => failure,
|
|
1635
|
+
redirect: () => redirect,
|
|
1636
|
+
success: () => success
|
|
1637
|
+
});
|
|
1638
|
+
/** Construct a {@link FormSuccess} (default status 200). */
|
|
1639
|
+
const success = (state, status = 200) => ({
|
|
1640
|
+
_tag: "Success",
|
|
1641
|
+
status,
|
|
1642
|
+
state
|
|
1643
|
+
});
|
|
1644
|
+
/** Construct a {@link FormFailure} (default status 400). */
|
|
1645
|
+
const failure = (state, status = 400) => ({
|
|
1646
|
+
_tag: "Failure",
|
|
1647
|
+
status,
|
|
1648
|
+
state
|
|
1649
|
+
});
|
|
1650
|
+
/** Construct a {@link FormRedirect} (default status 303). */
|
|
1651
|
+
const redirect = (location, status = 303) => ({
|
|
1652
|
+
_tag: "Redirect",
|
|
1653
|
+
status,
|
|
1654
|
+
location
|
|
1655
|
+
});
|
|
1656
|
+
/** Construct a {@link FormErrorResult} (default status 500). */
|
|
1657
|
+
const errorResult = (message, status = 500) => ({
|
|
1658
|
+
_tag: "Error",
|
|
1659
|
+
status,
|
|
1660
|
+
message
|
|
1661
|
+
});
|
|
1662
|
+
//#endregion
|
|
1663
|
+
//#region src/Validate.ts
|
|
1664
|
+
/**
|
|
1665
|
+
* @copyright Thomas Foster
|
|
1666
|
+
*
|
|
1667
|
+
* Low-level validation helpers — decode a value through a schema and get back
|
|
1668
|
+
* the {@link FormErrors} tree (or the list of {@link FormIssue}s) instead of a
|
|
1669
|
+
* raised failure. `Form.make` is the high-level entry; these are for ad
|
|
1670
|
+
* hoc field- or value-level validation (e.g. a Svelte client validating a
|
|
1671
|
+
* single field on blur).
|
|
1672
|
+
*
|
|
1673
|
+
* The second half of the module is per-field validators: {@link
|
|
1674
|
+
* SyncFieldValidator} / {@link AsyncFieldValidator} (bundled as {@link
|
|
1675
|
+
* FieldValidators}) are the framework-neutral shape a reactive store attaches
|
|
1676
|
+
* to one field, mirroring TanStack Form's `validators.onChange(Async)` /
|
|
1677
|
+
* `onBlur(Async)`. {@link runSyncFieldValidator} runs one synchronously;
|
|
1678
|
+
* {@link runAsyncFieldValidator} builds the debounced Effect an async one
|
|
1679
|
+
* runs as — the store is responsible for forking it into a fiber per field
|
|
1680
|
+
* and interrupting the previous fiber on every new run (see that function's
|
|
1681
|
+
* docs for why that alone gives debounce + "latest wins" cancellation).
|
|
1682
|
+
*
|
|
1683
|
+
* @module effect-forms/Validate
|
|
1684
|
+
*/
|
|
1685
|
+
var Validate_exports = /* @__PURE__ */ __exportAll({
|
|
1686
|
+
resolveFieldTrigger: () => resolveFieldTrigger,
|
|
1687
|
+
runAsyncFieldValidator: () => runAsyncFieldValidator,
|
|
1688
|
+
runSyncFieldValidator: () => runSyncFieldValidator,
|
|
1689
|
+
validateValue: () => validateValue,
|
|
1690
|
+
validateValueSync: () => validateValueSync
|
|
1691
|
+
});
|
|
1692
|
+
/**
|
|
1693
|
+
* Decode `input` through `schema`, returning a {@link ValidationResult} on the
|
|
1694
|
+
* success channel (decode failures become `valid: false`, not an Effect
|
|
1695
|
+
* failure). Decoding-service requirements propagate on the `R` channel.
|
|
1696
|
+
*/
|
|
1697
|
+
function validateValue(schema, input, format) {
|
|
1698
|
+
return SchemaParser.decodeUnknownEffect(schema, { errors: "all" })(input).pipe(Effect.matchEffect({
|
|
1699
|
+
onSuccess: (data) => Effect.succeed({
|
|
1700
|
+
valid: true,
|
|
1701
|
+
data
|
|
1702
|
+
}),
|
|
1703
|
+
onFailure: (issue) => {
|
|
1704
|
+
const issues = schemaIssuesToFormIssues(issue, format);
|
|
1705
|
+
return Effect.succeed({
|
|
1706
|
+
valid: false,
|
|
1707
|
+
errors: issuesToErrors(issues),
|
|
1708
|
+
issues
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
}));
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Synchronous {@link validateValue} for schemas with no decoding-service
|
|
1715
|
+
* requirements (`R = never`) and no async decoding — the common case for plain
|
|
1716
|
+
* form schemas. {@link validateValue} never fails on the error channel, so
|
|
1717
|
+
* running it with {@link Effect.runSync} is total.
|
|
1718
|
+
*/
|
|
1719
|
+
function validateValueSync(schema, input, format) {
|
|
1720
|
+
return Effect.runSync(validateValue(schema, input, format));
|
|
1721
|
+
}
|
|
1722
|
+
/**
|
|
1723
|
+
* Run a {@link SyncFieldValidator}, folding its {@link Schema.FilterOutput}
|
|
1724
|
+
* into {@link FormIssue}s (empty on success). Total — a sync field validator
|
|
1725
|
+
* cannot fail the Effect error channel.
|
|
1726
|
+
*/
|
|
1727
|
+
function runSyncFieldValidator(validator, value) {
|
|
1728
|
+
return filterOutputToFormIssues(validator(value));
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* Build the Effect an {@link AsyncFieldValidator} run is: sleep `debounceMs`
|
|
1732
|
+
* (a no-op at `0`, the default), then run `validator` and fold its output
|
|
1733
|
+
* into {@link FormIssue}s.
|
|
1734
|
+
*
|
|
1735
|
+
* The debounce is a plain {@link Effect.sleep}, so it rides Effect's `Clock`
|
|
1736
|
+
* — deterministic and instantly-advanceable under `TestClock` in tests, real
|
|
1737
|
+
* time otherwise (no `setTimeout` bookkeeping for a caller to manage).
|
|
1738
|
+
* "Latest wins" falls out of ordinary fiber interruption: a caller re-running
|
|
1739
|
+
* this on every keystroke forks a fresh fiber per call and interrupts the
|
|
1740
|
+
* previous one before forking the next — interrupting mid-sleep skips the
|
|
1741
|
+
* validator entirely (a true debounce, not just a discarded stale result),
|
|
1742
|
+
* and interrupting mid-validator aborts whatever the validator was doing
|
|
1743
|
+
* (e.g. an in-flight `fetch` wrapped as an Effect).
|
|
1744
|
+
*/
|
|
1745
|
+
function runAsyncFieldValidator(validator, value, debounceMs = 0) {
|
|
1746
|
+
return Effect.gen(function* () {
|
|
1747
|
+
if (debounceMs > 0) yield* Effect.sleep(Duration.millis(debounceMs));
|
|
1748
|
+
return filterOutputToFormIssues(yield* validator(value));
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* Pick `validators`' sync/async pair for `trigger` (TanStack's `onChange` /
|
|
1753
|
+
* `onBlur` vs `onChangeAsync` / `onBlurAsync`). A reactive store calls this
|
|
1754
|
+
* once per trigger instead of repeating the trigger ternary at each call
|
|
1755
|
+
* site — the pure decision half of what {@link module:effect-forms-svelte/FormStore}'s
|
|
1756
|
+
* field-trigger dispatch runs.
|
|
1757
|
+
*/
|
|
1758
|
+
function resolveFieldTrigger(validators, trigger) {
|
|
1759
|
+
return {
|
|
1760
|
+
sync: trigger === "change" ? validators?.onChange : validators?.onBlur,
|
|
1761
|
+
async: trigger === "change" ? validators?.onChangeAsync : validators?.onBlurAsync,
|
|
1762
|
+
asyncDebounceMs: validators?.asyncDebounceMs ?? 0
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
//#endregion
|
|
1766
|
+
export { Coerce_exports as Coerce, Command_exports as Command, Constraints_exports as Constraints, Errors_exports as Errors, Field_exports as Field, Form_exports as Form, FormBuilder_exports as FormBuilder, FormData_exports as FormData, Issues_exports as Issues, Mode_exports as Mode, Path_exports as Path, Result_exports as Result, State_exports as State, Validate_exports as Validate };
|
|
1767
|
+
|
|
1768
|
+
//# sourceMappingURL=index.js.map
|