@thomasfosterau/effect-forms 0.1.0 → 0.3.1
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/README.md +35 -39
- package/dist/index.d.ts +85 -13
- package/dist/index.js +529 -411
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.js";
|
|
2
|
+
import * as Arr from "effect/Array";
|
|
3
|
+
import * as Exit from "effect/Exit";
|
|
4
|
+
import * as Match from "effect/Match";
|
|
5
|
+
import * as Predicate from "effect/Predicate";
|
|
6
|
+
import * as Rec from "effect/Record";
|
|
2
7
|
import * as Schema from "effect/Schema";
|
|
3
8
|
import * as SchemaAST from "effect/SchemaAST";
|
|
4
|
-
import * as Effect from "effect/Effect";
|
|
5
9
|
import * as SchemaParser from "effect/SchemaParser";
|
|
6
|
-
import "effect/
|
|
10
|
+
import * as Option from "effect/Option";
|
|
11
|
+
import * as Effect from "effect/Effect";
|
|
12
|
+
import * as SchemaIssue from "effect/SchemaIssue";
|
|
7
13
|
import * as Duration from "effect/Duration";
|
|
8
14
|
//#region src/Coerce.ts
|
|
9
15
|
var Coerce_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -15,8 +21,6 @@ var Coerce_exports = /* @__PURE__ */ __exportAll({
|
|
|
15
21
|
trimToUndefined: () => trimToUndefined
|
|
16
22
|
});
|
|
17
23
|
/**
|
|
18
|
-
* @copyright Thomas Foster
|
|
19
|
-
*
|
|
20
24
|
* Tri-state coercers for the HTML-form boundary.
|
|
21
25
|
*
|
|
22
26
|
* An HTML form submits every value as a string, and an absent optional field
|
|
@@ -30,6 +34,7 @@ var Coerce_exports = /* @__PURE__ */ __exportAll({
|
|
|
30
34
|
* framework's `error()` helper (validation failures belong on the schema, not
|
|
31
35
|
* in a coercer).
|
|
32
36
|
*
|
|
37
|
+
* @copyright Thomas Foster
|
|
33
38
|
* @module effect-forms/Coerce
|
|
34
39
|
*/
|
|
35
40
|
/** Empty string / `undefined` → `undefined`; otherwise the value (skip semantics). */
|
|
@@ -56,7 +61,7 @@ function trimToNull(value) {
|
|
|
56
61
|
}
|
|
57
62
|
function numberFromField(raw, { nullable }) {
|
|
58
63
|
const coerced = nullable ? emptyToNull(raw) : emptyToUndefined(raw);
|
|
59
|
-
return
|
|
64
|
+
return coerced === void 0 || coerced === null ? coerced : Number(coerced);
|
|
60
65
|
}
|
|
61
66
|
/**
|
|
62
67
|
* Interpret a checkbox field. An HTML checkbox submits its `value` (default
|
|
@@ -73,8 +78,6 @@ function booleanFromCheckbox(raw) {
|
|
|
73
78
|
//#endregion
|
|
74
79
|
//#region src/Command.ts
|
|
75
80
|
/**
|
|
76
|
-
* @copyright Thomas Foster
|
|
77
|
-
*
|
|
78
81
|
* Command-struct introspection and form derivation.
|
|
79
82
|
*
|
|
80
83
|
* A host app's write surface is a pair of flat command `Schema.Struct`s per
|
|
@@ -102,6 +105,7 @@ function booleanFromCheckbox(raw) {
|
|
|
102
105
|
* (a create-time default, an asymmetric coercion) stay expressible via the
|
|
103
106
|
* per-field {@link AdaptOverrides} escape hatch instead of forcing ejection.
|
|
104
107
|
*
|
|
108
|
+
* @copyright Thomas Foster
|
|
105
109
|
* @module effect-forms/Command
|
|
106
110
|
*/
|
|
107
111
|
var Command_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -112,23 +116,21 @@ var Command_exports = /* @__PURE__ */ __exportAll({
|
|
|
112
116
|
plan: () => plan,
|
|
113
117
|
updateAdapter: () => updateAdapter
|
|
114
118
|
});
|
|
119
|
+
/** A command field {@link plan} cannot classify into any {@link FieldKind}. */
|
|
120
|
+
var UnclassifiableFieldError = class extends Schema.TaggedError()("UnclassifiableFieldError", { message: Schema.String }) {};
|
|
121
|
+
/** A malformed `jsonArray` field payload, as {@link jsonRowsFromField} rejects it. */
|
|
122
|
+
var InvalidJsonRowsError = class extends Schema.TaggedError()("InvalidJsonRowsError", { message: Schema.String }) {};
|
|
115
123
|
/**
|
|
116
124
|
* Run one decode probe: whether the schema accepts the input, plus the decoded
|
|
117
125
|
* value for instance checks (the date probe needs to see a `Date` come out,
|
|
118
126
|
* not just a successful decode).
|
|
119
127
|
*/
|
|
120
128
|
const probe = (schema, input) => {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
ok: true,
|
|
124
|
-
value: Schema.decodeUnknownSync(schema)(input)
|
|
125
|
-
};
|
|
126
|
-
} catch {
|
|
127
|
-
return { ok: false };
|
|
128
|
-
}
|
|
129
|
+
const decoder = schema;
|
|
130
|
+
return SchemaParser.decodeUnknownExit(decoder)(input);
|
|
129
131
|
};
|
|
130
|
-
/** True when a schema decodes the given input without
|
|
131
|
-
const decodes = (schema, input) => probe(schema, input)
|
|
132
|
+
/** True when a schema decodes the given input without failing. */
|
|
133
|
+
const decodes = (schema, input) => Exit.isSuccess(probe(schema, input));
|
|
132
134
|
/** A syntactically valid UUID no other field kind accepts as non-text. */
|
|
133
135
|
const PROBE_UUID = "9f8c1c2e-4b3a-4c5d-8e6f-0a1b2c3d4e5f";
|
|
134
136
|
/**
|
|
@@ -143,13 +145,10 @@ const PROBE_UUID = "9f8c1c2e-4b3a-4c5d-8e6f-0a1b2c3d4e5f";
|
|
|
143
145
|
* `enumOptions` below needs the full literal set, not just one branch of it.
|
|
144
146
|
*/
|
|
145
147
|
function unwrapWrapperUnion(ast) {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
current = real[0];
|
|
151
|
-
}
|
|
152
|
-
return current;
|
|
148
|
+
if (!SchemaAST.isUnion(ast)) return ast;
|
|
149
|
+
const real = Arr.filter(ast.types, (t) => !SchemaAST.isUndefined(t) && !SchemaAST.isNull(t));
|
|
150
|
+
if (real.length !== 1 || real.length === ast.types.length) return ast;
|
|
151
|
+
return unwrapWrapperUnion(real[0]);
|
|
153
152
|
}
|
|
154
153
|
/**
|
|
155
154
|
* The literal option values of a field shaped like `Schema.Literal(...)` or
|
|
@@ -162,8 +161,8 @@ function enumOptions(ast) {
|
|
|
162
161
|
const inner = unwrapWrapperUnion(ast);
|
|
163
162
|
if (SchemaAST.isLiteral(inner)) return [String(inner.literal)];
|
|
164
163
|
if (SchemaAST.isUnion(inner)) {
|
|
165
|
-
const literals = inner.types
|
|
166
|
-
if (literals.length === inner.types.length
|
|
164
|
+
const literals = Arr.filter(inner.types, SchemaAST.isLiteral);
|
|
165
|
+
if (Arr.isReadonlyArrayNonEmpty(literals) && literals.length === inner.types.length) return Arr.map(literals, (literal) => String(literal.literal));
|
|
167
166
|
}
|
|
168
167
|
}
|
|
169
168
|
/**
|
|
@@ -189,7 +188,7 @@ const probeKind = (schema) => {
|
|
|
189
188
|
if (decodes(schema, true)) return "boolean";
|
|
190
189
|
if (decodes(schema, 42)) return "number";
|
|
191
190
|
const date = probe(schema, "2024-01-02T03:04:05.000Z");
|
|
192
|
-
if (date
|
|
191
|
+
if (Exit.isSuccess(date) && date.value instanceof Date) return "date";
|
|
193
192
|
if (decodes(schema, PROBE_UUID) && !decodes(schema, "not-a-uuid")) return "id";
|
|
194
193
|
if (decodes(schema, "plain text")) return "text";
|
|
195
194
|
};
|
|
@@ -216,7 +215,7 @@ const classify = (key, schema) => {
|
|
|
216
215
|
const probed = probeKind(schema);
|
|
217
216
|
if (probed !== void 0) return { kind: probed };
|
|
218
217
|
if (isStructured(schema.ast)) return { kind: "structured" };
|
|
219
|
-
throw new
|
|
218
|
+
throw new UnclassifiableFieldError({ message: `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
219
|
};
|
|
221
220
|
/**
|
|
222
221
|
* Read a create/update command struct pair into a {@link CommandPlan}.
|
|
@@ -226,22 +225,23 @@ const classify = (key, schema) => {
|
|
|
226
225
|
const plan = (create, update) => {
|
|
227
226
|
const createFields = create.fields;
|
|
228
227
|
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);
|
|
228
|
+
const keys = [...Arr.filter(Rec.keys(updateFields), (key) => key !== "id"), ...Arr.filter(Rec.keys(createFields), (key) => key !== "id" && !(key in updateFields))];
|
|
232
229
|
return {
|
|
233
|
-
fields:
|
|
230
|
+
fields: Arr.map(keys, (key) => {
|
|
234
231
|
const createField = createFields[key];
|
|
235
232
|
const updateField = updateFields[key];
|
|
236
233
|
const { kind, options } = classify(key, createField ?? updateField);
|
|
237
|
-
|
|
234
|
+
const fieldPlan = {
|
|
238
235
|
key,
|
|
239
236
|
kind,
|
|
240
237
|
inCreate: createField !== void 0,
|
|
241
238
|
createRequired: createField !== void 0 && !decodes(createField, void 0),
|
|
242
239
|
inUpdate: updateField !== void 0,
|
|
243
|
-
updateClearable: updateField !== void 0 && decodes(updateField, null)
|
|
244
|
-
|
|
240
|
+
updateClearable: updateField !== void 0 && decodes(updateField, null)
|
|
241
|
+
};
|
|
242
|
+
return options === void 0 ? fieldPlan : {
|
|
243
|
+
...fieldPlan,
|
|
244
|
+
options
|
|
245
245
|
};
|
|
246
246
|
}),
|
|
247
247
|
clientId: "id" in createFields
|
|
@@ -255,17 +255,16 @@ const plan = (create, update) => {
|
|
|
255
255
|
* "create") })`, with `overrides` spread last for irregular fields.
|
|
256
256
|
*/
|
|
257
257
|
const formInputFields = (commandPlan, mode, overrides = {}) => {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
...
|
|
267
|
-
|
|
268
|
-
};
|
|
258
|
+
const idField = mode === "update" ? [["id", Schema.String.check(Schema.isUUID())]] : commandPlan.clientId ? [["id", Schema.optional(Schema.String)]] : [];
|
|
259
|
+
const planFields = Arr.flatMap(commandPlan.fields, (field) => {
|
|
260
|
+
if (mode === "create" ? !field.inCreate : !field.inUpdate) return [];
|
|
261
|
+
return [[field.key, mode === "create" && field.createRequired ? Schema.String.check(Schema.isMinLength(1)) : Schema.optional(Schema.String)]];
|
|
262
|
+
});
|
|
263
|
+
return Rec.fromEntries([
|
|
264
|
+
...idField,
|
|
265
|
+
...planFields,
|
|
266
|
+
...Rec.toEntries(overrides)
|
|
267
|
+
]);
|
|
269
268
|
};
|
|
270
269
|
/**
|
|
271
270
|
* Blank string defaults for one mode's Form-Input shape. Client-id entities
|
|
@@ -273,13 +272,8 @@ const formInputFields = (commandPlan, mode, overrides = {}) => {
|
|
|
273
272
|
* crypto.randomUUID() }`) so retries stay idempotent.
|
|
274
273
|
*/
|
|
275
274
|
const blankDefaults = (commandPlan, mode) => {
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
for (const field of commandPlan.fields) {
|
|
279
|
-
if (mode === "create" ? !field.inCreate : !field.inUpdate) continue;
|
|
280
|
-
defaults[field.key] = "";
|
|
281
|
-
}
|
|
282
|
-
return defaults;
|
|
275
|
+
const keys = [...mode === "update" || commandPlan.clientId ? ["id"] : [], ...Arr.flatMap(commandPlan.fields, (field) => (mode === "create" ? field.inCreate : field.inUpdate) ? [field.key] : [])];
|
|
276
|
+
return Rec.fromEntries(Arr.map(keys, (key) => [key, ""]));
|
|
283
277
|
};
|
|
284
278
|
/**
|
|
285
279
|
* Parse a JSON-encoded row list (a contributor list) from a multipart form
|
|
@@ -291,25 +285,28 @@ const blankDefaults = (commandPlan, mode) => {
|
|
|
291
285
|
*/
|
|
292
286
|
const jsonRowsFromField = (raw, label) => {
|
|
293
287
|
if (raw === void 0 || raw.trim() === "") return void 0;
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (
|
|
303
|
-
const
|
|
304
|
-
|
|
288
|
+
const decoded = decodeJson(raw);
|
|
289
|
+
if (Exit.isFailure(decoded)) throw new InvalidJsonRowsError({ message: `Invalid ${label} payload — expected JSON array` });
|
|
290
|
+
const parsed = decoded.value;
|
|
291
|
+
if (!Arr.isArray(parsed)) throw new InvalidJsonRowsError({ message: `Invalid ${label} payload — expected array` });
|
|
292
|
+
return Arr.map(parsed, (row, index) => {
|
|
293
|
+
if (!Predicate.isObjectOrArray(row)) throw new InvalidJsonRowsError({ message: `Invalid ${label} entry at index ${index}` });
|
|
294
|
+
const propertyOf = (key) => Predicate.hasProperty(row, key) ? row[key] : void 0;
|
|
295
|
+
const agentId = propertyOf("agentId");
|
|
296
|
+
if (!Predicate.isString(agentId)) throw new InvalidJsonRowsError({ message: `${label} entry at index ${index} missing agentId` });
|
|
297
|
+
const id = propertyOf("id");
|
|
298
|
+
const creditedName = propertyOf("creditedName");
|
|
299
|
+
const orderingPosition = propertyOf("orderingPosition");
|
|
305
300
|
return {
|
|
306
|
-
id:
|
|
307
|
-
agentId
|
|
308
|
-
creditedName:
|
|
309
|
-
orderingPosition:
|
|
301
|
+
id: Predicate.isString(id) ? id : void 0,
|
|
302
|
+
agentId,
|
|
303
|
+
creditedName: Predicate.isString(creditedName) ? creditedName : void 0,
|
|
304
|
+
orderingPosition: Predicate.isNumber(orderingPosition) ? orderingPosition : index
|
|
310
305
|
};
|
|
311
306
|
});
|
|
312
307
|
};
|
|
308
|
+
/** Parse a JSON text (`JSON.parse` semantics) to a success or a failed `Exit`. */
|
|
309
|
+
const decodeJson = SchemaParser.decodeUnknownExit(Schema.fromJsonString(Schema.Unknown));
|
|
313
310
|
/** Text fields: trim always; required create fields never skip. */
|
|
314
311
|
const coerceText = (ctx, raw) => ctx.required ? (raw ?? "").trim() : ctx.clearable ? trimToNull(raw) : trimToUndefined(raw);
|
|
315
312
|
/** Id fields: no trimming (pickers emit `""` or a UUID); required create FKs pass through raw. */
|
|
@@ -358,49 +355,66 @@ const coerceContextFor = (field, mode) => ({
|
|
|
358
355
|
/** The default coercion for one field in one mode. */
|
|
359
356
|
const coerce = (field, mode, raw) => {
|
|
360
357
|
const ctx = coerceContextFor(field, mode);
|
|
361
|
-
|
|
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
|
-
}
|
|
358
|
+
return Match.value(field.kind).pipe(Match.when(Match.is("text", "enum"), () => coerceText(ctx, raw)), Match.when("id", () => coerceId(ctx, raw)), Match.when("number", () => coerceNumber(ctx, raw)), Match.when("date", () => coerceDate(ctx, raw)), Match.when("jsonArray", () => jsonRowsFromField(raw, field.key)), Match.when("boolean", () => coerceBoolean(ctx, raw)), Match.when("structured", () => structuredFromField(raw, ctx.clearable)), Match.exhaustive);
|
|
371
359
|
};
|
|
372
360
|
/**
|
|
361
|
+
* Adapt every field of `commandPlan` present in `mode`, in plan order, to a
|
|
362
|
+
* `[key, value]` entry: through the field's override when it has one, else
|
|
363
|
+
* the default {@link coerce}.
|
|
364
|
+
*/
|
|
365
|
+
const adaptFields = (commandPlan, overrides, mode, input) => Arr.flatMap(commandPlan.fields, (field) => {
|
|
366
|
+
if (mode === "create" ? !field.inCreate : !field.inUpdate) return [];
|
|
367
|
+
const override = overrides[field.key];
|
|
368
|
+
const raw = input[field.key];
|
|
369
|
+
return [[field.key, override ? override(raw) : coerce(field, mode, raw)]];
|
|
370
|
+
});
|
|
371
|
+
/**
|
|
373
372
|
* Derive the create adapter: validated all-string input → encoded create
|
|
374
373
|
* command payload. `overrides` replace the default per-field coercion for
|
|
375
374
|
* genuinely irregular fields (e.g. a create-time fallback default).
|
|
376
375
|
*/
|
|
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
|
-
};
|
|
376
|
+
const createAdapter = (commandPlan, overrides = {}) => (input) => Rec.fromEntries([...commandPlan.clientId ? [["id", trimToUndefined(input.id)]] : [], ...adaptFields(commandPlan, overrides, "create", input)]);
|
|
387
377
|
/**
|
|
388
378
|
* Derive the update adapter: validated all-string input → encoded update
|
|
389
379
|
* command payload (value sets, `null` clears a clearable field, `undefined`
|
|
390
380
|
* skips). The required `id` passes through as-is — the command's canonical
|
|
391
381
|
* decode brands it.
|
|
392
382
|
*/
|
|
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
|
-
};
|
|
383
|
+
const updateAdapter = (commandPlan, overrides = {}) => (input) => Rec.fromEntries([["id", input.id], ...adaptFields(commandPlan, overrides, "update", input)]);
|
|
402
384
|
//#endregion
|
|
403
385
|
//#region src/Constraints.ts
|
|
386
|
+
/**
|
|
387
|
+
* HTML input constraints derived from an Effect Schema.
|
|
388
|
+
*
|
|
389
|
+
* Native validation attributes can be read (`required`, `minlength`, …)
|
|
390
|
+
* off the schema so a form can apply them to its inputs for free, layering
|
|
391
|
+
* browser-native validation under the schema-driven validation. This module
|
|
392
|
+
* does the framework-neutral half: walk the schema AST and emit an
|
|
393
|
+
* {@link InputConstraints} tree a UI can spread onto its `<input>`s.
|
|
394
|
+
*
|
|
395
|
+
* `required` comes from a struct field's optionality; `minlength` /
|
|
396
|
+
* `maxlength` / `min` / `max` / `pattern` / `step` are derived from the
|
|
397
|
+
* `representation` annotation Effect Schema's built-in checks carry
|
|
398
|
+
* (`isMinLength`, `isBetween`, `isPattern`, …), keyed by its stable
|
|
399
|
+
* `effect/schema/<check>` id. A check with no recognised representation id
|
|
400
|
+
* (a bare `Schema.filter` predicate, a custom `.check()`) contributes
|
|
401
|
+
* nothing; there is no attribute to guess at,
|
|
402
|
+
* so the derivation stays silent rather than wrong. The walk descends into
|
|
403
|
+
* nested structs and into a homogeneous array's item type (folding the
|
|
404
|
+
* array's own optionality into the item's `required`); a fixed-shape tuple
|
|
405
|
+
* with differently-typed elements has no single item schema to represent as
|
|
406
|
+
* one constraint, so it derives only `required`. Like
|
|
407
|
+
* {@link module:effect-forms/Field}'s `fromAst`, an `optional` / `NullOr`
|
|
408
|
+
* union is unwrapped by taking its first non-`null`/`undefined` member —
|
|
409
|
+
* scoped to that wrapping pattern, not general discriminated unions, whose
|
|
410
|
+
* branches can't be flattened into one constraint either. The `overrides`
|
|
411
|
+
* argument to {@link constraintsFromSchema} remains the seam for anything the
|
|
412
|
+
* walk cannot infer (or should not — a check that's semantically stronger
|
|
413
|
+
* than any HTML attribute).
|
|
414
|
+
*
|
|
415
|
+
* @copyright Thomas Foster
|
|
416
|
+
* @module effect-forms/Constraints
|
|
417
|
+
*/
|
|
404
418
|
var Constraints_exports = /* @__PURE__ */ __exportAll({
|
|
405
419
|
constraintAt: () => constraintAt,
|
|
406
420
|
constraintsFromSchema: () => constraintsFromSchema,
|
|
@@ -418,17 +432,15 @@ const isOptional = (type) => type.context?.isOptional === true;
|
|
|
418
432
|
*/
|
|
419
433
|
function unwrapOptional(type) {
|
|
420
434
|
if (!SchemaAST.isUnion(type)) return type;
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
}
|
|
425
|
-
return type;
|
|
435
|
+
return Arr.findFirst(type.types, (member) => !SchemaAST.isUndefined(member) && !SchemaAST.isNull(member)).pipe(Option.match({
|
|
436
|
+
onNone: () => type,
|
|
437
|
+
onSome: unwrapOptional
|
|
438
|
+
}));
|
|
426
439
|
}
|
|
427
440
|
/** Flatten a `Checks` array (recursing into `FilterGroup`s) into individual filters. */
|
|
428
|
-
function flattenChecks(checks
|
|
429
|
-
if (
|
|
430
|
-
|
|
431
|
-
else out.push(check);
|
|
441
|
+
function flattenChecks(checks) {
|
|
442
|
+
if (checks === void 0) return [];
|
|
443
|
+
return Arr.flatMap(checks, (check) => Predicate.isTagged(check, "FilterGroup") ? flattenChecks(check.checks) : [check]);
|
|
432
444
|
}
|
|
433
445
|
/**
|
|
434
446
|
* One constraint patch per recognised representation id — the built-in
|
|
@@ -441,7 +453,7 @@ function flattenChecks(checks, out) {
|
|
|
441
453
|
const payloadPatches = {
|
|
442
454
|
"effect/schema/isMinLength": (p) => ({ minlength: p.minLength }),
|
|
443
455
|
"effect/schema/isMaxLength": (p) => ({ maxlength: p.maxLength }),
|
|
444
|
-
"effect/schema/
|
|
456
|
+
"effect/schema/isBetweenLength": (p) => ({
|
|
445
457
|
minlength: p.minimum,
|
|
446
458
|
maxlength: p.maximum
|
|
447
459
|
}),
|
|
@@ -456,14 +468,18 @@ const payloadPatches = {
|
|
|
456
468
|
}),
|
|
457
469
|
"effect/schema/isMultipleOf": (p) => ({ step: p.divisor })
|
|
458
470
|
};
|
|
471
|
+
/** Whether `id` names a check {@link payloadPatches} has a patch for (an own key, never an inherited one). */
|
|
472
|
+
const isPatchedCheckId = (id) => Rec.has(payloadPatches, id);
|
|
459
473
|
/**
|
|
460
474
|
* Look up and apply `payloadPatches[representation.id]`. The cast is sound:
|
|
461
475
|
* each payload is authored by the same built-in check constructor its id
|
|
462
476
|
* names, so an entry is never called with a mismatched payload shape.
|
|
463
477
|
*/
|
|
464
478
|
function applyPayloadPatch(representation) {
|
|
465
|
-
const
|
|
466
|
-
|
|
479
|
+
const id = representation.id;
|
|
480
|
+
if (!isPatchedCheckId(id)) return void 0;
|
|
481
|
+
const patch = payloadPatches[id];
|
|
482
|
+
return patch(representation.payload);
|
|
467
483
|
}
|
|
468
484
|
/**
|
|
469
485
|
* Derive an {@link InputConstraint}'s refinement attributes from the checks
|
|
@@ -474,25 +490,20 @@ function applyPayloadPatch(representation) {
|
|
|
474
490
|
* filter) is skipped: there is no attribute to derive from it.
|
|
475
491
|
*/
|
|
476
492
|
function constraintFromChecks(outer, inner) {
|
|
477
|
-
const filters = [];
|
|
478
|
-
|
|
479
|
-
if (inner !== outer) flattenChecks(inner.checks, filters);
|
|
480
|
-
let out = {};
|
|
481
|
-
let hasIntCheck = false;
|
|
482
|
-
for (const filter of filters) {
|
|
493
|
+
const filters = inner === outer ? flattenChecks(outer.checks) : [...flattenChecks(outer.checks), ...flattenChecks(inner.checks)];
|
|
494
|
+
const representations = Arr.flatMap(filters, (filter) => {
|
|
483
495
|
const representation = filter.annotations?.representation;
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
return out;
|
|
496
|
+
return representation === void 0 ? [] : [representation];
|
|
497
|
+
});
|
|
498
|
+
const isIntCheck = (representation) => representation.id === "effect/schema/isInt";
|
|
499
|
+
const patched = Arr.reduce(representations, {}, (out, representation) => isIntCheck(representation) ? out : {
|
|
500
|
+
...out,
|
|
501
|
+
...applyPayloadPatch(representation)
|
|
502
|
+
});
|
|
503
|
+
return Arr.some(representations, isIntCheck) && patched.step === void 0 ? {
|
|
504
|
+
...patched,
|
|
505
|
+
step: 1
|
|
506
|
+
} : patched;
|
|
496
507
|
}
|
|
497
508
|
/**
|
|
498
509
|
* Whether `ast` is a homogeneous array/list — `Schema.Array(item)` or
|
|
@@ -505,8 +516,10 @@ function constraintFromChecks(outer, inner) {
|
|
|
505
516
|
function homogeneousItem(ast) {
|
|
506
517
|
if (ast.rest.length !== 1) return void 0;
|
|
507
518
|
const [item] = ast.rest;
|
|
508
|
-
|
|
509
|
-
|
|
519
|
+
return Arr.match(ast.elements, {
|
|
520
|
+
onEmpty: () => item,
|
|
521
|
+
onNonEmpty: (elements) => elements.length === 1 && elements[0] === item ? item : void 0
|
|
522
|
+
});
|
|
510
523
|
}
|
|
511
524
|
/**
|
|
512
525
|
* Derive the constraint (or nested tree) for one property/item type: a
|
|
@@ -537,30 +550,43 @@ function constraintsForType(type) {
|
|
|
537
550
|
}
|
|
538
551
|
/** Build the constraint tree for one object AST node. */
|
|
539
552
|
function constraintsForObjects(ast) {
|
|
540
|
-
|
|
541
|
-
for (const ps of ast.propertySignatures) out[String(ps.name)] = constraintsForType(ps.type);
|
|
542
|
-
return out;
|
|
553
|
+
return Rec.fromEntries(Arr.map(ast.propertySignatures, (ps) => [String(ps.name), constraintsForType(ps.type)]));
|
|
543
554
|
}
|
|
544
555
|
/** Deep-merge constraint overrides over a derived tree. */
|
|
545
556
|
function mergeConstraints(base, overrides) {
|
|
546
|
-
const
|
|
547
|
-
for (const key of Object.keys(overrides)) {
|
|
557
|
+
const merged = Arr.flatMap(Rec.keys(overrides), (key) => {
|
|
548
558
|
const override = overrides[key];
|
|
549
|
-
if (override === void 0)
|
|
550
|
-
const existing =
|
|
551
|
-
if (existing !== void 0 && !isLeafConstraint(existing) && !isLeafConstraint(override))
|
|
552
|
-
|
|
559
|
+
if (override === void 0) return [];
|
|
560
|
+
const existing = base[key];
|
|
561
|
+
if (existing !== void 0 && !isLeafConstraint(existing) && !isLeafConstraint(override)) return [[key, mergeConstraints(existing, override)]];
|
|
562
|
+
if (existing !== void 0 && isLeafConstraint(existing) && isLeafConstraint(override)) return [[key, {
|
|
553
563
|
...existing,
|
|
554
564
|
...override
|
|
555
|
-
};
|
|
556
|
-
|
|
557
|
-
}
|
|
558
|
-
return
|
|
565
|
+
}]];
|
|
566
|
+
return [[key, override]];
|
|
567
|
+
});
|
|
568
|
+
return {
|
|
569
|
+
...base,
|
|
570
|
+
...Rec.fromEntries(merged)
|
|
571
|
+
};
|
|
559
572
|
}
|
|
560
573
|
/**
|
|
561
574
|
* Derive the {@link InputConstraints} for a struct schema. Non-struct schemas
|
|
562
575
|
* yield an empty tree. `overrides` (deep-merged over the derived tree, and
|
|
563
576
|
* winning on conflict) is the seam for anything the AST walk does not infer.
|
|
577
|
+
*
|
|
578
|
+
* @example
|
|
579
|
+
* ```ts
|
|
580
|
+
* import * as Schema from "effect/Schema"
|
|
581
|
+
* import { Constraints } from "@thomasfosterau/effect-forms"
|
|
582
|
+
*
|
|
583
|
+
* const Signup = Schema.Struct({
|
|
584
|
+
* name: Schema.String.check(Schema.isMinLength(2)),
|
|
585
|
+
* nickname: Schema.optional(Schema.String),
|
|
586
|
+
* })
|
|
587
|
+
* console.log(Constraints.constraintsFromSchema(Signup))
|
|
588
|
+
* // { name: { required: true, minlength: 2 }, nickname: { required: false } }
|
|
589
|
+
* ```
|
|
564
590
|
*/
|
|
565
591
|
function constraintsFromSchema(schema, overrides = {}) {
|
|
566
592
|
const ast = schema.ast;
|
|
@@ -577,16 +603,34 @@ function constraintsFromSchema(schema, overrides = {}) {
|
|
|
577
603
|
* skips the index and resolves straight to `label`'s derived constraint.
|
|
578
604
|
*/
|
|
579
605
|
function constraintAt(constraints, path) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
if (
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
}
|
|
606
|
+
const node = Arr.reduce(path, constraints, (current, segment) => {
|
|
607
|
+
if (Predicate.isNumber(segment)) return current;
|
|
608
|
+
if (current === void 0 || isLeafConstraint(current)) return void 0;
|
|
609
|
+
return current[segment];
|
|
610
|
+
});
|
|
586
611
|
return node !== void 0 && isLeafConstraint(node) ? node : {};
|
|
587
612
|
}
|
|
588
613
|
//#endregion
|
|
589
614
|
//#region src/FormData.ts
|
|
615
|
+
/**
|
|
616
|
+
* Structured parsing of an HTML form body into a plain object the schema can
|
|
617
|
+
* decode.
|
|
618
|
+
*
|
|
619
|
+
* Browsers flatten structured data into `name=value` pairs using bracket /
|
|
620
|
+
* dot path notation (`author.name`, `author[name]`, `tags[0]`, `tags[]`).
|
|
621
|
+
* {@link formDataToObject} reverses that into a nested object / array tree —
|
|
622
|
+
* the framework-neutral step that turns a form body into structured data, before handing it
|
|
623
|
+
* to a validator. Coercion (strings → numbers / booleans / dates) is then the
|
|
624
|
+
* schema's job: decode the result with a schema built from coercing leaves
|
|
625
|
+
* (`Schema.FiniteFromString`, `Schema.BooleanFromString`, the {@link
|
|
626
|
+
* module:effect-forms/Coerce} helpers, …).
|
|
627
|
+
*
|
|
628
|
+
* `File` values pass through untouched; repeated bare keys collapse into an
|
|
629
|
+
* array. The parser is total and never throws.
|
|
630
|
+
*
|
|
631
|
+
* @copyright Thomas Foster
|
|
632
|
+
* @module effect-forms/FormData
|
|
633
|
+
*/
|
|
590
634
|
var FormData_exports = /* @__PURE__ */ __exportAll({
|
|
591
635
|
formDataToObject: () => formDataToObject,
|
|
592
636
|
parseFieldPath: () => parseFieldPath,
|
|
@@ -599,47 +643,56 @@ var FormData_exports = /* @__PURE__ */ __exportAll({
|
|
|
599
643
|
* `"a.b[]"` → `["a","b",""]`.
|
|
600
644
|
*/
|
|
601
645
|
function parseFieldPath(name) {
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
646
|
+
const nonEmpty = (buffer) => buffer === "" ? [] : [buffer];
|
|
647
|
+
const scanned = Arr.reduce(Arr.fromIterable(name), {
|
|
648
|
+
tokens: Arr.empty(),
|
|
649
|
+
buffer: "",
|
|
650
|
+
inBracket: false
|
|
651
|
+
}, ({ tokens, buffer, inBracket }, char) => {
|
|
652
|
+
if (char === "[") return {
|
|
653
|
+
tokens: [...tokens, ...nonEmpty(buffer)],
|
|
654
|
+
buffer: "",
|
|
655
|
+
inBracket: true
|
|
656
|
+
};
|
|
657
|
+
if (char === "]") return {
|
|
658
|
+
tokens: [...tokens, buffer],
|
|
659
|
+
buffer: "",
|
|
660
|
+
inBracket: false
|
|
661
|
+
};
|
|
662
|
+
if (char === "." && !inBracket) return {
|
|
663
|
+
tokens: [...tokens, ...nonEmpty(buffer)],
|
|
664
|
+
buffer: "",
|
|
665
|
+
inBracket
|
|
666
|
+
};
|
|
667
|
+
return {
|
|
668
|
+
tokens,
|
|
669
|
+
buffer: buffer + char,
|
|
670
|
+
inBracket
|
|
671
|
+
};
|
|
672
|
+
});
|
|
673
|
+
const tokens = [...scanned.tokens, ...nonEmpty(scanned.buffer)];
|
|
618
674
|
return tokens.length > 1 && tokens[0] === "" ? tokens.slice(1) : tokens;
|
|
619
675
|
}
|
|
620
676
|
const isIndexToken = (token) => token === "" || /^\d+$/.test(token);
|
|
621
677
|
/** Create the container a token should live in: array for index/append tokens. */
|
|
622
678
|
const makeContainer = (nextToken) => isIndexToken(nextToken) ? [] : {};
|
|
623
679
|
const setOnContainer = (container, token, value) => {
|
|
624
|
-
if (
|
|
625
|
-
|
|
626
|
-
|
|
680
|
+
if (Arr.isArray(container)) {
|
|
681
|
+
if (token === "") container.push(value);
|
|
682
|
+
else container[Number(token)] = value;
|
|
683
|
+
} else container[token] = value;
|
|
627
684
|
};
|
|
628
|
-
const getFromContainer = (container, token) =>
|
|
685
|
+
const getFromContainer = (container, token) => Arr.isArray(container) ? container[Number(token)] : container[token];
|
|
629
686
|
/** Assign `value` deep into `root` along `tokens`, creating containers as needed. */
|
|
630
687
|
function assign(root, tokens, value) {
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}
|
|
640
|
-
node = child;
|
|
641
|
-
}
|
|
642
|
-
const last = tokens[tokens.length - 1];
|
|
688
|
+
const last = Arr.lastNonEmpty(tokens);
|
|
689
|
+
const node = Arr.reduce(Arr.initNonEmpty(tokens), root, (parent, token, i) => {
|
|
690
|
+
const child = token === "" ? void 0 : getFromContainer(parent, token);
|
|
691
|
+
if (Predicate.isObjectOrArray(child)) return child;
|
|
692
|
+
const created = makeContainer(tokens[i + 1]);
|
|
693
|
+
setOnContainer(parent, token, created);
|
|
694
|
+
return created;
|
|
695
|
+
});
|
|
643
696
|
if (last !== "" && !Array.isArray(node)) {
|
|
644
697
|
const existing = getFromContainer(node, last);
|
|
645
698
|
if (existing !== void 0) {
|
|
@@ -652,27 +705,35 @@ function assign(root, tokens, value) {
|
|
|
652
705
|
setOnContainer(node, last, value);
|
|
653
706
|
}
|
|
654
707
|
/** Normalise a source into `[name, value]` entries. */
|
|
655
|
-
function
|
|
656
|
-
if (
|
|
657
|
-
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
for (const entry of source.entries()) yield entry;
|
|
708
|
+
function entriesOf(source) {
|
|
709
|
+
if (source instanceof FormData || source instanceof URLSearchParams) return source.entries();
|
|
710
|
+
return source;
|
|
661
711
|
}
|
|
662
712
|
/**
|
|
663
713
|
* Parse a form body into a nested object / array tree. Field names use dot or
|
|
664
714
|
* bracket path notation (see {@link parseFieldPath}); repeated bare keys
|
|
665
715
|
* become arrays; `File` values pass through. The result is ready to hand to
|
|
666
716
|
* `Schema.decodeUnknown` with a schema whose leaves coerce the string inputs.
|
|
717
|
+
*
|
|
718
|
+
* @example
|
|
719
|
+
* ```ts
|
|
720
|
+
* import { FormData } from "@thomasfosterau/effect-forms"
|
|
721
|
+
*
|
|
722
|
+
* const body = new URLSearchParams([
|
|
723
|
+
* ["author.name", "Ada"],
|
|
724
|
+
* ["tags[]", "math"],
|
|
725
|
+
* ["tags[]", "engines"],
|
|
726
|
+
* ])
|
|
727
|
+
* console.log(FormData.formDataToObject(body))
|
|
728
|
+
* // { author: { name: "Ada" }, tags: ["math", "engines"] }
|
|
729
|
+
* ```
|
|
667
730
|
*/
|
|
668
731
|
function formDataToObject(source) {
|
|
669
|
-
|
|
670
|
-
for (const [name, value] of entriesOf(source)) {
|
|
732
|
+
return Arr.reduce(entriesOf(source), Rec.empty(), (root, [name, value]) => {
|
|
671
733
|
const tokens = parseFieldPath(name);
|
|
672
|
-
if (tokens
|
|
673
|
-
|
|
674
|
-
}
|
|
675
|
-
return root;
|
|
734
|
+
if (Arr.isReadonlyArrayNonEmpty(tokens)) assign(root, tokens, value);
|
|
735
|
+
return root;
|
|
736
|
+
});
|
|
676
737
|
}
|
|
677
738
|
/** Read and parse a `Request`'s form body. Errors (e.g. wrong content-type) reject. */
|
|
678
739
|
async function requestToObject(request) {
|
|
@@ -681,8 +742,6 @@ async function requestToObject(request) {
|
|
|
681
742
|
//#endregion
|
|
682
743
|
//#region src/Path.ts
|
|
683
744
|
/**
|
|
684
|
-
* @copyright Thomas Foster
|
|
685
|
-
*
|
|
686
745
|
* Nested get / set along a {@link FormPath}.
|
|
687
746
|
*
|
|
688
747
|
* A form's data is a tree, and the reactive bindings address it by path:
|
|
@@ -700,6 +759,7 @@ async function requestToObject(request) {
|
|
|
700
759
|
* convert to and from the flat `author.name` / `tags[0]` notation the HTML
|
|
701
760
|
* body parser ({@link module:effect-forms/FormData}) uses.
|
|
702
761
|
*
|
|
762
|
+
* @copyright Thomas Foster
|
|
703
763
|
* @module effect-forms/Path
|
|
704
764
|
*/
|
|
705
765
|
var Path_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -722,32 +782,41 @@ var Path_exports = /* @__PURE__ */ __exportAll({
|
|
|
722
782
|
* traverses a non-object. The empty path returns the object itself.
|
|
723
783
|
*/
|
|
724
784
|
function getAt(obj, path) {
|
|
725
|
-
|
|
726
|
-
for (const segment of path) {
|
|
727
|
-
if (current === null || typeof current !== "object") return void 0;
|
|
728
|
-
current = current[segment];
|
|
729
|
-
}
|
|
730
|
-
return current;
|
|
785
|
+
return Arr.reduce(path, obj, (current, segment) => Predicate.isObjectOrArray(current) && Predicate.hasProperty(current, segment) ? current[segment] : void 0);
|
|
731
786
|
}
|
|
732
787
|
/**
|
|
733
788
|
* Return a new tree with the value at `path` replaced by `value`. Objects and
|
|
734
789
|
* arrays along the path are cloned (structural sharing elsewhere); missing
|
|
735
790
|
* containers are created as arrays for numeric segments and objects otherwise.
|
|
736
791
|
* The empty path returns `value`. Pure — `obj` is never mutated.
|
|
792
|
+
*
|
|
793
|
+
* @example
|
|
794
|
+
* ```ts
|
|
795
|
+
* import { Path } from "@thomasfosterau/effect-forms"
|
|
796
|
+
*
|
|
797
|
+
* const before = { authors: [{ name: "Ada" }] }
|
|
798
|
+
* const after = Path.setAt(before, ["authors", 0, "name"], "Grace")
|
|
799
|
+
* console.log(Path.getAt(after, ["authors", 0, "name"])) // "Grace"
|
|
800
|
+
* console.log(before.authors[0]?.name) // "Ada" — the input is untouched
|
|
801
|
+
* ```
|
|
737
802
|
*/
|
|
738
803
|
function setAt(obj, path, value) {
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
804
|
+
return Arr.match(path, {
|
|
805
|
+
onEmpty: () => value,
|
|
806
|
+
onNonEmpty: ([head, ...rest]) => {
|
|
807
|
+
const clone = Arr.isArray(obj) ? [...obj] : { ...Object(obj) };
|
|
808
|
+
const child = clone[head];
|
|
809
|
+
clone[head] = Arr.match(rest, {
|
|
810
|
+
onEmpty: () => value,
|
|
811
|
+
onNonEmpty: (tail) => setAt(Predicate.isObjectOrArray(child) ? child : Predicate.isNumber(tail[0]) ? [] : {}, tail, value)
|
|
812
|
+
});
|
|
813
|
+
return clone;
|
|
814
|
+
}
|
|
815
|
+
});
|
|
745
816
|
}
|
|
746
817
|
/** Whether `path` is `root` or nested beneath it. */
|
|
747
818
|
function isUnder(path, root) {
|
|
748
|
-
|
|
749
|
-
for (let i = 0; i < root.length; i++) if (path[i] !== root[i]) return false;
|
|
750
|
-
return true;
|
|
819
|
+
return path.length >= root.length && Arr.every(root, (segment, i) => path[i] === segment);
|
|
751
820
|
}
|
|
752
821
|
/**
|
|
753
822
|
* Render a {@link FormPath} as flat field-name notation: string segments join
|
|
@@ -755,10 +824,7 @@ function isUnder(path, root) {
|
|
|
755
824
|
* The inverse of {@link fromString}.
|
|
756
825
|
*/
|
|
757
826
|
function toString(path) {
|
|
758
|
-
|
|
759
|
-
for (const segment of path) if (typeof segment === "number") out += `[${segment}]`;
|
|
760
|
-
else out += out === "" ? segment : `.${segment}`;
|
|
761
|
-
return out;
|
|
827
|
+
return Arr.reduce(path, "", (out, segment) => Predicate.isNumber(segment) ? `${out}[${segment}]` : out === "" ? segment : `${out}.${segment}`);
|
|
762
828
|
}
|
|
763
829
|
/**
|
|
764
830
|
* Parse flat field-name notation into a {@link FormPath}: integer tokens become
|
|
@@ -803,7 +869,7 @@ const clearAll = () => null;
|
|
|
803
869
|
function remapIndex(path, arrayPath, map) {
|
|
804
870
|
if (!isUnder(path, arrayPath) || path.length === arrayPath.length) return path;
|
|
805
871
|
const segment = path[arrayPath.length];
|
|
806
|
-
if (
|
|
872
|
+
if (!Predicate.isNumber(segment)) return path;
|
|
807
873
|
const mapped = map(segment);
|
|
808
874
|
if (mapped === null) return null;
|
|
809
875
|
if (mapped === segment) return path;
|
|
@@ -819,15 +885,34 @@ function remapIndex(path, arrayPath, map) {
|
|
|
819
885
|
* maps to `null` are dropped. Pure.
|
|
820
886
|
*/
|
|
821
887
|
function remapKeyedRecord(record, arrayPath, map) {
|
|
822
|
-
|
|
823
|
-
for (const key of Object.keys(record)) {
|
|
888
|
+
return Rec.fromEntries(Arr.flatMap(Rec.toEntries(record), ([key, value]) => {
|
|
824
889
|
const remapped = remapIndex(fromString(key), arrayPath, map);
|
|
825
|
-
|
|
826
|
-
}
|
|
827
|
-
return out;
|
|
890
|
+
return remapped === null ? [] : [[toString(remapped), value]];
|
|
891
|
+
}));
|
|
828
892
|
}
|
|
829
893
|
//#endregion
|
|
830
894
|
//#region src/Errors.ts
|
|
895
|
+
/**
|
|
896
|
+
* The nested form-error tree and its operations.
|
|
897
|
+
*
|
|
898
|
+
* {@link FormErrors} is a nested error tree so a UI can read
|
|
899
|
+
* `errors.author.name` or `errors.tags[0]` directly. The representation is:
|
|
900
|
+
*
|
|
901
|
+
* - a **leaf** field's messages are a `string[]` keyed by the field name
|
|
902
|
+
* (`{ title: ["Required"] }`);
|
|
903
|
+
* - a **branch** (nested object / array) is a nested `FormErrors`
|
|
904
|
+
* (`{ author: { name: ["Required"] } }`); array elements are keyed by their
|
|
905
|
+
* stringified index (`{ tags: { "0": ["Too short"] } }`);
|
|
906
|
+
* - a message attached to a **branch itself** (a cross-field or
|
|
907
|
+
* whole-array message) lives under that branch's `_errors` key.
|
|
908
|
+
*
|
|
909
|
+
* The tree is plain JSON — it serialises across the SSR boundary unchanged —
|
|
910
|
+
* and the operations here ({@link issuesToErrors}, {@link flattenErrors},
|
|
911
|
+
* {@link setError}, {@link mergeErrors}, …) are pure and return new trees.
|
|
912
|
+
*
|
|
913
|
+
* @copyright Thomas Foster
|
|
914
|
+
* @module effect-forms/Errors
|
|
915
|
+
*/
|
|
831
916
|
var Errors_exports = /* @__PURE__ */ __exportAll({
|
|
832
917
|
ROOT_ERROR_KEY: () => ROOT_ERROR_KEY,
|
|
833
918
|
clearSourceAt: () => clearSourceAt,
|
|
@@ -846,7 +931,7 @@ var Errors_exports = /* @__PURE__ */ __exportAll({
|
|
|
846
931
|
});
|
|
847
932
|
/** The root key under which a branch's own (non-field) messages are stored. */
|
|
848
933
|
const ROOT_ERROR_KEY = "_errors";
|
|
849
|
-
const isMessageList = (value) =>
|
|
934
|
+
const isMessageList = (value) => Arr.isArray(value) && Arr.every(value, Predicate.isString);
|
|
850
935
|
const ownErrors = (node) => {
|
|
851
936
|
const existing = node[ROOT_ERROR_KEY];
|
|
852
937
|
if (isMessageList(existing)) return existing;
|
|
@@ -855,15 +940,26 @@ const ownErrors = (node) => {
|
|
|
855
940
|
return created;
|
|
856
941
|
};
|
|
857
942
|
/**
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
* path-descent, the one piece of their branch-walking
|
|
943
|
+
* Return the branch at `node[key]`, making one in place if needed: an absent
|
|
944
|
+
* key gets a new empty branch, and a leaf's message list is converted into a
|
|
945
|
+
* branch carrying those same messages as its own {@link ROOT_ERROR_KEY} (a
|
|
946
|
+
* path used as a leaf is now needed as a branch too). Shared by {@link insert}
|
|
947
|
+
* and {@link setLeaf}'s path-descent, the one piece of their branch-walking
|
|
948
|
+
* that's identical.
|
|
863
949
|
*/
|
|
864
|
-
const
|
|
950
|
+
const branchAt = (node, key) => {
|
|
865
951
|
const existing = node[key];
|
|
866
|
-
if (
|
|
952
|
+
if (existing === void 0) {
|
|
953
|
+
const created = {};
|
|
954
|
+
node[key] = created;
|
|
955
|
+
return created;
|
|
956
|
+
}
|
|
957
|
+
if (isMessageList(existing)) {
|
|
958
|
+
const promoted = { [ROOT_ERROR_KEY]: existing };
|
|
959
|
+
node[key] = promoted;
|
|
960
|
+
return promoted;
|
|
961
|
+
}
|
|
962
|
+
return existing;
|
|
867
963
|
};
|
|
868
964
|
/**
|
|
869
965
|
* Insert one message at `path` into a mutable error tree. A leaf becomes a
|
|
@@ -873,18 +969,12 @@ const promoteLeafToBranch = (node, key) => {
|
|
|
873
969
|
* tree root's {@link ROOT_ERROR_KEY}.
|
|
874
970
|
*/
|
|
875
971
|
const insert = (root, path, message) => {
|
|
876
|
-
if (path
|
|
972
|
+
if (!Arr.isReadonlyArrayNonEmpty(path)) {
|
|
877
973
|
ownErrors(root).push(message);
|
|
878
974
|
return;
|
|
879
975
|
}
|
|
880
|
-
|
|
881
|
-
|
|
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]);
|
|
976
|
+
const node = Arr.reduce(Arr.initNonEmpty(path), root, (parent, segment) => branchAt(parent, String(segment)));
|
|
977
|
+
const last = String(Arr.lastNonEmpty(path));
|
|
888
978
|
const existing = node[last];
|
|
889
979
|
if (existing === void 0) node[last] = [message];
|
|
890
980
|
else if (isMessageList(existing)) existing.push(message);
|
|
@@ -893,7 +983,7 @@ const insert = (root, path, message) => {
|
|
|
893
983
|
/** Build a nested {@link FormErrors} tree from a flat list of {@link FormIssue}s. */
|
|
894
984
|
function issuesToErrors(issues) {
|
|
895
985
|
const root = {};
|
|
896
|
-
|
|
986
|
+
Arr.forEach(issues, (issue) => insert(root, issue.path, issue.message));
|
|
897
987
|
return root;
|
|
898
988
|
}
|
|
899
989
|
/**
|
|
@@ -902,32 +992,25 @@ function issuesToErrors(issues) {
|
|
|
902
992
|
* ordering within a single field).
|
|
903
993
|
*/
|
|
904
994
|
function flattenErrors(errors) {
|
|
905
|
-
const
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
message
|
|
921
|
-
});
|
|
922
|
-
else walk(value, [...path, segment]);
|
|
923
|
-
}
|
|
924
|
-
};
|
|
925
|
-
walk(errors, []);
|
|
926
|
-
return out;
|
|
995
|
+
const walk = (node, path) => Arr.flatMap(Rec.keys(node), (key) => {
|
|
996
|
+
if (key === "_errors") return Arr.map(node["_errors"] ?? [], (message) => ({
|
|
997
|
+
path,
|
|
998
|
+
message
|
|
999
|
+
}));
|
|
1000
|
+
const value = node[key];
|
|
1001
|
+
if (value === void 0) return [];
|
|
1002
|
+
const segment = /^\d+$/.test(key) ? Number(key) : key;
|
|
1003
|
+
if (isMessageList(value)) return Arr.map(value, (message) => ({
|
|
1004
|
+
path: [...path, segment],
|
|
1005
|
+
message
|
|
1006
|
+
}));
|
|
1007
|
+
return walk(value, [...path, segment]);
|
|
1008
|
+
});
|
|
1009
|
+
return walk(errors, []);
|
|
927
1010
|
}
|
|
928
1011
|
/** Whether the tree carries any message anywhere. */
|
|
929
1012
|
function hasErrors(errors) {
|
|
930
|
-
return flattenErrors(errors)
|
|
1013
|
+
return Arr.isReadonlyArrayNonEmpty(flattenErrors(errors));
|
|
931
1014
|
}
|
|
932
1015
|
/**
|
|
933
1016
|
* Read the messages attached directly to `path` (a leaf's list, or a branch's
|
|
@@ -935,11 +1018,7 @@ function hasErrors(errors) {
|
|
|
935
1018
|
* messages.
|
|
936
1019
|
*/
|
|
937
1020
|
function getError(errors, path) {
|
|
938
|
-
|
|
939
|
-
for (const segment of path) {
|
|
940
|
-
if (node === void 0 || isMessageList(node)) return void 0;
|
|
941
|
-
node = node[String(segment)];
|
|
942
|
-
}
|
|
1021
|
+
const node = Arr.reduce(path, errors, (current, segment) => current === void 0 || isMessageList(current) ? void 0 : current[String(segment)]);
|
|
943
1022
|
if (node === void 0) return void 0;
|
|
944
1023
|
if (isMessageList(node)) return node;
|
|
945
1024
|
return node[ROOT_ERROR_KEY];
|
|
@@ -963,15 +1042,13 @@ const noErrors = {};
|
|
|
963
1042
|
* Messages whose index maps to `null` are dropped. Pure.
|
|
964
1043
|
*/
|
|
965
1044
|
function remapIndices(errors, arrayPath, map) {
|
|
966
|
-
|
|
967
|
-
for (const issue of flattenErrors(errors)) {
|
|
1045
|
+
return issuesToErrors(Arr.flatMap(flattenErrors(errors), (issue) => {
|
|
968
1046
|
const path = remapIndex(issue.path, arrayPath, map);
|
|
969
|
-
|
|
1047
|
+
return path === null ? [] : [{
|
|
970
1048
|
path,
|
|
971
1049
|
message: issue.message
|
|
972
|
-
}
|
|
973
|
-
}
|
|
974
|
-
return issuesToErrors(remapped);
|
|
1050
|
+
}];
|
|
1051
|
+
}));
|
|
975
1052
|
}
|
|
976
1053
|
/**
|
|
977
1054
|
* Return a new tree with the messages *directly at* `path` replaced by
|
|
@@ -988,51 +1065,45 @@ function replaceAt(errors, path, messages) {
|
|
|
988
1065
|
return next;
|
|
989
1066
|
}
|
|
990
1067
|
const setLeaf = (root, path, messages) => {
|
|
991
|
-
if (path
|
|
1068
|
+
if (!Arr.isReadonlyArrayNonEmpty(path)) {
|
|
992
1069
|
setOwnMessages(root, messages);
|
|
993
1070
|
return;
|
|
994
1071
|
}
|
|
995
|
-
const parent = descendClearing(root, path, messages);
|
|
1072
|
+
const parent = descendClearing(root, Arr.initNonEmpty(path), messages);
|
|
996
1073
|
if (parent === void 0) return;
|
|
997
|
-
setLeafMessages(parent, String(path
|
|
1074
|
+
setLeafMessages(parent, String(Arr.lastNonEmpty(path)), messages);
|
|
998
1075
|
};
|
|
999
1076
|
/** Set or clear a node's own {@link ROOT_ERROR_KEY} messages. */
|
|
1000
1077
|
const setOwnMessages = (node, messages) => {
|
|
1001
|
-
if (messages
|
|
1078
|
+
if (Arr.isReadonlyArrayEmpty(messages)) delete node[ROOT_ERROR_KEY];
|
|
1002
1079
|
else node[ROOT_ERROR_KEY] = [...messages];
|
|
1003
1080
|
};
|
|
1004
1081
|
/**
|
|
1005
|
-
* Walk
|
|
1082
|
+
* Walk down the `parents` segments (a path minus its last segment) to the
|
|
1083
|
+
* leaf's parent branch, creating branches as needed. When
|
|
1006
1084
|
* `messages` is empty (a clear, not a set) a missing branch means there is
|
|
1007
1085
|
* nothing to clear — descent stops early and returns `undefined` rather than
|
|
1008
1086
|
* creating empty branches just to immediately no-op inside them.
|
|
1009
1087
|
*/
|
|
1010
|
-
const descendClearing = (root,
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
node[key] = {};
|
|
1017
|
-
} else promoteLeafToBranch(node, key);
|
|
1018
|
-
node = node[key];
|
|
1019
|
-
}
|
|
1020
|
-
return node;
|
|
1021
|
-
};
|
|
1088
|
+
const descendClearing = (root, parents, messages) => Arr.reduce(parents, root, (node, segment) => {
|
|
1089
|
+
if (node === void 0) return void 0;
|
|
1090
|
+
const key = String(segment);
|
|
1091
|
+
if (node[key] === void 0 && Arr.isReadonlyArrayEmpty(messages)) return void 0;
|
|
1092
|
+
return branchAt(node, key);
|
|
1093
|
+
});
|
|
1022
1094
|
/** 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
1095
|
const setLeafMessages = (node, key, messages) => {
|
|
1024
1096
|
const existing = node[key];
|
|
1025
1097
|
if (existing === void 0) {
|
|
1026
|
-
if (messages
|
|
1098
|
+
if (Arr.isReadonlyArrayNonEmpty(messages)) node[key] = [...messages];
|
|
1027
1099
|
return;
|
|
1028
1100
|
}
|
|
1029
1101
|
if (isMessageList(existing)) {
|
|
1030
|
-
if (messages
|
|
1102
|
+
if (Arr.isReadonlyArrayEmpty(messages)) delete node[key];
|
|
1031
1103
|
else node[key] = [...messages];
|
|
1032
1104
|
return;
|
|
1033
1105
|
}
|
|
1034
|
-
|
|
1035
|
-
else existing[ROOT_ERROR_KEY] = [...messages];
|
|
1106
|
+
setOwnMessages(existing, messages);
|
|
1036
1107
|
};
|
|
1037
1108
|
/** Both source trees empty. */
|
|
1038
1109
|
const emptySourced = {
|
|
@@ -1059,6 +1130,26 @@ function clearSourceAt(sourced, source, path) {
|
|
|
1059
1130
|
}
|
|
1060
1131
|
//#endregion
|
|
1061
1132
|
//#region src/Field.ts
|
|
1133
|
+
/**
|
|
1134
|
+
* Field definitions for the {@link module:effect-forms/FormBuilder}.
|
|
1135
|
+
*
|
|
1136
|
+
* A {@link FormBuilder} composes a form one field at a time. Each field is
|
|
1137
|
+
* either a **scalar** {@link FieldDef} (bound to a leaf schema) or an
|
|
1138
|
+
* **array** {@link ArrayFieldDef} (bound to an item schema — the field is a
|
|
1139
|
+
* list of that item). This module is the small vocabulary the builder is made
|
|
1140
|
+
* of: the two field shapes, their guards, the type-level projections that turn
|
|
1141
|
+
* a record of fields into the form's encoded / decoded data types, and the
|
|
1142
|
+
* default-value derivation used to seed a blank form (or a freshly-appended
|
|
1143
|
+
* array item).
|
|
1144
|
+
*
|
|
1145
|
+
* Defaults are *encoded* — an HTML form holds strings, so a scalar seeds to
|
|
1146
|
+
* `""`, an array to `[]`, and a nested struct recurses. Coercion back to the
|
|
1147
|
+
* decoded type is the schema's job at validate time (see
|
|
1148
|
+
* {@link module:effect-forms/Coerce}).
|
|
1149
|
+
*
|
|
1150
|
+
* @copyright Thomas Foster
|
|
1151
|
+
* @module effect-forms/Field
|
|
1152
|
+
*/
|
|
1062
1153
|
var Field_exports = /* @__PURE__ */ __exportAll({
|
|
1063
1154
|
defaultEncodedValues: () => defaultEncodedValues,
|
|
1064
1155
|
defaultItemAt: () => defaultItemAt,
|
|
@@ -1070,9 +1161,9 @@ var Field_exports = /* @__PURE__ */ __exportAll({
|
|
|
1070
1161
|
schemaAt: () => schemaAt
|
|
1071
1162
|
});
|
|
1072
1163
|
/** Whether `def` is an {@link ArrayFieldDef}. */
|
|
1073
|
-
const isArrayFieldDef = (def) => def
|
|
1164
|
+
const isArrayFieldDef = (def) => Predicate.isTagged(def, "array");
|
|
1074
1165
|
/** Whether `def` is a scalar {@link FieldDef}. */
|
|
1075
|
-
const isFieldDef = (def) => def
|
|
1166
|
+
const isFieldDef = (def) => Predicate.isTagged(def, "field");
|
|
1076
1167
|
/** Construct a scalar {@link FieldDef}. */
|
|
1077
1168
|
const makeField = (key, schema) => ({
|
|
1078
1169
|
_tag: "field",
|
|
@@ -1102,19 +1193,14 @@ function defaultValue(schema) {
|
|
|
1102
1193
|
*/
|
|
1103
1194
|
function unwrapToStructured(ast) {
|
|
1104
1195
|
if (!SchemaAST.isUnion(ast)) return ast;
|
|
1105
|
-
|
|
1196
|
+
return Arr.findFirst(ast.types, (member) => {
|
|
1106
1197
|
const inner = unwrapToStructured(member);
|
|
1107
|
-
|
|
1108
|
-
}
|
|
1109
|
-
return ast;
|
|
1198
|
+
return SchemaAST.isObjects(inner) || SchemaAST.isArrays(inner) ? Option.some(inner) : Option.none();
|
|
1199
|
+
}).pipe(Option.getOrElse(() => ast));
|
|
1110
1200
|
}
|
|
1111
1201
|
function fromAst(ast) {
|
|
1112
1202
|
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
|
-
}
|
|
1203
|
+
if (SchemaAST.isObjects(unwrapped)) return Rec.fromEntries(Arr.map(unwrapped.propertySignatures, (ps) => [String(ps.name), fromAst(ps.type)]));
|
|
1118
1204
|
if (SchemaAST.isArrays(unwrapped)) return [];
|
|
1119
1205
|
return "";
|
|
1120
1206
|
}
|
|
@@ -1142,12 +1228,7 @@ function stepIntoStruct(ast, key) {
|
|
|
1142
1228
|
* schemas built directly with `Form.make` (no builder involved).
|
|
1143
1229
|
*/
|
|
1144
1230
|
function schemaAt(schema, path) {
|
|
1145
|
-
|
|
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;
|
|
1231
|
+
return Arr.reduce(path, unwrapToStructured(schema.ast), (ast, segment) => ast === void 0 ? void 0 : Predicate.isNumber(segment) ? stepIntoArray(ast) : stepIntoStruct(ast, segment));
|
|
1151
1232
|
}
|
|
1152
1233
|
/**
|
|
1153
1234
|
* Derive a blank encoded item for the array field at `path` (any nesting
|
|
@@ -1156,34 +1237,52 @@ function schemaAt(schema, path) {
|
|
|
1156
1237
|
*/
|
|
1157
1238
|
function defaultItemAt(schema, path) {
|
|
1158
1239
|
const ast = schemaAt(schema, path);
|
|
1159
|
-
|
|
1160
|
-
const item = arrayElementAst(ast);
|
|
1240
|
+
const item = ast !== void 0 && SchemaAST.isArrays(ast) ? arrayElementAst(ast) : void 0;
|
|
1161
1241
|
return item === void 0 ? "" : fromAst(item);
|
|
1162
1242
|
}
|
|
1163
1243
|
/** Seed a blank encoded value for every field: `[]` for arrays, `""` otherwise. */
|
|
1164
1244
|
function defaultEncodedValues(fields) {
|
|
1165
|
-
|
|
1166
|
-
for (const [key, def] of Object.entries(fields)) out[key] = isArrayFieldDef(def) ? [] : defaultValue(def.schema);
|
|
1167
|
-
return out;
|
|
1245
|
+
return Rec.map(fields, (def) => isArrayFieldDef(def) ? [] : defaultValue(def.schema));
|
|
1168
1246
|
}
|
|
1169
1247
|
//#endregion
|
|
1170
1248
|
//#region src/Issues.ts
|
|
1249
|
+
/**
|
|
1250
|
+
* Form issues — the flat, path-addressed representation of a single
|
|
1251
|
+
* validation failure, plus the walker that turns an Effect Schema issue
|
|
1252
|
+
* tree into a list of them.
|
|
1253
|
+
*
|
|
1254
|
+
* An Effect Schema decode failure is a recursive {@link SchemaIssue.Issue}
|
|
1255
|
+
* tree: `Pointer` nodes carry path segments, `Composite` / `AnyOf` group
|
|
1256
|
+
* sibling failures, `Filter` / `Encoding` wrap a single inner failure, and
|
|
1257
|
+
* the leaves (`InvalidType`, `InvalidValue`, `MissingKey`, …) describe the
|
|
1258
|
+
* actual problem. {@link schemaIssuesToFormIssues} flattens that tree into
|
|
1259
|
+
* one {@link FormIssue} per leaf, accumulating the `Pointer.path` segments
|
|
1260
|
+
* into a `FormPath`. This is the framework-neutral
|
|
1261
|
+
* flattened error list and the same walk the repo's JSON:API materialiser
|
|
1262
|
+
* performs — here it stays UI-shaped (`path` + `message`) rather than
|
|
1263
|
+
* JSON:API-pointer-shaped.
|
|
1264
|
+
*
|
|
1265
|
+
* @copyright Thomas Foster
|
|
1266
|
+
* @module effect-forms/Issues
|
|
1267
|
+
*/
|
|
1171
1268
|
var Issues_exports = /* @__PURE__ */ __exportAll({
|
|
1172
1269
|
defaultLeafFormatter: () => defaultLeafFormatter,
|
|
1173
|
-
filterOutputToFormIssues: () => filterOutputToFormIssues,
|
|
1270
|
+
filterOutputToFormIssues: () => filterOutputToFormIssues$1,
|
|
1174
1271
|
schemaIssuesToFormIssues: () => schemaIssuesToFormIssues
|
|
1175
1272
|
});
|
|
1176
|
-
/** The default leaf formatter — Effect Schema's own rendering of the
|
|
1177
|
-
const defaultLeafFormatter = (
|
|
1273
|
+
/** The default leaf formatter — Effect Schema's own rendering of the issue. */
|
|
1274
|
+
const defaultLeafFormatter = SchemaIssue.makeFormatterDefault();
|
|
1178
1275
|
/**
|
|
1179
1276
|
* Normalise a `Pointer.path` segment into a {@link FormPath} segment: integer
|
|
1180
1277
|
* keys become numbers (array indices), everything else becomes a string.
|
|
1181
1278
|
*/
|
|
1182
1279
|
const normaliseSegment = (segment) => {
|
|
1183
|
-
if (
|
|
1184
|
-
if (
|
|
1280
|
+
if (Predicate.isNumber(segment)) return segment;
|
|
1281
|
+
if (Predicate.isSymbol(segment)) return segment.toString();
|
|
1185
1282
|
return /^\d+$/.test(segment) ? Number(segment) : segment;
|
|
1186
1283
|
};
|
|
1284
|
+
/** Whether `issue` is a terminal leaf: it wraps no further issues. */
|
|
1285
|
+
const isTerminal = Match.type().pipe(Match.tag("Pointer", "Composite", "Filter", "Encoding", () => false), Match.tag("AnyOf", (issue) => Arr.isReadonlyArrayEmpty(issue.issues)), Match.orElse(() => true));
|
|
1187
1286
|
/**
|
|
1188
1287
|
* Walk an Effect Schema issue tree, emitting one {@link FormIssue} per leaf
|
|
1189
1288
|
* failure. Path segments accumulate through `Pointer` nodes; `Composite` and
|
|
@@ -1192,39 +1291,18 @@ const normaliseSegment = (segment) => {
|
|
|
1192
1291
|
* mismatch) is itself a leaf.
|
|
1193
1292
|
*/
|
|
1194
1293
|
function schemaIssuesToFormIssues(issue, format = defaultLeafFormatter) {
|
|
1195
|
-
const out = [];
|
|
1196
1294
|
const walk = (current, path) => {
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
-
}
|
|
1295
|
+
const leaf = (node) => [{
|
|
1296
|
+
path,
|
|
1297
|
+
message: format(node)
|
|
1298
|
+
}];
|
|
1299
|
+
const walkAll = (children) => Arr.flatMap(children, (child) => walk(child, path));
|
|
1300
|
+
return Match.value(current).pipe(Match.tag("Pointer", (pointer) => walk(pointer.issue, [...path, ...Arr.map(pointer.path, normaliseSegment)])), Match.tag("Composite", (composite) => walkAll(composite.issues)), Match.tag("AnyOf", (anyOf) => Arr.match(anyOf.issues, {
|
|
1301
|
+
onEmpty: () => leaf(anyOf),
|
|
1302
|
+
onNonEmpty: walkAll
|
|
1303
|
+
})), Match.tag("Filter", (filter) => isTerminal(filter.issue) ? leaf(filter) : walk(filter.issue, path)), Match.tag("Encoding", (encoding) => walk(encoding.issue, path)), Match.orElse(leaf));
|
|
1225
1304
|
};
|
|
1226
|
-
walk(issue, []);
|
|
1227
|
-
return out;
|
|
1305
|
+
return walk(issue, []);
|
|
1228
1306
|
}
|
|
1229
1307
|
/**
|
|
1230
1308
|
* Normalise a filter predicate's {@link Schema.FilterOutput} into a flat list of
|
|
@@ -1233,23 +1311,25 @@ function schemaIssuesToFormIssues(issue, format = defaultLeafFormatter) {
|
|
|
1233
1311
|
* ({@link module:effect-forms/Validate}) so both fold into the same nested
|
|
1234
1312
|
* error tree as schema-decode failures.
|
|
1235
1313
|
*/
|
|
1236
|
-
function filterOutputToFormIssues(output) {
|
|
1314
|
+
function filterOutputToFormIssues$1(output) {
|
|
1237
1315
|
if (output === void 0 || output === true) return [];
|
|
1238
1316
|
if (output === false) return [{
|
|
1239
1317
|
path: [],
|
|
1240
1318
|
message: "Invalid value"
|
|
1241
1319
|
}];
|
|
1242
|
-
if (
|
|
1320
|
+
if (isFilterIssueList(output)) return Arr.flatMap(output, filterIssueToFormIssues);
|
|
1243
1321
|
return filterIssueToFormIssues(output);
|
|
1244
1322
|
}
|
|
1323
|
+
/** `Array.isArray` as a guard that also narrows away a `ReadonlyArray` member. */
|
|
1324
|
+
const isFilterIssueList = (output) => Arr.isArray(output);
|
|
1245
1325
|
function filterIssueToFormIssues(issue) {
|
|
1246
|
-
if (
|
|
1326
|
+
if (Predicate.isString(issue)) return [{
|
|
1247
1327
|
path: [],
|
|
1248
1328
|
message: issue
|
|
1249
1329
|
}];
|
|
1250
1330
|
if ("path" in issue && "issue" in issue) {
|
|
1251
|
-
const prefix = issue.path
|
|
1252
|
-
return (
|
|
1331
|
+
const prefix = Arr.map(issue.path, (s) => Predicate.isSymbol(s) ? s.toString() : s);
|
|
1332
|
+
return (Predicate.isString(issue.issue) ? [{
|
|
1253
1333
|
path: [],
|
|
1254
1334
|
message: issue.issue
|
|
1255
1335
|
}] : schemaIssuesToFormIssues(issue.issue)).map((i) => ({
|
|
@@ -1262,8 +1342,6 @@ function filterIssueToFormIssues(issue) {
|
|
|
1262
1342
|
//#endregion
|
|
1263
1343
|
//#region src/FormBuilder.ts
|
|
1264
1344
|
/**
|
|
1265
|
-
* @copyright Thomas Foster
|
|
1266
|
-
*
|
|
1267
1345
|
* Compose a form schema one field at a time, with cross-field refinements.
|
|
1268
1346
|
*
|
|
1269
1347
|
* {@link make} (aliased {@link empty}) starts an empty builder;
|
|
@@ -1284,6 +1362,7 @@ function filterIssueToFormIssues(issue) {
|
|
|
1284
1362
|
* path — {@link asyncRefinements} exposes them and {@link runAsyncRefinements}
|
|
1285
1363
|
* runs them into a {@link FormIssue} list.
|
|
1286
1364
|
*
|
|
1365
|
+
* @copyright Thomas Foster
|
|
1287
1366
|
* @module effect-forms/FormBuilder
|
|
1288
1367
|
*/
|
|
1289
1368
|
var FormBuilder_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -1298,10 +1377,12 @@ var FormBuilder_exports = /* @__PURE__ */ __exportAll({
|
|
|
1298
1377
|
});
|
|
1299
1378
|
/** Brand for a {@link FormBuilder}. */
|
|
1300
1379
|
const TypeId = Symbol.for("@thomasfosterau/effect-forms/FormBuilder");
|
|
1380
|
+
/** Whether `addField` was called with the `(key, schema)` overload — the only one whose first argument is a string. */
|
|
1381
|
+
const isKeyedArgs = (args) => Predicate.isString(args[0]);
|
|
1301
1382
|
const proto = {
|
|
1302
1383
|
[TypeId]: TypeId,
|
|
1303
|
-
addField(
|
|
1304
|
-
const field =
|
|
1384
|
+
addField(...args) {
|
|
1385
|
+
const field = isKeyedArgs(args) ? makeField(...args) : args[0];
|
|
1305
1386
|
return build({
|
|
1306
1387
|
...this.fields,
|
|
1307
1388
|
[field.key]: field
|
|
@@ -1333,7 +1414,7 @@ function build(fields, refinements) {
|
|
|
1333
1414
|
return self;
|
|
1334
1415
|
}
|
|
1335
1416
|
/** Whether `u` is a {@link FormBuilder}. */
|
|
1336
|
-
const isFormBuilder = (u) =>
|
|
1417
|
+
const isFormBuilder = (u) => Predicate.isObjectOrArray(u) && Predicate.hasProperty(u, TypeId);
|
|
1337
1418
|
/** The empty builder — the starting point for {@link FormBuilder.addField}. */
|
|
1338
1419
|
const make$2 = build({}, []);
|
|
1339
1420
|
/** Alias of {@link make} — the empty builder. */
|
|
@@ -1344,32 +1425,43 @@ const empty = make$2;
|
|
|
1344
1425
|
* applied as a struct-level check. Ready for {@link module:effect-forms/Form}.
|
|
1345
1426
|
*/
|
|
1346
1427
|
function buildSchema(self) {
|
|
1347
|
-
const structFields = {
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1428
|
+
const structFields = Rec.fromEntries(Arr.flatMap(Rec.toEntries(self.fields), ([key, def]) => {
|
|
1429
|
+
if (isArrayFieldDef(def)) return [[key, Schema.Array(def.itemSchema)]];
|
|
1430
|
+
if (isFieldDef(def)) return [[key, def.schema]];
|
|
1431
|
+
return [];
|
|
1432
|
+
}));
|
|
1433
|
+
const struct = Schema.Struct(structFields);
|
|
1434
|
+
return Arr.reduce(self.refinements, struct, (refined, refinement) => Predicate.isTagged(refinement, "sync") ? refined.check(Schema.makeFilter(refinement.fn)) : refined);
|
|
1353
1435
|
}
|
|
1354
1436
|
/** The asynchronous refinements carried on a builder, as bare predicates. */
|
|
1355
1437
|
function asyncRefinements(self) {
|
|
1356
|
-
return self.refinements
|
|
1438
|
+
return Arr.flatMap(self.refinements, (refinement) => {
|
|
1439
|
+
if (!Predicate.isTagged(refinement, "async")) return [];
|
|
1440
|
+
return [refinement.fn];
|
|
1441
|
+
});
|
|
1357
1442
|
}
|
|
1358
1443
|
/**
|
|
1444
|
+
* Normalise a filter predicate's `Schema.FilterOutput` into a flat list of
|
|
1445
|
+
* {@link FormIssue}s (empty on success).
|
|
1446
|
+
*
|
|
1447
|
+
* @deprecated Use `Issues.filterOutputToFormIssues`; this re-export remains
|
|
1448
|
+
* only for callers that imported it from `FormBuilder`.
|
|
1449
|
+
*/
|
|
1450
|
+
const filterOutputToFormIssues = filterOutputToFormIssues$1;
|
|
1451
|
+
/**
|
|
1359
1452
|
* Run every asynchronous refinement on `self` against `values` and collect
|
|
1360
1453
|
* their failures as {@link FormIssue}s (empty when all pass). Runs them
|
|
1361
1454
|
* concurrently; requirements propagate on the `R` channel.
|
|
1362
1455
|
*/
|
|
1363
1456
|
function runAsyncRefinements(self, values) {
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1457
|
+
return Arr.match(asyncRefinements(self), {
|
|
1458
|
+
onEmpty: () => Effect.succeed([]),
|
|
1459
|
+
onNonEmpty: (refinements) => Effect.forEach(refinements, (fn) => fn(values), { concurrency: "unbounded" }).pipe(Effect.map((outputs) => Arr.flatMap(outputs, filterOutputToFormIssues$1)))
|
|
1460
|
+
});
|
|
1367
1461
|
}
|
|
1368
1462
|
//#endregion
|
|
1369
1463
|
//#region src/State.ts
|
|
1370
1464
|
/**
|
|
1371
|
-
* @copyright Thomas Foster
|
|
1372
|
-
*
|
|
1373
1465
|
* {@link FormState} — the serialisable snapshot of a form.
|
|
1374
1466
|
*
|
|
1375
1467
|
* This is the single object a
|
|
@@ -1378,6 +1470,7 @@ function runAsyncRefinements(self, values) {
|
|
|
1378
1470
|
* boundary, and {@link FormStateSchema} gives it an Effect Schema so it can be
|
|
1379
1471
|
* decoded back on arrival.
|
|
1380
1472
|
*
|
|
1473
|
+
* @copyright Thomas Foster
|
|
1381
1474
|
* @module effect-forms/State
|
|
1382
1475
|
*/
|
|
1383
1476
|
var State_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -1390,15 +1483,16 @@ var State_exports = /* @__PURE__ */ __exportAll({
|
|
|
1390
1483
|
/** Build a {@link FormState} from data plus overrides. `valid` defaults to "no errors". */
|
|
1391
1484
|
function make$1(data, options = {}) {
|
|
1392
1485
|
const errors = options.errors ?? noErrors;
|
|
1393
|
-
|
|
1486
|
+
const state = {
|
|
1394
1487
|
id: options.id ?? "default",
|
|
1395
1488
|
valid: options.valid ?? !hasErrors(errors),
|
|
1396
1489
|
posted: options.posted ?? false,
|
|
1397
1490
|
data,
|
|
1398
|
-
errors
|
|
1399
|
-
...options.message !== void 0 ? { message: options.message } : {},
|
|
1400
|
-
...options.constraints !== void 0 ? { constraints: options.constraints } : {}
|
|
1491
|
+
errors
|
|
1401
1492
|
};
|
|
1493
|
+
if (options.message !== void 0) state.message = options.message;
|
|
1494
|
+
if (options.constraints !== void 0) state.constraints = options.constraints;
|
|
1495
|
+
return state;
|
|
1402
1496
|
}
|
|
1403
1497
|
/** Return a new state carrying `message` (and not otherwise changed). */
|
|
1404
1498
|
function withMessage(state, message) {
|
|
@@ -1436,8 +1530,6 @@ function FormStateSchema(data) {
|
|
|
1436
1530
|
//#endregion
|
|
1437
1531
|
//#region src/Form.ts
|
|
1438
1532
|
/**
|
|
1439
|
-
* @copyright Thomas Foster
|
|
1440
|
-
*
|
|
1441
1533
|
* {@link make} — the core entry point: bind an Effect Schema to a form and
|
|
1442
1534
|
* get back a validator and an empty-state factory.
|
|
1443
1535
|
*
|
|
@@ -1448,9 +1540,8 @@ function FormStateSchema(data) {
|
|
|
1448
1540
|
* it through the schema, returning a {@link FormState} — valid with the
|
|
1449
1541
|
* decoded data, or invalid with the nested {@link FormErrors} and the posted
|
|
1450
1542
|
* values preserved so the form re-renders what the user typed. It is an
|
|
1451
|
-
* `Effect`, so a schema requiring decoding services composes naturally
|
|
1452
|
-
*
|
|
1453
|
-
* failures live in the returned state, not the error channel.
|
|
1543
|
+
* `Effect`, so a schema requiring decoding services composes naturally; it
|
|
1544
|
+
* has no error channel — validation failures live in the returned state.
|
|
1454
1545
|
* - `empty()` seeds an unposted, invalid-by-default form from the supplied
|
|
1455
1546
|
* defaults — what a `load` returns to render a blank create form.
|
|
1456
1547
|
*
|
|
@@ -1459,6 +1550,7 @@ function FormStateSchema(data) {
|
|
|
1459
1550
|
* {@link module:effect-forms/Coerce} helpers, …) so a multipart body decodes
|
|
1460
1551
|
* cleanly.
|
|
1461
1552
|
*
|
|
1553
|
+
* @copyright Thomas Foster
|
|
1462
1554
|
* @module effect-forms/Form
|
|
1463
1555
|
*/
|
|
1464
1556
|
var Form_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -1467,13 +1559,35 @@ var Form_exports = /* @__PURE__ */ __exportAll({
|
|
|
1467
1559
|
});
|
|
1468
1560
|
const toStructured = (input) => {
|
|
1469
1561
|
if (input === null || input === void 0) return Effect.succeed(null);
|
|
1470
|
-
if (input instanceof Request) return Effect.
|
|
1562
|
+
if (input instanceof Request) return Effect.tryPromise({
|
|
1563
|
+
try: () => requestToObject(input),
|
|
1564
|
+
catch: (cause) => cause
|
|
1565
|
+
}).pipe(Effect.orDie);
|
|
1471
1566
|
if (input instanceof FormData || input instanceof URLSearchParams) return Effect.succeed(formDataToObject(input));
|
|
1472
1567
|
return Effect.succeed(input);
|
|
1473
1568
|
};
|
|
1474
1569
|
/**
|
|
1475
|
-
* Bind `schema` to a form
|
|
1476
|
-
* {@link
|
|
1570
|
+
* Bind `schema` to a form: `validate(input)` decodes a submission into a
|
|
1571
|
+
* {@link FormState} (it never fails — validation failures live in the state;
|
|
1572
|
+
* its requirements are the schema's decoding services) and `empty()` seeds a
|
|
1573
|
+
* blank form from `defaults`. The returned {@link Form} is stateless and
|
|
1574
|
+
* reusable across requests.
|
|
1575
|
+
*
|
|
1576
|
+
* @example
|
|
1577
|
+
* ```ts
|
|
1578
|
+
* import * as Effect from "effect/Effect"
|
|
1579
|
+
* import * as Schema from "effect/Schema"
|
|
1580
|
+
* import { Form } from "@thomasfosterau/effect-forms"
|
|
1581
|
+
*
|
|
1582
|
+
* const signup = Form.make(
|
|
1583
|
+
* Schema.Struct({ name: Schema.String.check(Schema.isMinLength(2)) }),
|
|
1584
|
+
* { id: "signup", defaults: { name: "" } },
|
|
1585
|
+
* )
|
|
1586
|
+
*
|
|
1587
|
+
* const state = Effect.runSync(signup.validate(new URLSearchParams({ name: "A" })))
|
|
1588
|
+
* console.log(state.valid) // false
|
|
1589
|
+
* console.log(state.data) // { name: "A" } — the posted value, kept for re-render
|
|
1590
|
+
* ```
|
|
1477
1591
|
*/
|
|
1478
1592
|
function make(schema, options) {
|
|
1479
1593
|
const id = options.id ?? "default";
|
|
@@ -1497,16 +1611,19 @@ function make(schema, options) {
|
|
|
1497
1611
|
posted: true,
|
|
1498
1612
|
constraints
|
|
1499
1613
|
})),
|
|
1500
|
-
onFailure: (issue) =>
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1614
|
+
onFailure: (issue) => {
|
|
1615
|
+
const posted = {
|
|
1616
|
+
...defaults,
|
|
1617
|
+
...object
|
|
1618
|
+
};
|
|
1619
|
+
return Effect.succeed(make$1(posted, {
|
|
1620
|
+
id,
|
|
1621
|
+
valid: false,
|
|
1622
|
+
posted: true,
|
|
1623
|
+
constraints,
|
|
1624
|
+
errors: issuesToErrors(schemaIssuesToFormIssues(issue, leafFormatter))
|
|
1625
|
+
}));
|
|
1626
|
+
}
|
|
1510
1627
|
}));
|
|
1511
1628
|
}));
|
|
1512
1629
|
return {
|
|
@@ -1540,17 +1657,20 @@ function fromBuilder(builder, options = {}) {
|
|
|
1540
1657
|
...object
|
|
1541
1658
|
};
|
|
1542
1659
|
return decode(object).pipe(Effect.matchEffect({
|
|
1543
|
-
onSuccess: (data) => runAsyncRefinements(builder, data).pipe(Effect.map((issues) =>
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1660
|
+
onSuccess: (data) => runAsyncRefinements(builder, data).pipe(Effect.map((issues) => Arr.match(issues, {
|
|
1661
|
+
onEmpty: () => make$1(data, {
|
|
1662
|
+
id: base.id,
|
|
1663
|
+
valid: true,
|
|
1664
|
+
posted: true,
|
|
1665
|
+
constraints: base.constraints
|
|
1666
|
+
}),
|
|
1667
|
+
onNonEmpty: (failures) => make$1(posted, {
|
|
1668
|
+
id: base.id,
|
|
1669
|
+
valid: false,
|
|
1670
|
+
posted: true,
|
|
1671
|
+
constraints: base.constraints,
|
|
1672
|
+
errors: issuesToErrors(failures)
|
|
1673
|
+
})
|
|
1554
1674
|
}))),
|
|
1555
1675
|
onFailure: (issue) => Effect.succeed(make$1(posted, {
|
|
1556
1676
|
id: base.id,
|
|
@@ -1569,8 +1689,6 @@ function fromBuilder(builder, options = {}) {
|
|
|
1569
1689
|
//#endregion
|
|
1570
1690
|
//#region src/Mode.ts
|
|
1571
1691
|
/**
|
|
1572
|
-
* @copyright Thomas Foster
|
|
1573
|
-
*
|
|
1574
1692
|
* When a form validates (and, optionally, auto-submits).
|
|
1575
1693
|
*
|
|
1576
1694
|
* A reactive form has to decide *when* to run validation as the user types.
|
|
@@ -1595,6 +1713,7 @@ function fromBuilder(builder, options = {}) {
|
|
|
1595
1713
|
* once the form has been posted at least once; `"never"` keeps the classic
|
|
1596
1714
|
* frozen-until-resubmit behaviour.
|
|
1597
1715
|
*
|
|
1716
|
+
* @copyright Thomas Foster
|
|
1598
1717
|
* @module effect-forms/Mode
|
|
1599
1718
|
*/
|
|
1600
1719
|
var Mode_exports = /* @__PURE__ */ __exportAll({ parse: () => parse });
|
|
@@ -1662,8 +1781,6 @@ const errorResult = (message, status = 500) => ({
|
|
|
1662
1781
|
//#endregion
|
|
1663
1782
|
//#region src/Validate.ts
|
|
1664
1783
|
/**
|
|
1665
|
-
* @copyright Thomas Foster
|
|
1666
|
-
*
|
|
1667
1784
|
* Low-level validation helpers — decode a value through a schema and get back
|
|
1668
1785
|
* the {@link FormErrors} tree (or the list of {@link FormIssue}s) instead of a
|
|
1669
1786
|
* raised failure. `Form.make` is the high-level entry; these are for ad
|
|
@@ -1680,6 +1797,7 @@ const errorResult = (message, status = 500) => ({
|
|
|
1680
1797
|
* and interrupting the previous fiber on every new run (see that function's
|
|
1681
1798
|
* docs for why that alone gives debounce + "latest wins" cancellation).
|
|
1682
1799
|
*
|
|
1800
|
+
* @copyright Thomas Foster
|
|
1683
1801
|
* @module effect-forms/Validate
|
|
1684
1802
|
*/
|
|
1685
1803
|
var Validate_exports = /* @__PURE__ */ __exportAll({
|
|
@@ -1725,7 +1843,7 @@ function validateValueSync(schema, input, format) {
|
|
|
1725
1843
|
* cannot fail the Effect error channel.
|
|
1726
1844
|
*/
|
|
1727
1845
|
function runSyncFieldValidator(validator, value) {
|
|
1728
|
-
return filterOutputToFormIssues(validator(value));
|
|
1846
|
+
return filterOutputToFormIssues$1(validator(value));
|
|
1729
1847
|
}
|
|
1730
1848
|
/**
|
|
1731
1849
|
* Build the Effect an {@link AsyncFieldValidator} run is: sleep `debounceMs`
|
|
@@ -1745,7 +1863,7 @@ function runSyncFieldValidator(validator, value) {
|
|
|
1745
1863
|
function runAsyncFieldValidator(validator, value, debounceMs = 0) {
|
|
1746
1864
|
return Effect.gen(function* () {
|
|
1747
1865
|
if (debounceMs > 0) yield* Effect.sleep(Duration.millis(debounceMs));
|
|
1748
|
-
return filterOutputToFormIssues(yield* validator(value));
|
|
1866
|
+
return filterOutputToFormIssues$1(yield* validator(value));
|
|
1749
1867
|
});
|
|
1750
1868
|
}
|
|
1751
1869
|
/**
|