@marktiderman/genesis-forms 0.5.0 → 0.7.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-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';
1
+ import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-C5tb3mkf.cjs';
2
+ export { d as compactEvents } from './engine-C5tb3mkf.cjs';
3
+ import { J as Json, F as FormDefinition, e as FormEvent } from './types-oAckGR_Z.cjs';
4
+ import './validate-BFpNJbjf.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-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';
1
+ import { a as FormState, F as FormEngine, C as CreateFormOptions } from './engine-DcNQ-6oJ.js';
2
+ export { d as compactEvents } from './engine-DcNQ-6oJ.js';
3
+ import { J as Json, F as FormDefinition, e as FormEvent } from './types-oAckGR_Z.js';
4
+ import './validate-qwkdcr3H.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
@@ -829,6 +829,65 @@ function summarizeValue(value, field) {
829
829
  if (Array.isArray(value)) return value.join(", ");
830
830
  return String(value);
831
831
  }
832
+ var OPTION_LIMITS = {
833
+ /** Characters in a description. */
834
+ description: 500,
835
+ /** Characters in an image URL. */
836
+ image: 2048,
837
+ /** Attributes on one option. */
838
+ attributes: 20,
839
+ /** Characters in an attribute's name. */
840
+ attributeName: 64,
841
+ /** Characters in one attribute value. */
842
+ attributeValue: 200,
843
+ /** Values in one attribute's list. */
844
+ attributeItems: 50
845
+ };
846
+ function isHttpsUrl(url) {
847
+ return typeof url === "string" && url.length <= OPTION_LIMITS.image && /^https:\/\/[^\s/?#@\\]+(?:[/?#][^\s]*)?$/i.test(url) && // No control characters anywhere (a line break would end a CSV row or an HTML attribute).
848
+ ![...url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
849
+ }
850
+ function attributeName(name) {
851
+ return name.trim() !== "" && name.length <= OPTION_LIMITS.attributeName && !(name in Object.prototype);
852
+ }
853
+ function readAttributes(raw) {
854
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
855
+ const out = {};
856
+ let count = 0;
857
+ for (const name of Object.keys(raw)) {
858
+ if (count >= OPTION_LIMITS.attributes) break;
859
+ if (!attributeName(name)) continue;
860
+ const v = raw[name];
861
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
862
+ if (fits(v)) out[name] = v;
863
+ else if (Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits)) out[name] = [...v];
864
+ else continue;
865
+ count++;
866
+ }
867
+ return count ? out : void 0;
868
+ }
869
+ function readOption(raw) {
870
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
871
+ const o = raw;
872
+ if (typeof o.value !== "string") return null;
873
+ const out = { value: o.value };
874
+ if (typeof o.label === "string") out.label = o.label;
875
+ if (typeof o.description === "string" && o.description.trim() && o.description.length <= OPTION_LIMITS.description) {
876
+ out.description = o.description;
877
+ }
878
+ if (isHttpsUrl(o.image)) out.image = o.image;
879
+ const attributes = readAttributes(o.attributes);
880
+ if (attributes) out.attributes = attributes;
881
+ return out;
882
+ }
883
+ function optionJson(option) {
884
+ const out = { value: option.value };
885
+ if (option.label !== void 0) out.label = option.label;
886
+ if (option.description !== void 0) out.description = option.description;
887
+ if (option.image !== void 0) out.image = option.image;
888
+ if (option.attributes !== void 0) out.attributes = { ...option.attributes };
889
+ return out;
890
+ }
832
891
  var OTHER_MAX_LENGTH = 200;
833
892
  function otherOption(field) {
834
893
  const other = field.props?.other;
@@ -846,14 +905,21 @@ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !=
846
905
  function choiceOptions(field) {
847
906
  const raw = field.props?.options;
848
907
  if (!Array.isArray(raw)) return [];
849
- return raw.flatMap(
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 }] : []
851
- );
908
+ return raw.flatMap((o) => {
909
+ const option = readOption(o);
910
+ return option ? [option] : [];
911
+ });
852
912
  }
