@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/dist/server.cjs CHANGED
@@ -721,6 +721,80 @@ var structuredFieldTypes = [
721
721
  }
722
722
  ];
723
723
 
724
+ // src/photo.ts
725
+ var PHOTO_TYPE = "photo";
726
+ var PHOTO_FORMATS = {
727
+ jpeg: ["image/jpeg"],
728
+ png: ["image/png"],
729
+ heic: ["image/heic", "image/heif"],
730
+ webp: ["image/webp"]
731
+ };
732
+ var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
733
+ var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
734
+ var DEFAULT_PHOTO_FILES = 3;
735
+ var MAX_PHOTO_FILES = 5;
736
+ var DEFAULT_PHOTO_SIZE_MB = 10;
737
+ var MAX_PHOTO_SIZE_MB = 25;
738
+ var MAX_PHOTO_ID_LENGTH = 200;
739
+ var MAX_PHOTO_NAME_LENGTH = 255;
740
+ var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
741
+ var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
742
+ function photoLimits(field) {
743
+ const maxFiles = wholeIn(field.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field.props.maxFiles : DEFAULT_PHOTO_FILES;
744
+ const maxSizeMb = wholeIn(field.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
745
+ const raw = field.props?.formats;
746
+ const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f) => raw.includes(f)) : [];
747
+ const formats = picked.length ? picked : [...ALL_FORMATS];
748
+ return {
749
+ maxFiles,
750
+ maxSizeMb,
751
+ maxBytes: maxSizeMb * 1024 * 1024,
752
+ formats,
753
+ mimeTypes: formats.flatMap((f) => PHOTO_FORMATS[f])
754
+ };
755
+ }
756
+ function isPhotoRef(v) {
757
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
758
+ const r = v;
759
+ 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;
760
+ }
761
+ function photoRefs(value) {
762
+ if (!Array.isArray(value)) return [];
763
+ const refs = [];
764
+ for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
765
+ return refs;
766
+ }
767
+ var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
768
+ function photoFormatList(formats) {
769
+ const names = formats.map((f) => FORMAT_NAMES[f]);
770
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
771
+ }
772
+ var photoTypeMessage = (field) => `Use a ${photoFormatList(photoLimits(field).formats)} photo.`;
773
+ var photoSizeMessage = (field) => `Each photo must be ${photoLimits(field).maxSizeMb} MB or smaller.`;
774
+ var photoCountMessage = (field) => `Add at most ${plural2(photoLimits(field).maxFiles, "photo")}.`;
775
+ var photoFieldType = {
776
+ type: PHOTO_TYPE,
777
+ label: "Photo",
778
+ category: "media",
779
+ value: { json: true },
780
+ operators: ["answered", "empty"],
781
+ a11y: { semantics: "group" },
782
+ isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
783
+ validate: (value, field) => {
784
+ if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
785
+ const limits = photoLimits(field);
786
+ if (value.length > limits.maxFiles) return photoCountMessage(field);
787
+ const refs = value;
788
+ if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
789
+ if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field);
790
+ if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field);
791
+ return null;
792
+ },
793
+ normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
794
+ summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
795
+ samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
796
+ };
797
+
724
798
  // src/fields.ts
725
799
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
726
800
  var globalScope = globalThis;
@@ -757,6 +831,20 @@ function summarizeValue(value, field) {
757
831
  if (Array.isArray(value)) return value.join(", ");
758
832
  return String(value);
759
833
  }
