@marktiderman/genesis-forms 0.4.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -5
- package/dist/destinations.cjs +92 -4
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.d.cts +1 -1
- package/dist/destinations.d.ts +1 -1
- package/dist/destinations.js +92 -4
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-Dz9J5ej2.d.ts → engine-BDq0XJ21.d.cts} +25 -2
- package/dist/{engine-BkNuzlna.d.cts → engine-Qzp6bX6O.d.ts} +25 -2
- package/dist/index.cjs +383 -59
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +121 -76
- package/dist/index.d.ts +121 -76
- package/dist/index.js +360 -60
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +387 -62
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +39 -5
- package/dist/server.d.ts +39 -5
- package/dist/server.js +385 -63
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +386 -61
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +4 -4
- package/dist/testing.d.ts +4 -4
- package/dist/testing.js +386 -61
- package/dist/testing.js.map +1 -1
- package/dist/{types-BBKBQ8sp.d.cts → types-BhYUbLJ-.d.cts} +21 -1
- package/dist/{types-BBKBQ8sp.d.ts → types-BhYUbLJ-.d.ts} +21 -1
- package/dist/validate-Bs1lyoOl.d.ts +131 -0
- package/dist/validate-BsZ0YTQS.d.cts +131 -0
- package/package.json +1 -1
- package/dist/validate-BXXqi0fq.d.cts +0 -30
- package/dist/validate-BjNwF1UE.d.ts +0 -30
package/README.md
CHANGED
|
@@ -62,6 +62,13 @@ const version = eventFeedback.hash(); // content fingerprint for publish
|
|
|
62
62
|
`validateSubmission` refuses a definition that does not parse. Ids, keys, hidden names and ending
|
|
63
63
|
ids may not be `Object.prototype` names (`constructor`, `__proto__`…).
|
|
64
64
|
- **Unanswered recall renders its fallback** (`{{field:name|there}}`); the text is never dropped.
|
|
65
|
+
- **Saving answers as they go is a setting.** `settings.saveProgress` (off by default) says the
|
|
66
|
+
host keeps the answers so far each time the respondent moves on, so an unfinished response
|
|
67
|
+
survives. `formProgress(engine)` says how far they got: `{ step, total, fieldId }`, where
|
|
68
|
+
`fieldId` is the furthest question reached (kept after Back; in classic layout, the last one
|
|
69
|
+
answered). genesis-forms-web's `onProgress` calls the host with it; the server checks the
|
|
70
|
+
answers so far with `validateSubmission(definition, input, { complete: false })`. After a
|
|
71
|
+
reload, `createForm(definition, { initialValues: saved, resumeAt: fieldId })` picks up there.
|
|
65
72
|
|
|
66
73
|
## Pieces
|
|
67
74
|
|
|
@@ -129,6 +136,28 @@ Each type validates, normalizes (trimmed, blanks dropped, notes in time order),
|
|
|
129
136
|
samples for `coverage()`. Helpers: `listLimits`, `tableColumns`, `tableLimits`, `formatTimestamp`
|
|
130
137
|
and `parseTimestamp`; types `TableColumn`, `MediaNote` and `Highlight`.
|
|
131
138
|
|
|
139
|
+
### Photo fields
|
|
140
|
+
|
|
141
|
+
A `photo` field asks for up to a few photos, such as a picture of broken equipment:
|
|
142
|
+
`f.photo(key, { maxFiles, maxSizeMb, formats })`. The answer holds references the host's storage
|
|
143
|
+
returned, never the bytes: `PhotoRef[]`, each `{ id, name, type, size }`. The renderer uploads
|
|
144
|
+
through the host (`onUpload` on genesis-forms-web's `Form`), so the engine and `validateSubmission`
|
|
145
|
+
stay storage-agnostic; the host's server should check that every reference is a file it stored
|
|
146
|
+
for this respondent and form before saving.
|
|
147
|
+
|
|
148
|
+
| Prop | Default | Rule |
|
|
149
|
+
| --- | --- | --- |
|
|
150
|
+
| `maxFiles` | 3 | 1 to `MAX_PHOTO_FILES` (5). |
|
|
151
|
+
| `maxSizeMb` | 10 | 1 to `MAX_PHOTO_SIZE_MB` (25), per photo. |
|
|
152
|
+
| `formats` | all four | One or more of `jpeg`, `png`, `heic` (also HEIF) and `webp` (`PHOTO_FORMATS` maps each to its MIME types). |
|
|
153
|
+
|
|
154
|
+
A photo is neither `sensitive` nor `identifying` by default; set either on the field when it is.
|
|
155
|
+
Validation checks each reference's shape, type and size and the count; `normalize` keeps only the
|
|
156
|
+
four keys; `summarizeValue` lists the file names. Like the structured types it is opt-in in a
|
|
157
|
+
builder palette: `UPLOAD_FIELD_TYPES` (`["photo"]`). Helpers: `photoLimits(field)` (`maxFiles`,
|
|
158
|
+
`maxSizeMb`, `maxBytes`, `formats`, `mimeTypes`), `photoRefs(value)`, `isPhotoRef`,
|
|
159
|
+
`photoFormatList`, and the messages `photoTypeMessage`, `photoSizeMessage` and `photoCountMessage`.
|
|
160
|
+
|
|
132
161
|
### Calculated fields
|
|
133
162
|
|
|
134
163
|
A `calculated` field shows a value worked out from other answers, such as a total, an average or a
|
|
@@ -170,12 +199,29 @@ Limits, so a large submission can't make the server slow:
|
|
|
170
199
|
- A calculated field that reads a list or table of more than `MAX_FORMULA_ITEMS` (10,000) items
|
|
171
200
|
has no value: it is blank, never `0` or a partial total. The same goes for one that reads an
|
|
172
201
|
answer past `MAX_SETTLE_ITEMS` (50,000) items read by all of a form's formulas together (each
|
|
173
|
-
|
|
202
|
+
list or table counted once, however many of its columns are read, in form order; an answer over
|
|
203
|
+
10,000 items counts for none).
|
|
174
204
|
Nothing else reports this, so give a table a `maxRows` well under these if its total matters.
|
|
175
205
|
- A formula is at most `MAX_FORMULA_LENGTH` characters and 64 levels of parentheses, calls, signs
|
|
176
206
|
and `? :` deep, and all of a form's formulas together at most `MAX_FORM_FORMULA_LENGTH` (20,000).
|
|
177
|
-
- Lists are read once per answer, so treat answers as immutable: pass `setValue` a new
|
|
178
|
-
|
|
207
|
+
- Lists are read once per answer, so treat an engine's answers as immutable: pass `setValue` a new
|
|
208
|
+
array (the engine and the built-in renderers never change one in place). `setValue` with the
|
|
209
|
+
array it already holds works it out again. Each engine, and so each `validateSubmission` call,
|
|
210
|
+
keeps its own lists, so the same object passed again after a change is read afresh.
|
|
211
|
+
- `validateSubmission` refuses a submission that would be too much work, with an error under `""`,
|
|
212
|
+
and never cuts one short. It doesn't rely on the host's request size limit. Each limit is an
|
|
213
|
+
option for a host that needs more:
|
|
214
|
+
|
|
215
|
+
| Limit | Default | Option | Counts |
|
|
216
|
+
| --- | --- | --- | --- |
|
|
217
|
+
| `MAX_EVENTS` | 10,000 | `maxEvents` | Events, after `compactEvents`; at most `RAW_EVENTS_PER_EVENT` (10) times as many may be sent |
|
|
218
|
+
| `MAX_SUBMISSION_VALUES` | 200,000 | `maxValues` | JSON values in the answers, answer events and hidden inputs: each number, text, true / false, list item, row and cell. A run of answers to one field (a table sends itself on every keystroke) counts its last only |
|
|
219
|
+
| `MAX_REPLAY_WORK` | 1,000,000 | `maxReplayWork` | Replaying the events: one unit per block each time the path is walked, one per calculated field each time the answers settle, and a formula's size (its numbers, answers and operators) each time it is worked out. The submit after the replay isn't counted |
|
|
220
|
+
|
|
221
|
+
A respondent filling in a long form with formulas uses about 400,000 units of replay work at
|
|
222
|
+
most; the limit stops a log built to make every step work out every formula. After a refusal,
|
|
223
|
+
sending the same body again gets the same answer, so show the `""` message rather than offer
|
|
224
|
+
"Try again".
|
|
179
225
|
|
|
180
226
|
The engine works the values out from the answers on the path (an answer hidden by logic reads as
|
|
181
227
|
blank), again whenever an answer it reads changes, and stores them with the answers, so `engine.answers()` and a
|
|
@@ -193,6 +239,44 @@ as `The formula uses "wage", which is not the answer name of a question in this
|
|
|
193
239
|
own; `formatCalculated` shows a value as the field does. Builders offer the type only when the host
|
|
194
240
|
names it (`COMPUTED_FIELD_TYPES`).
|
|
195
241
|
|
|
242
|
+
### Options from the app
|
|
243
|
+
|
|
244
|
+
A single choice, multiple choice or dropdown question can take its options from the app showing
|
|
245
|
+
the form, such as a coach picker that always lists the current coaches:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
f.dropdown("coach", { label: "Who coached you?", optionsFrom: { source: "host", key: "coaches" } });
|
|
249
|
+
// stored as props: { optionsFrom: { source: "host", key: "coaches" } }
|
|
250
|
+
|
|
251
|
+
const lists = { coaches: [{ value: profileId, label: "Ada Lovelace" }] }; // loaded by the app
|
|
252
|
+
createForm(definition, { hostOptions: lists }); // or <Form hostOptions={lists}>
|
|
253
|
+
validateSubmission(definition, body, { hostOptions: lists }); // loaded again on the server
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
`withHostOptions(definition, lists)` fills them in: the question's own `props.options` first (it
|
|
257
|
+
may have some), then the list for its key, each value once. The engine does it for
|
|
258
|
+
`engine.definition`, so renderers show the list and an answer must be one of these options;
|
|
259
|
+
`validateSubmission` does it with the server's lists, so a value the server doesn't offer now (a
|
|
260
|
+
coach who left, a made-up id) is refused. Without a list for the key, the question offers its own
|
|
261
|
+
options only. Answers store the option's value; `summarizeValue` shows its label while the list has
|
|
262
|
+
it. `parseForm` checks the shape of `optionsFrom`, and doesn't warn about rules comparing with
|
|
263
|
+
values only the app knows.
|
|
264
|
+
|
|
265
|
+
### An "Other" answer
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
f.multiChoice("areas", { label: "Which area?", options: [...], other: true });
|
|
269
|
+
f.singleChoice("where", { label: "Where?", options: [...], other: { label: "Somewhere else" } });
|
|
270
|
+
// stored as props: { other: true } or { other: { label: "Somewhere else" } }
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
A single choice, multiple choice or dropdown question with `props.other` offers one more option,
|
|
274
|
+
"Other" (or its `label`), with a text box. What the respondent types is the answer: the value of a
|
|
275
|
+
single choice or dropdown, one more item on a multiple choice. `validateSubmission` accepts at most
|
|
276
|
+
one such value per question, not blank and at most `OTHER_MAX_LENGTH` (200) characters, and only on
|
|
277
|
+
a question with `other`. `otherOption(field)` reads the setting; `isOtherValue(field, value)` tells a
|
|
278
|
+
typed answer from an option.
|
|
279
|
+
|
|
196
280
|
### Props for the host
|
|
197
281
|
|
|
198
282
|
Props a field type doesn't use are kept and ignored: the engine, `parseForm` and
|
|
@@ -213,8 +297,8 @@ await store(result.submission); // answers, hidden, context, ending
|
|
|
213
297
|
```
|
|
214
298
|
|
|
215
299
|
Errors are keyed by field id. The `""` key is for the whole submission: `"Malformed submission."`
|
|
216
|
-
(a body of the wrong shape), a definition that fails `parseForm`, or
|
|
217
|
-
|
|
300
|
+
(a body of the wrong shape), a definition that fails `parseForm`, or a submission past one of the
|
|
301
|
+
limits above (`maxEvents`, `maxValues`, `maxReplayWork`). Events of an unknown type are dropped, so a newer
|
|
218
302
|
client's log still replays. With `settings.clearHiddenValues: false`, a hidden field's value is kept
|
|
219
303
|
only if its format is valid.
|
|
220
304
|
|
package/dist/destinations.cjs
CHANGED
|
@@ -328,6 +328,79 @@ var structuredFieldTypes = [
|
|
|
328
328
|
}
|
|
329
329
|
];
|
|
330
330
|
|
|
331
|
+
// src/photo.ts
|
|
332
|
+
var PHOTO_TYPE = "photo";
|
|
333
|
+
var PHOTO_FORMATS = {
|
|
334
|
+
jpeg: ["image/jpeg"],
|
|
335
|
+
png: ["image/png"],
|
|
336
|
+
heic: ["image/heic", "image/heif"],
|
|
337
|
+
webp: ["image/webp"]
|
|
338
|
+
};
|
|
339
|
+
var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
|
|
340
|
+
var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
|
|
341
|
+
var DEFAULT_PHOTO_FILES = 3;
|
|
342
|
+
var MAX_PHOTO_FILES = 5;
|
|
343
|
+
var DEFAULT_PHOTO_SIZE_MB = 10;
|
|
344
|
+
var MAX_PHOTO_SIZE_MB = 25;
|
|
345
|
+
var MAX_PHOTO_ID_LENGTH = 200;
|
|
346
|
+
var MAX_PHOTO_NAME_LENGTH = 255;
|
|
347
|
+
var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
|
|
348
|
+
function photoLimits(field) {
|
|
349
|
+
const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
|
|
350
|
+
const maxSizeMb = wholeIn(field.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
|
|
351
|
+
const raw = field.props?.formats;
|
|
352
|
+
const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f) => raw.includes(f)) : [];
|
|
353
|
+
const formats = picked.length ? picked : [...ALL_FORMATS];
|
|
354
|
+
return {
|
|
355
|
+
maxFiles,
|
|
356
|
+
maxSizeMb,
|
|
357
|
+
maxBytes: maxSizeMb * 1024 * 1024,
|
|
358
|
+
formats,
|
|
359
|
+
mimeTypes: formats.flatMap((f) => PHOTO_FORMATS[f])
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function isPhotoRef(v) {
|
|
363
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) return false;
|
|
364
|
+
const r = v;
|
|
365
|
+
return typeof r.id === "string" && r.id.length > 0 && r.id.length <= MAX_PHOTO_ID_LENGTH && typeof r.name === "string" && r.name.length <= MAX_PHOTO_NAME_LENGTH && typeof r.type === "string" && typeof r.size === "number" && Number.isInteger(r.size) && r.size > 0;
|
|
366
|
+
}
|
|
367
|
+
function photoRefs(value) {
|
|
368
|
+
if (!Array.isArray(value)) return [];
|
|
369
|
+
const refs = [];
|
|
370
|
+
for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
|
|
371
|
+
return refs;
|
|
372
|
+
}
|
|
373
|
+
var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
|
|
374
|
+
function photoFormatList(formats) {
|
|
375
|
+
const names = formats.map((f) => FORMAT_NAMES[f]);
|
|
376
|
+
return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
|
|
377
|
+
}
|
|
378
|
+
var photoTypeMessage = (field) => `Use a ${photoFormatList(photoLimits(field).formats)} photo.`;
|
|
379
|
+
var photoSizeMessage = (field) => `Each photo must be ${photoLimits(field).maxSizeMb} MB or smaller.`;
|
|
380
|
+
var photoCountMessage = (field) => `Add at most ${plural2(photoLimits(field).maxFiles, "photo")}.`;
|
|
381
|
+
var photoFieldType = {
|
|
382
|
+
type: PHOTO_TYPE,
|
|
383
|
+
label: "Photo",
|
|
384
|
+
category: "media",
|
|
385
|
+
value: { json: true },
|
|
386
|
+
operators: ["answered", "empty"],
|
|
387
|
+
a11y: { semantics: "group" },
|
|
388
|
+
isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
|
|
389
|
+
validate: (value, field) => {
|
|
390
|
+
if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
|
|
391
|
+
const limits = photoLimits(field);
|
|
392
|
+
if (value.length > limits.maxFiles) return photoCountMessage(field);
|
|
393
|
+
const refs = value;
|
|
394
|
+
if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
|
|
395
|
+
if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field);
|
|
396
|
+
if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field);
|
|
397
|
+
return null;
|
|
398
|
+
},
|
|
399
|
+
normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
|
|
400
|
+
summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
|
|
401
|
+
samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
|
|
402
|
+
};
|
|
403
|
+
|
|
331
404
|
// src/fields.ts
|
|
332
405
|
var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
|
|
333
406
|
var globalScope = globalThis;
|
|
@@ -363,6 +436,20 @@ function summarizeValue(value, field) {
|
|
|
363
436
|
if (Array.isArray(value)) return value.join(", ");
|
|
364
437
|
return String(value);
|
|
365
438
|
}
|
|
439
|
+
var OTHER_MAX_LENGTH = 200;
|
|
440
|
+
function otherOption(field) {
|
|
441
|
+
const other = field.props?.other;
|
|
442
|
+
if (other === true) return { label: "Other" };
|
|
443
|
+
if (other && typeof other === "object" && !Array.isArray(other)) {
|
|
444
|
+
const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
|
|
445
|
+
return { label };
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
function isOtherValue(field, value) {
|
|
450
|
+
return otherOption(field) !== null && typeof value === "string" && !choiceOptions(field).some((o) => o.value === value);
|
|
451
|
+
}
|
|
452
|
+
var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
|
|
366
453
|
function choiceOptions(field) {
|
|
367
454
|
const raw = field.props?.options;
|
|
368
455
|
if (!Array.isArray(raw)) return [];
|
|
@@ -488,7 +575,7 @@ var builtins = [
|
|
|
488
575
|
value: { json: true },
|
|
489
576
|
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
490
577
|
a11y: { semantics: "group" },
|
|
491
|
-
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
578
|
+
validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
|
|
492
579
|
summarize: (value, field) => optionLabel(field, String(value))
|
|
493
580
|
},
|
|
494
581
|
{
|
|
@@ -500,7 +587,8 @@ var builtins = [
|
|
|
500
587
|
a11y: { semantics: "group" },
|
|
501
588
|
validate: (value, field) => {
|
|
502
589
|
const allowed = new Set(choiceOptions(field).map((o) => o.value));
|
|
503
|
-
|
|
590
|
+
const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
|
|
591
|
+
return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field, others[0])) ? null : "Choose from the options.";
|
|
504
592
|
},
|
|
505
593
|
summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
|
|
506
594
|
},
|
|
@@ -521,7 +609,7 @@ var builtins = [
|
|
|
521
609
|
value: { json: true },
|
|
522
610
|
operators: ["eq", "neq", "in", "answered", "empty"],
|
|
523
611
|
a11y: { semantics: "control" },
|
|
524
|
-
validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
|
|
612
|
+
validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
|
|
525
613
|
summarize: (value, field) => optionLabel(field, String(value))
|
|
526
614
|
},
|
|
527
615
|
{
|
|
@@ -628,7 +716,7 @@ function dateLimits(field) {
|
|
|
628
716
|
if (typeof max === "string" && isIsoDate(max)) out.max = max;
|
|
629
717
|
return out;
|
|
630
718
|
}
|
|
631
|
-
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
|
|
719
|
+
for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
|
|
632
720
|
|
|
633
721
|
// src/logic.ts
|
|
634
722
|
function own(record, key) {
|