@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.
@@ -1,7 +1,7 @@
1
- import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-BkNuzlna.cjs';
2
- export { d as compactEvents } from './engine-BkNuzlna.cjs';
3
- import { J as Json, F as FormDefinition, e as FormEvent } from './types-BBKBQ8sp.cjs';
4
- import './validate-BXXqi0fq.cjs';
1
+ import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-BDq0XJ21.cjs';
2
+ export { d as compactEvents } from './engine-BDq0XJ21.cjs';
3
+ import { J as Json, F as FormDefinition, e as FormEvent } from './types-BhYUbLJ-.cjs';
4
+ import './validate-BsZ0YTQS.cjs';
5
5
 
6
6
  interface CoverageOptions {
7
7
  /** Stop after this many explored states and report `truncated`. Default 5000. */
package/dist/testing.d.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-Dz9J5ej2.js';
2
- export { d as compactEvents } from './engine-Dz9J5ej2.js';
3
- import { J as Json, F as FormDefinition, e as FormEvent } from './types-BBKBQ8sp.js';
4
- import './validate-BjNwF1UE.js';
1
+ import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-Qzp6bX6O.js';
2
+ export { d as compactEvents } from './engine-Qzp6bX6O.js';
3
+ import { J as Json, F as FormDefinition, e as FormEvent } from './types-BhYUbLJ-.js';
4
+ import './validate-Bs1lyoOl.js';
5
5
 
6
6
  interface CoverageOptions {
7
7
  /** Stop after this many explored states and report `truncated`. Default 5000. */
package/dist/testing.js CHANGED
@@ -719,6 +719,80 @@ var structuredFieldTypes = [
719
719
  }
720
720
  ];
721
721
 
722
+ // src/photo.ts
723
+ var PHOTO_TYPE = "photo";
724
+ var PHOTO_FORMATS = {
725
+ jpeg: ["image/jpeg"],
726
+ png: ["image/png"],
727
+ heic: ["image/heic", "image/heif"],
728
+ webp: ["image/webp"]
729
+ };
730
+ var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
731
+ var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
732
+ var DEFAULT_PHOTO_FILES = 3;
733
+ var MAX_PHOTO_FILES = 5;
734
+ var DEFAULT_PHOTO_SIZE_MB = 10;
735
+ var MAX_PHOTO_SIZE_MB = 25;
736
+ var MAX_PHOTO_ID_LENGTH = 200;
737
+ var MAX_PHOTO_NAME_LENGTH = 255;
738
+ var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
739
+ var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
740
+ function photoLimits(field) {
741
+ const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
742
+ const maxSizeMb = wholeIn(field.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
743
+ const raw = field.props?.formats;
744
+ const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f) => raw.includes(f)) : [];
745
+ const formats = picked.length ? picked : [...ALL_FORMATS];
746
+ return {
747
+ maxFiles,
748
+ maxSizeMb,
749
+ maxBytes: maxSizeMb * 1024 * 1024,
750
+ formats,
751
+ mimeTypes: formats.flatMap((f) => PHOTO_FORMATS[f])
752
+ };
753
+ }
754
+ function isPhotoRef(v) {
755
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
756
+ const r = v;
757
+ 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;
758
+ }
759
+ function photoRefs(value) {
760
+ if (!Array.isArray(value)) return [];
761
+ const refs = [];
762
+ for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
763
+ return refs;
764
+ }
765
+ var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
766
+ function photoFormatList(formats) {
767
+ const names = formats.map((f) => FORMAT_NAMES[f]);
768
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
769
+ }
770
+ var photoTypeMessage = (field) => `Use a ${photoFormatList(photoLimits(field).formats)} photo.`;
771
+ var photoSizeMessage = (field) => `Each photo must be ${photoLimits(field).maxSizeMb} MB or smaller.`;
772
+ var photoCountMessage = (field) => `Add at most ${plural2(photoLimits(field).maxFiles, "photo")}.`;
773
+ var photoFieldType = {
774
+ type: PHOTO_TYPE,
775
+ label: "Photo",
776
+ category: "media",
777
+ value: { json: true },
778
+ operators: ["answered", "empty"],
779
+ a11y: { semantics: "group" },
780
+ isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
781
+ validate: (value, field) => {
782
+ if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
783
+ const limits = photoLimits(field);
784
+ if (value.length > limits.maxFiles) return photoCountMessage(field);
785
+ const refs = value;
786
+ if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
787
+ if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field);
788
+ if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field);
789
+ return null;
790
+ },
791
+ normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
792
+ summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
793
+ samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
794
+ };
795
+
722
796
  // src/fields.ts