853
913
  function optionsSource(field) {
854
914
  const raw = field.props?.optionsFrom;
855
915
  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;
916
+ if (raw.source !== "host" || typeof raw.key !== "string" || !raw.key) return void 0;
917
+ const out = { source: "host", key: raw.key };
918
+ const filter = raw.filter;
919
+ if (filter && typeof filter === "object" && !Array.isArray(filter) && typeof filter.attribute === "string" && filter.attribute && typeof filter.field === "string" && filter.field) {
920
+ out.filter = { attribute: filter.attribute, field: filter.field };
921
+ }
922
+ return out;
857
923
  }
858
924
  function hostList(hostOptions, key) {
859
925
  if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
@@ -862,11 +928,10 @@ function hostList(hostOptions, key) {
862
928
  const seen = /* @__PURE__ */ new Set();
863
929
  const out = [];
864
930
  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 });
931
+ const option = readOption(o);
932
+ if (!option || !option.value || seen.has(option.value)) continue;
933
+ seen.add(option.value);
934
+ out.push(option);
870
935
  }
871
936
  return out;
872
937
  }
@@ -881,7 +946,7 @@ function withHostOptions(definition, hostOptions) {
881
946
  const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
882
947
  if (!added.length) return block;
883
948
  changed = true;
884
- const options = [...own2, ...added].map((o) => o.label === void 0 ? { value: o.value } : { value: o.value, label: o.label });
949
+ const options = [...own2, ...added].map(optionJson);
885
950
  return { ...block, props: { ...block.props, options } };
886
951
  });
887
952
  return changed ? { ...definition, blocks } : definition;
@@ -1361,6 +1426,150 @@ function fieldMap(def) {
1361
1426
  return map;
1362
1427
  }
1363
1428
 
