@marktiderman/genesis-forms 0.3.1 → 0.4.1
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 +85 -1
- package/dist/destinations.cjs +159 -6
- package/dist/destinations.cjs.map +1 -1
- package/dist/destinations.js +159 -6
- package/dist/destinations.js.map +1 -1
- package/dist/{engine-CPLtKVew.d.cts → engine-BkNuzlna.d.cts} +2 -1
- package/dist/{engine-DOrPgFDw.d.ts → engine-Dz9J5ej2.d.ts} +2 -1
- package/dist/index.cjs +859 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +205 -4
- package/dist/index.d.ts +205 -4
- package/dist/index.js +838 -23
- package/dist/index.js.map +1 -1
- package/dist/server.cjs +767 -18
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +5 -1
- package/dist/server.d.ts +5 -1
- package/dist/server.js +767 -18
- package/dist/server.js.map +1 -1
- package/dist/testing.cjs +769 -18
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +769 -18
- package/dist/testing.js.map +1 -1
- package/package.json +1 -1
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,77 @@ 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
|
+
|
|
168
|
+
Limits, so a large submission can't make the server slow:
|
|
169
|
+
|
|
170
|
+
- A calculated field that reads a list or table of more than `MAX_FORMULA_ITEMS` (10,000) items
|
|
171
|
+
has no value: it is blank, never `0` or a partial total. The same goes for one that reads an
|
|
172
|
+
answer past `MAX_SETTLE_ITEMS` (50,000) items read by all of a form's formulas together (each
|
|
173
|
+
answer and column counted once, in form order; an answer over 10,000 items counts for none).
|
|
174
|
+
Nothing else reports this, so give a table a `maxRows` well under these if its total matters.
|
|
175
|
+
- A formula is at most `MAX_FORMULA_LENGTH` characters and 64 levels of parentheses, calls, signs
|
|
176
|
+
and `? :` deep, and all of a form's formulas together at most `MAX_FORM_FORMULA_LENGTH` (20,000).
|
|
177
|
+
- Lists are read once per answer, so treat answers as immutable: pass `setValue` a new array, never
|
|
178
|
+
one changed in place (the engine and the built-in renderers never change one).
|
|
179
|
+
|
|
180
|
+
The engine works the values out from the answers on the path (an answer hidden by logic reads as
|
|
181
|
+
blank), again whenever an answer it reads changes, and stores them with the answers, so `engine.answers()` and a
|
|
182
|
+
checked submission hold them for later use. `setValue` on a calculated field does nothing, and
|
|
183
|
+
`validateSubmission` works the values out itself, dropping any the client sent. A calculated field
|
|
184
|
+
is never required. Logic may compare its value.
|
|
185
|
+
|
|
186
|
+
`parseForm` checks each formula: it must parse, every key must be a question of the form, a table
|
|
187
|
+
must be read by one of its columns, and a calculated field may read only calculated fields before
|
|
188
|
+
it, so values never go round in a circle. A problem is an issue on `blocks[i].props.formula`, such
|
|
189
|
+
as `The formula uses "wage", which is not the answer name of a question in this form.` or
|
|
190
|
+
`Expected a number, an answer or "(", but the formula ends there. (at character 9)`.
|
|
191
|
+
|
|
192
|
+
`parseFormula`, `evaluateFormula`, `formulaRefs` and `renameFormulaKey` work with formulas on their
|
|
193
|
+
own; `formatCalculated` shows a value as the field does. Builders offer the type only when the host
|
|
194
|
+
names it (`COMPUTED_FIELD_TYPES`).
|
|
195
|
+
|
|
196
|
+
### Props for the host
|
|
197
|
+
|
|
198
|
+
Props a field type doesn't use are kept and ignored: the engine, `parseForm` and
|
|
199
|
+
`validateSubmission` treat them as data for the app. A host can mark where an answer goes after a
|
|
200
|
+
submit, for example `props: { record_column: "nickname" }`, and read it from the definition when it
|
|
201
|
+
stores the response. The builder offers such props as `outputs` (see its README).
|
|
202
|
+
|
|
119
203
|
## On the server
|
|
120
204
|
|
|
121
205
|
```ts
|
package/dist/destinations.cjs
CHANGED
|
@@ -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
|
|
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:
|
|
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) =>
|
|
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:
|
|
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) =>
|
|
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
|
-
|
|
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) {
|