@marktiderman/genesis-forms 0.3.1 → 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
@@ -114,6 +114,39 @@ async function sha256Hex(text) {
114
114
  return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("");
115
115
  }
116
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
+
117
150
  // src/structured.ts
118
151
  var isObject = (v) => !!v && typeof v === "object" && !Array.isArray(v);
119
152
  function intProp(field, name, fallback, floor = 1) {
@@ -345,7 +378,7 @@ function intRange(field, min, maxDefault) {
345
378
  const max = typeof field.props?.max === "number" ? field.props.max : maxDefault;
346
379
  return { min, max };
347
380
  }
348
- var numberOps = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
381
+ var numberOps2 = ["eq", "neq", "lt", "lte", "gt", "gte", "in", "answered", "empty"];
349
382
  var textOps = ["eq", "neq", "contains", "answered", "empty"];
350
383
  function maxLength(field, fallback) {
351
384
  return typeof field.props?.maxLength === "number" ? field.props.maxLength : fallback;
@@ -423,23 +456,30 @@ var builtins = [
423
456
  label: "Rating",
424
457
  category: "scale",
425
458
  value: { json: true },
426
- operators: numberOps,
459
+ operators: numberOps2,
427
460
  a11y: { semantics: "group" },
428
461
  validate: (value, field) => {
429
462
  const { min, max } = intRange(field, 1, 5);
430
463
  return Number.isInteger(value) && value >= min && value <= max ? null : `Choose a rating from ${min} to ${max}.`;
431
464
  },
432
- 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
+ }
433
470
  },
434
471
  {
435
472
  type: "nps",
436
473
  label: "Net Promoter Score",
437
474
  category: "scale",
438
475
  value: { json: true },
439
- operators: numberOps,
476
+ operators: numberOps2,
440
477
  a11y: { semantics: "group" },
441
478
  validate: (value) => Number.isInteger(value) && value >= 0 && value <= 10 ? null : "Choose a score from 0 to 10.",
442
- 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
+ }
443
483
  },
444
484
  {
445
485
  type: "single-choice",
@@ -473,9 +513,122 @@ var builtins = [
473
513
  a11y: { semantics: "group" },
474
514
  validate: (value) => typeof value === "boolean" ? null : "Choose yes or no.",
475
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]
476
587
  }
477
588
  ];
478
- 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);
479
632
 
480
633
  // src/logic.ts
481
634
  function own(record, key) {