@thomasfosterau/effect-forms 0.2.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 +19 -19
- package/dist/index.d.ts +55 -4
- package/dist/index.js +494 -399
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
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";
|
|
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
|
//#region src/Coerce.ts
|
|
@@ -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
|
|
@@ -110,23 +116,21 @@ var Command_exports = /* @__PURE__ */ __exportAll({
|
|
|
110
116
|
plan: () => plan,
|
|
111
117
|
updateAdapter: () => updateAdapter
|
|
112
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 }) {};
|
|
113
123
|
/**
|
|
114
124
|
* Run one decode probe: whether the schema accepts the input, plus the decoded
|
|
115
125
|
* value for instance checks (the date probe needs to see a `Date` come out,
|
|
116
126
|
* not just a successful decode).
|
|
117
127
|
*/
|
|
118
128
|
const probe = (schema, input) => {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
ok: true,
|
|
122
|
-
value: Schema.decodeUnknownSync(schema)(input)
|
|
123
|
-
};
|
|
124
|
-
} catch {
|
|
125
|
-
return { ok: false };
|
|
126
|
-
}
|
|
129
|
+
const decoder = schema;
|
|
130
|
+
return SchemaParser.decodeUnknownExit(decoder)(input);
|
|
127
131
|
};
|
|
128
|
-
/** True when a schema decodes the given input without
|
|
129
|
-
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));
|
|
130
134
|
/** A syntactically valid UUID no other field kind accepts as non-text. */
|
|
131
135
|
const PROBE_UUID = "9f8c1c2e-4b3a-4c5d-8e6f-0a1b2c3d4e5f";
|
|
132
136
|
/**
|
|
@@ -141,13 +145,10 @@ const PROBE_UUID = "9f8c1c2e-4b3a-4c5d-8e6f-0a1b2c3d4e5f";
|
|
|
141
145
|
* `enumOptions` below needs the full literal set, not just one branch of it.
|
|
142
146
|
*/
|
|
143
147
|
function unwrapWrapperUnion(ast) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
current = real[0];
|
|
149
|
-
}
|
|
150
|
-
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]);
|
|
151
152
|
}
|
|
152
153
|
/**
|
|
153
154
|
* The literal option values of a field shaped like `Schema.Literal(...)` or
|
|
@@ -160,8 +161,8 @@ function enumOptions(ast) {
|
|
|
160
161
|
const inner = unwrapWrapperUnion(ast);
|
|
161
162
|
if (SchemaAST.isLiteral(inner)) return [String(inner.literal)];
|
|
162
163
|
if (SchemaAST.isUnion(inner)) {
|
|
163
|
-
const literals = inner.types
|
|
164
|
-
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));
|
|
165
166
|
}
|
|
166
167
|
}
|
|
167
168
|
/**
|
|
@@ -187,7 +188,7 @@ const probeKind = (schema) => {
|
|
|
187
188
|
if (decodes(schema, true)) return "boolean";
|
|
188
189
|
if (decodes(schema, 42)) return "number";
|
|
189
190
|
const date = probe(schema, "2024-01-02T03:04:05.000Z");
|
|
190
|
-
if (date
|
|
191
|
+
if (Exit.isSuccess(date) && date.value instanceof Date) return "date";
|
|
191
192
|
if (decodes(schema, PROBE_UUID) && !decodes(schema, "not-a-uuid")) return "id";
|
|
192
193
|
if (decodes(schema, "plain text")) return "text";
|
|
193
194
|
};
|
|
@@ -214,7 +215,7 @@ const classify = (key, schema) => {
|
|
|
214
215
|
const probed = probeKind(schema);
|
|
215
216
|
if (probed !== void 0) return { kind: probed };
|
|
216
217
|
if (isStructured(schema.ast)) return { kind: "structured" };
|
|
217
|
-
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` });
|
|
218
219
|
};
|
|
219
220
|
/**
|
|
220
221
|
* Read a create/update command struct pair into a {@link CommandPlan}.
|
|
@@ -224,22 +225,23 @@ const classify = (key, schema) => {
|
|
|
224
225
|
const plan = (create, update) => {
|
|
225
226
|
const createFields = create.fields;
|
|
226
227
|
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);
|
|
228
|
+
const keys = [...Arr.filter(Rec.keys(updateFields), (key) => key !== "id"), ...Arr.filter(Rec.keys(createFields), (key) => key !== "id" && !(key in updateFields))];
|
|
230
229
|
return {
|
|
231
|
-
fields:
|
|
230
|
+
fields: Arr.map(keys, (key) => {
|
|
232
231
|
const createField = createFields[key];
|
|
233
232
|
const updateField = updateFields[key];
|
|
234
233
|
const { kind, options } = classify(key, createField ?? updateField);
|
|
235
|
-
|
|
234
|
+
const fieldPlan = {
|
|
236
235
|
key,
|
|
237
236
|
kind,
|
|
238
237
|
inCreate: createField !== void 0,
|
|
239
238
|
createRequired: createField !== void 0 && !decodes(createField, void 0),
|
|
240
239
|
inUpdate: updateField !== void 0,
|
|
241
|
-
updateClearable: updateField !== void 0 && decodes(updateField, null)
|
|
242
|
-
|
|
240
|
+
updateClearable: updateField !== void 0 && decodes(updateField, null)
|
|
241
|
+
};
|
|
242
|
+
return options === void 0 ? fieldPlan : {
|
|
243
|
+
...fieldPlan,
|
|
244
|
+
options
|
|
243
245
|
};
|
|
244
246
|
}),
|
|
245
247
|
clientId: "id" in createFields
|
|
@@ -253,17 +255,16 @@ const plan = (create, update) => {
|
|
|
253
255
|
* "create") })`, with `overrides` spread last for irregular fields.
|
|
254
256
|
*/
|
|
255
257
|
const formInputFields = (commandPlan, mode, overrides = {}) => {
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
...
|
|
265
|
-
|
|
266
|
-
};
|
|
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
|
+
]);
|
|
267
268
|
};
|
|
268
269
|
/**
|
|
269
270
|
* Blank string defaults for one mode's Form-Input shape. Client-id entities
|
|
@@ -271,13 +272,8 @@ const formInputFields = (commandPlan, mode, overrides = {}) => {
|
|
|
271
272
|
* crypto.randomUUID() }`) so retries stay idempotent.
|
|
272
273
|
*/
|
|
273
274
|
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;
|
|
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, ""]));
|
|
281
277
|
};
|
|
282
278
|
/**
|
|
283
279
|
* Parse a JSON-encoded row list (a contributor list) from a multipart form
|
|
@@ -289,25 +285,28 @@ const blankDefaults = (commandPlan, mode) => {
|
|
|
289
285
|
*/
|
|
290
286
|
const jsonRowsFromField = (raw, label) => {
|
|
291
287
|
if (raw === void 0 || raw.trim() === "") return void 0;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
if (
|
|
301
|
-
const
|
|
302
|
-
|
|
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");
|
|
303
300
|
return {
|
|
304
|
-
id:
|
|
305
|
-
agentId
|
|
306
|
-
creditedName:
|
|
307
|
-
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
|
|
308
305
|
};
|
|
309
306
|
});
|
|
310
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));
|
|
311
310
|
/** Text fields: trim always; required create fields never skip. */
|
|
312
311
|
const coerceText = (ctx, raw) => ctx.required ? (raw ?? "").trim() : ctx.clearable ? trimToNull(raw) : trimToUndefined(raw);
|
|
313
312
|
/** Id fields: no trimming (pickers emit `""` or a UUID); required create FKs pass through raw. */
|
|
@@ -356,49 +355,66 @@ const coerceContextFor = (field, mode) => ({
|
|
|
356
355
|
/** The default coercion for one field in one mode. */
|
|
357
356
|
const coerce = (field, mode, raw) => {
|
|
358
357
|
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
|
-
}
|
|
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);
|
|
369
359
|
};
|
|
370
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
|
+
/**
|
|
371
372
|
* Derive the create adapter: validated all-string input → encoded create
|
|
372
373
|
* command payload. `overrides` replace the default per-field coercion for
|
|
373
374
|
* genuinely irregular fields (e.g. a create-time fallback default).
|
|
374
375
|
*/
|
|
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
|
-
};
|
|
376
|
+
const createAdapter = (commandPlan, overrides = {}) => (input) => Rec.fromEntries([...commandPlan.clientId ? [["id", trimToUndefined(input.id)]] : [], ...adaptFields(commandPlan, overrides, "create", input)]);
|
|
385
377
|
/**
|
|
386
378
|
* Derive the update adapter: validated all-string input → encoded update
|
|
387
379
|
* command payload (value sets, `null` clears a clearable field, `undefined`
|
|
388
380
|
* skips). The required `id` passes through as-is — the command's canonical
|
|
389
381
|
* decode brands it.
|
|
390
382
|
*/
|
|
391
|
-
const updateAdapter = (commandPlan, overrides = {}) => (input) =>
|
|
392
|
-
const out = { id: input.id };
|
|
393
|
-
for (const field of commandPlan.fields) {
|
|
394
|
-
if (!field.inUpdate) continue;
|
|
395
|
-
const override = overrides[field.key];
|
|
396
|
-
out[field.key] = override ? override(input[field.key]) : coerce(field, "update", input[field.key]);
|
|
397
|
-
}
|
|
398
|
-
return out;
|
|
399
|
-
};
|
|
383
|
+
const updateAdapter = (commandPlan, overrides = {}) => (input) => Rec.fromEntries([["id", input.id], ...adaptFields(commandPlan, overrides, "update", input)]);
|
|
400
384
|
//#endregion
|
|
401
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
|
+
*/
|
|
402
418
|
var Constraints_exports = /* @__PURE__ */ __exportAll({
|
|
403
419
|
constraintAt: () => constraintAt,
|
|
404
420
|
constraintsFromSchema: () => constraintsFromSchema,
|
|
@@ -416,17 +432,15 @@ const isOptional = (type) => type.context?.isOptional === true;
|
|
|
416
432
|
*/
|
|
417
433
|
function unwrapOptional(type) {
|
|
418
434
|
if (!SchemaAST.isUnion(type)) return type;
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
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
|
+
}));
|
|
424
439
|
}
|
|
425
440
|
/** Flatten a `Checks` array (recursing into `FilterGroup`s) into individual filters. */
|
|
426
|
-
function flattenChecks(checks
|
|
427
|
-
if (
|
|
428
|
-
|
|
429
|
-
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]);
|
|
430
444
|
}
|
|
431
445
|
/**
|
|
432
446
|
* One constraint patch per recognised representation id — the built-in
|
|
@@ -454,14 +468,18 @@ const payloadPatches = {
|
|
|
454
468
|
}),
|
|
455
469
|
"effect/schema/isMultipleOf": (p) => ({ step: p.divisor })
|
|
456
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);
|
|
457
473
|
/**
|
|
458
474
|
* Look up and apply `payloadPatches[representation.id]`. The cast is sound:
|
|
459
475
|
* each payload is authored by the same built-in check constructor its id
|
|
460
476
|
* names, so an entry is never called with a mismatched payload shape.
|
|
461
477
|
*/
|
|
462
478
|
function applyPayloadPatch(representation) {
|
|
463
|
-
const
|
|
464
|
-
|
|
479
|
+
const id = representation.id;
|
|
480
|
+
if (!isPatchedCheckId(id)) return void 0;
|
|
481
|
+
const patch = payloadPatches[id];
|
|
482
|
+
return patch(representation.payload);
|
|
465
483
|
}
|
|
466
484
|
/**
|
|
467
485
|
* Derive an {@link InputConstraint}'s refinement attributes from the checks
|
|
@@ -472,25 +490,20 @@ 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
|
-
|
|
493
|
-
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;
|
|
494
507
|
}
|
|
495
508
|
/**
|
|
496
509
|
* Whether `ast` is a homogeneous array/list — `Schema.Array(item)` or
|
|
@@ -503,8 +516,10 @@ function constraintFromChecks(outer, inner) {
|
|
|
503
516
|
function homogeneousItem(ast) {
|
|
504
517
|
if (ast.rest.length !== 1) return void 0;
|
|
505
518
|
const [item] = ast.rest;
|
|
506
|
-
|
|
507
|
-
|
|
519
|
+
return Arr.match(ast.elements, {
|
|
520
|
+
onEmpty: () => item,
|
|
521
|
+
onNonEmpty: (elements) => elements.length === 1 && elements[0] === item ? item : void 0
|
|
522
|
+
});
|
|
508
523
|
}
|
|
509
524
|
/**
|
|
510
525
|
* Derive the constraint (or nested tree) for one property/item type: a
|
|
@@ -535,30 +550,43 @@ function constraintsForType(type) {
|
|
|
535
550
|
}
|
|
536
551
|
/** Build the constraint tree for one object AST node. */
|
|
537
552
|
function constraintsForObjects(ast) {
|
|
538
|
-
|
|
539
|
-
for (const ps of ast.propertySignatures) out[String(ps.name)] = constraintsForType(ps.type);
|
|
540
|
-
return out;
|
|
553
|
+
return Rec.fromEntries(Arr.map(ast.propertySignatures, (ps) => [String(ps.name), constraintsForType(ps.type)]));
|
|
541
554
|
}
|
|
542
555
|
/** Deep-merge constraint overrides over a derived tree. */
|
|
543
556
|
function mergeConstraints(base, overrides) {
|
|
544
|
-
const
|
|
545
|
-
for (const key of Object.keys(overrides)) {
|
|
557
|
+
const merged = Arr.flatMap(Rec.keys(overrides), (key) => {
|
|
546
558
|
const override = overrides[key];
|
|
547
|
-
if (override === void 0)
|
|
548
|
-
const existing =
|
|
549
|
-
if (existing !== void 0 && !isLeafConstraint(existing) && !isLeafConstraint(override))
|
|
550
|
-
|
|
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, {
|
|
551
563
|
...existing,
|
|
552
564
|
...override
|
|
553
|
-
};
|
|
554
|
-
|
|
555
|
-
}
|
|
556
|
-
return
|
|
565
|
+
}]];
|
|
566
|
+
return [[key, override]];
|
|
567
|
+
});
|
|
568
|
+
return {
|
|
569
|
+
...base,
|
|
570
|
+
...Rec.fromEntries(merged)
|
|
571
|
+
};
|
|
557
572
|
}
|
|
558
573
|
/**
|
|
559
574
|
* Derive the {@link InputConstraints} for a struct schema. Non-struct schemas
|
|
560
575
|
* yield an empty tree. `overrides` (deep-merged over the derived tree, and
|
|
561
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
|
+
* ```
|
|
562
590
|
*/
|
|
563
591
|
function constraintsFromSchema(schema, overrides = {}) {
|
|
564
592
|
const ast = schema.ast;
|
|
@@ -575,16 +603,34 @@ function constraintsFromSchema(schema, overrides = {}) {
|
|
|
575
603
|
* skips the index and resolves straight to `label`'s derived constraint.
|
|
576
604
|
*/
|
|
577
605
|
function constraintAt(constraints, path) {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
}
|
|
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
|
+
});
|
|
584
611
|
return node !== void 0 && isLeafConstraint(node) ? node : {};
|
|
585
612
|
}
|
|
586
613
|
//#endregion
|
|
587
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
|
+
*/
|
|
588
634
|
var FormData_exports = /* @__PURE__ */ __exportAll({
|
|
589
635
|
formDataToObject: () => formDataToObject,
|
|
590
636
|
parseFieldPath: () => parseFieldPath,
|
|
@@ -597,48 +643,56 @@ 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 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)];
|
|
616
674
|
return tokens.length > 1 && tokens[0] === "" ? tokens.slice(1) : tokens;
|
|
617
675
|
}
|
|
618
676
|
const isIndexToken = (token) => token === "" || /^\d+$/.test(token);
|
|
619
677
|
/** Create the container a token should live in: array for index/append tokens. */
|
|
620
678
|
const makeContainer = (nextToken) => isIndexToken(nextToken) ? [] : {};
|
|
621
679
|
const setOnContainer = (container, token, value) => {
|
|
622
|
-
if (
|
|
680
|
+
if (Arr.isArray(container)) {
|
|
623
681
|
if (token === "") container.push(value);
|
|
624
682
|
else container[Number(token)] = value;
|
|
625
683
|
} else container[token] = value;
|
|
626
684
|
};
|
|
627
|
-
const getFromContainer = (container, token) =>
|
|
685
|
+
const getFromContainer = (container, token) => Arr.isArray(container) ? container[Number(token)] : container[token];
|
|
628
686
|
/** Assign `value` deep into `root` along `tokens`, creating containers as needed. */
|
|
629
687
|
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];
|
|
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
|
+
});
|
|
642
696
|
if (last !== "" && !Array.isArray(node)) {
|
|
643
697
|
const existing = getFromContainer(node, last);
|
|
644
698
|
if (existing !== void 0) {
|
|
@@ -651,27 +705,35 @@ function assign(root, tokens, value) {
|
|
|
651
705
|
setOnContainer(node, last, value);
|
|
652
706
|
}
|
|
653
707
|
/** Normalise a source into `[name, value]` entries. */
|
|
654
|
-
function
|
|
655
|
-
if (
|
|
656
|
-
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
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;
|
|
660
711
|
}
|
|
661
712
|
/**
|
|
662
713
|
* Parse a form body into a nested object / array tree. Field names use dot or
|
|
663
714
|
* bracket path notation (see {@link parseFieldPath}); repeated bare keys
|
|
664
715
|
* become arrays; `File` values pass through. The result is ready to hand to
|
|
665
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
|
+
* ```
|
|
666
730
|
*/
|
|
667
731
|
function formDataToObject(source) {
|
|
668
|
-
|
|
669
|
-
for (const [name, value] of entriesOf(source)) {
|
|
732
|
+
return Arr.reduce(entriesOf(source), Rec.empty(), (root, [name, value]) => {
|
|
670
733
|
const tokens = parseFieldPath(name);
|
|
671
|
-
if (tokens
|
|
672
|
-
|
|
673
|
-
}
|
|
674
|
-
return root;
|
|
734
|
+
if (Arr.isReadonlyArrayNonEmpty(tokens)) assign(root, tokens, value);
|
|
735
|
+
return root;
|
|
736
|
+
});
|
|
675
737
|
}
|
|
676
738
|
/** Read and parse a `Request`'s form body. Errors (e.g. wrong content-type) reject. */
|
|
677
739
|
async function requestToObject(request) {
|
|
@@ -720,32 +782,41 @@ var Path_exports = /* @__PURE__ */ __exportAll({
|
|
|
720
782
|
* traverses a non-object. The empty path returns the object itself.
|
|
721
783
|
*/
|
|
722
784
|
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;
|
|
785
|
+
return Arr.reduce(path, obj, (current, segment) => Predicate.isObjectOrArray(current) && Predicate.hasProperty(current, segment) ? current[segment] : void 0);
|
|
729
786
|
}
|
|
730
787
|
/**
|
|
731
788
|
* Return a new tree with the value at `path` replaced by `value`. Objects and
|
|
732
789
|
* arrays along the path are cloned (structural sharing elsewhere); missing
|
|
733
790
|
* containers are created as arrays for numeric segments and objects otherwise.
|
|
734
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
|
+
* ```
|
|
735
802
|
*/
|
|
736
803
|
function setAt(obj, path, value) {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
+
});
|
|
743
816
|
}
|
|
744
817
|
/** Whether `path` is `root` or nested beneath it. */
|
|
745
818
|
function isUnder(path, root) {
|
|
746
|
-
|
|
747
|
-
for (let i = 0; i < root.length; i++) if (path[i] !== root[i]) return false;
|
|
748
|
-
return true;
|
|
819
|
+
return path.length >= root.length && Arr.every(root, (segment, i) => path[i] === segment);
|
|
749
820
|
}
|
|
750
821
|
/**
|
|
751
822
|
* Render a {@link FormPath} as flat field-name notation: string segments join
|
|
@@ -753,10 +824,7 @@ function isUnder(path, root) {
|
|
|
753
824
|
* The inverse of {@link fromString}.
|
|
754
825
|
*/
|
|
755
826
|
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;
|
|
827
|
+
return Arr.reduce(path, "", (out, segment) => Predicate.isNumber(segment) ? `${out}[${segment}]` : out === "" ? segment : `${out}.${segment}`);
|
|
760
828
|
}
|
|
761
829
|
/**
|
|
762
830
|
* Parse flat field-name notation into a {@link FormPath}: integer tokens become
|
|
@@ -801,7 +869,7 @@ const clearAll = () => null;
|
|
|
801
869
|
function remapIndex(path, arrayPath, map) {
|
|
802
870
|
if (!isUnder(path, arrayPath) || path.length === arrayPath.length) return path;
|
|
803
871
|
const segment = path[arrayPath.length];
|
|
804
|
-
if (
|
|
872
|
+
if (!Predicate.isNumber(segment)) return path;
|
|
805
873
|
const mapped = map(segment);
|
|
806
874
|
if (mapped === null) return null;
|
|
807
875
|
if (mapped === segment) return path;
|
|
@@ -817,15 +885,34 @@ function remapIndex(path, arrayPath, map) {
|
|
|
817
885
|
* maps to `null` are dropped. Pure.
|
|
818
886
|
*/
|
|
819
887
|
function remapKeyedRecord(record, arrayPath, map) {
|
|
820
|
-
|
|
821
|
-
for (const key of Object.keys(record)) {
|
|
888
|
+
return Rec.fromEntries(Arr.flatMap(Rec.toEntries(record), ([key, value]) => {
|
|
822
889
|
const remapped = remapIndex(fromString(key), arrayPath, map);
|
|
823
|
-
|
|
824
|
-
}
|
|
825
|
-
return out;
|
|
890
|
+
return remapped === null ? [] : [[toString(remapped), value]];
|
|
891
|
+
}));
|
|
826
892
|
}
|
|
827
893
|
//#endregion
|
|
828
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
|
+
*/
|
|
829
916
|
var Errors_exports = /* @__PURE__ */ __exportAll({
|
|
830
917
|
ROOT_ERROR_KEY: () => ROOT_ERROR_KEY,
|
|
831
918
|
clearSourceAt: () => clearSourceAt,
|
|
@@ -844,7 +931,7 @@ var Errors_exports = /* @__PURE__ */ __exportAll({
|
|
|
844
931
|
});
|
|
845
932
|
/** The root key under which a branch's own (non-field) messages are stored. */
|
|
846
933
|
const ROOT_ERROR_KEY = "_errors";
|
|
847
|
-
const isMessageList = (value) =>
|
|
934
|
+
const isMessageList = (value) => Arr.isArray(value) && Arr.every(value, Predicate.isString);
|
|
848
935
|
const ownErrors = (node) => {
|
|
849
936
|
const existing = node[ROOT_ERROR_KEY];
|
|
850
937
|
if (isMessageList(existing)) return existing;
|
|
@@ -853,15 +940,26 @@ const ownErrors = (node) => {
|
|
|
853
940
|
return created;
|
|
854
941
|
};
|
|
855
942
|
/**
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
* 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.
|
|
861
949
|
*/
|
|
862
|
-
const
|
|
950
|
+
const branchAt = (node, key) => {
|
|
863
951
|
const existing = node[key];
|
|
864
|
-
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;
|
|
865
963
|
};
|
|
866
964
|
/**
|
|
867
965
|
* Insert one message at `path` into a mutable error tree. A leaf becomes a
|
|
@@ -871,18 +969,12 @@ const promoteLeafToBranch = (node, key) => {
|
|
|
871
969
|
* tree root's {@link ROOT_ERROR_KEY}.
|
|
872
970
|
*/
|
|
873
971
|
const insert = (root, path, message) => {
|
|
874
|
-
if (path
|
|
972
|
+
if (!Arr.isReadonlyArrayNonEmpty(path)) {
|
|
875
973
|
ownErrors(root).push(message);
|
|
876
974
|
return;
|
|
877
975
|
}
|
|
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]);
|
|
976
|
+
const node = Arr.reduce(Arr.initNonEmpty(path), root, (parent, segment) => branchAt(parent, String(segment)));
|
|
977
|
+
const last = String(Arr.lastNonEmpty(path));
|
|
886
978
|
const existing = node[last];
|
|
887
979
|
if (existing === void 0) node[last] = [message];
|
|
888
980
|
else if (isMessageList(existing)) existing.push(message);
|
|
@@ -891,7 +983,7 @@ const insert = (root, path, message) => {
|
|
|
891
983
|
/** Build a nested {@link FormErrors} tree from a flat list of {@link FormIssue}s. */
|
|
892
984
|
function issuesToErrors(issues) {
|
|
893
985
|
const root = {};
|
|
894
|
-
|
|
986
|
+
Arr.forEach(issues, (issue) => insert(root, issue.path, issue.message));
|
|
895
987
|
return root;
|
|
896
988
|
}
|
|
897
989
|
/**
|
|
@@ -900,32 +992,25 @@ function issuesToErrors(issues) {
|
|
|
900
992
|
* ordering within a single field).
|
|
901
993
|
*/
|
|
902
994
|
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;
|
|
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, []);
|
|
925
1010
|
}
|
|
926
1011
|
/** Whether the tree carries any message anywhere. */
|
|
927
1012
|
function hasErrors(errors) {
|
|
928
|
-
return flattenErrors(errors)
|
|
1013
|
+
return Arr.isReadonlyArrayNonEmpty(flattenErrors(errors));
|
|
929
1014
|
}
|
|
930
1015
|
/**
|
|
931
1016
|
* Read the messages attached directly to `path` (a leaf's list, or a branch's
|
|
@@ -933,11 +1018,7 @@ function hasErrors(errors) {
|
|
|
933
1018
|
* messages.
|
|
934
1019
|
*/
|
|
935
1020
|
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
|
-
}
|
|
1021
|
+
const node = Arr.reduce(path, errors, (current, segment) => current === void 0 || isMessageList(current) ? void 0 : current[String(segment)]);
|
|
941
1022
|
if (node === void 0) return void 0;
|
|
942
1023
|
if (isMessageList(node)) return node;
|
|
943
1024
|
return node[ROOT_ERROR_KEY];
|
|
@@ -961,15 +1042,13 @@ const noErrors = {};
|
|
|
961
1042
|
* Messages whose index maps to `null` are dropped. Pure.
|
|
962
1043
|
*/
|
|
963
1044
|
function remapIndices(errors, arrayPath, map) {
|
|
964
|
-
|
|
965
|
-
for (const issue of flattenErrors(errors)) {
|
|
1045
|
+
return issuesToErrors(Arr.flatMap(flattenErrors(errors), (issue) => {
|
|
966
1046
|
const path = remapIndex(issue.path, arrayPath, map);
|
|
967
|
-
|
|
1047
|
+
return path === null ? [] : [{
|
|
968
1048
|
path,
|
|
969
1049
|
message: issue.message
|
|
970
|
-
}
|
|
971
|
-
}
|
|
972
|
-
return issuesToErrors(remapped);
|
|
1050
|
+
}];
|
|
1051
|
+
}));
|
|
973
1052
|
}
|
|
974
1053
|
/**
|
|
975
1054
|
* Return a new tree with the messages *directly at* `path` replaced by
|
|
@@ -986,51 +1065,45 @@ function replaceAt(errors, path, messages) {
|
|
|
986
1065
|
return next;
|
|
987
1066
|
}
|
|
988
1067
|
const setLeaf = (root, path, messages) => {
|
|
989
|
-
if (path
|
|
1068
|
+
if (!Arr.isReadonlyArrayNonEmpty(path)) {
|
|
990
1069
|
setOwnMessages(root, messages);
|
|
991
1070
|
return;
|
|
992
1071
|
}
|
|
993
|
-
const parent = descendClearing(root, path, messages);
|
|
1072
|
+
const parent = descendClearing(root, Arr.initNonEmpty(path), messages);
|
|
994
1073
|
if (parent === void 0) return;
|
|
995
|
-
setLeafMessages(parent, String(path
|
|
1074
|
+
setLeafMessages(parent, String(Arr.lastNonEmpty(path)), messages);
|
|
996
1075
|
};
|
|
997
1076
|
/** Set or clear a node's own {@link ROOT_ERROR_KEY} messages. */
|
|
998
1077
|
const setOwnMessages = (node, messages) => {
|
|
999
|
-
if (messages
|
|
1078
|
+
if (Arr.isReadonlyArrayEmpty(messages)) delete node[ROOT_ERROR_KEY];
|
|
1000
1079
|
else node[ROOT_ERROR_KEY] = [...messages];
|
|
1001
1080
|
};
|
|
1002
1081
|
/**
|
|
1003
|
-
* 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
|
|
1004
1084
|
* `messages` is empty (a clear, not a set) a missing branch means there is
|
|
1005
1085
|
* nothing to clear — descent stops early and returns `undefined` rather than
|
|
1006
1086
|
* creating empty branches just to immediately no-op inside them.
|
|
1007
1087
|
*/
|
|
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
|
-
};
|
|
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
|
+
});
|
|
1020
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}. */
|
|
1021
1095
|
const setLeafMessages = (node, key, messages) => {
|
|
1022
1096
|
const existing = node[key];
|
|
1023
1097
|
if (existing === void 0) {
|
|
1024
|
-
if (messages
|
|
1098
|
+
if (Arr.isReadonlyArrayNonEmpty(messages)) node[key] = [...messages];
|
|
1025
1099
|
return;
|
|
1026
1100
|
}
|
|
1027
1101
|
if (isMessageList(existing)) {
|
|
1028
|
-
if (messages
|
|
1102
|
+
if (Arr.isReadonlyArrayEmpty(messages)) delete node[key];
|
|
1029
1103
|
else node[key] = [...messages];
|
|
1030
1104
|
return;
|
|
1031
1105
|
}
|
|
1032
|
-
|
|
1033
|
-
else existing[ROOT_ERROR_KEY] = [...messages];
|
|
1106
|
+
setOwnMessages(existing, messages);
|
|
1034
1107
|
};
|
|
1035
1108
|
/** Both source trees empty. */
|
|
1036
1109
|
const emptySourced = {
|
|
@@ -1057,6 +1130,26 @@ function clearSourceAt(sourced, source, path) {
|
|
|
1057
1130
|
}
|
|
1058
1131
|
//#endregion
|
|
1059
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
|
+
*/
|
|
1060
1153
|
var Field_exports = /* @__PURE__ */ __exportAll({
|
|
1061
1154
|
defaultEncodedValues: () => defaultEncodedValues,
|
|
1062
1155
|
defaultItemAt: () => defaultItemAt,
|
|
@@ -1068,9 +1161,9 @@ var Field_exports = /* @__PURE__ */ __exportAll({
|
|
|
1068
1161
|
schemaAt: () => schemaAt
|
|
1069
1162
|
});
|
|
1070
1163
|
/** Whether `def` is an {@link ArrayFieldDef}. */
|
|
1071
|
-
const isArrayFieldDef = (def) => def
|
|
1164
|
+
const isArrayFieldDef = (def) => Predicate.isTagged(def, "array");
|
|
1072
1165
|
/** Whether `def` is a scalar {@link FieldDef}. */
|
|
1073
|
-
const isFieldDef = (def) => def
|
|
1166
|
+
const isFieldDef = (def) => Predicate.isTagged(def, "field");
|
|
1074
1167
|
/** Construct a scalar {@link FieldDef}. */
|
|
1075
1168
|
const makeField = (key, schema) => ({
|
|
1076
1169
|
_tag: "field",
|
|
@@ -1100,19 +1193,14 @@ function defaultValue(schema) {
|
|
|
1100
1193
|
*/
|
|
1101
1194
|
function unwrapToStructured(ast) {
|
|
1102
1195
|
if (!SchemaAST.isUnion(ast)) return ast;
|
|
1103
|
-
|
|
1196
|
+
return Arr.findFirst(ast.types, (member) => {
|
|
1104
1197
|
const inner = unwrapToStructured(member);
|
|
1105
|
-
|
|
1106
|
-
}
|
|
1107
|
-
return ast;
|
|
1198
|
+
return SchemaAST.isObjects(inner) || SchemaAST.isArrays(inner) ? Option.some(inner) : Option.none();
|
|
1199
|
+
}).pipe(Option.getOrElse(() => ast));
|
|
1108
1200
|
}
|
|
1109
1201
|
function fromAst(ast) {
|
|
1110
1202
|
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
|
-
}
|
|
1203
|
+
if (SchemaAST.isObjects(unwrapped)) return Rec.fromEntries(Arr.map(unwrapped.propertySignatures, (ps) => [String(ps.name), fromAst(ps.type)]));
|
|
1116
1204
|
if (SchemaAST.isArrays(unwrapped)) return [];
|
|
1117
1205
|
return "";
|
|
1118
1206
|
}
|
|
@@ -1140,12 +1228,7 @@ function stepIntoStruct(ast, key) {
|
|
|
1140
1228
|
* schemas built directly with `Form.make` (no builder involved).
|
|
1141
1229
|
*/
|
|
1142
1230
|
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;
|
|
1231
|
+
return Arr.reduce(path, unwrapToStructured(schema.ast), (ast, segment) => ast === void 0 ? void 0 : Predicate.isNumber(segment) ? stepIntoArray(ast) : stepIntoStruct(ast, segment));
|
|
1149
1232
|
}
|
|
1150
1233
|
/**
|
|
1151
1234
|
* Derive a blank encoded item for the array field at `path` (any nesting
|
|
@@ -1154,18 +1237,34 @@ function schemaAt(schema, path) {
|
|
|
1154
1237
|
*/
|
|
1155
1238
|
function defaultItemAt(schema, path) {
|
|
1156
1239
|
const ast = schemaAt(schema, path);
|
|
1157
|
-
|
|
1158
|
-
const item = arrayElementAst(ast);
|
|
1240
|
+
const item = ast !== void 0 && SchemaAST.isArrays(ast) ? arrayElementAst(ast) : void 0;
|
|
1159
1241
|
return item === void 0 ? "" : fromAst(item);
|
|
1160
1242
|
}
|
|
1161
1243
|
/** Seed a blank encoded value for every field: `[]` for arrays, `""` otherwise. */
|
|
1162
1244
|
function defaultEncodedValues(fields) {
|
|
1163
|
-
|
|
1164
|
-
for (const [key, def] of Object.entries(fields)) out[key] = isArrayFieldDef(def) ? [] : defaultValue(def.schema);
|
|
1165
|
-
return out;
|
|
1245
|
+
return Rec.map(fields, (def) => isArrayFieldDef(def) ? [] : defaultValue(def.schema));
|
|
1166
1246
|
}
|
|
1167
1247
|
//#endregion
|
|
1168
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
|
+
*/
|
|
1169
1268
|
var Issues_exports = /* @__PURE__ */ __exportAll({
|
|
1170
1269
|
defaultLeafFormatter: () => defaultLeafFormatter,
|
|
1171
1270
|
filterOutputToFormIssues: () => filterOutputToFormIssues$1,
|
|
@@ -1178,21 +1277,12 @@ const defaultLeafFormatter = SchemaIssue.makeFormatterDefault();
|
|
|
1178
1277
|
* keys become numbers (array indices), everything else becomes a string.
|
|
1179
1278
|
*/
|
|
1180
1279
|
const normaliseSegment = (segment) => {
|
|
1181
|
-
if (
|
|
1182
|
-
if (
|
|
1280
|
+
if (Predicate.isNumber(segment)) return segment;
|
|
1281
|
+
if (Predicate.isSymbol(segment)) return segment.toString();
|
|
1183
1282
|
return /^\d+$/.test(segment) ? Number(segment) : segment;
|
|
1184
1283
|
};
|
|
1185
1284
|
/** 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
|
-
};
|
|
1285
|
+
const isTerminal = Match.type().pipe(Match.tag("Pointer", "Composite", "Filter", "Encoding", () => false), Match.tag("AnyOf", (issue) => Arr.isReadonlyArrayEmpty(issue.issues)), Match.orElse(() => true));
|
|
1196
1286
|
/**
|
|
1197
1287
|
* Walk an Effect Schema issue tree, emitting one {@link FormIssue} per leaf
|
|
1198
1288
|
* failure. Path segments accumulate through `Pointer` nodes; `Composite` and
|
|
@@ -1201,48 +1291,18 @@ const isTerminal = (issue) => {
|
|
|
1201
1291
|
* mismatch) is itself a leaf.
|
|
1202
1292
|
*/
|
|
1203
1293
|
function schemaIssuesToFormIssues(issue, format = defaultLeafFormatter) {
|
|
1204
|
-
const out = [];
|
|
1205
1294
|
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
|
-
}
|
|
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));
|
|
1243
1304
|
};
|
|
1244
|
-
walk(issue, []);
|
|
1245
|
-
return out;
|
|
1305
|
+
return walk(issue, []);
|
|
1246
1306
|
}
|
|
1247
1307
|
/**
|
|
1248
1308
|
* Normalise a filter predicate's {@link Schema.FilterOutput} into a flat list of
|
|
@@ -1257,17 +1317,19 @@ function filterOutputToFormIssues$1(output) {
|
|
|
1257
1317
|
path: [],
|
|
1258
1318
|
message: "Invalid value"
|
|
1259
1319
|
}];
|
|
1260
|
-
if (
|
|
1320
|
+
if (isFilterIssueList(output)) return Arr.flatMap(output, filterIssueToFormIssues);
|
|
1261
1321
|
return filterIssueToFormIssues(output);
|
|
1262
1322
|
}
|
|
1323
|
+
/** `Array.isArray` as a guard that also narrows away a `ReadonlyArray` member. */
|
|
1324
|
+
const isFilterIssueList = (output) => Arr.isArray(output);
|
|
1263
1325
|
function filterIssueToFormIssues(issue) {
|
|
1264
|
-
if (
|
|
1326
|
+
if (Predicate.isString(issue)) return [{
|
|
1265
1327
|
path: [],
|
|
1266
1328
|
message: issue
|
|
1267
1329
|
}];
|
|
1268
1330
|
if ("path" in issue && "issue" in issue) {
|
|
1269
|
-
const prefix = issue.path
|
|
1270
|
-
return (
|
|
1331
|
+
const prefix = Arr.map(issue.path, (s) => Predicate.isSymbol(s) ? s.toString() : s);
|
|
1332
|
+
return (Predicate.isString(issue.issue) ? [{
|
|
1271
1333
|
path: [],
|
|
1272
1334
|
message: issue.issue
|
|
1273
1335
|
}] : schemaIssuesToFormIssues(issue.issue)).map((i) => ({
|
|
@@ -1315,10 +1377,12 @@ var FormBuilder_exports = /* @__PURE__ */ __exportAll({
|
|
|
1315
1377
|
});
|
|
1316
1378
|
/** Brand for a {@link FormBuilder}. */
|
|
1317
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]);
|
|
1318
1382
|
const proto = {
|
|
1319
1383
|
[TypeId]: TypeId,
|
|
1320
|
-
addField(
|
|
1321
|
-
const field =
|
|
1384
|
+
addField(...args) {
|
|
1385
|
+
const field = isKeyedArgs(args) ? makeField(...args) : args[0];
|
|
1322
1386
|
return build({
|
|
1323
1387
|
...this.fields,
|
|
1324
1388
|
[field.key]: field
|
|
@@ -1350,7 +1414,7 @@ function build(fields, refinements) {
|
|
|
1350
1414
|
return self;
|
|
1351
1415
|
}
|
|
1352
1416
|
/** Whether `u` is a {@link FormBuilder}. */
|
|
1353
|
-
const isFormBuilder = (u) =>
|
|
1417
|
+
const isFormBuilder = (u) => Predicate.isObjectOrArray(u) && Predicate.hasProperty(u, TypeId);
|
|
1354
1418
|
/** The empty builder — the starting point for {@link FormBuilder.addField}. */
|
|
1355
1419
|
const make$2 = build({}, []);
|
|
1356
1420
|
/** Alias of {@link make} — the empty builder. */
|
|
@@ -1361,16 +1425,20 @@ const empty = make$2;
|
|
|
1361
1425
|
* applied as a struct-level check. Ready for {@link module:effect-forms/Form}.
|
|
1362
1426
|
*/
|
|
1363
1427
|
function buildSchema(self) {
|
|
1364
|
-
const structFields = {
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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);
|
|
1370
1435
|
}
|
|
1371
1436
|
/** The asynchronous refinements carried on a builder, as bare predicates. */
|
|
1372
1437
|
function asyncRefinements(self) {
|
|
1373
|
-
return self.refinements
|
|
1438
|
+
return Arr.flatMap(self.refinements, (refinement) => {
|
|
1439
|
+
if (!Predicate.isTagged(refinement, "async")) return [];
|
|
1440
|
+
return [refinement.fn];
|
|
1441
|
+
});
|
|
1374
1442
|
}
|
|
1375
1443
|
/**
|
|
1376
1444
|
* Normalise a filter predicate's `Schema.FilterOutput` into a flat list of
|
|
@@ -1386,9 +1454,10 @@ const filterOutputToFormIssues = filterOutputToFormIssues$1;
|
|
|
1386
1454
|
* concurrently; requirements propagate on the `R` channel.
|
|
1387
1455
|
*/
|
|
1388
1456
|
function runAsyncRefinements(self, values) {
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
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
|
+
});
|
|
1392
1461
|
}
|
|
1393
1462
|
//#endregion
|
|
1394
1463
|
//#region src/State.ts
|
|
@@ -1414,15 +1483,16 @@ var State_exports = /* @__PURE__ */ __exportAll({
|
|
|
1414
1483
|
/** Build a {@link FormState} from data plus overrides. `valid` defaults to "no errors". */
|
|
1415
1484
|
function make$1(data, options = {}) {
|
|
1416
1485
|
const errors = options.errors ?? noErrors;
|
|
1417
|
-
|
|
1486
|
+
const state = {
|
|
1418
1487
|
id: options.id ?? "default",
|
|
1419
1488
|
valid: options.valid ?? !hasErrors(errors),
|
|
1420
1489
|
posted: options.posted ?? false,
|
|
1421
1490
|
data,
|
|
1422
|
-
errors
|
|
1423
|
-
...options.message !== void 0 ? { message: options.message } : {},
|
|
1424
|
-
...options.constraints !== void 0 ? { constraints: options.constraints } : {}
|
|
1491
|
+
errors
|
|
1425
1492
|
};
|
|
1493
|
+
if (options.message !== void 0) state.message = options.message;
|
|
1494
|
+
if (options.constraints !== void 0) state.constraints = options.constraints;
|
|
1495
|
+
return state;
|
|
1426
1496
|
}
|
|
1427
1497
|
/** Return a new state carrying `message` (and not otherwise changed). */
|
|
1428
1498
|
function withMessage(state, message) {
|
|
@@ -1489,7 +1559,10 @@ var Form_exports = /* @__PURE__ */ __exportAll({
|
|
|
1489
1559
|
});
|
|
1490
1560
|
const toStructured = (input) => {
|
|
1491
1561
|
if (input === null || input === void 0) return Effect.succeed(null);
|
|
1492
|
-
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);
|
|
1493
1566
|
if (input instanceof FormData || input instanceof URLSearchParams) return Effect.succeed(formDataToObject(input));
|
|
1494
1567
|
return Effect.succeed(input);
|
|
1495
1568
|
};
|
|
@@ -1499,6 +1572,22 @@ const toStructured = (input) => {
|
|
|
1499
1572
|
* its requirements are the schema's decoding services) and `empty()` seeds a
|
|
1500
1573
|
* blank form from `defaults`. The returned {@link Form} is stateless and
|
|
1501
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
|
+
* ```
|
|
1502
1591
|
*/
|
|
1503
1592
|
function make(schema, options) {
|
|
1504
1593
|
const id = options.id ?? "default";
|
|
@@ -1522,16 +1611,19 @@ function make(schema, options) {
|
|
|
1522
1611
|
posted: true,
|
|
1523
1612
|
constraints
|
|
1524
1613
|
})),
|
|
1525
|
-
onFailure: (issue) =>
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
+
}
|
|
1535
1627
|
}));
|
|
1536
1628
|
}));
|
|
1537
1629
|
return {
|
|
@@ -1565,17 +1657,20 @@ function fromBuilder(builder, options = {}) {
|
|
|
1565
1657
|
...object
|
|
1566
1658
|
};
|
|
1567
1659
|
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
|
-
|
|
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
|
+
})
|
|
1579
1674
|
}))),
|
|
1580
1675
|
onFailure: (issue) => Effect.succeed(make$1(posted, {
|
|
1581
1676
|
id: base.id,
|