← Files WixARCHIVED FILE
skills/wix-headless-templates/forms/project/src/wix/forms/form-store.ts
24.7 KB · Oct 8, 2026 · 12:02 UTC
// One form as a framework-free store — the logic behind useWixForm, usable from React (useWixForm
// wraps it with useSyncExternalStore), from a static page or Vue/Svelte (subscribe and render), or
// as the specification for a port. Schema in, validated submission out, minus the markup: load the
// form, hold the visitor's values, apply the owner's rules on every change, validate against the
// schema's own rules in Wix's wording, move between steps, upload attachments, create the
// submission, hand a paid form to checkout, and map a rejection back onto the controls.
//
// It imports the data layer by names the REST twin exports identically (getForm, uploadFiles,
// createSubmission, checkoutUrl, submissionErrors, formLevelError, toSubmissionValues), so the same
// file runs over the SDK in Astro/React and over REST on a static page.
//
// SSR-friendly: pass a server-fetched FormDto as `initialForm` and no client fetch happens;
// `start()` then does nothing. Without it `start()` loads the schema. One store per mounted form:
// createFormStore(), not a singleton — a page can hold two forms.
import { applyRules, getForm, isClosed, otherText, otherValue } from "./forms";
import { addressPartsForCountry } from "./forms-core";
import {
EMAIL_PATTERN,
PHONE_PATTERN,
addressPartMessage,
dateRangeMessage,
itemsMessage,
lengthMessage,
multipleOfMessage,
rangeMessage,
requiredMessage,
withSeconds,
} from "./submissions-core";
import {
checkoutUrl,
createSubmission,
formLevelError,
normalizePhone,
normalizeUrl,
submissionErrors,
toSubmissionValues,
uploadFiles,
} from "./submissions";
import type { FormDto, FormErrors, FormFieldDto, FormStep, FormValues, SubmitOutcome } from "./types";
export { otherText, otherValue };
/**
* The key in `errors` for a message that belongs to the FORM rather than one field — a schema
* that failed to load, a closed form, or a rejection with no per-field violations. `@` cannot
* appear in a form `target`, so this never collides with a field's own error.
*/
export const FORM_ERROR = "@form";
/** The empty value of a field's SHAPE — what a control binds to before anyone typed. */
export function emptyValue(field: FormFieldDto): FormValues[string] {
return field.control === "address" ? {} :
field.inputType === "ARRAY" || field.inputType === "WIX_FILE" ? [] :
field.inputType === "BOOLEAN" ? false : "";
}
/** The empty form: every field at its prefill, or the empty value of its shape. */
export function defaultValues(fields: FormFieldDto[]): FormValues {
const values: FormValues = {};
for (const f of fields) values[f.target] = Array.isArray(f.defaultValue) ? [...f.defaultValue] : f.defaultValue;
return values;
}
const isBlank = (v: unknown): boolean => v === undefined || v === null || String(v).trim() === "";
/**
* Check one field's value against its own schema. A plain function — usable outside the store,
* and the place to look when a message needs rewording. Messages are Wix's own copy for the
* common cases, so the inline check and a server rejection read alike.
*
* Every rule comes from the schema, never from a field's NAME. (The classic mistake is keying
* the email check on `target === "email"`; deriving it from `format` means an owner-added
* PHONE/URL/length rule is honored with no code change.)
*
* A client check LAXER than the server's is worse than none — the visitor then learns about
* the problem only after a round trip, in the server's wording rather than yours.
*/
export function validateValue(field: FormFieldDto, value: unknown): string {
const v = String(value ?? "").trim();
const rules = field.validation;
// A read-only field submits its prefill; Wix leaves it out of the required set.
if (field.required && !field.readOnly && !v) return requiredMessage(field);
if (!v) return ""; // optional and empty → fine
if (field.control === "number" || field.control === "rating") {
// The control hands back a string, so parse before comparing: "9" > 10 is false but
// "9" > "10" is true.
const n = Number(v);
if (!Number.isFinite(n)) return "Enter a number.";
// A rating is one of 1..5 (form-viewer isRating); 0 means empty.
if (field.control === "rating" && !(Number.isInteger(n) && n >= 1 && n <= 5)) return "Choose a star rating.";
if ((rules.minimum != null && n < rules.minimum) || (rules.maximum != null && n > rules.maximum)) return rangeMessage(rules.minimum, rules.maximum);
if (rules.multipleOf && Math.abs(n / rules.multipleOf - Math.round(n / rules.multipleOf)) > 1e-9) return multipleOfMessage(rules.multipleOf);
return "";
}
if (field.control === "date" || field.control === "time" || field.control === "datetime") {
// Values in the field's own ISO spelling compare as strings once seconds are normalized.
const x = withSeconds(v);
if ((rules.minDate && x < withSeconds(rules.minDate)) || (rules.maxDate && x > withSeconds(rules.maxDate))) {
return field.identifier === "CONTACTS_BIRTHDATE" ? "Enter a date from January 1, 1900 to today." : dateRangeMessage(rules.minDate, rules.maxDate);
}
return "";
}
if (field.control === "select" || field.control === "radio") {
// After a rule narrowed the choices, a stale value is no longer allowed. A free-text
// "Other" entry is anything outside the list.
if (field.choices.length && !field.otherOption && !field.choices.some((c) => c.value === v)) return "The chosen value is not allowed.";
return "";
}
if ((rules.minLength && v.length < rules.minLength) || (rules.maxLength && v.length > rules.maxLength))
return lengthMessage(rules.minLength, rules.maxLength);
if (rules.format === "EMAIL" && !EMAIL_PATTERN.test(v)) return "Enter an email address like example@mysite.com.";
if (rules.format === "URL" && !/^https?:\/\/[^\s/?#]+[^\s]*$/i.test(normalizeUrl(v))) return "Enter a web URL like https://www.example.com.";
// PHONE is E.164 server-side: leading +, country code, digits. Strip formatting first —
// visitors add spaces, dashes and parens, and rejecting those is a UX bug, not validation.
if (rules.format === "PHONE" && !PHONE_PATTERN.test(normalizePhone(v))) return "Enter a valid phone number.";
if (rules.pattern) {
try {
if (!new RegExp(rules.pattern).test(v)) return rules.patternMessage ?? "Enter a valid answer.";
} catch { /* an owner's pattern the engine cannot compile: the server decides */ }
}
return "";
}
/**
* One field's error entries, keyed the way the controls are named. A plain field yields at most
* one (`target`); an ADDRESS yields one per failing subfield (`target/sub`). A hidden field
* yields none — Wix drops hidden fields from the required set and clears their values.
*
* An address subfield gets the `required` check only — `subdivision` is a country-dependent
* enum the schema does not enumerate, so its content is the server's call.
*/
export function errorsForField(field: FormFieldDto, values: FormValues): FormErrors {
const errors: FormErrors = {};
if (field.hidden) return errors;
const required = field.required && !field.readOnly;
if (field.control === "address") {
const parts = (values[field.target] ?? {}) as Record<string, unknown>;
for (const { sub, required: subRequired } of field.addressParts) {
if (subRequired && isBlank(parts[sub])) errors[`${field.target}/${sub}`] = addressPartMessage(sub);
}
return errors;
}
if (field.control === "file" || field.control === "signature") {
// Files are File objects, which no string rule can judge — check the count instead. Wix
// caps every upload field at 30 files.
const picked = ([] as unknown[]).concat(values[field.target] ?? []).filter(Boolean);
const limit = Math.min(field.validation.fileLimit ?? 30, 30);
if (required && !picked.length) errors[field.target] = requiredMessage(field);
else if (picked.length > limit) errors[field.target] = `There is an upload limit of ${limit} file${limit === 1 ? "" : "s"}.`;
return errors;
}
if (field.inputType === "ARRAY") {
const picked = (Array.isArray(values[field.target]) ? (values[field.target] as unknown[]) : []).filter((x) => !isBlank(x));
const { minItems, maxItems } = field.validation;
if (required && !picked.length) errors[field.target] = requiredMessage(field);
else if ((minItems && picked.length < minItems) || (maxItems && picked.length > maxItems)) errors[field.target] = itemsMessage(minItems, maxItems);
else if (field.choices.length && !field.otherOption && picked.some((x) => !field.choices.some((c) => c.value === x)))
errors[field.target] = "The chosen value is not allowed.";
return errors;
}
if (field.control === "checkbox") {
// `required` on a boolean only checks presence server-side; "must be ticked" is the enum
// [true] (`mustBeTrue`). Either way the visitor has to tick it before we send.
if ((required || field.validation.mustBeTrue) && values[field.target] !== true) errors[field.target] = "Check the box to continue.";
return errors;
}
const message = validateValue(field, values[field.target]);
if (message) errors[field.target] = message;
return errors;
}
export function errorsForForm(fields: FormFieldDto[], values: FormValues): FormErrors {
const errors: FormErrors = {};
for (const field of fields) Object.assign(errors, errorsForField(field, values));
return errors;
}
/**
* Move focus to a control by input name. `namedItem` returns a RadioNodeList for a radio or
* checkbox group and an element for everything else — a guard checking only for an element
* silently skips every choice group. FOCUS, not scrollIntoView: scrolling moves the viewport and
* nothing else, leaving a keyboard or screen-reader user where they were.
*/
export function focusControl(formEl: HTMLFormElement | null, name: string): void {
const control = formEl?.elements?.namedItem?.(name) as unknown;
const node =
typeof RadioNodeList !== "undefined" && control instanceof RadioNodeList
? (control[0] as HTMLElement | undefined)
: (control as HTMLElement | undefined);
node?.focus?.();
}
export interface FormStoreOptions {
/** The form to load (the seed's `formId`). Ignored when `initialForm` is given. */
formId: string;
/** Server-fetched form (Astro frontmatter) — skips the client fetch entirely. */
initialForm?: FormDto;
}
/** Everything a form surface renders from. Read it with getState() or through a subscription. */
export interface FormState {
/**
* null while the schema is loading — render a skeleton, not an empty form. Once loaded, the
* owner's rules are already applied to the CURRENT values: `form.fields` holds only the fields
* to render right now (hidden ones removed), each with its effective `required` and `choices`,
* and `form.steps[].targets` lists the visible targets of each step.
*/
form: FormDto | null;
/** `target` → current value. Arrays for multi-choice and files, objects for an address. */
values: FormValues;
/** `target` (or `target/sub`) → a visitor-facing message; errors[FORM_ERROR] is form-level. */
errors: FormErrors;
/** Loading the schema, or submitting. */
loading: boolean;
/** Index into `form.steps` of the page being shown. 0 on a single-step form. */
step: number;
/** The form is not accepting submissions (switched off, or past its deadline): show `form.disabledMessage`. */
closed: boolean;
/** The last successful submit, until `reset()` (or the owner's auto-hide) clears it. null before. */
outcome: SubmitOutcome | null;
}
/** A submit event as the store needs it — a React SyntheticEvent or a native Event both fit. */
export type FormSubmitEvent = { preventDefault?: () => void; currentTarget?: unknown };
export interface FormStore {
getState(): FormState;
subscribe(listener: () => void): () => void;
/** Load the schema when no `initialForm` was given. Call once when mounted. */
start(): void;
/** Stop reacting; drop a late schema response. */
stop(): void;
setValues(next: FormValues | ((prev: FormValues) => FormValues)): void;
/** One field's value — what a control's change handler calls. Rules re-run; a field a rule just hid is cleared. */
setValue(target: string, value: unknown): void;
/** One field, one address subfield (`target/sub`), or the whole form when called with nothing. A URL field is https-prefixed here when it reads like a bare domain. */
validate(target?: string): boolean;
/** Multi-step: validate the current step; on success show the next one. Returns whether it moved. */
next(event?: FormSubmitEvent): boolean;
/** Multi-step: show the previous step (nothing to validate). */
previous(): void;
goToStep(index: number): void;
/** A captcha widget's token, sent with the next submit. The server asks for one with INVALID_CAPTCHA. */
setCaptchaToken(token: string | null): void;
/** Clear `outcome` (dismiss the thank-you and show the empty form again). */
reset(): void;
/**
* The `onSubmit` handler. Client validation in Wix's wording first (focus lands on the first
* invalid control, switching step if needed), then uploads, then the create, then the owner's
* submit settings. Resolves the outcome when the submission was created — that IS the success
* signal; the values are back at the schema's defaults. Resolves FALSE when it did not send.
*/
submit(event?: FormSubmitEvent): Promise<SubmitOutcome | false>;
}
export function createFormStore({ formId, initialForm }: FormStoreOptions): FormStore {
// `base` is the schema as loaded; `form` (in state) is `base` with the rules applied to `values`.
let base: FormDto | null = initialForm ?? null;
let applied: FormFieldDto[] = base ? applyRules(base, defaultValues(base.fields)) : [];
// The empty form to reset to after a successful submit — the schema's own defaults.
let empty: FormValues = defaultValues(base?.fields ?? []);
let values: FormValues = empty;
let errors: FormErrors = {};
let loading = !initialForm;
let step = 0;
let outcome: SubmitOutcome | null = null;
let captchaToken: string | null = null;
let started = false;
let generation = 0;
let hideTimer: ReturnType<typeof setTimeout> | null = null;
const listeners = new Set<() => void>();
let snapshot: FormState | null = null;
const emit = () => { snapshot = null; for (const fn of listeners) fn(); };
/**
* An address field with its parts for the country the visitor picked: Israel shows a street
* name and number and no subdivision, the United States one address line and a state. Values
* typed under a subfield the new country lacks stay in `values` but are neither validated nor
* submitted (both read `addressParts`), so switching countries never sends an unknown key.
*/
const withCountryParts = (f: FormFieldDto): FormFieldDto => {
if (f.control !== "address" || !f.addressOverrides) return f;
const country = (values[f.target] as Record<string, unknown> | undefined)?.country;
return { ...f, addressParts: addressPartsForCountry(typeof country === "string" && country ? country : undefined, f.addressOverrides, f.required) };
};
/** `base` narrowed to what is visible right now. */
function visibleForm(): FormDto | null {
if (!base) return null;
const fields = applied.filter((f) => !f.hidden).map(withCountryParts);
const shown = new Set(fields.map((f) => f.target));
const steps: FormStep[] = base.steps.map((s) => ({ ...s, targets: s.targets.filter((t) => shown.has(t)) }));
return { ...base, fields, steps };
}
function getState(): FormState {
if (snapshot) return snapshot;
snapshot = { form: visibleForm(), values, errors, loading, step, closed: base ? isClosed(base) : false, outcome };
return snapshot;
}
const visibleFields = (): FormFieldDto[] => applied.filter((f) => !f.hidden).map(withCountryParts);
const stepFields = (i: number): FormFieldDto[] => {
const s = base?.steps[i];
return s ? visibleFields().filter((f) => f.stepId === s.id) : visibleFields();
};
/**
* Adopt new values: re-run the rules, and clear the value and errors of every field a rule
* just hid, repeating until nothing else hides (clear-fields.ts does the same fixed point —
* clearing one field can satisfy another rule's condition).
*/
function adoptValues(next: FormValues): void {
if (!base) { values = next; return; }
let hiddenBefore = new Set(applied.filter((f) => f.hidden).map((f) => f.target));
const cleared: string[] = [];
for (let i = 0; i <= base.fields.length; i++) {
applied = applyRules(base, next);
const toClear = applied.filter((f) => f.hidden && !hiddenBefore.has(f.target));
if (!toClear.length) break;
next = { ...next };
for (const f of toClear) { next[f.target] = emptyValue(f); cleared.push(f.target); }
hiddenBefore = new Set(applied.filter((f) => f.hidden).map((f) => f.target));
}
values = next;
if (cleared.length) {
const kept: FormErrors = {};
for (const [k, msg] of Object.entries(errors)) if (!cleared.includes(k.split("/")[0])) kept[k] = msg;
errors = kept;
}
}
function adopt(loaded: FormDto): void {
base = loaded;
// Seed the controls once the schema is in: every control is controlled from the first
// render, so each target holds a value of the right shape before any of them mount.
empty = defaultValues(loaded.fields);
applied = applyRules(loaded, empty);
values = empty;
errors = {};
step = 0;
loading = false;
emit();
}
function setErrors(next: FormErrors): void { errors = next; emit(); }
/** Show the step that holds a control, then focus it. */
function focusError(formEl: HTMLFormElement | null, key: string): void {
const target = key.split("/")[0];
const field = applied.find((f) => f.target === target);
const at = base?.steps.findIndex((s) => s.id === field?.stepId) ?? -1;
if (at >= 0 && at !== step) { step = at; emit(); }
focusControl(formEl, key);
}
/** The keys a `validate(target)` call owns, so a re-check CLEARS what it fixed as well as flagging what it did not. */
function ownedKeys(key: string, field: FormFieldDto): string[] {
return key.includes("/") ? [key] : field.control === "address" ? field.addressParts.map(({ sub }) => `${field.target}/${sub}`) : [field.target];
}
function validateFields(fields: FormFieldDto[], formEl: HTMLFormElement | null): boolean {
const found = errorsForForm(fields, values);
const owned = new Set(fields.flatMap((f) => ownedKeys(f.target, f)));
const next: FormErrors = {};
for (const [k, msg] of Object.entries(errors)) if (!owned.has(k) && k !== FORM_ERROR) next[k] = msg;
Object.assign(next, found);
setErrors(next);
const first = fields.flatMap((f) => ownedKeys(f.target, f)).find((k) => found[k]);
if (first) focusError(formEl, first);
return !first;
}
return {
getState,
subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); },
start() {
if (started) return;
started = true;
if (base) return; // the SSR pass already answered this
if (!formId) {
loading = false;
errors = { [FORM_ERROR]: "No formId — pass one from the seed's forms map." };
emit();
return;
}
const id = ++generation;
loading = true;
emit();
getForm(formId)
.then((loaded) => { if (started && generation === id) adopt(loaded); })
.catch((e: unknown) => {
if (!started || generation !== id) return;
// Fail loudly. A form that cannot load is a setup problem — never fall back to a
// hand-built form, which would drop real enquiries silently.
loading = false;
errors = { [FORM_ERROR]: e instanceof Error ? e.message : "Could not load the form." };
emit();
});
},
stop() { started = false; generation++; if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; } },
setValues(next) {
adoptValues(typeof next === "function" ? next(values) : next);
emit();
},
setValue(target, value) {
adoptValues({ ...values, [target]: value });
emit();
},
validate(target) {
if (!target) return validateFields(visibleFields(), null);
const key = String(target);
const field = visibleFields().find((f) => f.target === key.split("/")[0]);
if (!field) return true;
// Wix's URL field completes a bare domain on blur; do it before checking, so the visitor
// sees the value that will be sent.
if (field.control === "url" && typeof values[field.target] === "string") {
const fixed = normalizeUrl(values[field.target]);
if (fixed !== values[field.target]) { values = { ...values, [field.target]: fixed }; }
}
const owned = ownedKeys(key, field);
const found = errorsForField(field, values);
const next = { ...errors };
for (const k of owned) {
delete next[k];
if (found[k]) next[k] = found[k];
}
setErrors(next);
return owned.every((k) => !found[k]);
},
next(event) {
event?.preventDefault?.();
const formEl = (event?.currentTarget ?? null) as HTMLFormElement | null;
if (!base || step >= base.steps.length - 1) return false;
// Only the current step's fields (use-validation.ts validateStep): a later step's
// required field must not block moving forward.
if (!validateFields(stepFields(step), formEl)) return false;
step += 1;
emit();
return true;
},
previous() {
if (step > 0) { step -= 1; emit(); }
},
goToStep(index) {
if (base && index >= 0 && index < base.steps.length && index !== step) { step = index; emit(); }
},
setCaptchaToken(token) { captchaToken = token; },
reset() {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
outcome = null;
step = 0;
emit();
},
async submit(event) {
event?.preventDefault?.();
// Capture the <form> NOW: React clears currentTarget once the handler returns, so reading
// it after the await below (to focus a server-rejected control) comes back null.
const formEl = (event?.currentTarget ?? null) as HTMLFormElement | null;
if (!base) return false;
const current = base;
if (isClosed(current)) {
setErrors({ [FORM_ERROR]: current.disabledMessage || "This form is no longer accepting submissions." });
return false;
}
const currentFields = visibleFields();
// Client pass first, so the visitor gets inline feedback before a round trip.
if (!validateFields(currentFields, formEl)) return false;
loading = true;
emit();
try {
// Attachments go up FIRST — a File is not something the submission API takes, and its
// value is the file entry this hands back. No file fields → nothing happens here.
const uploaded = await uploadFiles(current.id, currentFields, values);
values = uploaded; // keep the uploaded entries, so a rejection on another field never re-uploads
emit();
const submission = await createSubmission(current.id, toSubmissionValues(currentFields, uploaded), captchaToken ? { captchaToken } : {});
errors = {};
captchaToken = null; // a token is single-use
// What happens next is the owner's call (use-submit.ts): a paid form goes to checkout,
// else the submit settings — thank-you text, a redirect, or nothing.
if (submission.checkoutId) {
outcome = { submission, action: "CHECKOUT" };
try {
outcome.url = await checkoutUrl(submission.checkoutId);
} catch (e) {
errors = { [FORM_ERROR]: e instanceof Error ? e.message : "Checkout could not start." };
}
} else {
const s = current.success;
outcome = {
submission,
action: s.action,
...(s.message ? { message: s.message } : {}),
...(s.durationSeconds ? { durationSeconds: s.durationSeconds } : {}),
...(s.redirectUrl ? { url: s.redirectUrl, newTab: s.newTab === true } : {}),
};
if (s.durationSeconds && typeof setTimeout !== "undefined") {
if (hideTimer) clearTimeout(hideTimer);
hideTimer = setTimeout(() => { hideTimer = null; outcome = null; emit(); }, s.durationSeconds * 1000);
}
}
adoptValues(empty); // back to the schema's defaults, ready for another
step = 0;
return outcome;
} catch (e) {
const mapped = submissionErrors(e, currentFields);
if (Object.keys(mapped).length) {
errors = mapped;
focusError(formEl, Object.keys(mapped)[0]);
} else {
errors = { [FORM_ERROR]: formLevelError(e, current) ?? (e instanceof Error ? e.message : "Could not send the form. Please try again.") };
}
return false;
} finally {
loading = false;
emit();
}
},
};
}
SHA-256: 6ab03339693f0203a59e5739a3422559fd312888d6808395682d5afee70c4d38