834
+ var OTHER_MAX_LENGTH = 200;
835
+ function otherOption(field) {
836
+ const other = field.props?.other;
837
+ if (other === true) return { label: "Other" };
838
+ if (other && typeof other === "object" && !Array.isArray(other)) {
839
+ const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
840
+ return { label };
841
+ }
842
+ return null;
843
+ }
844
+ function isOtherValue(field, value) {
845
+ return otherOption(field) !== null && typeof value === "string" && !choiceOptions(field).some((o) => o.value === value);
846
+ }
847
+ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
760
848
  function choiceOptions(field) {
761
849
  const raw = field.props?.options;
762
850
  if (!Array.isArray(raw)) return [];
@@ -764,6 +852,42 @@ function choiceOptions(field) {
764
852
  (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
765
853
  );
766
854
  }
855
+ function optionsSource(field) {
856
+ const raw = field.props?.optionsFrom;
857
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
858
+ return raw.source === "host" && typeof raw.key === "string" && raw.key ? { source: "host", key: raw.key } : void 0;
859
+ }
860
+ function hostList(hostOptions, key) {
861
+ if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
862
+ const list = hostOptions[key];
863
+ if (!Array.isArray(list)) return [];
864
+ const seen = /* @__PURE__ */ new Set();
865
+ const out = [];
866
+ for (const o of list) {
867
+ if (!o || typeof o !== "object" || Array.isArray(o)) continue;
868
+ const { value, label } = o;
869
+ if (typeof value !== "string" || !value || seen.has(value)) continue;
870
+ seen.add(value);
871
+ out.push(typeof label === "string" ? { value, label } : { value });
872
+ }
873
+ return out;
874
+ }
875
+ function withHostOptions(definition, hostOptions) {
876
+ let changed = false;
877
+ const blocks = definition.blocks.map((block) => {
878
+ if (block.kind !== "field") return block;
879
+ const from = optionsSource(block);
880
+ if (!from) return block;
881
+ const own2 = choiceOptions(block);
882
+ const taken = new Set(own2.map((o) => o.value));
883
+ const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
884
+ if (!added.length) return block;
885
+ changed = true;
886
+ const options = [...own2, ...added].map((o) => o.label === void 0 ? { value: o.value } : { value: o.value, label: o.label });
887
+ return { ...block, props: { ...block.props, options } };
888
+ });
889
+ return changed ? { ...definition, blocks } : definition;
890
+ }
767
891
  function optionLabel(field, value) {
768
892
  const option = choiceOptions(field).find((o) => o.value === value);
769
893
  return option?.label ?? value;
@@ -882,7 +1006,7 @@ var builtins = [
882
1006
  value: { json: true },
883
1007
  operators: ["eq", "neq", "in", "answered", "empty"],
884
1008
  a11y: { semantics: "group" },
885
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1009
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
886
1010
  summarize: (value, field) => optionLabel(field, String(value))
887
1011
  },
888
1012
  {
@@ -894,7 +1018,8 @@ var builtins = [
894
1018
  a11y: { semantics: "group" },
895
1019
  validate: (value, field) => {
896
1020
  const allowed = new Set(choiceOptions(field).map((o) => o.value));
897
- 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.";
1021
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1022
+ 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.";
898
1023
  },
899
1024
  summarize: (value, field) => Array.isArray(value) ? value.map((v) => optionLabel(field, String(v))).join(", ") : String(value)
900
1025
  },
@@ -915,7 +1040,7 @@ var builtins = [
915
1040
  value: { json: true },
916
1041
  operators: ["eq", "neq", "in", "answered", "empty"],
917
1042
  a11y: { semantics: "control" },
918
- validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
1043
+ validate: (value, field) => typeof value === "string" && (choiceOptions(field).some((o) => o.value === value) || typedOther(field, value)) ? null : "Choose one of the options.",
919
1044
  summarize: (value, field) => optionLabel(field, String(value))
920
1045
  },
921
1046
  {
@@ -1022,7 +1147,7 @@ function dateLimits(field) {
1022
1147
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1023
1148
  return out;
1024
1149
  }
1025
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
1150
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1026
1151
 
1027
1152
  // src/events.ts
1028
1153
  function isPlainObject(value) {
@@ -1291,7 +1416,8 @@ function readsAny(node, ids) {
1291
1416
  function createForm(definition, options = {}) {
1292
1417
  return build(definition, options).engine;
1293
1418
  }
1294
- function build(definition, options, replaying = false, budget) {
1419
+ function build(given, options, replaying = false, budget) {
1420
+ const definition = withHostOptions(given, options.hostOptions);
1295
1421
  const fields = fieldMap(definition);
1296
1422
  const byKey = new Map([...fields.values()].map((f) => [f.key, f]));
1297
1423
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -1337,6 +1463,7 @@ function build(definition, options, replaying = false, budget) {
1337
1463
  path: first.path,
1338
1464
  current: first.path[0] ?? null,
1339
1465
  history: [],
1466
+ furthest: first.path[0] ?? null,
1340
1467
  errors: {},
1341
1468
  status: "in_progress",
1342
1469
  ending: null,
@@ -1437,7 +1564,8 @@ function build(definition, options, replaying = false, budget) {
1437
1564
  emit({ ...state, events });
1438
1565
  return true;
1439
1566
  }
1440
- emit({ ...state, current: target, history: [...state.history, state.current], events });
1567
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
1568
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
1441
1569
  return true;
1442
1570
  },
1443
1571
  back() {
@@ -1482,6 +1610,15 @@ function build(definition, options, replaying = false, budget) {
1482
1610
  settleNow();
1483
1611
  batch = wasBatch;
1484
1612
  }
1613
+ const resumeAt = options.resumeAt;
1614
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
1615
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
1616
+ if (!engine.next()) {
1617
+ state = { ...state, errors: {} };
1618
+ break;
1619
+ }
1620
+ }
1621
+ }
1485
1622
  const apply = (event) => {
1486
1623
  if (event.type === "answer") {
1487
1624
  if (fields.has(event.field)) {
@@ -1669,6 +1806,22 @@ function parseForm(input) {
1669
1806
  }
1670
1807
  }
1671
1808
  if (type === "consent") optionalText(props.text, `${path}.text`);
1809
+ if (type === PHOTO_TYPE) {
1810
+ if (props.maxFiles !== void 0 && !(isInt(props.maxFiles) && props.maxFiles >= 1 && props.maxFiles <= MAX_PHOTO_FILES)) {
1811
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
1812
+ }
1813
+ if (props.maxSizeMb !== void 0 && !(isInt(props.maxSizeMb) && props.maxSizeMb >= 1 && props.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
1814
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
1815
+ }
1816
+ if (props.formats !== void 0) {
1817
+ const formats = props.formats;
1818
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
1819
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
1820
+ } else if (new Set(formats).size !== formats.length) {
1821
+ issue(`${path}.formats`, "Name each format once.");
1822
+ }
1823
+ }
1824
+ }
1672
1825
  };
1673
1826
  if (!isObject2(input)) {
1674
1827
  issue("", "A form definition must be an object.");
@@ -1689,6 +1842,7 @@ function parseForm(input) {
1689
1842
  else {
1690
1843
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1691
1844
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
1845
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1692
1846
  }
1693
1847
  }
1694
1848
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1729,6 +1883,14 @@ function parseForm(input) {
1729
1883
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1730
1884
  }
1731
1885
  if (block.props !== void 0) {
1886
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
1887
+ if (from !== void 0) {
1888
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
1889
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
1890
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1891
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
1892
+ }
1893
+ }
1732
1894
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1733
1895
  else if (block.props.options !== void 0) {
1734
1896
  const options = block.props.options;
@@ -1739,10 +1901,20 @@ function parseForm(input) {
1739
1901
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1740
1902
  else optionalText(o.label, `${at}.label`);
1741
1903
  });
1742
- choices.set(
1743
- block.id,
1744
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1745
- );
1904
+ if (from === void 0) {
1905
+ choices.set(
1906
+ block.id,
1907
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1908
+ );
1909
+ }
1910
+ }
1911
+ }
1912
+ const other = isObject2(block.props) ? block.props.other : void 0;
1913
+ if (other !== void 0) {
1914
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
1915
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
1916
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1917
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1746
1918
  }
1747
1919
  }
1748
1920
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1817,6 +1989,8 @@ function parseForm(input) {
1817
1989
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1818
1990
  else if (targetType === "media-notes" || targetType === "text-highlights") {
1819
1991
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
1992
+ } else if (targetType === PHOTO_TYPE) {
1993
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
1820
1994
  }
1821
1995
  }
1822
1996
  });
@@ -2081,9 +2255,10 @@ function wellFormed(input) {
2081
2255
  }
2082
2256
  return { answers, events: known, hidden };
2083
2257
  }
2084
- function validateSubmission(definition, submission, options = {}) {
2085
- const invalid = checkDefinition(definition);
2258
+ function validateSubmission(published, submission, options = {}) {
2259
+ const invalid = checkDefinition(published);
2086
2260
  if (invalid) return { ok: false, errors: { "": invalid } };
2261
+ const definition = withHostOptions(published, options.hostOptions);
2087
2262
  if (!isPlainObject(submission)) return MALFORMED;
2088
2263
  const maxEvents = options.maxEvents ?? MAX_EVENTS;
2089
2264
  let sent = submission;