@marktiderman/genesis-forms 0.3.0 → 0.4.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
@@ -5,7 +5,7 @@ can be stored, diffed, versioned, and evaluated identically in a browser, in Rea
5
5
  server. Zero dependencies.
6
6
 
7
7
  **Status: 0.1, release R1.** Field types: `short-text`, `long-text`, `email`, `phone`, `rating`,
8
- `nps`, `single-choice`, `multi-choice`, `yes-no`. Logic: show, hide, jump to a field or an ending. Layouts
8
+ `nps`, `single-choice`, `multi-choice`, `yes-no`, `dropdown`, `number`, `date`, `consent`. Logic: show, hide, jump to a field or an ending. Layouts
9
9
  (rendered by `genesis-forms-web`): focus (one question at a time) and classic. The full plan is in
10
10
  [docs/FORMS.md](../../docs/FORMS.md).
11
11
 
@@ -85,6 +85,19 @@ space, `-`, `(`, `)` and `.`, 7 to 15 digits, at most 32 characters. Operators:
85
85
  `answered`, `empty`. Its manifest carries `autocomplete: "tel"` and `inputMode: "tel"` (email:
86
86
  `"email"` for both).
87
87
 
88
+ ### Dropdown, number, date and consent
89
+
90
+ | Type and builder | Value | Notes |
91
+ | --- | --- | --- |
92
+ | `dropdown`, `f.dropdown(key, { options })` | one option's value | Like single choice, from a compact menu. Operators: `eq`, `neq`, `in`, `answered`, `empty`. |
93
+ | `number`, `f.number(key, { min, max, step })` | `number` | All limits optional; `step` counts from `min`, else 0 (`step: 1` for whole numbers). Supports `lt`/`gt` logic. `numberLimits(field)`. |
94
+ | `date`, `f.date(key, { min, max })` | `"YYYY-MM-DD"` | A real calendar day within the optional limits. Operators: `eq`, `neq`, `answered`, `empty`. `isIsoDate`, `dateLimits(field)`. |
95
+ | `consent`, `f.consent(key, { text })` | `true` | One checkbox; unchecked is unanswered, so `required` means it must be checked. Summarized as "Agreed". |
96
+
97
+ `f.rating` also takes `lowLabel`, `highLabel` (words under the ends, such as "Poor" and "Great") and
98
+ `display: "numbers" | "stars"`; `f.nps` takes the labels too. `summarizeValue` adds an end's label:
99
+ `"5/5 (Great)"`. Helpers: `scaleLabels(field)`, `ratingDisplay(field)`.
100
+
88
101
  ### Structured field types
89
102
 
90
103
  Four built-in types collect more than one value for a question. `STRUCTURED_FIELD_TYPES` lists
@@ -116,6 +129,67 @@ Each type validates, normalizes (trimmed, blanks dropped, notes in time order),
116
129
  samples for `coverage()`. Helpers: `listLimits`, `tableColumns`, `tableLimits`, `formatTimestamp`
117
130
  and `parseTimestamp`; types `TableColumn`, `MediaNote` and `Highlight`.
118
131
 
