@marktiderman/genesis-forms 0.4.2 → 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 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
@@ -210,6 +239,44 @@ as `The formula uses "wage", which is not the answer name of a question in this
210
239
  own; `formatCalculated` shows a value as the field does. Builders offer the type only when the host
211
240
  names it (`COMPUTED_FIELD_TYPES`).
212
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
+
213
280
  ### Props for the host
214
281
 
215
282
  Props a field type doesn't use are kept and ignored: the engine, `parseForm` and
@@ -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
- return Array.isArray(value) && value.length <= allowed.size && new Set(value).size === value.length && value.every((v) => typeof v === "string" && allowed.has(v)) ? null : "Choose from the options.";
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) {