@businessdash/sdk 0.9.60
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/LICENSE +21 -0
- package/README.md +899 -0
- package/dist/adapters-revalidate.d.ts +44 -0
- package/dist/adapters-revalidate.d.ts.map +1 -0
- package/dist/adapters-revalidate.js +68 -0
- package/dist/adapters-revalidate.js.map +1 -0
- package/dist/alpha.d.ts +21 -0
- package/dist/alpha.d.ts.map +1 -0
- package/dist/alpha.js +21 -0
- package/dist/alpha.js.map +1 -0
- package/dist/analytics-core.d.ts +34 -0
- package/dist/analytics-core.d.ts.map +1 -0
- package/dist/analytics-core.js +138 -0
- package/dist/analytics-core.js.map +1 -0
- package/dist/analytics-server.d.ts +54 -0
- package/dist/analytics-server.d.ts.map +1 -0
- package/dist/analytics-server.js +78 -0
- package/dist/analytics-server.js.map +1 -0
- package/dist/attribution-logo.d.ts +2 -0
- package/dist/attribution-logo.d.ts.map +1 -0
- package/dist/attribution-logo.js +5 -0
- package/dist/attribution-logo.js.map +1 -0
- package/dist/auth-handler.d.ts +92 -0
- package/dist/auth-handler.d.ts.map +1 -0
- package/dist/auth-handler.js +277 -0
- package/dist/auth-handler.js.map +1 -0
- package/dist/biab-forms.css +484 -0
- package/dist/cli.d.ts +46 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1824 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +1681 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +2845 -0
- package/dist/client.js.map +1 -0
- package/dist/collections.d.ts +13 -0
- package/dist/collections.d.ts.map +1 -0
- package/dist/collections.js +13 -0
- package/dist/collections.js.map +1 -0
- package/dist/contracts.d.ts +14609 -0
- package/dist/contracts.d.ts.map +1 -0
- package/dist/contracts.js +2428 -0
- package/dist/contracts.js.map +1 -0
- package/dist/data-model-schema.d.ts +962 -0
- package/dist/data-model-schema.d.ts.map +1 -0
- package/dist/data-model-schema.js +2295 -0
- package/dist/data-model-schema.js.map +1 -0
- package/dist/distribution.d.ts +38 -0
- package/dist/distribution.d.ts.map +1 -0
- package/dist/distribution.js +61 -0
- package/dist/distribution.js.map +1 -0
- package/dist/element.d.ts +121 -0
- package/dist/element.d.ts.map +1 -0
- package/dist/element.js +276 -0
- package/dist/element.js.map +1 -0
- package/dist/enums.d.ts +25 -0
- package/dist/enums.d.ts.map +1 -0
- package/dist/enums.js +38 -0
- package/dist/enums.js.map +1 -0
- package/dist/errors.d.ts +195 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +188 -0
- package/dist/errors.js.map +1 -0
- package/dist/forms-core/autocomplete.d.ts +37 -0
- package/dist/forms-core/autocomplete.d.ts.map +1 -0
- package/dist/forms-core/autocomplete.js +82 -0
- package/dist/forms-core/autocomplete.js.map +1 -0
- package/dist/forms-core/business-function.d.ts +61 -0
- package/dist/forms-core/business-function.d.ts.map +1 -0
- package/dist/forms-core/business-function.js +149 -0
- package/dist/forms-core/business-function.js.map +1 -0
- package/dist/forms-core/calculation.d.ts +72 -0
- package/dist/forms-core/calculation.d.ts.map +1 -0
- package/dist/forms-core/calculation.js +103 -0
- package/dist/forms-core/calculation.js.map +1 -0
- package/dist/forms-core/controller.d.ts +249 -0
- package/dist/forms-core/controller.d.ts.map +1 -0
- package/dist/forms-core/controller.js +590 -0
- package/dist/forms-core/controller.js.map +1 -0
- package/dist/forms-core/elements.d.ts +44 -0
- package/dist/forms-core/elements.d.ts.map +1 -0
- package/dist/forms-core/elements.js +68 -0
- package/dist/forms-core/elements.js.map +1 -0
- package/dist/forms-core/email-domains.d.ts +10 -0
- package/dist/forms-core/email-domains.d.ts.map +1 -0
- package/dist/forms-core/email-domains.js +69 -0
- package/dist/forms-core/email-domains.js.map +1 -0
- package/dist/forms-core/file-rules.d.ts +51 -0
- package/dist/forms-core/file-rules.d.ts.map +1 -0
- package/dist/forms-core/file-rules.js +105 -0
- package/dist/forms-core/file-rules.js.map +1 -0
- package/dist/forms-core/formula.d.ts +114 -0
- package/dist/forms-core/formula.d.ts.map +1 -0
- package/dist/forms-core/formula.js +1170 -0
- package/dist/forms-core/formula.js.map +1 -0
- package/dist/forms-core/index.d.ts +24 -0
- package/dist/forms-core/index.d.ts.map +1 -0
- package/dist/forms-core/index.js +28 -0
- package/dist/forms-core/index.js.map +1 -0
- package/dist/forms-core/units.d.ts +49 -0
- package/dist/forms-core/units.d.ts.map +1 -0
- package/dist/forms-core/units.js +255 -0
- package/dist/forms-core/units.js.map +1 -0
- package/dist/forms-core/us-states.d.ts +14 -0
- package/dist/forms-core/us-states.d.ts.map +1 -0
- package/dist/forms-core/us-states.js +66 -0
- package/dist/forms-core/us-states.js.map +1 -0
- package/dist/forms-core/validation.d.ts +266 -0
- package/dist/forms-core/validation.d.ts.map +1 -0
- package/dist/forms-core/validation.js +850 -0
- package/dist/forms-core/validation.js.map +1 -0
- package/dist/forms.d.ts +19 -0
- package/dist/forms.d.ts.map +1 -0
- package/dist/forms.js +29 -0
- package/dist/forms.js.map +1 -0
- package/dist/gallery-types.d.ts +32 -0
- package/dist/gallery-types.d.ts.map +1 -0
- package/dist/gallery-types.js +2 -0
- package/dist/gallery-types.js.map +1 -0
- package/dist/graphql.d.ts +97 -0
- package/dist/graphql.d.ts.map +1 -0
- package/dist/graphql.js +212 -0
- package/dist/graphql.js.map +1 -0
- package/dist/index.cjs +19448 -0
- package/dist/index.d.cts +19 -0
- package/dist/index.d.mts +19 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/marketing-schema.d.ts +216 -0
- package/dist/marketing-schema.d.ts.map +1 -0
- package/dist/marketing-schema.js +200 -0
- package/dist/marketing-schema.js.map +1 -0
- package/dist/mcp.d.ts +42 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +87 -0
- package/dist/mcp.js.map +1 -0
- package/dist/native.d.ts +45 -0
- package/dist/native.d.ts.map +1 -0
- package/dist/native.js +43 -0
- package/dist/native.js.map +1 -0
- package/dist/next-revalidate.d.ts +43 -0
- package/dist/next-revalidate.d.ts.map +1 -0
- package/dist/next-revalidate.js +84 -0
- package/dist/next-revalidate.js.map +1 -0
- package/dist/parallel-pages.d.ts +102 -0
- package/dist/parallel-pages.d.ts.map +1 -0
- package/dist/parallel-pages.js +108 -0
- package/dist/parallel-pages.js.map +1 -0
- package/dist/payment-icons.d.ts +66 -0
- package/dist/payment-icons.d.ts.map +1 -0
- package/dist/payment-icons.js +160 -0
- package/dist/payment-icons.js.map +1 -0
- package/dist/proxy.d.ts +67 -0
- package/dist/proxy.d.ts.map +1 -0
- package/dist/proxy.js +97 -0
- package/dist/proxy.js.map +1 -0
- package/dist/react-analytics.d.ts +39 -0
- package/dist/react-analytics.d.ts.map +1 -0
- package/dist/react-analytics.js +59 -0
- package/dist/react-analytics.js.map +1 -0
- package/dist/react-attribution.d.ts +99 -0
- package/dist/react-attribution.d.ts.map +1 -0
- package/dist/react-attribution.js +52 -0
- package/dist/react-attribution.js.map +1 -0
- package/dist/react-marketing.d.ts +181 -0
- package/dist/react-marketing.d.ts.map +1 -0
- package/dist/react-marketing.js +215 -0
- package/dist/react-marketing.js.map +1 -0
- package/dist/react.d.ts +727 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +2418 -0
- package/dist/react.js.map +1 -0
- package/dist/reserved-paths.d.ts +24 -0
- package/dist/reserved-paths.d.ts.map +1 -0
- package/dist/reserved-paths.js +60 -0
- package/dist/reserved-paths.js.map +1 -0
- package/dist/revalidate-verify.d.ts +30 -0
- package/dist/revalidate-verify.d.ts.map +1 -0
- package/dist/revalidate-verify.js +74 -0
- package/dist/revalidate-verify.js.map +1 -0
- package/dist/sdk.d.ts +678 -0
- package/dist/sdk.d.ts.map +1 -0
- package/dist/sdk.js +801 -0
- package/dist/sdk.js.map +1 -0
- package/dist/seo.d.ts +218 -0
- package/dist/seo.d.ts.map +1 -0
- package/dist/seo.js +309 -0
- package/dist/seo.js.map +1 -0
- package/dist/socials.d.ts +47 -0
- package/dist/socials.d.ts.map +1 -0
- package/dist/socials.js +172 -0
- package/dist/socials.js.map +1 -0
- package/dist/static.d.ts +14 -0
- package/dist/static.d.ts.map +1 -0
- package/dist/static.js +14 -0
- package/dist/static.js.map +1 -0
- package/dist/url-pattern.d.ts +47 -0
- package/dist/url-pattern.d.ts.map +1 -0
- package/dist/url-pattern.js +124 -0
- package/dist/url-pattern.js.map +1 -0
- package/dist/vanilla.d.ts +77 -0
- package/dist/vanilla.d.ts.map +1 -0
- package/dist/vanilla.js +903 -0
- package/dist/vanilla.js.map +1 -0
- package/openapi.json +13779 -0
- package/package.json +204 -0
- package/src/adapters-revalidate.ts +88 -0
- package/src/alpha.ts +89 -0
- package/src/analytics-core.ts +175 -0
- package/src/analytics-server.ts +99 -0
- package/src/angular/biab-form.component.ts +1443 -0
- package/src/angular/index.ts +26 -0
- package/src/attribution-logo.ts +5 -0
- package/src/auth-handler.ts +352 -0
- package/src/cli.ts +2517 -0
- package/src/client.ts +3578 -0
- package/src/collections.ts +101 -0
- package/src/contracts.ts +3390 -0
- package/src/data-model-schema.ts +4042 -0
- package/src/distribution.ts +73 -0
- package/src/element.ts +321 -0
- package/src/enums.ts +65 -0
- package/src/errors.ts +338 -0
- package/src/forms-core/autocomplete.ts +97 -0
- package/src/forms-core/business-function.ts +209 -0
- package/src/forms-core/calculation.ts +167 -0
- package/src/forms-core/controller.ts +922 -0
- package/src/forms-core/elements.ts +86 -0
- package/src/forms-core/email-domains.ts +68 -0
- package/src/forms-core/file-rules.ts +123 -0
- package/src/forms-core/formula.ts +1339 -0
- package/src/forms-core/index.ts +122 -0
- package/src/forms-core/units.ts +308 -0
- package/src/forms-core/us-states.ts +68 -0
- package/src/forms-core/validation.ts +1037 -0
- package/src/forms.ts +63 -0
- package/src/gallery-types.ts +31 -0
- package/src/graphql.ts +295 -0
- package/src/index.ts +505 -0
- package/src/marketing-schema.ts +395 -0
- package/src/mcp.ts +108 -0
- package/src/native.ts +73 -0
- package/src/next-revalidate.ts +124 -0
- package/src/parallel-pages.ts +192 -0
- package/src/payment-icons.ts +205 -0
- package/src/proxy.ts +173 -0
- package/src/qwik.tsx +1234 -0
- package/src/react-analytics.tsx +76 -0
- package/src/react-attribution.tsx +192 -0
- package/src/react-marketing.tsx +338 -0
- package/src/react.tsx +5495 -0
- package/src/reserved-paths.ts +65 -0
- package/src/revalidate-verify.ts +98 -0
- package/src/sdk.ts +1671 -0
- package/src/seo.ts +488 -0
- package/src/socials.ts +230 -0
- package/src/solid.tsx +1466 -0
- package/src/static.ts +21 -0
- package/src/styles/biab-forms.css +484 -0
- package/src/svelte/BiabForm.svelte +1069 -0
- package/src/svelte/context.ts +57 -0
- package/src/svelte/index.ts +79 -0
- package/src/url-pattern.ts +153 -0
- package/src/vanilla.ts +1290 -0
- package/src/vue/BiabForm.vue +1538 -0
- package/src/vue/index.ts +119 -0
|
@@ -0,0 +1,1037 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic, isomorphic form validation + condition evaluation.
|
|
3
|
+
*
|
|
4
|
+
* Zero UI deps, zero React, zero DOM. This is the single source of truth every
|
|
5
|
+
* `<BiabForm>` binding (React / Vue / Svelte / Solid / Qwik / Angular / Element /
|
|
6
|
+
* vanilla) shares. It is a faithful port of three existing BIAB modules so the
|
|
7
|
+
* client never disagrees with the server:
|
|
8
|
+
*
|
|
9
|
+
* - `src/server/forms/validate.ts` — isEmpty / validateValue /
|
|
10
|
+
* evalCondition / findMissingRequired
|
|
11
|
+
* - `src/app/.../builder/FormPreview.tsx` — seedInitialData / concurrentRevealCount /
|
|
12
|
+
* the or_condition + condition_block walk
|
|
13
|
+
* - `biab-dev/src/sdk.ts` validateFormSubmission — the wire-level submission check
|
|
14
|
+
*
|
|
15
|
+
* The schema published by `client.forms.schema(slug)` carries the FULL nested
|
|
16
|
+
* field tree (layout nodes, `condition_block`, `or_condition`, `input_group`,
|
|
17
|
+
* `flex_row` with `children`, plus the resolved output `key` on each
|
|
18
|
+
* value-bearing field). These helpers walk that tree, NOT the flattened
|
|
19
|
+
* `FormFieldDef[]` view.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { getEmailDomain, isConsumerEmailDomain } from "./email-domains.js";
|
|
23
|
+
import {
|
|
24
|
+
baseUnitFor,
|
|
25
|
+
isMeasurementCategory,
|
|
26
|
+
isUnitInCategory,
|
|
27
|
+
unitsForCategory,
|
|
28
|
+
} from "./units.js";
|
|
29
|
+
import { isValidUsZip } from "./us-states.js";
|
|
30
|
+
|
|
31
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
32
|
+
// Structural field type — the nested tree shape the published schema carries.
|
|
33
|
+
// (A superset of the SDK's flat `FormFieldDef`; mirrors the server `FormField`.)
|
|
34
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** A condition clause: one operator (+ optional target value) on a field. */
|
|
37
|
+
export interface FormConditionClause {
|
|
38
|
+
operator: string;
|
|
39
|
+
value?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A field's `conditionRule` — supports the modern multi-clause form and the
|
|
43
|
+
* legacy single `{ operator, value }` form (read as one clause). */
|
|
44
|
+
export interface FormConditionRule {
|
|
45
|
+
fieldId?: string;
|
|
46
|
+
operator?: string;
|
|
47
|
+
value?: string;
|
|
48
|
+
clauses?: FormConditionClause[];
|
|
49
|
+
/** How clauses combine: "all" (default, AND) or "any" (OR). This was missing
|
|
50
|
+
* from the type, so a rule the builder saved as "ANY of" silently evaluated
|
|
51
|
+
* as "ALL of" once it rendered through the SDK. */
|
|
52
|
+
match?: "all" | "any";
|
|
53
|
+
/** Collection pickers store record IDS — compare THIS column of the chosen
|
|
54
|
+
* record instead ("__label__" = the displayed label). Resolved by surfaces
|
|
55
|
+
* that load the picker's options; unresolved falls back to the raw value. */
|
|
56
|
+
matchField?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Structural view of a form field as it arrives on the wire from
|
|
61
|
+
* `client.forms.schema()`. Deliberately permissive: every binding renders by
|
|
62
|
+
* `type`, reads the value-bearing fields, and treats the rest as layout. This
|
|
63
|
+
* mirrors the server `FormField` + the builder's `FieldX`.
|
|
64
|
+
*/
|
|
65
|
+
export interface FormCoreField {
|
|
66
|
+
id: string;
|
|
67
|
+
/** Stable output key for the submission payload (preferred over `id`). */
|
|
68
|
+
key?: string;
|
|
69
|
+
type: string;
|
|
70
|
+
label?: string | null;
|
|
71
|
+
/** Explicit HTML `autocomplete` token for this field (e.g. "email",
|
|
72
|
+
* "given-name", "off"). Overrides the SDK's inference. Optional / forward-
|
|
73
|
+
* compatible — set by the form builder when present. */
|
|
74
|
+
autocomplete?: string;
|
|
75
|
+
required?: boolean;
|
|
76
|
+
/** Consent semantic for boolean fields — the value must be `true` to submit
|
|
77
|
+
* (independent of the toggle/checkbox `display`). */
|
|
78
|
+
mustBeTrue?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Render variant for a field whose validated type stays the same:
|
|
81
|
+
* boolean → toggle/checkbox, checkbox group → dropdown, select → combobox
|
|
82
|
+
* (the searchable "ComboBox" palette card). Display-only — never affects the
|
|
83
|
+
* submitted value or its validation.
|
|
84
|
+
*/
|
|
85
|
+
display?: "toggle" | "checkbox" | "dropdown" | "combobox";
|
|
86
|
+
/** select / radio / checkbox: accept a free-text "Other…" value outside
|
|
87
|
+
* `options` (the options whitelist isn't enforced when on). */
|
|
88
|
+
allowCustom?: boolean;
|
|
89
|
+
placeholder?: string;
|
|
90
|
+
helpText?: string;
|
|
91
|
+
options?: string[];
|
|
92
|
+
defaultValue?: string | boolean | number | null;
|
|
93
|
+
content?: string;
|
|
94
|
+
stepTitle?: string;
|
|
95
|
+
outputKey?: string | null;
|
|
96
|
+
regexPattern?: string;
|
|
97
|
+
concurrentReveal?: boolean;
|
|
98
|
+
showGroupLabel?: boolean;
|
|
99
|
+
children?: FormCoreField[] | null;
|
|
100
|
+
conditionRule?: FormConditionRule;
|
|
101
|
+
validation?: { min?: number; max?: number };
|
|
102
|
+
sliderConfig?: { min?: number; max?: number; step?: number; unit?: string };
|
|
103
|
+
starRatingConfig?: { maxStars?: number; defaultValue?: number };
|
|
104
|
+
fileConfig?: { allowedTypes?: string[]; maxFiles?: number; maxSize?: number };
|
|
105
|
+
/** `icon_select`: which icons may be picked, and the grid width. The value is
|
|
106
|
+
* the icon's kebab-case name. */
|
|
107
|
+
iconConfig?: {
|
|
108
|
+
set?: "lucide";
|
|
109
|
+
allowedIcons?: string[];
|
|
110
|
+
columns?: number;
|
|
111
|
+
};
|
|
112
|
+
/** `image_input`: upload OR paste a URL — the value is one URL string. */
|
|
113
|
+
imageInputConfig?: {
|
|
114
|
+
allowedTypes?: string[];
|
|
115
|
+
maxSize?: number;
|
|
116
|
+
showPreview?: boolean;
|
|
117
|
+
sources?: "both" | "upload" | "url";
|
|
118
|
+
};
|
|
119
|
+
phoneConfig?: { international?: boolean; showFlags?: boolean };
|
|
120
|
+
timeConfig?: {
|
|
121
|
+
mode?: "simple" | "time_block" | "appointment";
|
|
122
|
+
format?: "12h" | "24h";
|
|
123
|
+
stepMinutes?: number;
|
|
124
|
+
};
|
|
125
|
+
orConditionConfig?: { leftLabel?: string; rightLabel?: string };
|
|
126
|
+
/** choice_cards: icon + label options. `single` = radio (value is the chosen
|
|
127
|
+
* label string); `multi` = checkbox (value is a string[] of chosen labels).
|
|
128
|
+
* Options can also carry richer card content (image/price/etc.) — purely
|
|
129
|
+
* display + metadata; the submitted value is always the `label`. */
|
|
130
|
+
choiceConfig?: {
|
|
131
|
+
mode?: "single" | "multi";
|
|
132
|
+
options?: Array<{
|
|
133
|
+
label: string;
|
|
134
|
+
icon?: string;
|
|
135
|
+
imageUrl?: string;
|
|
136
|
+
price?: string;
|
|
137
|
+
description?: string;
|
|
138
|
+
productId?: string;
|
|
139
|
+
href?: string;
|
|
140
|
+
}>;
|
|
141
|
+
};
|
|
142
|
+
/** bullet_list display element: "bullets" = optional title + plain bullets;
|
|
143
|
+
* "details" = icon + heading + description per point. Carries no value. */
|
|
144
|
+
listConfig?: {
|
|
145
|
+
variant?: "bullets" | "details";
|
|
146
|
+
title?: string;
|
|
147
|
+
items?: Array<{ text: string; description?: string; icon?: string }>;
|
|
148
|
+
};
|
|
149
|
+
/** table display element: columns (header/type/align) + rows of cells. A
|
|
150
|
+
* "check" column renders ✓/– from a truthy/falsy cell. Carries no value. */
|
|
151
|
+
tableConfig?: {
|
|
152
|
+
columns?: Array<{
|
|
153
|
+
header: string;
|
|
154
|
+
type?: "text" | "check";
|
|
155
|
+
align?: "left" | "center" | "right";
|
|
156
|
+
}>;
|
|
157
|
+
rows?: Array<{ cells: string[] }>;
|
|
158
|
+
highlightColumn?: number;
|
|
159
|
+
};
|
|
160
|
+
/** multi_populator repeater: value is an array of instance records (each keyed
|
|
161
|
+
* by the child field ids). `maxInstances` caps the count (default 10). */
|
|
162
|
+
populatorConfig?: {
|
|
163
|
+
maxInstances?: number;
|
|
164
|
+
minInstances?: number;
|
|
165
|
+
addButtonLabel?: string;
|
|
166
|
+
itemLabel?: string;
|
|
167
|
+
};
|
|
168
|
+
/** measurement field: number + unit. Value is { value, unit, base, baseUnit }. */
|
|
169
|
+
unitConfig?: {
|
|
170
|
+
category?: string;
|
|
171
|
+
units?: string[];
|
|
172
|
+
mode?: "select" | "fixed";
|
|
173
|
+
defaultUnit?: string;
|
|
174
|
+
outputUnit?: string;
|
|
175
|
+
allowDecimal?: boolean;
|
|
176
|
+
};
|
|
177
|
+
/** number field: when false, only whole numbers are accepted. */
|
|
178
|
+
allowDecimal?: boolean;
|
|
179
|
+
/** number field: when false, negatives are rejected (enforce >= 0). */
|
|
180
|
+
allowNegative?: boolean;
|
|
181
|
+
/** currency field config. */
|
|
182
|
+
currencyConfig?: {
|
|
183
|
+
currency?: string;
|
|
184
|
+
symbol?: string;
|
|
185
|
+
allowNegative?: boolean;
|
|
186
|
+
allowDecimal?: boolean;
|
|
187
|
+
};
|
|
188
|
+
// Forward-compat: tolerate any extra config keys the builder ships later.
|
|
189
|
+
[key: string]: unknown;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A submission's working data — keyed by field `id` internally. */
|
|
193
|
+
export type FormData = Record<string, unknown>;
|
|
194
|
+
|
|
195
|
+
/** A required-field gap, surfaced to the UI for inline error display. */
|
|
196
|
+
export interface MissingField {
|
|
197
|
+
id: string;
|
|
198
|
+
label: string;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
202
|
+
// Shared constants — kept in sync with validate.ts / FormPreview / output-keys.
|
|
203
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
/** Local part @ domain . TLD (TLD ≥ 2 chars). Mirrors validate.ts EMAIL_RE. */
|
|
206
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
|
|
207
|
+
/** An icon name: lowercase kebab-case slug. Mirrors validate.ts ICON_NAME_RE. */
|
|
208
|
+
const ICON_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
209
|
+
/** Valid web address. Mirrors validate.ts URL_RE. */
|
|
210
|
+
const URL_RE =
|
|
211
|
+
/^(https?:\/\/)?([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}(?::\d{1,5})?(?:[/?#]\S*)?$/i;
|
|
212
|
+
/** A hex color: #rgb or #rrggbb. Mirrors validate.ts HEX_COLOR_RE. */
|
|
213
|
+
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;
|
|
214
|
+
|
|
215
|
+
const MAX_STRING_LEN = 50_000;
|
|
216
|
+
const MAX_ARRAY_LEN = 500;
|
|
217
|
+
const MAX_PHONE_LEN = 64;
|
|
218
|
+
const MAX_DATE_LEN = 64;
|
|
219
|
+
const MAX_ADDRESS_FIELD_LEN = 512;
|
|
220
|
+
const MAX_FILE_FIELD_LEN = 4096;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Pure layout / static / grouping types that never carry a submitted value.
|
|
224
|
+
* Mirrors `STATIC_OR_LAYOUT` in validate.ts + FormPreview (note: numeric
|
|
225
|
+
* "stepper" IS value-bearing, so it stays in this skip-set for the tree walk
|
|
226
|
+
* but is still validated when it carries a value).
|
|
227
|
+
*/
|
|
228
|
+
const STATIC_OR_LAYOUT = new Set([
|
|
229
|
+
"title",
|
|
230
|
+
"subtitle",
|
|
231
|
+
"separator",
|
|
232
|
+
"flex_row",
|
|
233
|
+
"input_group",
|
|
234
|
+
"condition_block",
|
|
235
|
+
"or_condition",
|
|
236
|
+
"image",
|
|
237
|
+
"richtext",
|
|
238
|
+
"body_text",
|
|
239
|
+
"bullet_list",
|
|
240
|
+
"table",
|
|
241
|
+
"stepper",
|
|
242
|
+
// `calculation` is computed server-side, never user-input → skip validation.
|
|
243
|
+
"calculation",
|
|
244
|
+
]);
|
|
245
|
+
|
|
246
|
+
/** Containers whose own id never holds a value (their children do). */
|
|
247
|
+
const STEP_CONTAINERS = new Set([
|
|
248
|
+
"input_group",
|
|
249
|
+
"condition_block",
|
|
250
|
+
"or_condition",
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
/** Static elements that carry no value AND get no seeded default. */
|
|
254
|
+
const NO_SEED_STATIC = new Set([
|
|
255
|
+
"title",
|
|
256
|
+
"subtitle",
|
|
257
|
+
"separator",
|
|
258
|
+
"image",
|
|
259
|
+
"richtext",
|
|
260
|
+
"body_text",
|
|
261
|
+
"bullet_list",
|
|
262
|
+
"table",
|
|
263
|
+
// computed on submit — never seeded with a user default.
|
|
264
|
+
"calculation",
|
|
265
|
+
]);
|
|
266
|
+
|
|
267
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
268
|
+
// isEmpty — per-type "is this answered?" rule. Mirrors validate.ts isEmpty.
|
|
269
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/** True when a field's value should be treated as missing for required checks. */
|
|
272
|
+
export function isEmpty(field: FormCoreField, value: unknown): boolean {
|
|
273
|
+
switch (field.type) {
|
|
274
|
+
case "checkbox":
|
|
275
|
+
case "tags":
|
|
276
|
+
return !Array.isArray(value) || value.length === 0;
|
|
277
|
+
case "multi_populator":
|
|
278
|
+
return (
|
|
279
|
+
!Array.isArray(value) ||
|
|
280
|
+
!value.some(
|
|
281
|
+
(inst) =>
|
|
282
|
+
inst != null &&
|
|
283
|
+
typeof inst === "object" &&
|
|
284
|
+
Object.values(inst as Record<string, unknown>).some(
|
|
285
|
+
(v) =>
|
|
286
|
+
!(
|
|
287
|
+
v === undefined ||
|
|
288
|
+
v === null ||
|
|
289
|
+
v === "" ||
|
|
290
|
+
(Array.isArray(v) && v.length === 0)
|
|
291
|
+
),
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
);
|
|
295
|
+
case "measurement": {
|
|
296
|
+
const mv = value as { value?: unknown } | undefined;
|
|
297
|
+
return !mv || typeof mv.value !== "number";
|
|
298
|
+
}
|
|
299
|
+
case "url": {
|
|
300
|
+
const v = value as { url?: unknown } | undefined;
|
|
301
|
+
return !v || typeof v.url !== "string" || v.url === "";
|
|
302
|
+
}
|
|
303
|
+
case "choice_cards":
|
|
304
|
+
return field.choiceConfig?.mode === "multi"
|
|
305
|
+
? !Array.isArray(value) || value.length === 0
|
|
306
|
+
: typeof value !== "string" || value === "";
|
|
307
|
+
case "boolean":
|
|
308
|
+
// mustBeTrue (consent) → empty unless exactly `true`; otherwise answered
|
|
309
|
+
// by either choice (an optional yes/no can stay "no" and still submit).
|
|
310
|
+
return field.mustBeTrue ? value !== true : typeof value !== "boolean";
|
|
311
|
+
case "file_upload":
|
|
312
|
+
return !Array.isArray(value) || value.length === 0;
|
|
313
|
+
case "schedule": {
|
|
314
|
+
const s = (value ?? {}) as {
|
|
315
|
+
slotStartAt?: unknown;
|
|
316
|
+
asap?: boolean;
|
|
317
|
+
days?: unknown[];
|
|
318
|
+
times?: unknown[];
|
|
319
|
+
};
|
|
320
|
+
return !(
|
|
321
|
+
(typeof s.slotStartAt === "string" && s.slotStartAt.length > 0) ||
|
|
322
|
+
s.asap === true ||
|
|
323
|
+
(Array.isArray(s.days) && s.days.length > 0) ||
|
|
324
|
+
(Array.isArray(s.times) && s.times.length > 0)
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
case "date_range": {
|
|
328
|
+
const v = value as { start?: string; end?: string } | undefined;
|
|
329
|
+
return !v?.start || !v.end;
|
|
330
|
+
}
|
|
331
|
+
case "address": {
|
|
332
|
+
const v = value as
|
|
333
|
+
| { line1?: string; city?: string; state?: string; zip?: string }
|
|
334
|
+
| undefined;
|
|
335
|
+
return !v?.line1 || !v.city || !v.state || !v.zip;
|
|
336
|
+
}
|
|
337
|
+
case "time_picker": {
|
|
338
|
+
const mode = field.timeConfig?.mode ?? "simple";
|
|
339
|
+
if (mode === "time_block") {
|
|
340
|
+
const v = value as { start?: string; end?: string } | undefined;
|
|
341
|
+
return !v?.start || !v.end;
|
|
342
|
+
}
|
|
343
|
+
if (mode === "appointment") {
|
|
344
|
+
const v = value as { date?: string; time?: string } | undefined;
|
|
345
|
+
return !v?.date || !v.time;
|
|
346
|
+
}
|
|
347
|
+
return value === undefined || value === null || value === "";
|
|
348
|
+
}
|
|
349
|
+
case "star_rating":
|
|
350
|
+
case "happiness_rating":
|
|
351
|
+
return typeof value !== "number" || value <= 0;
|
|
352
|
+
default:
|
|
353
|
+
return value === undefined || value === null || value === "";
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
358
|
+
// validateValue — per-value type / constraint check. Mirrors validate.ts.
|
|
359
|
+
// Returns an error reason string, or null when the value conforms. Empty values
|
|
360
|
+
// pass here (required-ness is checked separately).
|
|
361
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
function regexFail(field: FormCoreField, value: string): string | null {
|
|
364
|
+
const pattern = field.regexPattern;
|
|
365
|
+
if (!pattern) return null;
|
|
366
|
+
try {
|
|
367
|
+
if (!new RegExp(pattern).test(value)) return "regex";
|
|
368
|
+
} catch {
|
|
369
|
+
// A broken stored pattern shouldn't block a submission.
|
|
370
|
+
}
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
375
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** True when every leaf is a bounded primitive (no nesting). */
|
|
379
|
+
function onlyBoundedPrimitives(
|
|
380
|
+
obj: Record<string, unknown>,
|
|
381
|
+
maxLen: number,
|
|
382
|
+
): boolean {
|
|
383
|
+
for (const v of Object.values(obj)) {
|
|
384
|
+
if (v == null) continue;
|
|
385
|
+
if (typeof v === "string") {
|
|
386
|
+
if (v.length > maxLen) return false;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
if (typeof v === "number" || typeof v === "boolean") continue;
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
return true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Validate one value against its field definition. Returns an error reason
|
|
397
|
+
* (`"type"`, `"format"`, `"min"`, `"max"`, `"options"`, `"regex"`), or null when
|
|
398
|
+
* the value conforms. Lenient where the renderer's output is ambiguous (numeric
|
|
399
|
+
* fields accept numeric strings) so legitimate live submissions never break.
|
|
400
|
+
* Faithful port of `validateValue` in src/server/forms/validate.ts.
|
|
401
|
+
*/
|
|
402
|
+
export function validateValue(
|
|
403
|
+
field: FormCoreField,
|
|
404
|
+
value: unknown,
|
|
405
|
+
): string | null {
|
|
406
|
+
if (value === undefined || value === null || value === "") return null;
|
|
407
|
+
const opts = Array.isArray(field.options) ? field.options : null;
|
|
408
|
+
|
|
409
|
+
switch (field.type) {
|
|
410
|
+
case "email": {
|
|
411
|
+
if (typeof value !== "string") return "type";
|
|
412
|
+
if (value.length > MAX_STRING_LEN) return "max";
|
|
413
|
+
if (!EMAIL_RE.test(value)) return "format";
|
|
414
|
+
if (
|
|
415
|
+
(field as { emailConfig?: { businessOnly?: boolean } }).emailConfig
|
|
416
|
+
?.businessOnly &&
|
|
417
|
+
isConsumerEmailDomain(getEmailDomain(value) ?? "")
|
|
418
|
+
) {
|
|
419
|
+
return "business_email";
|
|
420
|
+
}
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
case "url": {
|
|
424
|
+
// Value object: { url, verified } (verified is stamped server-side).
|
|
425
|
+
if (!isPlainObject(value)) return "type";
|
|
426
|
+
const u = value["url"];
|
|
427
|
+
if (u === undefined || u === null || u === "") return null;
|
|
428
|
+
if (typeof u !== "string") return "type";
|
|
429
|
+
if (u.length > MAX_STRING_LEN) return "max";
|
|
430
|
+
if (!URL_RE.test(u.trim())) return "format";
|
|
431
|
+
const verified = value["verified"];
|
|
432
|
+
if (
|
|
433
|
+
verified !== undefined &&
|
|
434
|
+
verified !== "unverified" &&
|
|
435
|
+
verified !== "safe" &&
|
|
436
|
+
verified !== "unsafe"
|
|
437
|
+
) {
|
|
438
|
+
return "options";
|
|
439
|
+
}
|
|
440
|
+
return onlyBoundedPrimitives(value, MAX_STRING_LEN) ? null : "type";
|
|
441
|
+
}
|
|
442
|
+
case "phone": {
|
|
443
|
+
if (typeof value !== "string") return "type";
|
|
444
|
+
if (value.length > MAX_PHONE_LEN) return "max";
|
|
445
|
+
const rx = regexFail(field, value);
|
|
446
|
+
if (rx) return rx;
|
|
447
|
+
const digits = value.replace(/\D/g, "");
|
|
448
|
+
if (field.phoneConfig?.international === true) {
|
|
449
|
+
return digits.length >= 7 && digits.length <= 15 ? null : "format";
|
|
450
|
+
}
|
|
451
|
+
const national =
|
|
452
|
+
digits.length === 11 && digits.startsWith("1")
|
|
453
|
+
? digits.slice(1)
|
|
454
|
+
: digits;
|
|
455
|
+
return national.length === 10 ? null : "format";
|
|
456
|
+
}
|
|
457
|
+
case "number":
|
|
458
|
+
case "slider":
|
|
459
|
+
case "stepper": {
|
|
460
|
+
const n =
|
|
461
|
+
typeof value === "number"
|
|
462
|
+
? value
|
|
463
|
+
: typeof value === "string" &&
|
|
464
|
+
value.trim() !== "" &&
|
|
465
|
+
!Number.isNaN(Number(value))
|
|
466
|
+
? Number(value)
|
|
467
|
+
: Number.NaN;
|
|
468
|
+
if (Number.isNaN(n)) return "type";
|
|
469
|
+
if (
|
|
470
|
+
field.type === "number" &&
|
|
471
|
+
field.allowDecimal === false &&
|
|
472
|
+
!Number.isInteger(n)
|
|
473
|
+
)
|
|
474
|
+
return "format";
|
|
475
|
+
// number "allow negative" off → enforce >= 0.
|
|
476
|
+
if (
|
|
477
|
+
field.type === "number" &&
|
|
478
|
+
(field as { allowNegative?: boolean }).allowNegative === false &&
|
|
479
|
+
n < 0
|
|
480
|
+
)
|
|
481
|
+
return "min";
|
|
482
|
+
const min = field.validation?.min ?? field.sliderConfig?.min;
|
|
483
|
+
const max = field.validation?.max ?? field.sliderConfig?.max;
|
|
484
|
+
if (typeof min === "number" && n < min) return "min";
|
|
485
|
+
if (typeof max === "number" && n > max) return "max";
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
case "star_rating":
|
|
489
|
+
case "happiness_rating": {
|
|
490
|
+
if (typeof value !== "number") return "type";
|
|
491
|
+
const maxStars = field.starRatingConfig?.maxStars ?? 5;
|
|
492
|
+
if (value < 0 || value > maxStars + 0.0001) return "max";
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
case "boolean":
|
|
496
|
+
return typeof value === "boolean" ? null : "type";
|
|
497
|
+
case "select":
|
|
498
|
+
case "radio":
|
|
499
|
+
if (typeof value !== "string") return "type";
|
|
500
|
+
if (value.length > MAX_STRING_LEN) return "max";
|
|
501
|
+
if (opts && !field.allowCustom && !opts.includes(value)) return "options";
|
|
502
|
+
return null;
|
|
503
|
+
case "color_picker":
|
|
504
|
+
// A color is a hex value — presets and the "Other" custom picker both
|
|
505
|
+
// emit one, so validate the format rather than options membership.
|
|
506
|
+
if (typeof value !== "string") return "type";
|
|
507
|
+
if (value !== "" && !HEX_COLOR_RE.test(value)) return "format";
|
|
508
|
+
return null;
|
|
509
|
+
case "icon_select": {
|
|
510
|
+
// An icon NAME (kebab-case slug), e.g. "cloud-sun". Checked as a slug
|
|
511
|
+
// rather than against a pinned icon manifest, matching the server —
|
|
512
|
+
// the set can grow without either side needing a release.
|
|
513
|
+
if (typeof value !== "string") return "type";
|
|
514
|
+
if (value === "") return null;
|
|
515
|
+
if (value.length > 64) return "max";
|
|
516
|
+
if (!ICON_NAME_RE.test(value)) return "format";
|
|
517
|
+
const allowed = (field as { iconConfig?: { allowedIcons?: string[] } })
|
|
518
|
+
.iconConfig?.allowedIcons;
|
|
519
|
+
if (allowed && allowed.length > 0 && !allowed.includes(value)) {
|
|
520
|
+
return "options";
|
|
521
|
+
}
|
|
522
|
+
return null;
|
|
523
|
+
}
|
|
524
|
+
case "image_input":
|
|
525
|
+
// One image URL, whether uploaded or pasted. A plain string — unlike
|
|
526
|
+
// `url`, which carries a { url, verified } object.
|
|
527
|
+
if (typeof value !== "string") return "type";
|
|
528
|
+
if (value === "") return null;
|
|
529
|
+
if (value.length > MAX_STRING_LEN) return "max";
|
|
530
|
+
if (!URL_RE.test(value.trim())) return "format";
|
|
531
|
+
return null;
|
|
532
|
+
case "tags": {
|
|
533
|
+
// Free-text tag array — string items, no fixed options list.
|
|
534
|
+
if (!Array.isArray(value)) return "type";
|
|
535
|
+
if (value.length > MAX_ARRAY_LEN) return "max";
|
|
536
|
+
for (const v of value) {
|
|
537
|
+
if (typeof v !== "string") return "type";
|
|
538
|
+
if (v.length > MAX_STRING_LEN) return "max";
|
|
539
|
+
}
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
case "checkbox": {
|
|
543
|
+
if (!Array.isArray(value)) return "type";
|
|
544
|
+
if (value.length > MAX_ARRAY_LEN) return "max";
|
|
545
|
+
for (const v of value) {
|
|
546
|
+
if (typeof v !== "string") return "type";
|
|
547
|
+
if (opts && !field.allowCustom && !opts.includes(v)) return "options";
|
|
548
|
+
}
|
|
549
|
+
return null;
|
|
550
|
+
}
|
|
551
|
+
case "currency": {
|
|
552
|
+
if (typeof value !== "number" || Number.isNaN(value)) return "type";
|
|
553
|
+
if (field.currencyConfig?.allowNegative !== true && value < 0)
|
|
554
|
+
return "format";
|
|
555
|
+
if (
|
|
556
|
+
field.currencyConfig?.allowDecimal === false &&
|
|
557
|
+
!Number.isInteger(value)
|
|
558
|
+
)
|
|
559
|
+
return "format";
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
case "measurement": {
|
|
563
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
564
|
+
return "type";
|
|
565
|
+
const mv = value as { value?: unknown; unit?: unknown };
|
|
566
|
+
if (mv.value === "" || mv.value === undefined || mv.value === null)
|
|
567
|
+
return null;
|
|
568
|
+
if (typeof mv.value !== "number" || Number.isNaN(mv.value)) return "type";
|
|
569
|
+
if (typeof mv.unit !== "string") return "type";
|
|
570
|
+
const cat = field.unitConfig?.category;
|
|
571
|
+
if (cat && isMeasurementCategory(cat) && !isUnitInCategory(mv.unit, cat))
|
|
572
|
+
return "options";
|
|
573
|
+
if (
|
|
574
|
+
field.unitConfig?.allowDecimal === false &&
|
|
575
|
+
!Number.isInteger(mv.value)
|
|
576
|
+
)
|
|
577
|
+
return "format";
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
case "multi_populator": {
|
|
581
|
+
// Lenient client check (the server validates each instance deeply):
|
|
582
|
+
// must be an array within the instance cap.
|
|
583
|
+
if (!Array.isArray(value)) return "type";
|
|
584
|
+
const max = field.populatorConfig?.maxInstances ?? 10;
|
|
585
|
+
if (value.length > Math.min(max, MAX_ARRAY_LEN)) return "max";
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
588
|
+
case "choice_cards": {
|
|
589
|
+
const labels = (field.choiceConfig?.options ?? []).map((o) => o.label);
|
|
590
|
+
if (field.choiceConfig?.mode === "multi") {
|
|
591
|
+
if (!Array.isArray(value)) return "type";
|
|
592
|
+
if (value.length > MAX_ARRAY_LEN) return "max";
|
|
593
|
+
for (const v of value) {
|
|
594
|
+
if (typeof v !== "string") return "type";
|
|
595
|
+
if (labels.length && !labels.includes(v)) return "options";
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
if (typeof value !== "string") return "type";
|
|
600
|
+
if (value.length > MAX_STRING_LEN) return "max";
|
|
601
|
+
if (labels.length && !labels.includes(value)) return "options";
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
case "file_upload": {
|
|
605
|
+
if (!Array.isArray(value)) return "type";
|
|
606
|
+
const maxFiles = Math.min(
|
|
607
|
+
field.fileConfig?.maxFiles ?? MAX_ARRAY_LEN,
|
|
608
|
+
MAX_ARRAY_LEN,
|
|
609
|
+
);
|
|
610
|
+
if (value.length > maxFiles) return "max";
|
|
611
|
+
for (const item of value) {
|
|
612
|
+
if (!isPlainObject(item)) return "type";
|
|
613
|
+
if (!onlyBoundedPrimitives(item, MAX_FILE_FIELD_LEN)) return "type";
|
|
614
|
+
}
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
case "date_range": {
|
|
618
|
+
if (!isPlainObject(value)) return "type";
|
|
619
|
+
return onlyBoundedPrimitives(value, MAX_DATE_LEN) ? null : "type";
|
|
620
|
+
}
|
|
621
|
+
case "time_picker": {
|
|
622
|
+
const mode = field.timeConfig?.mode ?? "simple";
|
|
623
|
+
if (mode === "simple") {
|
|
624
|
+
if (typeof value !== "string") return "type";
|
|
625
|
+
return value.length > MAX_DATE_LEN ? "max" : null;
|
|
626
|
+
}
|
|
627
|
+
if (!isPlainObject(value)) return "type";
|
|
628
|
+
return onlyBoundedPrimitives(value, MAX_DATE_LEN) ? null : "type";
|
|
629
|
+
}
|
|
630
|
+
case "address": {
|
|
631
|
+
if (!isPlainObject(value)) return "type";
|
|
632
|
+
if (!onlyBoundedPrimitives(value, MAX_ADDRESS_FIELD_LEN)) return "type";
|
|
633
|
+
// ZIP, when present, must be a US ZIP (5 digits or ZIP+4) — numbers only.
|
|
634
|
+
const zip = (value as { zip?: unknown }).zip;
|
|
635
|
+
if (typeof zip === "string" && !isValidUsZip(zip)) return "format";
|
|
636
|
+
return null;
|
|
637
|
+
}
|
|
638
|
+
case "schedule": {
|
|
639
|
+
// Availability value: { asap?, days?: string[], times?: string[], notes? }.
|
|
640
|
+
// Auto value: { slotStartAt: <ISO> } (the chosen instant-booking slot).
|
|
641
|
+
if (!isPlainObject(value)) return "type";
|
|
642
|
+
const s = value as {
|
|
643
|
+
slotStartAt?: unknown;
|
|
644
|
+
asap?: unknown;
|
|
645
|
+
days?: unknown;
|
|
646
|
+
times?: unknown;
|
|
647
|
+
notes?: unknown;
|
|
648
|
+
};
|
|
649
|
+
if (s.slotStartAt !== undefined) {
|
|
650
|
+
if (typeof s.slotStartAt !== "string") return "type";
|
|
651
|
+
if (s.slotStartAt.length > MAX_DATE_LEN) return "max";
|
|
652
|
+
if (Number.isNaN(Date.parse(s.slotStartAt))) return "format";
|
|
653
|
+
}
|
|
654
|
+
if (s.asap !== undefined && typeof s.asap !== "boolean") return "type";
|
|
655
|
+
for (const key of ["days", "times"] as const) {
|
|
656
|
+
const arr = s[key];
|
|
657
|
+
if (arr === undefined) continue;
|
|
658
|
+
if (!Array.isArray(arr)) return "type";
|
|
659
|
+
if (arr.length > MAX_ARRAY_LEN) return "max";
|
|
660
|
+
for (const item of arr) {
|
|
661
|
+
if (typeof item !== "string") return "type";
|
|
662
|
+
if (item.length > MAX_DATE_LEN) return "max";
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (s.notes !== undefined) {
|
|
666
|
+
if (typeof s.notes !== "string") return "type";
|
|
667
|
+
if (s.notes.length > MAX_STRING_LEN) return "max";
|
|
668
|
+
}
|
|
669
|
+
return null;
|
|
670
|
+
}
|
|
671
|
+
default: {
|
|
672
|
+
// text / textarea / date / and any plain-text input.
|
|
673
|
+
if (typeof value === "number" || typeof value === "boolean") return null;
|
|
674
|
+
if (typeof value !== "string") return "type";
|
|
675
|
+
if (value.length > MAX_STRING_LEN) return "max";
|
|
676
|
+
return regexFail(field, value);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
682
|
+
// Condition evaluation — condition_block + or_condition. Mirrors validate.ts.
|
|
683
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
684
|
+
|
|
685
|
+
function evalClause(value: unknown, operator: string, target: string): boolean {
|
|
686
|
+
switch (operator) {
|
|
687
|
+
case "eq":
|
|
688
|
+
return String(value ?? "") === target;
|
|
689
|
+
case "neq":
|
|
690
|
+
return String(value ?? "") !== target;
|
|
691
|
+
case "contains":
|
|
692
|
+
return String(value ?? "")
|
|
693
|
+
.toLowerCase()
|
|
694
|
+
.includes(target.toLowerCase());
|
|
695
|
+
case "gt":
|
|
696
|
+
return Number(value) > Number(target);
|
|
697
|
+
case "lt":
|
|
698
|
+
return Number(value) < Number(target);
|
|
699
|
+
case "is_empty":
|
|
700
|
+
return value === undefined || value === null || value === "";
|
|
701
|
+
case "is_not_empty":
|
|
702
|
+
return value !== undefined && value !== null && value !== "";
|
|
703
|
+
default:
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* A condition passes when its field satisfies ALL clauses (AND). Back-compat: a
|
|
710
|
+
* legacy single `{ operator, value }` rule is read as one clause. An absent rule
|
|
711
|
+
* (or one without a `fieldId`) passes. Faithful port of `evalCondition`.
|
|
712
|
+
*/
|
|
713
|
+
export function evalCondition(
|
|
714
|
+
rule: FormConditionRule | undefined,
|
|
715
|
+
data: FormData,
|
|
716
|
+
): boolean {
|
|
717
|
+
if (!rule?.fieldId) return true;
|
|
718
|
+
const clauses =
|
|
719
|
+
rule.clauses && rule.clauses.length > 0
|
|
720
|
+
? rule.clauses
|
|
721
|
+
: rule.operator
|
|
722
|
+
? [{ operator: rule.operator, value: rule.value }]
|
|
723
|
+
: [];
|
|
724
|
+
if (clauses.length === 0) return true;
|
|
725
|
+
const value = data[rule.fieldId];
|
|
726
|
+
const test = (c: { operator: string; value?: string | undefined }) =>
|
|
727
|
+
evalClause(value, c.operator, c.value ?? "");
|
|
728
|
+
// `match: "any"` is OR across clauses. This used to always AND, so a rule the
|
|
729
|
+
// builder saved as "ANY of" behaved as "ALL of" once it rendered through the
|
|
730
|
+
// SDK — the dashboard and a consumer's site disagreed about the same form.
|
|
731
|
+
return rule.match === "any" ? clauses.some(test) : clauses.every(test);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Is a gating container currently visible?
|
|
736
|
+
*
|
|
737
|
+
* `condition_block` has always gated on its rule. `input_group` now does too:
|
|
738
|
+
* at the root of a consecutive form a group IS a page, so a rule there makes the
|
|
739
|
+
* whole page conditional. Every other type is unconditionally visible — a stray
|
|
740
|
+
* rule on a text field is ignored rather than hiding it.
|
|
741
|
+
*/
|
|
742
|
+
export function isNodeVisible(
|
|
743
|
+
node: { type?: string; conditionRule?: FormConditionRule },
|
|
744
|
+
data: FormData,
|
|
745
|
+
): boolean {
|
|
746
|
+
if (node.type !== "condition_block" && node.type !== "input_group") {
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
return evalCondition(node.conditionRule, data);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
753
|
+
// Tree walks — subtreeHasValue / findMissingRequired / concurrentRevealCount.
|
|
754
|
+
// Mirrors FormPreview.tsx exactly so the binding matches the dashboard renderer.
|
|
755
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
756
|
+
|
|
757
|
+
/** Recurse a subtree; report whether any non-static leaf has a non-empty value.
|
|
758
|
+
* Used by `or_condition` to detect which side the user chose. */
|
|
759
|
+
function subtreeHasValue(nodes: FormCoreField[], data: FormData): boolean {
|
|
760
|
+
for (const f of nodes) {
|
|
761
|
+
if (f.type === "condition_block") {
|
|
762
|
+
if (!evalCondition(f.conditionRule, data)) continue;
|
|
763
|
+
if (f.children && subtreeHasValue(f.children, data)) return true;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
if (
|
|
767
|
+
f.type === "or_condition" ||
|
|
768
|
+
f.type === "input_group" ||
|
|
769
|
+
f.type === "flex_row"
|
|
770
|
+
) {
|
|
771
|
+
// Stale data behind a hidden group must not decide an or_condition
|
|
772
|
+
// branch — as far as the visitor is concerned it holds nothing.
|
|
773
|
+
if (!isNodeVisible(f, data)) continue;
|
|
774
|
+
if (f.children && subtreeHasValue(f.children, data)) return true;
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
if (STATIC_OR_LAYOUT.has(f.type)) continue;
|
|
778
|
+
if (f.id && !isEmpty(f, data[f.id])) return true;
|
|
779
|
+
}
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Returns the required fields that haven't been filled in, evaluated under the
|
|
785
|
+
* current condition state. A `condition_block` whose rule is false is skipped
|
|
786
|
+
* with its children; an `or_condition` requires at least one side filled, then
|
|
787
|
+
* recurses only into filled sides. Non-empty text/number values must also
|
|
788
|
+
* satisfy the field's regex pattern. Faithful port of FormPreview's
|
|
789
|
+
* `findMissingRequired`.
|
|
790
|
+
*/
|
|
791
|
+
export function findMissingRequired(
|
|
792
|
+
fields: FormCoreField[],
|
|
793
|
+
data: FormData,
|
|
794
|
+
): MissingField[] {
|
|
795
|
+
const missing: MissingField[] = [];
|
|
796
|
+
// `scope` is the data context: the whole submission, or one instance record
|
|
797
|
+
// inside a multi_populator.
|
|
798
|
+
const walk = (nodes: FormCoreField[], scope: FormData) => {
|
|
799
|
+
for (const f of nodes) {
|
|
800
|
+
if (f.type === "condition_block") {
|
|
801
|
+
if (!evalCondition(f.conditionRule, scope)) continue;
|
|
802
|
+
if (f.children) walk(f.children, scope);
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
if (f.type === "or_condition") {
|
|
806
|
+
// Up to N branches (sides). At least one must be filled; then every
|
|
807
|
+
// filled branch is walked for its own required fields.
|
|
808
|
+
const children = f.children ?? [];
|
|
809
|
+
const filled = children.map((c) => subtreeHasValue([c], scope));
|
|
810
|
+
if (children.length > 0 && !filled.some(Boolean)) {
|
|
811
|
+
missing.push({
|
|
812
|
+
id: f.id,
|
|
813
|
+
label: f.label || "Either side required",
|
|
814
|
+
});
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
children.forEach((c, i) => {
|
|
818
|
+
if (filled[i]) walk([c], scope);
|
|
819
|
+
});
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (f.type === "input_group" || f.type === "flex_row") {
|
|
823
|
+
// A conditional PAGE that isn't showing requires nothing — the
|
|
824
|
+
// visitor never saw it, so it must not block Next or Submit.
|
|
825
|
+
if (!isNodeVisible(f, scope)) continue;
|
|
826
|
+
if (f.children) walk(f.children, scope);
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
if (f.type === "multi_populator") {
|
|
830
|
+
const raw = scope[f.id];
|
|
831
|
+
const instances = (Array.isArray(raw) ? raw : []).filter(
|
|
832
|
+
(x): x is FormData =>
|
|
833
|
+
x != null && typeof x === "object" && !Array.isArray(x),
|
|
834
|
+
);
|
|
835
|
+
const nonEmpty = instances.filter((inst) =>
|
|
836
|
+
Object.values(inst).some(
|
|
837
|
+
(v) =>
|
|
838
|
+
!(
|
|
839
|
+
v === undefined ||
|
|
840
|
+
v === null ||
|
|
841
|
+
v === "" ||
|
|
842
|
+
(Array.isArray(v) && v.length === 0)
|
|
843
|
+
),
|
|
844
|
+
),
|
|
845
|
+
);
|
|
846
|
+
if (f.required && nonEmpty.length === 0) {
|
|
847
|
+
missing.push({
|
|
848
|
+
id: f.id,
|
|
849
|
+
label: f.label || "At least one entry required",
|
|
850
|
+
});
|
|
851
|
+
continue;
|
|
852
|
+
}
|
|
853
|
+
for (const inst of nonEmpty) walk(f.children ?? [], inst);
|
|
854
|
+
continue;
|
|
855
|
+
}
|
|
856
|
+
if (STATIC_OR_LAYOUT.has(f.type)) continue;
|
|
857
|
+
if ((f.required || f.mustBeTrue) && f.id && isEmpty(f, scope[f.id])) {
|
|
858
|
+
missing.push({ id: f.id, label: f.label || "Untitled" });
|
|
859
|
+
} else if (f.id && (f.type === "text" || f.type === "number")) {
|
|
860
|
+
const pattern = f.regexPattern;
|
|
861
|
+
const v = String(scope[f.id] ?? "");
|
|
862
|
+
if (pattern && v) {
|
|
863
|
+
try {
|
|
864
|
+
if (!new RegExp(pattern).test(v)) {
|
|
865
|
+
missing.push({ id: f.id, label: f.label || "Invalid format" });
|
|
866
|
+
}
|
|
867
|
+
} catch {
|
|
868
|
+
/* ignore an invalid pattern */
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
};
|
|
874
|
+
walk(fields, data);
|
|
875
|
+
return missing;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* For a "concurrent questions" group: how many leading children to reveal. Shows
|
|
880
|
+
* every already-satisfied child plus the first unsatisfied one (the active
|
|
881
|
+
* question), so each required field gates the next. Optional/empty fields count
|
|
882
|
+
* as satisfied. Faithful port of FormPreview's `concurrentRevealCount`.
|
|
883
|
+
*/
|
|
884
|
+
export function concurrentRevealCount(
|
|
885
|
+
children: FormCoreField[],
|
|
886
|
+
data: FormData,
|
|
887
|
+
): number {
|
|
888
|
+
for (let i = 0; i < children.length; i++) {
|
|
889
|
+
const child = children[i];
|
|
890
|
+
if (child && findMissingRequired([child], data).length > 0) return i + 1;
|
|
891
|
+
}
|
|
892
|
+
return children.length;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
896
|
+
// seedInitialData — walk the tree once, seed every input's id with a default.
|
|
897
|
+
// Mirrors FormPreview's seedInitialData + defaultValueFor.
|
|
898
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
899
|
+
|
|
900
|
+
function defaultValueFor(field: FormCoreField): unknown {
|
|
901
|
+
switch (field.type) {
|
|
902
|
+
case "checkbox":
|
|
903
|
+
case "tags":
|
|
904
|
+
return [];
|
|
905
|
+
case "choice_cards":
|
|
906
|
+
return field.choiceConfig?.mode === "multi" ? [] : "";
|
|
907
|
+
case "multi_populator":
|
|
908
|
+
return [{}];
|
|
909
|
+
case "measurement": {
|
|
910
|
+
const cat = isMeasurementCategory(field.unitConfig?.category)
|
|
911
|
+
? field.unitConfig.category
|
|
912
|
+
: "length";
|
|
913
|
+
const units = unitsForCategory(cat);
|
|
914
|
+
const unit = field.unitConfig?.defaultUnit ?? units[0] ?? "";
|
|
915
|
+
return { value: "", unit, baseUnit: baseUnitFor(cat) };
|
|
916
|
+
}
|
|
917
|
+
case "boolean":
|
|
918
|
+
return field.defaultValue === true;
|
|
919
|
+
case "number":
|
|
920
|
+
case "stepper":
|
|
921
|
+
case "slider":
|
|
922
|
+
case "star_rating":
|
|
923
|
+
case "happiness_rating":
|
|
924
|
+
return 0;
|
|
925
|
+
case "date_range":
|
|
926
|
+
return { start: "", end: "" };
|
|
927
|
+
case "time_picker": {
|
|
928
|
+
const mode = field.timeConfig?.mode ?? "simple";
|
|
929
|
+
if (mode === "time_block") return { start: "", end: "" };
|
|
930
|
+
if (mode === "appointment") return { date: "", time: "" };
|
|
931
|
+
return "";
|
|
932
|
+
}
|
|
933
|
+
default:
|
|
934
|
+
return typeof field.defaultValue === "string" ? field.defaultValue : "";
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/**
|
|
939
|
+
* Walk the field tree once and seed every value-bearing input's id with a
|
|
940
|
+
* type-appropriate default. Static/layout/container nodes are skipped so no
|
|
941
|
+
* UUID-keyed empty string leaks into the payload (which the server would reject
|
|
942
|
+
* as an unknown field). Faithful port of FormPreview's `seedInitialData`.
|
|
943
|
+
*/
|
|
944
|
+
export function seedInitialData(fields: FormCoreField[]): FormData {
|
|
945
|
+
const out: FormData = {};
|
|
946
|
+
const walk = (nodes: FormCoreField[]) => {
|
|
947
|
+
for (const f of nodes) {
|
|
948
|
+
// A populator is value-bearing (array of instances); its children live
|
|
949
|
+
// inside that array, so seed its value and DON'T hoist children up.
|
|
950
|
+
if (f.type === "multi_populator") {
|
|
951
|
+
if (f.id) out[f.id] = defaultValueFor(f);
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (
|
|
955
|
+
f.id &&
|
|
956
|
+
!NO_SEED_STATIC.has(f.type) &&
|
|
957
|
+
!STEP_CONTAINERS.has(f.type) &&
|
|
958
|
+
f.type !== "flex_row"
|
|
959
|
+
) {
|
|
960
|
+
out[f.id] = defaultValueFor(f);
|
|
961
|
+
}
|
|
962
|
+
if (f.children) walk(f.children);
|
|
963
|
+
}
|
|
964
|
+
};
|
|
965
|
+
walk(fields);
|
|
966
|
+
return out;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
970
|
+
// Submission-key remapping — id ⇆ output key. Lets the controller emit a
|
|
971
|
+
// payload keyed by the published `key` (preferred wire contract) while working
|
|
972
|
+
// internally by `id`. The server accepts either (back-compat).
|
|
973
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
974
|
+
|
|
975
|
+
/** Flat list of value-bearing fields (depth-first, document order). */
|
|
976
|
+
export function flattenValueFields(fields: FormCoreField[]): FormCoreField[] {
|
|
977
|
+
const out: FormCoreField[] = [];
|
|
978
|
+
const walk = (nodes: FormCoreField[]) => {
|
|
979
|
+
for (const f of nodes) {
|
|
980
|
+
// A populator is a single array-valued field; its children are nested in
|
|
981
|
+
// the array, not top-level — include it but don't descend.
|
|
982
|
+
if (f.type === "multi_populator") {
|
|
983
|
+
if (f.id) out.push(f);
|
|
984
|
+
continue;
|
|
985
|
+
}
|
|
986
|
+
if (!STATIC_OR_LAYOUT.has(f.type) && f.id) out.push(f);
|
|
987
|
+
// "stepper" is in STATIC_OR_LAYOUT for the layout-skip semantics but is a
|
|
988
|
+
// value-bearing numeric input — include it explicitly.
|
|
989
|
+
else if (f.type === "stepper" && f.id) out.push(f);
|
|
990
|
+
if (f.children) walk(f.children);
|
|
991
|
+
}
|
|
992
|
+
};
|
|
993
|
+
walk(fields);
|
|
994
|
+
return out;
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
/**
|
|
998
|
+
* Re-key working data (keyed by field `id`) to the published output `key` for
|
|
999
|
+
* the wire payload. Fields without a published `key` keep their `id`. Only
|
|
1000
|
+
* value-bearing fields present in `data` are emitted.
|
|
1001
|
+
*/
|
|
1002
|
+
export function toSubmissionPayload(
|
|
1003
|
+
fields: FormCoreField[],
|
|
1004
|
+
data: FormData,
|
|
1005
|
+
): FormData {
|
|
1006
|
+
const remapScope = (
|
|
1007
|
+
scopeFields: FormCoreField[],
|
|
1008
|
+
scopeData: FormData,
|
|
1009
|
+
): FormData => {
|
|
1010
|
+
const byId = new Map(
|
|
1011
|
+
flattenValueFields(scopeFields).map((field) => [field.id, field]),
|
|
1012
|
+
);
|
|
1013
|
+
const out: FormData = {};
|
|
1014
|
+
|
|
1015
|
+
for (const [id, inputValue] of Object.entries(scopeData)) {
|
|
1016
|
+
const field = byId.get(id);
|
|
1017
|
+
if (!field) {
|
|
1018
|
+
out[id] = inputValue;
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
let value = inputValue;
|
|
1023
|
+
if (field.type === "multi_populator" && Array.isArray(inputValue)) {
|
|
1024
|
+
value = inputValue.map((instance) =>
|
|
1025
|
+
instance && typeof instance === "object" && !Array.isArray(instance)
|
|
1026
|
+
? remapScope(field.children ?? [], instance as FormData)
|
|
1027
|
+
: instance,
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
out[field.key ?? id] = value;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
return out;
|
|
1034
|
+
};
|
|
1035
|
+
|
|
1036
|
+
return remapScope(fields, data);
|
|
1037
|
+
}
|