132
+ ### Calculated fields
133
+
134
+ A `calculated` field shows a value worked out from other answers, such as a total, an average or a
135
+ band, and the respondent can't change it. Its props are `{ formula, format?, decimals? }`, with
136
+ `format` one of `"number"`, `"percent"` (`0.25` shows `25%`) or `"text"`.
137
+
138
+ ```ts
139
+ const budget = defineForm({
140
+ id: "budget",
141
+ title: "Weekly budget",
142
+ blocks: [
143
+ f.shortText("hours", { label: "Hours worked" }),
144
+ f.shortText("rate", { label: "Hourly rate" }),
145
+ f.table("costs", { label: "Costs", columns: [{ key: "item", label: "Item" }, { key: "amount", label: "Amount" }] }),
146
+ f.calculated("income", { label: "Income", formula: "hours * rate", decimals: 2 }),
147
+ f.calculated("left", { label: "Left over", formula: "income - sum(costs.amount)", decimals: 2 }),
148
+ f.calculated("status", { label: "Status", formula: "left >= 0 ? 'On budget' : 'Over budget'", format: "text" }),
149
+ ],
150
+ });
151
+ ```
152
+
153
+ The formula language is small and is parsed and evaluated by this package, never run as code:
154
+
155
+ | Part | Written as |
156
+ | --- | --- |
157
+ | Answers | Their key (`hours`); one column of a table as `key.column` (`costs.amount`) |
158
+ | Values | Numbers (`12`, `0.5`) and text in quotes (`"High"`, `'High'`) |
159
+ | Arithmetic | `+ - * /` (also `− × ÷`), unary minus, parentheses |
160
+ | Comparisons | `== != < <= > >=` (also `= ≠ ≤ ≥`), true or false |
161
+ | A choice | `test ? a : b` |
162
+ | Functions | `sum`, `avg`, `min`, `max` of numbers, lists and table columns |
163
+
164
+ An unanswered question is blank, and arithmetic with a blank is blank, so a total stays empty until
165
+ its answers are in; dividing by zero is blank too. The functions skip blanks (`sum` of nothing is
166
+ `0`). Text holding a number (`"12"`, `"1,200"`) counts as that number, and yes / no as 1 / 0.
167
+ A list or table of more than `MAX_FORMULA_ITEMS` (10,000) items reads as blank. A formula is at
168
+ most `MAX_FORMULA_LENGTH` characters and 64 levels of parentheses, calls, signs and `? :` deep.
169
+
170
+ The engine works the values out from the answers on the path (an answer hidden by logic reads as
171
+ blank), again whenever an answer it reads changes, and stores them with the answers, so `engine.answers()` and a
172
+ checked submission hold them for later use. `setValue` on a calculated field does nothing, and
173
+ `validateSubmission` works the values out itself, dropping any the client sent. A calculated field
174
+ is never required. Logic may compare its value.
175
+
176
+ `parseForm` checks each formula: it must parse, every key must be a question of the form, a table
177
+ must be read by one of its columns, and a calculated field may read only calculated fields before
178
+ it, so values never go round in a circle. A problem is an issue on `blocks[i].props.formula`, such
179
+ as `The formula uses "wage", which is not the answer name of a question in this form.` or
180
+ `Expected a number, an answer or "(", but the formula ends there. (at character 9)`.
181
+
182
+ `parseFormula`, `evaluateFormula`, `formulaRefs` and `renameFormulaKey` work with formulas on their
183
+ own; `formatCalculated` shows a value as the field does. Builders offer the type only when the host
184
+ names it (`COMPUTED_FIELD_TYPES`).
185
+
186
+ ### Props for the host
187
+
188
+ Props a field type doesn't use are kept and ignored: the engine, `parseForm` and
189
+ `validateSubmission` treat them as data for the app. A host can mark where an answer goes after a
190
+ submit, for example `props: { record_column: "nickname" }`, and read it from the definition when it
191
+ stores the response. The builder offers such props as `outputs` (see its README).
192
+
119
193
  ## On the server
120
194
 
