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