1429
+ // src/options.ts
1430
+ var CHOICE_FIELD_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
1431
+ var isChoice = (block) => !!block && block.kind === "field" && CHOICE_FIELD_TYPES.has(block.type);
1432
+ function put(record, key, value) {
1433
+ Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
1434
+ }
1435
+ function optionFilter(field) {
1436
+ return optionsSource(field)?.filter;
1437
+ }
1438
+ function answerValues(answer) {
1439
+ const one = (v) => typeof v === "string" ? [v] : typeof v === "number" || typeof v === "boolean" ? [String(v)] : [];
1440
+ if (answer === void 0 || answer === null) return [];
1441
+ return Array.isArray(answer) ? answer.flatMap(one) : one(answer);
1442
+ }
1443
+ function optionMatches(option, attribute, answer) {
1444
+ const have = option.attributes && Object.prototype.hasOwnProperty.call(option.attributes, attribute) ? option.attributes[attribute] : void 0;
1445
+ if (have === void 0) return false;
1446
+ const wanted = answerValues(answer);
1447
+ return (Array.isArray(have) ? have : [have]).some((v) => wanted.includes(v));
1448
+ }
1449
+ function filterSource(definition, key) {
1450
+ return definition.blocks.find((b) => b.kind === "field" && b.key === key);
1451
+ }
1452
+ function visibleOptions(definition, field, values) {
1453
+ const options = choiceOptions(field);
1454
+ const filter = optionFilter(field);
1455
+ if (!filter) return options;
1456
+ const source = filterSource(definition, filter.field);
1457
+ if (!source || source.id === field.id) return options;
1458
+ const answer = own(values, source.id);
1459
+ if (isEmptyValue(answer, source)) return options;
1460
+ return options.filter((o) => optionMatches(o, filter.attribute, answer));
1461
+ }
1462
+ function answersOnPath(state) {
1463
+ const out = /* @__PURE__ */ Object.create(null);
1464
+ for (const id of state.path) out[id] = own(state.values, id);
1465
+ return out;
1466
+ }
1467
+ var OUTSIDE_FILTER = "Choose one of the options.";
1468
+ function filteredChoiceError(definition, field, value, values) {
1469
+ if (!optionFilter(field) || isEmptyValue(value, field)) return null;
1470
+ const all = new Set(choiceOptions(field).map((o) => o.value));
1471
+ const shown2 = new Set(visibleOptions(definition, field, values).map((o) => o.value));
1472
+ const picked = answerValues(value);
1473
+ return picked.some((v) => all.has(v) && !shown2.has(v)) ? OUTSIDE_FILTER : null;
1474
+ }
1475
+ function hasOptionFilters(definition) {
1476
+ return definition.blocks.some((b) => isChoice(b) && optionFilter(b) !== void 0);
1477
+ }
1478
+ function clearFilteredAnswers(definition, values, changedId) {
1479
+ const changed = definition.blocks.find((b) => b.kind === "field" && b.id === changedId);
1480
+ if (!changed) return values;
1481
+ const keys = /* @__PURE__ */ new Set([changed.key]);
1482
+ let out = values;
1483
+ for (const block of definition.blocks) {
1484
+ if (!isChoice(block)) continue;
1485
+ const filter = optionFilter(block);
1486
+ if (!filter || !keys.has(filter.field)) continue;
1487
+ const value = own(out, block.id);
1488
+ if (value === void 0 || isEmptyValue(value, block)) continue;
1489
+ const all = new Set(choiceOptions(block).map((o) => o.value));
1490
+ const shown2 = new Set(visibleOptions(definition, block, out).map((o) => o.value));
1491
+ const keep = (v) => typeof v !== "string" || !all.has(v) || shown2.has(v);
1492
+ let next = value;
1493
+ if (Array.isArray(value)) {
1494
+ const kept = value.filter(keep);
1495
+ if (kept.length !== value.length) next = kept.length ? kept : void 0;
1496
+ } else if (!keep(value)) next = void 0;
1497
+ if (next === value) continue;
1498
+ if (out === values) out = { ...values };
1499
+ put(out, block.id, next);
1500
+ keys.add(block.key);
1501
+ }
1502
+ return out;
1503
+ }
1504
+ var MAX_PREFILL_PARAM_LENGTH = 64;
1505
+ function isPrefillParam(param) {
1506
+ return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
1507
+ }
1508
+ function prefillSetting(field) {
1509
+ if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
1510
+ const raw = field.props?.prefill;
1511
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
1512
+ return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
1513
+ }
1514
+ function paramValues(params, name) {
1515
+ let raw;
1516
+ if (typeof params.getAll === "function") {
1517
+ raw = params.getAll(name);
1518
+ } else if (Object.prototype.hasOwnProperty.call(params, name)) {
1519
+ raw = params[name];
1520
+ }
1521
+ const list = Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw];
1522
+ return list.filter((v) => typeof v === "string" && v !== "" && v.length <= MAX_HIDDEN_STRING_LENGTH);
1523
+ }
1524
+ function prefillFromLink(definition, params, values = {}) {
1525
+ const out = {};
1526
+ if (!params || typeof params !== "object") return out;
1527
+ const seen = {};
1528
+ for (const id of Object.keys(values)) put(seen, id, values[id]);
1529
+ for (const block of definition.blocks) {
1530
+ if (!isChoice(block)) continue;
1531
+ const setting = prefillSetting(block);
1532
+ if (!setting || own(seen, block.id) !== void 0) continue;
1533
+ const raw = paramValues(params, setting.param);
1534
+ if (!raw.length) continue;
1535
+ const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
1536
+ let value;
1537
+ if (block.type === "multi-choice") {
1538
+ const picked = [];
1539
+ for (const r of raw) {
1540
+ for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
1541
+ if (shown2.has(v) && !picked.includes(v)) picked.push(v);
1542
+ }
1543
+ }
1544
+ if (picked.length) value = picked;
1545
+ } else value = raw.find((v) => shown2.has(v));
1546
+ if (value === void 0) continue;
1547
+ put(out, block.id, value);
1548
+ put(seen, block.id, value);
1549
+ }
1550
+ return out;
1551
+ }
1552
+ function hiddenByPrefill(definition, state) {
1553
+ const hideable = /* @__PURE__ */ new Set();
1554
+ for (const b of definition.blocks) {
1555
+ if (isChoice(b) && prefillSetting(b)?.hideWhenPrefilled) hideable.add(b.id);
1556
+ }
1557
+ if (!hideable.size) return [];
1558
+ const prefilled = /* @__PURE__ */ new Set();
1559
+ for (const e of state.events) {
1560
+ if (e.type !== "answer" || !hideable.has(e.field)) continue;
1561
+ if (e.source === "prefill") prefilled.add(e.field);
1562
+ else prefilled.delete(e.field);
1563
+ }
1564
+ if (!prefilled.size) return [];
1565
+ const fields = new Map(definition.blocks.map((b) => [b.id, b]));
1566
+ return state.path.filter((id) => {
1567
+ if (!prefilled.has(id) || own(state.errors, id) !== void 0) return false;
1568
+ const field = fields.get(id);
1569
+ return !isEmptyValue(own(state.values, id), field);
1570
+ });
1571
+ }
1572
+
1364
1573
  // src/recall.ts
