@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,922 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* createFormController — the framework-agnostic observable store that drives
|
|
3
|
+
* every `<BiabForm>` binding (React / Vue / Svelte / Solid / Qwik / Angular /
|
|
4
|
+
* web-component / vanilla).
|
|
5
|
+
*
|
|
6
|
+
* It owns ALL of the form's runtime state and behaviour:
|
|
7
|
+
* - working values (+ seeded defaults), condition-aware visible fields,
|
|
8
|
+
* - field-level validation errors (mirrored from the server's rules),
|
|
9
|
+
* - multi-step ("consecutive") navigation + progress,
|
|
10
|
+
* - concurrent-reveal gating inside groups,
|
|
11
|
+
* - submission lifecycle (submitting / result),
|
|
12
|
+
* - file uploads via the SDK.
|
|
13
|
+
*
|
|
14
|
+
* Bindings stay dumb: subscribe to `snapshot()`, render by field `type` using
|
|
15
|
+
* the `biab-*` class convention (light DOM, no shadow root, so consumer CSS
|
|
16
|
+
* cascades), and call `setValue` / `next` / `back` / `submit` / `uploadFile` /
|
|
17
|
+
* `reset`. The store is a tiny subscribe/notify observable — no framework
|
|
18
|
+
* reactivity primitives leak in.
|
|
19
|
+
*
|
|
20
|
+
* Data flows entirely through the SDK: it calls `client.forms.schema(slug)` to
|
|
21
|
+
* load and `client.forms.submit(slug, data, opts)` to submit, so auth, caching,
|
|
22
|
+
* revalidation, HMAC, retries, and CRM fan-out all stay invisible inside
|
|
23
|
+
* biab-dev. SSR callers can hand a pre-fetched schema instead of a client.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
FormFieldDef,
|
|
28
|
+
FormFileValue,
|
|
29
|
+
FormRenderSettings,
|
|
30
|
+
FormSchema,
|
|
31
|
+
FormSubmitResult,
|
|
32
|
+
FormValidationIssue,
|
|
33
|
+
} from "../sdk.js";
|
|
34
|
+
import {
|
|
35
|
+
type BusinessFunctionDef,
|
|
36
|
+
functionsToEvalOptions,
|
|
37
|
+
} from "./business-function.js";
|
|
38
|
+
import { type CalculationConfig, computeCalculation } from "./calculation.js";
|
|
39
|
+
import { FORM_WIDE_MAX_FILES } from "./file-rules.js";
|
|
40
|
+
import type { EvalOptions } from "./formula.js";
|
|
41
|
+
import {
|
|
42
|
+
concurrentRevealCount,
|
|
43
|
+
evalCondition,
|
|
44
|
+
type FormConditionRule,
|
|
45
|
+
type FormCoreField,
|
|
46
|
+
type FormData,
|
|
47
|
+
findMissingRequired,
|
|
48
|
+
isNodeVisible,
|
|
49
|
+
type MissingField,
|
|
50
|
+
seedInitialData,
|
|
51
|
+
toSubmissionPayload,
|
|
52
|
+
} from "./validation.js";
|
|
53
|
+
|
|
54
|
+
export type { FormCoreField, FormData, MissingField } from "./validation.js";
|
|
55
|
+
|
|
56
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
// Options
|
|
58
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
/** Per-field validation error surfaced to the binding (`id` → message). */
|
|
61
|
+
export interface FieldError {
|
|
62
|
+
id: string;
|
|
63
|
+
label: string;
|
|
64
|
+
message: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A file the visitor picked but hasn't uploaded — uploads are DEFERRED to
|
|
69
|
+
* `submit()`. A binding stores these in the field's value (an array mixing
|
|
70
|
+
* `PendingFileValue` + already-uploaded `FormFileValue`); `submit()` flushes
|
|
71
|
+
* the pending ones to R2, replacing each with its uploaded record. Deferring
|
|
72
|
+
* means an abandoned form never orphans an object and a failed upload surfaces
|
|
73
|
+
* as a submit error rather than vanishing.
|
|
74
|
+
*/
|
|
75
|
+
export interface PendingFileValue {
|
|
76
|
+
pending: true;
|
|
77
|
+
file: File | Blob;
|
|
78
|
+
name: string;
|
|
79
|
+
size: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isPendingFileValue(v: unknown): v is PendingFileValue {
|
|
83
|
+
return (
|
|
84
|
+
!!v && typeof v === "object" && (v as PendingFileValue).pending === true
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Walk the field tree and collect every `file_upload` node. */
|
|
89
|
+
function collectFileFields(nodes: FormCoreField[]): FormCoreField[] {
|
|
90
|
+
const out: FormCoreField[] = [];
|
|
91
|
+
const walk = (ns: FormCoreField[]) => {
|
|
92
|
+
for (const n of ns) {
|
|
93
|
+
if (n.type === "file_upload") out.push(n);
|
|
94
|
+
const kids = (n as { children?: FormCoreField[] }).children;
|
|
95
|
+
if (kids) walk(kids);
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
walk(nodes);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Provenance + recipient passthrough for the underlying `forms.submit`. */
|
|
103
|
+
export interface FormSubmitOptions {
|
|
104
|
+
submitterEmail?: string;
|
|
105
|
+
submitterName?: string;
|
|
106
|
+
/** Validate on the server but store nothing (no side effects). */
|
|
107
|
+
dryRun?: boolean;
|
|
108
|
+
/** Lead-source provenance (e.g. "facebook", "google-ads:summer"). */
|
|
109
|
+
source?: string;
|
|
110
|
+
referrer?: string;
|
|
111
|
+
metadata?: Record<string, unknown>;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The slice of the SDK client the form controller actually needs. Any object exposing a
|
|
116
|
+
* `forms` resource with `schema` / `submit` / `uploadFile` satisfies it — the
|
|
117
|
+
* full `BiabClient` from `createBiabClient()`, or a thin same-origin proxy that
|
|
118
|
+
* forwards to `/api/biab/forms/*` (the pattern every starter template uses so the
|
|
119
|
+
* bearer key never reaches the browser).
|
|
120
|
+
*
|
|
121
|
+
* It lives here rather than in `react.tsx` because `forms-core` is the layer
|
|
122
|
+
* that consumes it — solid, qwik, svelte, vanilla and the custom element all
|
|
123
|
+
* run this controller, and none of them should reach through React for a type.
|
|
124
|
+
* `react.tsx` re-exports the name, so the public API is unchanged.
|
|
125
|
+
*/
|
|
126
|
+
export interface BiabFormsClient {
|
|
127
|
+
forms: {
|
|
128
|
+
schema(slug: string): Promise<FormSchema>;
|
|
129
|
+
submit(
|
|
130
|
+
slug: string,
|
|
131
|
+
data: Record<string, unknown>,
|
|
132
|
+
opts?: FormSubmitOptions,
|
|
133
|
+
): Promise<FormSubmitResult>;
|
|
134
|
+
uploadFile?(
|
|
135
|
+
slug: string,
|
|
136
|
+
fieldId: string,
|
|
137
|
+
file: File | Blob,
|
|
138
|
+
opts?: { filename?: string },
|
|
139
|
+
): Promise<FormFileValue>;
|
|
140
|
+
/** Open booking slots for an auto-mode `schedule` field. */
|
|
141
|
+
scheduleSlots?(
|
|
142
|
+
slug: string,
|
|
143
|
+
input: { fieldId: string; from: Date; to: Date },
|
|
144
|
+
): Promise<{ slots: Array<{ startAt: string }> }>;
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `uploadFile` is optional on `BiabFormsClient`, and legitimately so: a
|
|
150
|
+
* same-origin proxy that forwards only `schema` and `submit` is a valid client,
|
|
151
|
+
* and that is exactly what the starter templates ship so the bearer key never
|
|
152
|
+
* reaches the browser. A form with a file field needs more than that.
|
|
153
|
+
*
|
|
154
|
+
* Resolving it here turns the failure into one sentence naming the cause, at
|
|
155
|
+
* the moment of upload. Previously the controller called it unguarded — under
|
|
156
|
+
* the old typing that was invisible, and a proxy client hit
|
|
157
|
+
* `client.forms.uploadFile is not a function` from inside a submit handler.
|
|
158
|
+
*/
|
|
159
|
+
function requireUploadFile(client: BiabFormsClient) {
|
|
160
|
+
const upload = client.forms.uploadFile;
|
|
161
|
+
if (!upload) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
"createFormController: this form has a file field, but the configured client does not implement `forms.uploadFile`.",
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return upload.bind(client.forms);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface CreateFormControllerOptions {
|
|
170
|
+
/**
|
|
171
|
+
* The SDK client. Required unless `schema` is supplied (SSR / pre-fetched).
|
|
172
|
+
* When both are given, `client` is still used for `submit` / `uploadFile`.
|
|
173
|
+
*
|
|
174
|
+
* Typed as `BiabFormsClient` — the `forms` surface — rather than the whole
|
|
175
|
+
* `BiabClient`, because that is all this controller ever touches. Demanding
|
|
176
|
+
* the full client was what forced `client as never` at the React call site:
|
|
177
|
+
* the hook resolves a forms-shaped client from a provider or a token, which
|
|
178
|
+
* is not a `BiabClient` and never needed to be. A full `BiabClient` still
|
|
179
|
+
* satisfies this structurally, so existing callers are unaffected.
|
|
180
|
+
*/
|
|
181
|
+
client?: BiabFormsClient;
|
|
182
|
+
/** Form slug. Required when loading via `client`; optional with a pre-fetched
|
|
183
|
+
* `schema` that already carries its own `slug`. */
|
|
184
|
+
slug?: string;
|
|
185
|
+
/**
|
|
186
|
+
* Pre-fetched schema (SSR / RSC). When provided, `load()` resolves
|
|
187
|
+
* synchronously against it instead of hitting the network. `submit` /
|
|
188
|
+
* `uploadFile` still require `client`.
|
|
189
|
+
*/
|
|
190
|
+
schema?: FormSchema;
|
|
191
|
+
/**
|
|
192
|
+
* Default submit options merged into every `submit()` call (the call's own
|
|
193
|
+
* options win per-key).
|
|
194
|
+
*/
|
|
195
|
+
submitOptions?: FormSubmitOptions;
|
|
196
|
+
/**
|
|
197
|
+
* Called after a successful submit (result.ok === true). Fires before the
|
|
198
|
+
* final snapshot notify so a binding can read the result in the same tick.
|
|
199
|
+
*/
|
|
200
|
+
onSuccess?: (result: Extract<FormSubmitResult, { ok: true }>) => void;
|
|
201
|
+
/** Called after a failed submit (result.ok === false). */
|
|
202
|
+
onError?: (result: Extract<FormSubmitResult, { ok: false }>) => void;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
206
|
+
// Snapshot — the immutable view a binding renders from. Re-created on each
|
|
207
|
+
// notify; field names are STABLE and load-bearing for all 7 bindings.
|
|
208
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
export type FormStatus = "idle" | "loading" | "ready" | "error";
|
|
211
|
+
|
|
212
|
+
export interface FormSnapshot {
|
|
213
|
+
/** Lifecycle of the schema load. `ready` once fields are available. */
|
|
214
|
+
status: FormStatus;
|
|
215
|
+
/** Set when `status === "error"` (schema load failed). */
|
|
216
|
+
loadError: string | null;
|
|
217
|
+
/** The loaded schema (null until `ready`). */
|
|
218
|
+
schema: FormSchema | null;
|
|
219
|
+
/** Flat public field defs from the schema (convenience; the tree is `fields`). */
|
|
220
|
+
fieldDefs: FormFieldDef[];
|
|
221
|
+
/** The nested field tree to render (the authoritative render source). */
|
|
222
|
+
fields: FormCoreField[];
|
|
223
|
+
/** Resolved render settings (animation / steps / progress) with defaults. */
|
|
224
|
+
settings: ResolvedFormSettings;
|
|
225
|
+
/** Org icon URL when the form opted into `showOrgIcon`, else null. */
|
|
226
|
+
orgIcon: string | null;
|
|
227
|
+
/** Form title, when present. */
|
|
228
|
+
title: string | null;
|
|
229
|
+
/** Form description, when present. */
|
|
230
|
+
description: string | null;
|
|
231
|
+
|
|
232
|
+
/** Working values, keyed by field `id`. */
|
|
233
|
+
values: FormData;
|
|
234
|
+
/** Field-level validation errors, keyed by field `id` (for O(1) lookup). */
|
|
235
|
+
errors: Record<string, FieldError>;
|
|
236
|
+
/** Ordered list of current errors (same data as `errors`, render-friendly). */
|
|
237
|
+
errorList: FieldError[];
|
|
238
|
+
|
|
239
|
+
/** Steps: each entry is the field subtree for one step. In single-page mode
|
|
240
|
+
* there is exactly one step containing every field. */
|
|
241
|
+
steps: FormCoreField[][];
|
|
242
|
+
/** Zero-based index of the active step (clamped to a valid range). */
|
|
243
|
+
step: number;
|
|
244
|
+
/** Total number of steps (`steps.length`). */
|
|
245
|
+
stepCount: number;
|
|
246
|
+
/** True when on the first step. */
|
|
247
|
+
isFirstStep: boolean;
|
|
248
|
+
/** True when on the last step. */
|
|
249
|
+
isLastStep: boolean;
|
|
250
|
+
/** The field subtree for the active step (already condition-pruned for the
|
|
251
|
+
* top level — bindings still skip hidden `condition_block`s as they recurse). */
|
|
252
|
+
visibleFields: FormCoreField[];
|
|
253
|
+
|
|
254
|
+
/** 0–1 completion fraction = (step + 1) / stepCount. */
|
|
255
|
+
progress: number;
|
|
256
|
+
/** Whole-percent form of `progress` (0–100), convenient for width styles. */
|
|
257
|
+
progressPercent: number;
|
|
258
|
+
|
|
259
|
+
/** True when the active step has no missing-required / regex problems —
|
|
260
|
+
* drives the Next button's disabled state. */
|
|
261
|
+
canAdvance: boolean;
|
|
262
|
+
/** True when the WHOLE form passes required/regex checks — drives Submit. */
|
|
263
|
+
canSubmit: boolean;
|
|
264
|
+
|
|
265
|
+
/** Nav direction of the last step change (`true` = forward) — for slide anims. */
|
|
266
|
+
forward: boolean;
|
|
267
|
+
|
|
268
|
+
/** True while a `submit()` round-trip is in flight. */
|
|
269
|
+
submitting: boolean;
|
|
270
|
+
/** True while ANY field is uploading a file. */
|
|
271
|
+
uploading: boolean;
|
|
272
|
+
/** The most recent submit result (null until the first submit). */
|
|
273
|
+
result: FormSubmitResult | null;
|
|
274
|
+
/** True after a successful submit (result.ok === true). */
|
|
275
|
+
submitted: boolean;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Render settings after applying defaults. */
|
|
279
|
+
export interface ResolvedFormSettings {
|
|
280
|
+
animation: NonNullable<FormRenderSettings["animation"]>;
|
|
281
|
+
isConsecutive: boolean;
|
|
282
|
+
showProgress: boolean;
|
|
283
|
+
progressStyle: NonNullable<FormRenderSettings["progressStyle"]>;
|
|
284
|
+
/** Warn before abandoning a dirty, unsubmitted form (undefined = off). */
|
|
285
|
+
exitConfirm?: { enabled?: boolean; message?: string };
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
289
|
+
// Controller surface — the public contract the 7 bindings target verbatim.
|
|
290
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
export interface FormController {
|
|
293
|
+
/** Load (or adopt the pre-fetched) schema and seed initial values. Idempotent:
|
|
294
|
+
* safe to call again to re-fetch. Resolves once the schema is `ready` or
|
|
295
|
+
* `error`. */
|
|
296
|
+
load(): Promise<void>;
|
|
297
|
+
/** The current immutable snapshot. Cheap; returns the same object until the
|
|
298
|
+
* next mutation. */
|
|
299
|
+
snapshot(): FormSnapshot;
|
|
300
|
+
/** Subscribe to snapshot changes. Returns an unsubscribe fn. The callback is
|
|
301
|
+
* NOT invoked on subscribe — read `snapshot()` for the initial value. */
|
|
302
|
+
subscribe(callback: (snapshot: FormSnapshot) => void): () => void;
|
|
303
|
+
/** Set one field's value (keyed by field `id`). Clears that field's error and
|
|
304
|
+
* recomputes condition-driven visibility / advance gating. */
|
|
305
|
+
setValue(id: string, value: unknown): void;
|
|
306
|
+
/** Advance to the next step. No-op (and surfaces step errors) when the current
|
|
307
|
+
* step has missing-required / invalid fields, or already on the last step. */
|
|
308
|
+
next(): void;
|
|
309
|
+
/** Go back one step. No-op on the first step. */
|
|
310
|
+
back(): void;
|
|
311
|
+
/** Jump to a specific step index (clamped). Validates nothing — use for a
|
|
312
|
+
* progress-indicator click-back. */
|
|
313
|
+
goToStep(index: number): void;
|
|
314
|
+
/**
|
|
315
|
+
* Validate the whole form, then submit via the SDK. Populates `errors` and
|
|
316
|
+
* returns early (without a network call) when local validation fails. Always
|
|
317
|
+
* resolves to a `FormSubmitResult` (never throws for an expected failure).
|
|
318
|
+
*/
|
|
319
|
+
submit(options?: FormSubmitOptions): Promise<FormSubmitResult>;
|
|
320
|
+
/**
|
|
321
|
+
* Upload one file for a `file_upload` field via the SDK, append the resulting
|
|
322
|
+
* `FormFileValue` to that field's value array, and return it. Throws on
|
|
323
|
+
* plan-gate / transfer failure (mirrors `client.forms.uploadFile`).
|
|
324
|
+
*/
|
|
325
|
+
uploadFile(
|
|
326
|
+
fieldId: string,
|
|
327
|
+
file: File | Blob,
|
|
328
|
+
opts?: { filename?: string },
|
|
329
|
+
): Promise<FormFileValue>;
|
|
330
|
+
/** Re-run validation against the current values and publish `errors`. Returns
|
|
331
|
+
* the missing/invalid list (empty when valid). */
|
|
332
|
+
validate(): MissingField[];
|
|
333
|
+
/** Reset values to seeded defaults, clear errors / result, go to step 0. */
|
|
334
|
+
reset(): void;
|
|
335
|
+
/** Stop notifying and drop all subscribers. */
|
|
336
|
+
destroy(): void;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
340
|
+
// Implementation
|
|
341
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
const DEFAULT_SETTINGS: ResolvedFormSettings = {
|
|
344
|
+
animation: "none",
|
|
345
|
+
isConsecutive: false,
|
|
346
|
+
showProgress: false,
|
|
347
|
+
progressStyle: "steps",
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
function resolveSettings(
|
|
351
|
+
settings: FormRenderSettings | undefined,
|
|
352
|
+
): ResolvedFormSettings {
|
|
353
|
+
return {
|
|
354
|
+
animation: settings?.animation ?? DEFAULT_SETTINGS.animation,
|
|
355
|
+
isConsecutive: settings?.isConsecutive ?? DEFAULT_SETTINGS.isConsecutive,
|
|
356
|
+
showProgress: settings?.showProgress ?? DEFAULT_SETTINGS.showProgress,
|
|
357
|
+
progressStyle: settings?.progressStyle ?? DEFAULT_SETTINGS.progressStyle,
|
|
358
|
+
...(settings?.exitConfirm ? { exitConfirm: settings.exitConfirm } : {}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Compute the step partition. Single-page → one step of all fields; consecutive
|
|
364
|
+
* → each top-level node is its own step (mirrors FormPreview's `steps`).
|
|
365
|
+
*
|
|
366
|
+
* A page whose `conditionRule` is currently false is DROPPED rather than left
|
|
367
|
+
* empty, so "Next" skips straight past it and the progress indicator loses its
|
|
368
|
+
* entry. Every renderer reads `stepCount` / `steps` from here, so this one
|
|
369
|
+
* change gives React, Svelte, Vue, Angular, Solid, Qwik and vanilla the same
|
|
370
|
+
* behaviour as the dashboard.
|
|
371
|
+
*/
|
|
372
|
+
function computeSteps(
|
|
373
|
+
fields: FormCoreField[],
|
|
374
|
+
isConsecutive: boolean,
|
|
375
|
+
data: FormData,
|
|
376
|
+
): FormCoreField[][] {
|
|
377
|
+
if (!isConsecutive) return [fields];
|
|
378
|
+
return fields.filter((f) => isNodeVisible(f, data)).map((f) => [f]);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export function createFormController(
|
|
382
|
+
options: CreateFormControllerOptions,
|
|
383
|
+
): FormController {
|
|
384
|
+
if (!options.client && !options.schema) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
"createFormController: provide a `client` (with `slug`) or a pre-fetched `schema`.",
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (options.client && !options.slug && !options.schema?.slug) {
|
|
390
|
+
throw new Error(
|
|
391
|
+
"createFormController: `slug` is required when loading via `client`.",
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const client = options.client;
|
|
396
|
+
const slug = options.slug ?? options.schema?.slug ?? "";
|
|
397
|
+
|
|
398
|
+
// ── Mutable internal state (never exposed directly) ──────────────────────
|
|
399
|
+
let status: FormStatus = "idle";
|
|
400
|
+
let loadError: string | null = null;
|
|
401
|
+
let schema: FormSchema | null = options.schema ?? null;
|
|
402
|
+
let fields: FormCoreField[] = [];
|
|
403
|
+
let settings: ResolvedFormSettings = DEFAULT_SETTINGS;
|
|
404
|
+
let values: FormData = {};
|
|
405
|
+
let errors: Record<string, FieldError> = {};
|
|
406
|
+
let step = 0;
|
|
407
|
+
let forward = true;
|
|
408
|
+
let submitting = false;
|
|
409
|
+
let uploadingCount = 0;
|
|
410
|
+
let result: FormSubmitResult | null = null;
|
|
411
|
+
let destroyed = false;
|
|
412
|
+
|
|
413
|
+
let cachedSnapshot: FormSnapshot | null = null;
|
|
414
|
+
const subscribers = new Set<(s: FormSnapshot) => void>();
|
|
415
|
+
|
|
416
|
+
// `calculation` field support: derived once per schema adopt.
|
|
417
|
+
let calcFields: FormCoreField[] = [];
|
|
418
|
+
let calcFunctionsById: Record<string, BusinessFunctionDef> = {};
|
|
419
|
+
let calcEvalOptions: EvalOptions = {};
|
|
420
|
+
|
|
421
|
+
/** Walk the field tree and collect every `calculation` node. */
|
|
422
|
+
function collectCalcFields(nodes: FormCoreField[]): FormCoreField[] {
|
|
423
|
+
const out: FormCoreField[] = [];
|
|
424
|
+
const walk = (ns: FormCoreField[]) => {
|
|
425
|
+
for (const n of ns) {
|
|
426
|
+
if (n.type === "calculation") out.push(n);
|
|
427
|
+
const kids = (n as { children?: FormCoreField[] }).children;
|
|
428
|
+
if (kids) walk(kids);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
walk(nodes);
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Recompute every `calculation` field into `vals`. Non-sensitive only — a
|
|
436
|
+
* sensitive function has no published def, so it resolves to null here and
|
|
437
|
+
* the server computes the authoritative value on submit. Idempotent. */
|
|
438
|
+
function applyCalcs(vals: FormData): FormData {
|
|
439
|
+
if (calcFields.length === 0) return vals;
|
|
440
|
+
let changed = false;
|
|
441
|
+
const next = { ...vals };
|
|
442
|
+
for (const f of calcFields) {
|
|
443
|
+
const cfg = (f as { calculationConfig?: CalculationConfig })
|
|
444
|
+
.calculationConfig;
|
|
445
|
+
if (!cfg) continue;
|
|
446
|
+
const r = computeCalculation(cfg, (id) => next[id], {
|
|
447
|
+
functionsById: calcFunctionsById,
|
|
448
|
+
evalOptions: calcEvalOptions,
|
|
449
|
+
});
|
|
450
|
+
const val = r.ok ? r.value : null;
|
|
451
|
+
if (JSON.stringify(next[f.id] ?? null) !== JSON.stringify(val ?? null)) {
|
|
452
|
+
next[f.id] = val;
|
|
453
|
+
changed = true;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return changed ? next : vals;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// ── Snapshot building ────────────────────────────────────────────────────
|
|
460
|
+
function buildSnapshot(): FormSnapshot {
|
|
461
|
+
const steps = computeSteps(fields, settings.isConsecutive, values);
|
|
462
|
+
const stepCount = Math.max(steps.length, 1);
|
|
463
|
+
const safeStep = Math.min(Math.max(step, 0), stepCount - 1);
|
|
464
|
+
const visibleFields = settings.isConsecutive
|
|
465
|
+
? (steps[safeStep] ?? [])
|
|
466
|
+
: fields;
|
|
467
|
+
|
|
468
|
+
const stepFields = steps[safeStep] ?? [];
|
|
469
|
+
const canAdvance = findMissingRequired(stepFields, values).length === 0;
|
|
470
|
+
const canSubmit = findMissingRequired(fields, values).length === 0;
|
|
471
|
+
const errorList = Object.values(errors);
|
|
472
|
+
const progress = stepCount > 0 ? (safeStep + 1) / stepCount : 0;
|
|
473
|
+
|
|
474
|
+
return {
|
|
475
|
+
status,
|
|
476
|
+
loadError,
|
|
477
|
+
schema,
|
|
478
|
+
fieldDefs: (schema?.fields ?? []) as FormFieldDef[],
|
|
479
|
+
fields,
|
|
480
|
+
settings,
|
|
481
|
+
orgIcon: schema?.orgIcon ?? null,
|
|
482
|
+
title: schema?.title ?? null,
|
|
483
|
+
description: schema?.description ?? null,
|
|
484
|
+
values,
|
|
485
|
+
errors,
|
|
486
|
+
errorList,
|
|
487
|
+
steps,
|
|
488
|
+
step: safeStep,
|
|
489
|
+
stepCount,
|
|
490
|
+
isFirstStep: safeStep === 0,
|
|
491
|
+
isLastStep: safeStep === stepCount - 1,
|
|
492
|
+
visibleFields,
|
|
493
|
+
progress,
|
|
494
|
+
progressPercent: Math.round(progress * 100),
|
|
495
|
+
canAdvance,
|
|
496
|
+
canSubmit,
|
|
497
|
+
forward,
|
|
498
|
+
submitting,
|
|
499
|
+
uploading: uploadingCount > 0,
|
|
500
|
+
result,
|
|
501
|
+
submitted: result?.ok === true,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function notify(): void {
|
|
506
|
+
if (destroyed) return;
|
|
507
|
+
cachedSnapshot = buildSnapshot();
|
|
508
|
+
for (const cb of subscribers) cb(cachedSnapshot);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function snapshot(): FormSnapshot {
|
|
512
|
+
if (!cachedSnapshot) cachedSnapshot = buildSnapshot();
|
|
513
|
+
return cachedSnapshot;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ── Error helpers ────────────────────────────────────────────────────────
|
|
517
|
+
function setErrorsFromMissing(missing: MissingField[]): void {
|
|
518
|
+
const next: Record<string, FieldError> = {};
|
|
519
|
+
for (const m of missing) {
|
|
520
|
+
next[m.id] = {
|
|
521
|
+
id: m.id,
|
|
522
|
+
label: m.label,
|
|
523
|
+
message: `${m.label} is required.`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
errors = next;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Field tree extraction ────────────────────────────────────────────────
|
|
530
|
+
function adoptSchema(loaded: FormSchema): void {
|
|
531
|
+
schema = loaded;
|
|
532
|
+
// The published schema's `fields` carries the full nested tree (layout
|
|
533
|
+
// nodes, condition_block, or_condition, children, output `key`).
|
|
534
|
+
fields = (loaded.fields ?? []) as unknown as FormCoreField[];
|
|
535
|
+
settings = resolveSettings(loaded.settings);
|
|
536
|
+
// Build the calculation context from the schema's (non-sensitive) functions.
|
|
537
|
+
calcFields = collectCalcFields(fields);
|
|
538
|
+
calcFunctionsById = {};
|
|
539
|
+
const bySlug: Record<string, BusinessFunctionDef> = {};
|
|
540
|
+
for (const fn of loaded.businessFunctions ?? []) {
|
|
541
|
+
const def = {
|
|
542
|
+
slug: fn.slug,
|
|
543
|
+
inputs: fn.inputs,
|
|
544
|
+
outputs: fn.outputs,
|
|
545
|
+
} as BusinessFunctionDef;
|
|
546
|
+
calcFunctionsById[fn.id] = def;
|
|
547
|
+
bySlug[fn.slug.toUpperCase()] = def;
|
|
548
|
+
}
|
|
549
|
+
calcEvalOptions = functionsToEvalOptions(bySlug);
|
|
550
|
+
values = applyCalcs(seedInitialData(fields));
|
|
551
|
+
errors = {};
|
|
552
|
+
step = 0;
|
|
553
|
+
forward = true;
|
|
554
|
+
result = null;
|
|
555
|
+
status = "ready";
|
|
556
|
+
loadError = null;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// ── Public methods ─────────────────────────────────────────────────────
|
|
560
|
+
async function load(): Promise<void> {
|
|
561
|
+
// Revive on (re)load. `load()` is idempotent and "safe to call again",
|
|
562
|
+
// and React StrictMode runs effects mount → cleanup → mount: the cleanup
|
|
563
|
+
// calls `destroy()` (sets `destroyed = true`), so the remount's `load()`
|
|
564
|
+
// must reset the flag or it would bail here and the form would hang on
|
|
565
|
+
// "loading" forever — even after the schema fetch returns 200. A genuine
|
|
566
|
+
// unmount never calls `load()` again, so reviving here is safe.
|
|
567
|
+
destroyed = false;
|
|
568
|
+
// Pre-fetched schema path: adopt synchronously.
|
|
569
|
+
if (options.schema && status === "idle") {
|
|
570
|
+
adoptSchema(options.schema);
|
|
571
|
+
notify();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (!client) {
|
|
575
|
+
// No client and no (further) schema — nothing to load.
|
|
576
|
+
if (schema && status !== "ready") {
|
|
577
|
+
adoptSchema(schema);
|
|
578
|
+
notify();
|
|
579
|
+
}
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
status = "loading";
|
|
583
|
+
loadError = null;
|
|
584
|
+
notify();
|
|
585
|
+
try {
|
|
586
|
+
const loaded = await client.forms.schema(slug);
|
|
587
|
+
if (destroyed) return;
|
|
588
|
+
adoptSchema(loaded);
|
|
589
|
+
notify();
|
|
590
|
+
} catch (err) {
|
|
591
|
+
if (destroyed) return;
|
|
592
|
+
status = "error";
|
|
593
|
+
loadError =
|
|
594
|
+
err instanceof Error ? err.message : "Failed to load the form schema.";
|
|
595
|
+
notify();
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function subscribe(callback: (s: FormSnapshot) => void): () => void {
|
|
600
|
+
subscribers.add(callback);
|
|
601
|
+
return () => {
|
|
602
|
+
subscribers.delete(callback);
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
function setValue(id: string, value: unknown): void {
|
|
607
|
+
// Recompute calc fields after the change so derived values stay live and
|
|
608
|
+
// flow into the submission + downstream conditions.
|
|
609
|
+
values = applyCalcs({ ...values, [id]: value });
|
|
610
|
+
if (errors[id]) {
|
|
611
|
+
const { [id]: _removed, ...rest } = errors;
|
|
612
|
+
errors = rest;
|
|
613
|
+
}
|
|
614
|
+
notify();
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function validate(): MissingField[] {
|
|
618
|
+
const missing = findMissingRequired(fields, values);
|
|
619
|
+
setErrorsFromMissing(missing);
|
|
620
|
+
notify();
|
|
621
|
+
return missing;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function next(): void {
|
|
625
|
+
const steps = computeSteps(fields, settings.isConsecutive, values);
|
|
626
|
+
const stepCount = Math.max(steps.length, 1);
|
|
627
|
+
const safeStep = Math.min(Math.max(step, 0), stepCount - 1);
|
|
628
|
+
if (safeStep >= stepCount - 1) return;
|
|
629
|
+
const stepFields = steps[safeStep] ?? [];
|
|
630
|
+
const missing = findMissingRequired(stepFields, values);
|
|
631
|
+
if (missing.length > 0) {
|
|
632
|
+
setErrorsFromMissing(missing);
|
|
633
|
+
notify();
|
|
634
|
+
return;
|
|
635
|
+
}
|
|
636
|
+
errors = {};
|
|
637
|
+
forward = true;
|
|
638
|
+
step = safeStep + 1;
|
|
639
|
+
notify();
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function back(): void {
|
|
643
|
+
const safeStep = Math.max(step, 0);
|
|
644
|
+
if (safeStep <= 0) return;
|
|
645
|
+
forward = false;
|
|
646
|
+
step = safeStep - 1;
|
|
647
|
+
errors = {};
|
|
648
|
+
notify();
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function goToStep(index: number): void {
|
|
652
|
+
const steps = computeSteps(fields, settings.isConsecutive, values);
|
|
653
|
+
const stepCount = Math.max(steps.length, 1);
|
|
654
|
+
const target = Math.min(Math.max(index, 0), stepCount - 1);
|
|
655
|
+
forward = target >= step;
|
|
656
|
+
step = target;
|
|
657
|
+
notify();
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function submit(
|
|
661
|
+
callOptions?: FormSubmitOptions,
|
|
662
|
+
): Promise<FormSubmitResult> {
|
|
663
|
+
// Validate the WHOLE form (not just the visible step).
|
|
664
|
+
const missing = findMissingRequired(fields, values);
|
|
665
|
+
if (missing.length > 0) {
|
|
666
|
+
setErrorsFromMissing(missing);
|
|
667
|
+
const failure: FormSubmitResult = {
|
|
668
|
+
ok: false,
|
|
669
|
+
status: 0,
|
|
670
|
+
reason: "client_validation_failed",
|
|
671
|
+
message: `Please complete the required field${
|
|
672
|
+
missing.length === 1 ? "" : "s"
|
|
673
|
+
}: ${missing.map((m) => m.label).join(", ")}`,
|
|
674
|
+
issues: missing.map((m) => ({
|
|
675
|
+
fieldId: m.id,
|
|
676
|
+
label: m.label,
|
|
677
|
+
reason: "required" as const,
|
|
678
|
+
message: `${m.label} is required.`,
|
|
679
|
+
})),
|
|
680
|
+
};
|
|
681
|
+
result = failure;
|
|
682
|
+
notify();
|
|
683
|
+
options.onError?.(failure);
|
|
684
|
+
return failure;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
if (!client) {
|
|
688
|
+
const failure: FormSubmitResult = {
|
|
689
|
+
ok: false,
|
|
690
|
+
status: 0,
|
|
691
|
+
reason: "network_error",
|
|
692
|
+
message:
|
|
693
|
+
"createFormController: a `client` is required to submit. Pass one in options.",
|
|
694
|
+
};
|
|
695
|
+
result = failure;
|
|
696
|
+
notify();
|
|
697
|
+
options.onError?.(failure);
|
|
698
|
+
return failure;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Form-wide file cap — at most FORM_WIDE_MAX_FILES across all file fields.
|
|
702
|
+
const fileFields = collectFileFields(fields);
|
|
703
|
+
const totalFiles = fileFields.reduce(
|
|
704
|
+
(n, f) =>
|
|
705
|
+
n +
|
|
706
|
+
(Array.isArray(values[f.id]) ? (values[f.id] as unknown[]).length : 0),
|
|
707
|
+
0,
|
|
708
|
+
);
|
|
709
|
+
if (totalFiles > FORM_WIDE_MAX_FILES) {
|
|
710
|
+
const over = fileFields.filter(
|
|
711
|
+
(f) =>
|
|
712
|
+
Array.isArray(values[f.id]) && (values[f.id] as unknown[]).length > 0,
|
|
713
|
+
);
|
|
714
|
+
const issues: FormValidationIssue[] = over.map((f) => ({
|
|
715
|
+
fieldId: f.id,
|
|
716
|
+
label: f.label ?? "Files",
|
|
717
|
+
reason: "max" as const,
|
|
718
|
+
message: `At most ${FORM_WIDE_MAX_FILES} files total.`,
|
|
719
|
+
}));
|
|
720
|
+
setErrorsFromMissing(
|
|
721
|
+
over.map((f) => ({ id: f.id, label: f.label ?? "Files" })),
|
|
722
|
+
);
|
|
723
|
+
const failure: FormSubmitResult = {
|
|
724
|
+
ok: false,
|
|
725
|
+
status: 0,
|
|
726
|
+
reason: "client_validation_failed",
|
|
727
|
+
message: `Please attach at most ${FORM_WIDE_MAX_FILES} files in total.`,
|
|
728
|
+
issues,
|
|
729
|
+
};
|
|
730
|
+
result = failure;
|
|
731
|
+
notify();
|
|
732
|
+
options.onError?.(failure);
|
|
733
|
+
return failure;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
errors = {};
|
|
737
|
+
submitting = true;
|
|
738
|
+
notify();
|
|
739
|
+
|
|
740
|
+
// Flush DEFERRED uploads: every buffered PendingFileValue is uploaded now
|
|
741
|
+
// and replaced by its FormFileValue. A failed upload aborts the submit and
|
|
742
|
+
// surfaces as a field error rather than silently dropping the file.
|
|
743
|
+
const uploadIssues: FormValidationIssue[] = [];
|
|
744
|
+
for (const f of fileFields) {
|
|
745
|
+
const arr = Array.isArray(values[f.id])
|
|
746
|
+
? (values[f.id] as unknown[])
|
|
747
|
+
: [];
|
|
748
|
+
const pending = arr.filter(isPendingFileValue);
|
|
749
|
+
if (pending.length === 0) continue;
|
|
750
|
+
const done = arr.filter((e) => !isPendingFileValue(e)) as FormFileValue[];
|
|
751
|
+
for (const p of pending) {
|
|
752
|
+
try {
|
|
753
|
+
uploadingCount += 1;
|
|
754
|
+
notify();
|
|
755
|
+
done.push(
|
|
756
|
+
await requireUploadFile(client)(slug, f.id, p.file, {
|
|
757
|
+
filename: p.name,
|
|
758
|
+
}),
|
|
759
|
+
);
|
|
760
|
+
} catch (err) {
|
|
761
|
+
uploadIssues.push({
|
|
762
|
+
fieldId: f.id,
|
|
763
|
+
label: f.label ?? "Files",
|
|
764
|
+
reason: "type" as const,
|
|
765
|
+
message:
|
|
766
|
+
err instanceof Error
|
|
767
|
+
? err.message
|
|
768
|
+
: `Couldn't upload a file for ${f.label}.`,
|
|
769
|
+
});
|
|
770
|
+
} finally {
|
|
771
|
+
uploadingCount = Math.max(uploadingCount - 1, 0);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
values = { ...values, [f.id]: done };
|
|
775
|
+
}
|
|
776
|
+
if (uploadIssues.length > 0) {
|
|
777
|
+
submitting = false;
|
|
778
|
+
const next: Record<string, FieldError> = {};
|
|
779
|
+
for (const issue of uploadIssues) {
|
|
780
|
+
if (issue.fieldId) {
|
|
781
|
+
next[issue.fieldId] = {
|
|
782
|
+
id: issue.fieldId,
|
|
783
|
+
label: issue.label,
|
|
784
|
+
message: issue.message,
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
errors = next;
|
|
789
|
+
const failure: FormSubmitResult = {
|
|
790
|
+
ok: false,
|
|
791
|
+
status: 0,
|
|
792
|
+
reason: "network_error",
|
|
793
|
+
message: "Some files couldn't be uploaded — please retry.",
|
|
794
|
+
issues: uploadIssues,
|
|
795
|
+
};
|
|
796
|
+
result = failure;
|
|
797
|
+
notify();
|
|
798
|
+
options.onError?.(failure);
|
|
799
|
+
return failure;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Re-key by the published output key for the wire contract (server also
|
|
803
|
+
// accepts the legacy `id`).
|
|
804
|
+
const payload = toSubmissionPayload(fields, values);
|
|
805
|
+
const merged: FormSubmitOptions = {
|
|
806
|
+
...options.submitOptions,
|
|
807
|
+
...callOptions,
|
|
808
|
+
};
|
|
809
|
+
let res: FormSubmitResult;
|
|
810
|
+
try {
|
|
811
|
+
res = await client.forms.submit(slug, payload, merged);
|
|
812
|
+
} catch (err) {
|
|
813
|
+
// `forms.submit` is designed never to throw for an expected failure;
|
|
814
|
+
// this guards against an unexpected transport error.
|
|
815
|
+
res = {
|
|
816
|
+
ok: false,
|
|
817
|
+
status: 0,
|
|
818
|
+
reason: "network_error",
|
|
819
|
+
message: err instanceof Error ? err.message : "The submission failed.",
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
if (destroyed) return res;
|
|
823
|
+
submitting = false;
|
|
824
|
+
result = res;
|
|
825
|
+
// Surface server-side field issues back onto `errors` for inline display.
|
|
826
|
+
if (!res.ok && res.issues && res.issues.length > 0) {
|
|
827
|
+
const next: Record<string, FieldError> = {};
|
|
828
|
+
for (const issue of res.issues) {
|
|
829
|
+
if (!issue.fieldId) continue;
|
|
830
|
+
next[issue.fieldId] = {
|
|
831
|
+
id: issue.fieldId,
|
|
832
|
+
label: issue.label,
|
|
833
|
+
message: issue.message,
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
errors = next;
|
|
837
|
+
}
|
|
838
|
+
notify();
|
|
839
|
+
if (res.ok) options.onSuccess?.(res);
|
|
840
|
+
else options.onError?.(res);
|
|
841
|
+
return res;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function uploadFile(
|
|
845
|
+
fieldId: string,
|
|
846
|
+
file: File | Blob,
|
|
847
|
+
opts?: { filename?: string },
|
|
848
|
+
): Promise<FormFileValue> {
|
|
849
|
+
if (!client) {
|
|
850
|
+
throw new Error(
|
|
851
|
+
"createFormController: a `client` is required to upload files.",
|
|
852
|
+
);
|
|
853
|
+
}
|
|
854
|
+
uploadingCount += 1;
|
|
855
|
+
notify();
|
|
856
|
+
try {
|
|
857
|
+
const uploaded = await requireUploadFile(client)(
|
|
858
|
+
slug,
|
|
859
|
+
fieldId,
|
|
860
|
+
file,
|
|
861
|
+
opts,
|
|
862
|
+
);
|
|
863
|
+
if (destroyed) return uploaded;
|
|
864
|
+
const existing = Array.isArray(values[fieldId])
|
|
865
|
+
? (values[fieldId] as FormFileValue[])
|
|
866
|
+
: [];
|
|
867
|
+
values = { ...values, [fieldId]: [...existing, uploaded] };
|
|
868
|
+
if (errors[fieldId]) {
|
|
869
|
+
const { [fieldId]: _removed, ...rest } = errors;
|
|
870
|
+
errors = rest;
|
|
871
|
+
}
|
|
872
|
+
return uploaded;
|
|
873
|
+
} finally {
|
|
874
|
+
uploadingCount = Math.max(uploadingCount - 1, 0);
|
|
875
|
+
if (!destroyed) notify();
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function reset(): void {
|
|
880
|
+
values = seedInitialData(fields);
|
|
881
|
+
errors = {};
|
|
882
|
+
step = 0;
|
|
883
|
+
forward = true;
|
|
884
|
+
result = null;
|
|
885
|
+
submitting = false;
|
|
886
|
+
notify();
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function destroy(): void {
|
|
890
|
+
destroyed = true;
|
|
891
|
+
subscribers.clear();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Seed an initial snapshot so `snapshot()` is valid pre-load.
|
|
895
|
+
cachedSnapshot = buildSnapshot();
|
|
896
|
+
|
|
897
|
+
return {
|
|
898
|
+
load,
|
|
899
|
+
snapshot,
|
|
900
|
+
subscribe,
|
|
901
|
+
setValue,
|
|
902
|
+
next,
|
|
903
|
+
back,
|
|
904
|
+
goToStep,
|
|
905
|
+
submit,
|
|
906
|
+
uploadFile,
|
|
907
|
+
validate,
|
|
908
|
+
reset,
|
|
909
|
+
destroy,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
export type { FormConditionRule };
|
|
914
|
+
// Re-export the condition primitives bindings commonly need when they render
|
|
915
|
+
// the tree themselves (e.g. deciding whether to show a condition_block child).
|
|
916
|
+
export {
|
|
917
|
+
concurrentRevealCount,
|
|
918
|
+
evalCondition,
|
|
919
|
+
findMissingRequired,
|
|
920
|
+
isNodeVisible,
|
|
921
|
+
seedInitialData,
|
|
922
|
+
};
|