121
195
  ```ts
@@ -62,6 +62,11 @@ async function timedFetch(deps, url, init, signal) {
62
62
  const timeout = AbortSignal.timeout(deps.timeoutMs ?? REQUEST_TIMEOUT_MS);
63
63
  const any = AbortSignal.any;
64
64
  const combined = signal ? any ? any([timeout, signal]) : signal.aborted ? signal : timeout : timeout;
65
+ try {
66
+ new Request(url, { method: init.method ?? "GET", headers: init.headers ?? {} });
67
+ } catch {
68
+ throw new DeliveryError("Invalid request (a header value or the URL is malformed)", { retryable: false });
69
+ }
65
70
  try {
66
71
  return await (deps.fetch ?? fetch)(url, { ...init, signal: combined });
67
72
  } catch (error) {
@@ -109,6 +114,39 @@ async function sha256Hex(text) {
109
114
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
110
115
  }
111
116
 
117
+ // src/calculated.ts
118
+ var CALCULATED_TYPE = "calculated";
119
+ var MAX_CALCULATED_DECIMALS = 10;
120
+ function calculatedProps(field) {
121
+ const p = field.props ?? {};
122
+ const out = { formula: typeof p.formula === "string" ? p.formula : "" };
123
+ if (p.format === "number" || p.format === "percent" || p.format === "text") out.format = p.format;
124
+ if (typeof p.decimals === "number" && Number.isInteger(p.decimals) && p.decimals >= 0 && p.decimals <= MAX_CALCULATED_DECIMALS) {
125
+ out.decimals = p.decimals;
126
+ }
127
+ return out;
128
+ }
129
+ function formatCalculated(value, field) {
130
+ if (value === void 0 || value === null) return "";
131
+ const { format, decimals } = calculatedProps(field);
132
+ const fixed = (n) => decimals === void 0 ? String(Number(n.toFixed(6))) : n.toFixed(decimals);
133
+ if (typeof value === "number") return format === "percent" ? `${fixed(value * 100)}%` : fixed(value);
134
+ if (typeof value === "boolean") return value ? "Yes" : "No";
135
+ return typeof value === "string" ? value : "";
136
+ }
137
+ var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
138
+ var calculatedFieldType = {
139
+ type: CALCULATED_TYPE,
140
+ label: "Calculated",
141
+ category: "advanced",
142
+ value: { json: true },
143
+ operators: numberOps,
144
+ a11y: { semantics: "control" },
145
+ // The engine works the value out; any number, text or true / false it made is valid.
146
+ validate: (value) => typeof value === "number" || typeof value === "string" || typeof value === "boolean" ? null : "This value is worked out from other answers.",
147
+ summarize: (value, field) => formatCalculated(value, field)
148
+ };
149
+
112
150
  // src/structured.ts
113
151
  var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
114
152
  function intProp(field, name, fallback, floor = 1) {
@@ -340,7 +378,7 @@ function intRange(field, min, maxDefault) {
340
378
  const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
341
379
  return { min, max };
342
380
  }
343
- var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
381
+ var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
344
382
  var textOps = ["eq", "neq", "contains", "answered", "empty"];
345
383
  function maxLength(field, fallback) {
346
384
  return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
@@ -418,23 +456,30 @@ var builtins = [
418
456
  label: "Rating",
419
457
  category: "scale",
420
458
  value: { json: true },
421
- operators: numberOps,
459
+ operators: numberOps2,
422
460
  a11y: { semantics: "group" },
423
461
  validate: (value, field) => {
424
462
  const { min, max } = intRange(field, 1, 5);
425
463
  return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
426
464
  },
427
- summarize: (value, field) => `${value}/${intRange(field, 1, 5).max}`
465
+ summarize: (value, field) => {
466
+ const { max } = intRange(field, 1, 5);
467
+ const end = scaleEndLabel(field, value, 1, max);
468
+ return end ? `${value}/${max} (${end})` : `${value}/${max}`;
469
+ }
428
470
  },
429
471
  {
430
472
  type: "nps",
431
473
  label: "Net Promoter Score",
432
474
  category: "scale",
433
475
  value: { json: true },
434
- operators: numberOps,
476
+ operators: numberOps2,
435
477
  a11y: { semantics: "group" },
436
478
  validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
437
- summarize: (value) => `${value}/10`
479
+ summarize: (value, field) => {
480
+ const end = scaleEndLabel(field, value, 0, 10);
481
+ return end ? `${value}/10 (${end})` : `${value}/10`;
482
+ }
438
483
  },
439
484
  {
440
485
  type: "single-choice",
@@ -468,9 +513,122 @@ var builtins = [
468
513
  a11y: { semantics: "group" },
469
514
  validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
470
515
  summarize: (value) => value ? "Yes" : "No"
516
+ },
517
+ {
518
+ type: "dropdown",
519
+ label: "Dropdown",
520
+ category: "choice",
521
+ value: { json: true },
522
+ operators: ["eq", "neq", "in", "answered", "empty"],
523
+ a11y: { semantics: "control" },
524
+ validate: (value, field) => typeof value === "string" && choiceOptions(field).some((o) => o.value === value) ? null : "Choose one of the options.",
525
+ summarize: (value, field) => optionLabel(field, String(value))
526
+ },
527
+ {
528
+ type: "number",
529
+ label: "Number",
530
+ category: "text",
531
+ value: { json: true },
532
+ operators: numberOps2,
533
+ a11y: { semantics: "control", inputMode: "decimal" },
534
+ validate: (value, field) => {
535
+ if (typeof value !== "number" || !Number.isFinite(value)) return "Enter a number.";
536
+ const { min, max, step } = numberLimits(field);
537
+ if (min !== void 0 && max !== void 0 && (value < min || value > max)) {
538
+ return `Enter a number from ${min} to ${max}.`;
539
+ }
540
+ if (min !== void 0 && value < min) return `Enter ${min} or more.`;
541
+ if (max !== void 0 && value > max) return `Enter ${max} or less.`;
542
+ if (step !== void 0 && !onStep(value, min ?? 0, step)) {
543
+ return step === 1 && Number.isInteger(min ?? 0) ? "Enter a whole number." : `Enter a number in steps of ${step}.`;
544
+ }
545
+ return null;
546
+ },
547
+ summarize: (value) => String(value),
548
+ samples: (field) => {
549
+ const { min, max } = numberLimits(field);
550
+ return [min, max, 0, 1].filter((v) => v !== void 0);
551
+ }
552
+ },
553
+ {
554
+ type: "date",
555
+ label: "Date",
556
+ category: "date",
557
+ value: { json: true },
558
+ operators: ["eq", "neq", "answered", "empty"],
559
+ a11y: { semantics: "control" },
560
+ validate: (value, field) => {
561
+ if (typeof value !== "string" || !isIsoDate(value.trim())) return "Enter a date like 2026-10-22.";
562
+ const date = value.trim();
563
+ const { min, max } = dateLimits(field);
564
+ if (min && max && (date < min || date > max)) return `Choose a date from ${min} to ${max}.`;
565
+ if (min && date < min) return `Choose ${min} or later.`;
566
+ if (max && date > max) return `Choose ${max} or earlier.`;
567
+ return null;
568
+ },
569
+ normalize: (value) => typeof value === "string" ? value.trim() : value,
570
+ samples: (field) => {
571
+ const { min, max } = dateLimits(field);
572
+ return [min ?? max ?? "2000-01-01"];
573
+ }
574
+ },
575
+ {
576
+ type: "consent",
577
+ label: "Consent checkbox",
578
+ category: "choice",
579
+ value: { json: true },
580
+ operators: ["answered", "empty"],
581
+ a11y: { semantics: "group" },
582
+ // Unchecked is unanswered, so a required consent must be checked.
583
+ isEmpty: (value) => value === void 0 || value === null || value === false,
584
+ validate: (value) => value === true ? null : "Check the box to agree.",
585
+ summarize: () => "Agreed",
586
+ samples: () => [true]
471
587
  }
472
588
  ];
473
- for (const manifest of [...builtins, ...structuredFieldTypes]) registerFieldType(manifest);
589
+ function scaleLabels(field) {
590
+ const out = {};
591
+ const low = field.props?.lowLabel;
592
+ const high = field.props?.highLabel;
593
+ if (typeof low === "string" && low.trim()) out.low = low.trim();
594
+ if (typeof high === "string" && high.trim()) out.high = high.trim();
595
+ return out;
596
+ }
597
+ function scaleEndLabel(field, value, min, max) {
598
+ const { low, high } = scaleLabels(field);
599
+ return value === min ? low : value === max ? high : void 0;
600
+ }
601
+ var finite = (v) => typeof v === "number" && Number.isFinite(v) ? v : void 0;
602
+ function numberLimits(field) {
603
+ const out = {};
604
+ const min = finite(field.props?.min);
605
+ const max = finite(field.props?.max);
606
+ const step = finite(field.props?.step);
607
+ if (min !== void 0) out.min = min;
608
+ if (max !== void 0) out.max = max;
609
+ if (step !== void 0 && step > 0) out.step = step;
610
+ return out;
611
+ }
612
+ function onStep(value, base, step) {
613
+ const n = (value - base) / step;
614
+ return Math.abs(n - Math.round(n)) < 1e-9 * Math.max(1, Math.abs(n));
615
+ }
616
+ function isIsoDate(value) {
617
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
618
+ const [y, m, d] = value.split("-").map(Number);
619
+ if (y < 1 || m < 1 || m > 12 || d < 1) return false;
620
+ const days = [31, y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
621
+ return d <= days[m - 1];
622
+ }
623
+ function dateLimits(field) {
624
+ const out = {};
625
+ const min = field.props?.min;
626
+ const max = field.props?.max;
627
+ if (typeof min === "string" && isIsoDate(min)) out.min = min;
628
+ if (typeof max === "string" && isIsoDate(max)) out.max = max;
629
+ return out;
630
+ }
631
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
474
632
 
475
633
  // src/logic.ts
476
634
  function own(record, key) {
@@ -758,7 +916,7 @@ async function googleAccessToken(account, options = {}) {
758
916
  if (!clientEmail || !privateKey) throw new DeliveryError("The Google service account email or private key is not set");
759
917
  const scope = options.scope ?? SHEETS_SCOPE;
760
918
  const now = options.now?.() ?? Date.now();
761
- const cacheKey = `${clientEmail} ${scope}`;
919
+ const cacheKey = `${clientEmail} ${scope} ${await sha256Hex(privateKey.replace(/\\n/g, "\n").trim())}`;
762
920
  const hit = cache.get(cacheKey);
763
921
  if (hit && hit.expiresAt - 6e4 > now) return hit.token;
764
922
  const iat = Math.floor(now / 1e3);
@@ -789,6 +947,7 @@ var SHEETS_API = "https://sheets.googleapis.com/v4/spreadsheets";
789
947
  var DEFAULT_SHEET_NAME = "Responses";
790
948
  var DEDUPE_WINDOW = 500;
791
949
  var HEADER_SCAN_ROWS = 100;
950
+ var HEADER_WRITE_ATTEMPTS = 3;
792
951
  function mergeHeader(existing, keys, width = 0) {
793
952
  const header = existing.map((h) => h == null ? "" : String(h));
794
953
  while (header.length < width) header.push("");
@@ -884,35 +1043,52 @@ function sheetsDestination(options) {
884
1043
  });
885
1044
  const keys = [...new Set(rows.flatMap((r) => r.columns))];
886
1045
  async function setUp(previous) {
887
- const meta = await call(
888
- "read",
889
- `${base}?fields=${encodeURIComponent("sheets.properties(sheetId,title,gridProperties.columnCount)")}`
890
- );
891
- let sheet = meta.sheets?.map((s) => s.properties).find((p) => p?.title === sheetName);
1046
+ const readTabs = async () => {
1047
+ const meta = await call(
1048
+ "read",
1049
+ `${base}?fields=${encodeURIComponent("sheets.properties(sheetId,title,gridProperties.columnCount)")}`
1050
+ );
1051
+ return meta.sheets?.map((s) => s.properties).find((p) => p?.title === sheetName);
1052
+ };
1053
+ let sheet = await readTabs();
892
1054
  if (!sheet) {
893
- const added = await batchUpdate("add tab", [{ addSheet: { properties: { title: sheetName } } }]);
894
- sheet = added.replies?.[0]?.addSheet?.properties ?? { sheetId: 0, title: sheetName };
895
- }
896
- const read = await call(
897
- "read header",
898
- `${range(`A1:ZZZ${HEADER_SCAN_ROWS}`)}?majorDimension=COLUMNS`
899
- );
900
- const used = read.values ?? [];
901
- const existing = used.map((column) => column[0] ?? "");
902
- const header = mergeHeader(existing, keys, used.length);
903
- if (!header.includes("id")) {
904
- if (used.length) header.push("id");
905
- else header.unshift("id");
1055
+ try {
1056
+ const added = await batchUpdate("add tab", [{ addSheet: { properties: { title: sheetName } } }]);
1057
+ sheet = added.replies?.[0]?.addSheet?.properties ?? { sheetId: 0, title: sheetName };
1058
+ } catch (error) {
1059
+ if (!(error instanceof DeliveryError) || error.status !== 400) throw error;
1060
+ sheet = await readTabs();
1061
+ if (!sheet) throw error;
1062
+ }
906
1063
  }
1064
+ const readHeader = async () => {
1065
+ const read = await call(
1066
+ "read header",
1067
+ `${range(`A1:ZZZ${HEADER_SCAN_ROWS}`)}?majorDimension=COLUMNS`
1068
+ );
1069
+ const used2 = read.values ?? [];
1070
+ return { used: used2.length, existing: used2.map((column) => column[0] ?? "") };
1071
+ };
907
1072
  let columns = sheet.gridProperties?.columnCount ?? 26;
908
- if (header.length > columns) {
909
- await batchUpdate("add columns", [
910
- { appendDimension: { sheetId: sheet.sheetId, dimension: "COLUMNS", length: header.length - columns } }
911
- ]);
912
- columns = header.length;
913
- }
914
- const changed = header.length !== existing.length || header.some((h, i) => h !== String(existing[i] ?? ""));
915
- if (changed) {
1073
+ let { used, existing } = await readHeader();
1074
+ let header = [];
1075
+ for (let attempt = 0; ; attempt++) {
1076
+ header = mergeHeader(existing, keys, used);
1077
+ if (!header.includes("id")) {
1078
+ if (used) header.push("id");
1079
+ else header.unshift("id");
1080
+ }
1081
+ const changed = header.length !== existing.length || header.some((h, i) => h !== String(existing[i] ?? ""));
1082
+ if (!changed) break;
1083
+ if (attempt >= HEADER_WRITE_ATTEMPTS) {
1084
+ throw new DeliveryError("Google Sheets header kept changing while it was written", { retryable: true });
1085
+ }
1086
+ if (header.length > columns) {
1087
+ await batchUpdate("add columns", [
1088
+ { appendDimension: { sheetId: sheet.sheetId, dimension: "COLUMNS", length: header.length - columns } }
1089
+ ]);
1090
+ columns = header.length;
1091
+ }
916
1092
  await call("write header", `${range("A1")}?valueInputOption=RAW`, {
917
1093
  method: "PUT",
918
1094
  body: JSON.stringify({ values: [header] })
@@ -927,6 +1103,7 @@ function sheetsDestination(options) {
927
1103
  }
928
1104
  ]);
929
1105
  }
1106
+ ({ used, existing } = await readHeader());
930
1107
  }
931
1108
  return {
932
1109
  spreadsheet_id: spreadsheetId,