1365
1574
  var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
1366
1575
  function recall(text, scope) {
@@ -1482,16 +1691,34 @@ function build(given, options, replaying = false, budget) {
1482
1691
  return at === void 0 ? event : { ...event, at };
1483
1692
  };
1484
1693
  const isVisibleIn = (s, id) => s.path.includes(id);
1694
+ const filters = hasOptionFilters(definition);
1695
+ const clearFiltered = (values, id) => filters ? clearFilteredAnswers(definition, values, id) : values;
1485
1696
  const errorsFor = (s, ids) => {
1486
1697
  const errors = {};
1698
+ let shown2;
1487
1699
  for (const id of ids) {
1488
1700
  const field = fields.get(id);
1489
1701
  if (!field || !isVisibleIn(s, id)) continue;
1490
- const message = validateField(field, own(s.values, id), { custom: options.validators?.[id] });
1702
+ const value = own(s.values, id);
1703
+ let message = validateField(field, value, { custom: options.validators?.[id] });
1704
+ if (!message && filters) message = filteredChoiceError(definition, field, value, shown2 ??= answersOnPath(s));
1491
1705
  if (message) errors[id] = message;
1492
1706
  }
1493
1707
  return errors;
1494
1708
  };
1709
+ const skipped = (s) => new Set(hiddenByPrefill(definition, s));
1710
+ const shownFrom = (s, from, skip) => {
1711
+ for (let i = from; i < s.path.length; i++) if (!skip.has(s.path[i])) return s.path[i];
1712
+ return void 0;
1713
+ };
1714
+ const showCurrent = () => {
1715
+ if (!state.current) return;
1716
+ const skip = skipped(state);
1717
+ if (!skip.has(state.current)) return;
1718
+ const at = state.path.indexOf(state.current);
1719
+ const shown2 = at < 0 ? void 0 : shownFrom(state, at, skip);
1720
+ if (shown2) state = { ...state, current: shown2, furthest: state.furthest === state.current ? shown2 : state.furthest };
1721
+ };
1495
1722
  let unsettled = false;
1496
1723
  let formulasStale = false;
1497
1724
  const settleNow = (needValues = true) => {
@@ -1520,13 +1747,13 @@ function build(given, options, replaying = false, budget) {
1520
1747
  const errors = { ...state.errors };
1521
1748
  delete errors[id];
1522
1749
  if (batch) {
1523
- state.values = { ...state.values, [id]: value };
1750
+ state.values = clearFiltered({ ...state.values, [id]: value }, id);
1524
1751
  state.errors = errors;
1525
1752
  log(event);
1526
1753
  unsettled = true;
1527
1754
  return;
1528
1755
  }
1529
- const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
1756
+ const { values, path } = settle(clearFiltered({ ...state.values, [id]: value }, id), state.hidden);
1530
1757
  emit({ ...state, values, path, errors, events: log(event) });
1531
1758
  };
1532
1759
  const engine = {
@@ -1557,7 +1784,7 @@ function build(given, options, replaying = false, budget) {
1557
1784
  return false;
1558
1785
  }
1559
1786
  const at = state.path.indexOf(state.current);
1560
- const target = state.path[at + 1];
1787
+ const target = shownFrom(state, at + 1, skipped(state));
1561
1788
  if (!target) {
1562
1789
  emit({ ...state, events });
1563
1790
  return true;
@@ -1569,8 +1796,9 @@ function build(given, options, replaying = false, budget) {
1569
1796
  back() {
1570
1797
  if (state.status === "completed") return;
1571
1798
  const history = [...state.history];
1799
+ const skip = skipped(state);
1572
1800
  let previous = history.pop();
1573
- while (previous && !state.path.includes(previous)) previous = history.pop();
1801
+ while (previous && (!state.path.includes(previous) || skip.has(previous))) previous = history.pop();
1574
1802
  if (!previous) return;
1575
1803
  emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
1576
1804
  },
@@ -1598,15 +1826,18 @@ function build(given, options, replaying = false, budget) {
1598
1826
  return out;
1599
1827
  }
1600
1828
  };
1601
- if (options.initialValues) {
1829
+ const fromLink = options.linkParams ? prefillFromLink(definition, options.linkParams, options.initialValues) : {};
1830
+ const initialValues = Object.keys(fromLink).length ? { ...fromLink, ...options.initialValues } : options.initialValues;
1831
+ if (initialValues) {
1602
1832
  const wasBatch = batch;
1603
1833
  batch = true;
1604
- for (const [id, value] of Object.entries(options.initialValues)) {
1834
+ for (const [id, value] of Object.entries(initialValues)) {
1605
1835
  if (!fields.has(id) || value === void 0) continue;
1606
1836
  answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
1607
1837
  }
1608
1838
  settleNow();
1609
1839
  batch = wasBatch;
1840
+ showCurrent();
1610
1841
  }
1611
1842
  const resumeAt = options.resumeAt;
1612
1843
  if (resumeAt && !replaying && state.path.includes(resumeAt)) {
@@ -1625,18 +1856,22 @@ function build(given, options, replaying = false, budget) {
1625
1856
  }
1626
1857
  } else if (event.type === "next") {
1627
1858
  settleNow(false);
1859
+ showCurrent();
1628
1860
  if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
1629
1861
  engine.next();
1630
1862
  } else if (event.type === "back") {
1631
1863
  settleNow(false);
1864
+ showCurrent();
1632
1865
  engine.back();
1633
1866
  } else if (event.type === "submit") {
1634
1867
  settleNow();
1868
+ showCurrent();
1635
1869
  engine.submit();
1636
1870
  }
1637
1871
  };
1638
1872
  const finish = () => {
1639
1873
  settleNow();
1874
+ showCurrent();
1640
1875
  batch = false;
1641
1876
  state = { ...state, events: [...state.events] };
1642
1877
  };
@@ -1647,7 +1882,12 @@ function replay(definition, events, options = {}) {
1647
1882
  }
1648
1883
  function replayWithin(definition, events, options, maxWork) {
1649
1884
  const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
1650
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
1885
+ const { engine, apply, finish } = build(
1886
+ definition,
1887
+ { ...options, initialValues: void 0, linkParams: void 0, clock: () => void 0 },
1888
+ true,
1889
+ budget
1890
+ );
1651
1891
  try {
1652
1892
  for (const event of events) apply(event);
1653
1893
  finish();
@@ -1898,6 +2138,31 @@ function parseForm(input) {
1898
2138
  const optionalBoolean = (v, path) => {
1899
2139
  if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
1900
2140
  };
2141
+ const optionDetails = (o, at) => {
2142
+ if (o.description !== void 0 && (typeof o.description !== "string" || o.description.length > OPTION_LIMITS.description)) {
2143
+ issue(`${at}.description`, `Must be text of at most ${OPTION_LIMITS.description} characters.`);
2144
+ }
2145
+ if (o.image !== void 0 && !isHttpsUrl(o.image)) {
2146
+ issue(`${at}.image`, `Must be an https:// link of at most ${OPTION_LIMITS.image} characters.`);
2147
+ }
2148
+ if (o.attributes === void 0) return;
2149
+ const attributes = o.attributes;
2150
+ if (!isObject2(attributes) || Object.keys(attributes).length > OPTION_LIMITS.attributes) {
2151
+ issue(`${at}.attributes`, `Must be an object of at most ${OPTION_LIMITS.attributes} attributes.`);
2152
+ return;
2153
+ }
2154
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
2155
+ for (const [name2, v] of Object.entries(attributes)) {
2156
+ if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
2157
+ issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
2158
+ } else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
2159
+ issue(
2160
+ `${at}.attributes.${name2}`,
2161
+ `Must be text, or a list of at most ${OPTION_LIMITS.attributeItems} texts, each at most ${OPTION_LIMITS.attributeValue} characters.`
2162
+ );
2163
+ }
2164
+ }
2165
+ };
1901
2166
  const name = (v, path, what) => {
1902
2167
  if (typeof v !== "string" || !v) {
1903
2168
  issue(path, `${what} must be a non-empty string.`);
@@ -2070,7 +2335,31 @@ function parseForm(input) {
2070
2335
  } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2071
2336
  warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
2072
2337
  }
2338
+ const filter = isObject2(from) ? from.filter : void 0;
2339
+ if (filter !== void 0) {
2340
+ const at = `${path}.props.optionsFrom.filter`;
2341
+ if (!isObject2(filter) || typeof filter.attribute !== "string" || !filter.attribute || filter.attribute.length > OPTION_LIMITS.attributeName || typeof filter.field !== "string" || !filter.field) {
2342
+ issue(at, `Must be { attribute, field }: an attribute name (at most ${OPTION_LIMITS.attributeName} characters) and an earlier question's key.`);
2343
+ } else if (!keys.has(filter.field)) {
2344
+ issue(`${at}.field`, `"${filter.field}" must be the key of an earlier question.`);
2345
+ }
2346
+ }
2347
+ }
2348
+ const prefill = isObject2(block.props) ? block.props.prefill : void 0;
2349
+ if (prefill !== void 0) {
2350
+ if (!isObject2(prefill) || !isPrefillParam(prefill.param)) {
2351
+ issue(
2352
+ `${path}.props.prefill`,
2353
+ `Must be { param, hideWhenPrefilled? }: a link parameter of letters, digits, "_", "-" or "." (at most ${MAX_PREFILL_PARAM_LENGTH} characters).`
2354
+ );
2355
+ } else {
2356
+ optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
2357
+ if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2358
+ warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
2359
+ }
2360
+ }
2073
2361
  }
2362
+ if (isObject2(block.props)) optionalBoolean(block.props.searchable, `${path}.props.searchable`);
2074
2363
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
2075
2364
  else if (block.props.options !== void 0) {
2076
2365
  const options = block.props.options;
@@ -2079,7 +2368,10 @@ function parseForm(input) {
2079
2368
  options.forEach((o, j) => {
2080
2369
  const at = `${path}.props.options[${j}]`;
2081
2370
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
2082
- else optionalText(o.label, `${at}.label`);
2371
+ else {
2372
+ optionalText(o.label, `${at}.label`);
2373
+ optionDetails(o, at);
2374
+ }
2083
2375
  });
2084
2376
  if (from === void 0) {
2085
2377
  choices.set(
@@ -2509,11 +2801,12 @@ function validateSubmission(published, submission, options = {}) {
2509
2801
  }
2510
2802
  } else {
2511
2803
  const errors = {};
2512
- for (const [id, value] of Object.entries(engine.answers())) {
2804
+ const kept = engine.answers();
2805
+ for (const [id, value] of Object.entries(kept)) {
2513
2806
  const message = validateField(fields.get(id), value, {
2514
2807
  requireAnswers: false,
2515
2808
  custom: options.validators?.[id]
2516
- });
2809
+ }) ?? filteredChoiceError(definition, fields.get(id), value, kept);
2517
2810
  if (message) errors[id] = message;
2518
2811
  }
2519
2812
  if (Object.keys(errors).length) return { ok: false, errors };