723
797
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
724
798
  var globalScope = globalThis;
@@ -755,6 +829,20 @@ function summarizeValue(value, field) {
755
829
  if (Array.isArray(value)) return value.join(", ");
756
830
  return String(value);
757
831
  }
832
+ var OTHER_MAX_LENGTH = 200;
833
+ function otherOption(field) {
834
+ const other = field.props?.other;
835
+ if (other === true) return { label: "Other" };
836
+ if (other && typeof other === "object" && !Array.isArray(other)) {
837
+ const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
838
+ return { label };
839
+ }
840
+ return null;
841
+ }
842
+ function isOtherValue(field, value) {
843
+ return otherOption(field) !== null && typeof value === "string" && !choiceOptions(field).some((o) => o.value === value);
844
+ }
845
+ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
758
846
  function choiceOptions(field) {
759
847
  const raw = field.props?.options;
760
848
  if (!Array.isArray(raw)) return [];
@@ -762,6 +850,42 @@ function choiceOptions(field) {
762
850
  (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
763
851
  );
764
852
  }
853
+ function optionsSource(field) {
854
+ const raw = field.props?.optionsFrom;
855
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
856
+ return raw.source === "host" && typeof raw.key === "string" && raw.key ? { source: "host", key: raw.key } : void 0;
857
+ }
858
+ function hostList(hostOptions, key) {
859
+ if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
860
+ const list = hostOptions[key];
861
+ if (!Array.isArray(list)) return [];
862
+ const seen = /* @__PURE__ */ new Set();
863
+ const out = [];
864
+ for (const o of list) {
865
+ if (!o || typeof o !== "object" || Array.isArray(o)) continue;
866
+ const { value, label } = o;
867
+ if (typeof value !== "string" || !value || seen.has(value)) continue;
868
+ seen.add(value);
869
+ out.push(typeof label === "string" ? { value, label } : { value });
870
+ }
871
+ return out;
872
+ }
873
+ function withHostOptions(definition, hostOptions) {
874
+ let changed = false;
875
+ const blocks = definition.blocks.map((block) => {
876
+ if (block.kind !== "field") return block;
877
+ const from = optionsSource(block);
878
+ if (!from) return block;
879
+ const own2 = choiceOptions(block);
880
+ const taken = new Set(own2.map((o) => o.value));
881
+ const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
882
+ if (!added.length) return block;
883
+ changed = true;
884
+ const options = [...own2, ...added].map((o) => o.label === void 0 ? { value: o.value } : { value: o.value, label: o.label });
885
+ return { ...block, props: { ...block.props, options } };
886
+ });
887
+ return changed ? { ...definition, blocks } : definition;
888
+ }
765
889
  function optionLabel(field, value) {
766
890
  const option = choiceOptions(field).find((o) => o.value === value);
767
891
  return option?.label ?? value;
@@ -880,7 +1004,7 @@ var builtins = [
880
1004
  value: { json: true },
881
1005
  operators: ["eq", "neq", "in", "answered", "empty"],
882
1006
  a11y: { semantics: "group" },
883
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1007
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
884
1008
  summarize: (value, field) => optionLabel(field, String(value))
885
1009
  },
886
1010
  {
@@ -892,7 +1016,8 @@ var builtins = [
892
1016
  a11y: { semantics: "group" },
893
1017
  validate: (value, field) => {
894
1018
  const allowed = new Set(choiceOptions(field).map((o) => o.value));
895
- 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.";
1019
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1020
+ 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.";
896
1021
  },
897
1022
  summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
898
1023
  },
@@ -913,7 +1038,7 @@ var builtins = [
913
1038
  value: { json: true },
914
1039
  operators: ["eq", "neq", "in", "answered", "empty"],
915
1040
  a11y: { semantics: "control" },
916
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1041
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
917
1042
  summarize: (value, field) => optionLabel(field, String(value))
918
1043
  },
919
1044
  {
@@ -1020,7 +1145,7 @@ function dateLimits(field) {
1020
1145
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1021
1146
  return out;
1022
1147
  }
1023
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
1148
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1024
1149
 
1025
1150
  // src/events.ts
1026
1151
  function isPlainObject(value) {
@@ -1289,7 +1414,8 @@ function readsAny(node, ids) {
1289
1414
  function createForm(definition, options = {}) {
1290
1415
  return build(definition, options).engine;
1291
1416
  }
1292
- function build(definition, options, replaying = false, budget) {
1417
+ function build(given, options, replaying = false, budget) {
1418
+ const definition = withHostOptions(given, options.hostOptions);
1293
1419
  const fields = fieldMap(definition);
1294
1420
  const byKey = new Map([...fields.values()].map((f) => [f.key, f]));
1295
1421
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -1335,6 +1461,7 @@ function build(definition, options, replaying = false, budget) {
1335
1461
  path: first.path,
1336
1462
  current: first.path[0] ?? null,
1337
1463
  history: [],
1464
+ furthest: first.path[0] ?? null,
1338
1465
  errors: {},
1339
1466
  status: "in_progress",
1340
1467
  ending: null,
@@ -1435,7 +1562,8 @@ function build(definition, options, replaying = false, budget) {
1435
1562
  emit({ ...state, events });
1436
1563
  return true;
1437
1564
  }
1438
- emit({ ...state, current: target, history: [...state.history, state.current], events });
1565
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
1566
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
1439
1567
  return true;
1440
1568
  },
1441
1569
  back() {
@@ -1480,6 +1608,15 @@ function build(definition, options, replaying = false, budget) {
1480
1608
  settleNow();
1481
1609
  batch = wasBatch;
1482
1610
  }
1611
+ const resumeAt = options.resumeAt;
1612
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
1613
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
1614
+ if (!engine.next()) {
1615
+ state = { ...state, errors: {} };
1616
+ break;
1617
+ }
1618
+ }
1619
+ }
1483
1620
  const apply = (event) => {
1484
1621
  if (event.type === "answer") {
1485
1622
  if (fields.has(event.field)) {
@@ -1849,6 +1986,22 @@ function parseForm(input) {
1849
1986
  }
1850
1987
  }
1851
1988
  if (type === "consent") optionalText(props.text, `${path}.text`);
1989
+ if (type === PHOTO_TYPE) {
1990
+ if (props.maxFiles !== void 0 && !(isInt(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
1991
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
1992
+ }
1993
+ if (props.maxSizeMb !== void 0 && !(isInt(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
1994
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
1995
+ }
1996
+ if (props.formats !== void 0) {
1997
+ const formats = props.formats;
1998
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
1999
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
2000
+ } else if (new Set(formats).size !== formats.length) {
2001
+ issue(`${path}.formats`, "Name each format once.");
2002
+ }
2003
+ }
2004
+ }
1852
2005
  };
1853
2006
  if (!isObject2(input)) {
1854
2007
  issue("", "A form definition must be an object.");
@@ -1869,6 +2022,7 @@ function parseForm(input) {
1869
2022
  else {
1870
2023
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1871
2024
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
2025
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1872
2026
  }
1873
2027
  }
1874
2028
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1909,6 +2063,14 @@ function parseForm(input) {
1909
2063
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1910
2064
  }
1911
2065
  if (block.props !== void 0) {
2066
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
2067
+ if (from !== void 0) {
2068
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
2069
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
2070
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2071
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
2072
+ }
2073
+ }
1912
2074
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1913
2075
  else if (block.props.options !== void 0) {
1914
2076
  const options = block.props.options;
@@ -1919,10 +2081,20 @@ function parseForm(input) {
1919
2081
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1920
2082
  else optionalText(o.label, `${at}.label`);
1921
2083
  });
1922
- choices.set(
1923
- block.id,
1924
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1925
- );
2084
+ if (from === void 0) {
2085
+ choices.set(
2086
+ block.id,
2087
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
2088
+ );
2089
+ }
2090
+ }
2091
+ }
2092
+ const other = isObject2(block.props) ? block.props.other : void 0;
2093
+ if (other !== void 0) {
2094
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
2095
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
2096
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2097
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1926
2098
  }
1927
2099
  }
1928
2100
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1997,6 +2169,8 @@ function parseForm(input) {
1997
2169
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1998
2170
  else if (targetType === "media-notes" || targetType === "text-highlights") {
1999
2171
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
2172
+ } else if (targetType === PHOTO_TYPE) {
2173
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
2000
2174
  }
2001
2175
  }
2002
2176
  });
@@ -2261,9 +2435,10 @@ function wellFormed(input) {
2261
2435
  }
2262
2436
  return { answers, events: known, hidden };
2263
2437
  }
2264
- function validateSubmission(definition, submission, options = {}) {
2265
- const invalid = checkDefinition(definition);
2438
+ function validateSubmission(published, submission, options = {}) {
2439
+ const invalid = checkDefinition(published);
2266
2440
  if (invalid) return { ok: false, errors: { "": invalid } };
2441
+ const definition = withHostOptions(published, options.hostOptions);
2267
2442
  if (!isPlainObject(submission)) return MALFORMED;
2268
2443
  const maxEvents = options.maxEvents ?? MAX_EVENTS;
2269
2444
  let sent = submission;