Setting the file. One moment.
Use Wix Form · Wix Vibe Headless · wix/skills · Skills Docs
ContentsBack to the top of the page 22.10
Post Detail
56 of 145 references/forms/app/hooks/ useWixForm.js
JavaScript · 425 lines · 21 KB
14 // read.orderedInputs(form).map((field) => <label key={read.targetOf(field)}>{read.labelOf(field)}…</label>)
15 //
16 // The controls are CONTROLLED: `values` (keyed by each field's `target`) is the form's state, so
17 // validate and submit read it directly — there is no FormData pass and no ref to hand over.
18 //
19 // Reading the live schema rather than a hardcoded field list is what makes an owner's dashboard edit
20 // (add, relabel, reorder, require, constrain a field) show up on the site with no code change, and
21 // what keeps the submission map from desyncing.
22 //
23 // Not on React? `validateField` below and everything in `lib/wix-form-schema-utils.js` are plain
24 // functions — port the ~80 lines of state around them.
25 import { useCallback, useEffect, useMemo, useRef, useState } from "react" ;
26 import { getForm, phoneExample, normalizePhone } from "@/rest/wix-forms" ;
27 import {
28 uploadFormFiles,
29 toSubmissionValues,
30 createSubmission,
31 submissionViolations,
32 } from "@/rest/wix-forms-submissions" ;
33 import * as read from "@/lib/wix-form-schema-utils" ;
34 import {
35 targetOf, labelOf, isRequired, isMulti, isFile, isNumber, isAddress,
36 addressPartsOf, validationOf, phoneCountryOf, orderedInputs,
37 } from "@/lib/wix-form-schema-utils" ;
38
39 /**
40 * The key in `errors` for a message that belongs to the FORM rather than one field — a schema that
41 * failed to load, or a submit rejection with no per-field violations. `@` can't appear in a Wix
42 * form `target`, so this can never collide with a field's own error.
43 */
44 export const FORM_ERROR = "@form" ;
45
46 /**
47 * The empty form: one entry per field, in the shape that field's control binds to — `[]` for a
48 * multi-choice field, `{}` for an address, and the owner's `default` prefill (else `""`) for the
49 * rest. Dropping `defaultValue` here would silently discard a dashboard setting.
50 */
51 function defaultValues ( fields ) {
52 const values = {};
53 for ( const field of fields) {
54 values[ targetOf (field)] =
55 isAddress (field) ? {} :
56 isMulti (field) || isFile (field) ? [] : // a file field holds File objects
57 // The owner's prefill. Wix spells it `default` on most components and `defaultValue` on a
58 // rating, so read both — picking one silently drops a dashboard setting on the other.
59 read. componentOf (field).default ?? read. componentOf (field).defaultValue ?? "" ;
60 }
61 return values;
62 }
63
64 /**
65 * Client-side validation for one field — the rules behind `validate(target)`. It lives here rather
66 * than in `rest/` because nothing about it is a REST call: it's the UI's own read of the schema.
67 * Derives EVERY check from that schema — never from the field's name. (The classic mistake is keying the email check on `target === "email"`; doing it
68 * off `format` means an owner-added PHONE/URL/length rule is honored with no code change.)
69 *
70 * ⚠️ A client check LAXER than the server's is worse than none — the visitor then learns about the
71 * problem only after a round trip, in the server's wording rather than yours. Keep these at least
72 * as strict as the server, and submit the NORMALIZED value the check passed.
73 *
74 * @param {object} field One raw field, from `fields`.
75 * @param {unknown} value The control's current value.
76 * @returns {string} A visitor-facing message, or "" when it passes.
77 */
78 export function validateField ( field , value ) {
79 const rules = validationOf (field);
80 const label = labelOf (field);
81 const v = String (value ?? "" ). trim ();
82 if ( isRequired (field) && ! v) return `${ label } is required.` ;
83 if ( ! v) return "" ; // optional + empty → ok
84 if (rules.minLength && v. length < rules.minLength) return `${ label } must be at least ${ rules . minLength } characters.` ;
85 if (rules.maxLength && v. length > rules.maxLength) return `${ label } must be at most ${ rules . maxLength } characters.` ;
86 // A NUMBER field carries no `format`; its rules are the bounds, and the control hands back a
87 // string, so parse before comparing — `"9" > 10` is false but `"9" > "10"` is true.
88 if ( isNumber (field)) {
89 const n = Number (v);
90 if ( ! Number. isFinite (n)) return `${ label } must be a number.` ;
91 const min = minimumOf (field);
92 const max = maximumOf (field);
93 if (min != null && n < min) return `${ label } must be ${ min } or more.` ;
94 if (max != null && n > max) return `${ label } must be ${ max } or less.` ;
95 return "" ;
96 }
97 if (rules.format === "EMAIL" && ! / ^ [ ^ \s@] + @ [ ^ \s@] + \. [ ^ \s@] +$ / . test (v)) return "Please enter a valid email address." ;
98 if (rules.format === "URL" && ! / ^ https ? : \/\/ . + / . test (v)) return "Please enter a valid URL." ;
99 // PHONE is E.164 server-side: leading +, country code, digits only. Strip formatting first —
100 // visitors add spaces, dashes and parens, and rejecting those is a UX bug, not validation.
101 if (rules.format === "PHONE" && ! / ^ \+ [1-9]\d {6,14}$ / . test ( normalizePhone (v)))
102 return `Use international format, e.g. ${ phoneExample ( phoneCountryOf ( field )) }.` ;
103 if (rules.pattern && !new RegExp (rules.pattern). test (v)) return `${ label } is not in the expected format.` ;
104 return "" ;
105 }
106
107 /**
108 * A NUMBER field's bounds. The validation block is JSON-Schema shaped everywhere else
109 * (`minLength`, `maxLength`, `pattern`, `format`, `enum`), so the numeric pair is `minimum` /
110 * `maximum`; `minValue` / `maxValue` are read too, since the public reference documents neither
111 * spelling and an older schema may carry them. Undefined when the owner set no bound.
112 */
113 const minimumOf = ( field ) => validationOf (field).minimum ?? validationOf (field).minValue;
114 const maximumOf = ( field ) => validationOf (field).maximum ?? validationOf (field).maxValue;
115
116 /** `postalCode` → "Postal code" — an address subfield is a key, not a label the schema carries. */
117 function humanizeSub ( sub ) {
118 const words = sub. replace ( /( [A-Z] ) | ( \d + )/ g , " $1$2" ). toLowerCase (). trim ();
119 return words. charAt ( 0 ). toUpperCase () + words. slice ( 1 );
120 }
121
122 /**
123 * The error entries one field currently has, keyed by input NAME — the same keys the controls and
124 * the server's error paths use. (Distinct from `validateField` above, which judges a single VALUE
125 * against one field's rules and returns one message; this maps that onto the form's keys, and an
126 * address has several.) A plain field yields at most one entry (`target`); an ADDRESS
127 * yields one per failing subfield (`target/sub`).
128 *
129 * Every rule comes from the schema (`validateField` above): `required`,
130 * `minLength`/`maxLength`, `pattern`, and `format` — EMAIL, PHONE (E.164), URL. Nothing is keyed off
131 * a field's NAME, so an owner-added constraint is honored with no code change.
132 *
133 * An address subfield gets the `required` check only: `country` and `subdivision` are
134 * country-dependent enums the schema doesn't enumerate, so their content is the server's call.
135 */
136 function errorsForField ( field , values ) {
137 const errors = {};
138 const target = targetOf (field);
139
140 if ( isAddress (field)) {
141 const parts = values[target] ?? {};
142 for ( const { sub , required } of addressPartsOf (field)) {
143 // Only `required` is checkable here: country/subdivision are country-dependent enums the
144 // schema doesn't enumerate, so their content is the server's call.
145 if (required && ! String (parts[sub] ?? "" ). trim ())
146 errors[ `${ target }/${ sub }` ] = `${ humanizeSub ( sub ) } is required.` ;
147 }
148 return errors;
149 }
150
151 // A file field holds File objects, which no string rule can judge — check the count instead.
152 if ( isFile (field)) {
153 const limit = validationOf (field).fileLimit;
154 const picked = []. concat (values[target] ?? []). filter (Boolean);
155 if ( isRequired (field) && ! picked. length ) errors[target] = `${ labelOf ( field ) } is required.` ;
156 else if (limit && picked. length > limit)
157 errors[target] = `Attach at most ${ limit } file${ limit === 1 ? "" : "s"}.` ;
158 return errors;
159 }
160
161 const message = validateField (field, values[target]);
162 if (message) errors[target] = message;
163 return errors;
164 }
165
166 /** Every field's error entries, merged into one `name → message` map. */
167 function errorsForForm ( fields , values ) {
168 const errors = {};
169 for ( const field of fields) Object. assign (errors, errorsForField (field, values));
170 return errors;
171 }
172
173 /** `format` → the copy for a FORMAT_ERROR, since "wrong shape" means something different per format. */
174 const FORMAT_COPY = {
175 EMAIL : () => "Enter a valid email address." ,
176 PHONE : ( f ) => `Use international format, e.g. ${ phoneExample ( phoneCountryOf ( f )) }.` ,
177 URL : () => "Enter a full URL starting with https://" ,
178 DATE : () => "Choose a valid date." ,
179 };
180
181 /** errorType → visitor-facing copy, written from the field's own schema. */
182 const ERROR_COPY = {
183 REQUIRED_VALUE_ERROR : ( f ) => `${ labelOf ( f ) } is required.` ,
184 MIN_LENGTH_ERROR : ( f ) => `${ labelOf ( f ) } must be at least ${ validationOf ( f ). minLength } characters.` ,
185 MAX_LENGTH_ERROR : ( f ) => `${ labelOf ( f ) } must be at most ${ validationOf ( f ). maxLength } characters.` ,
186 MIN_VALUE_ERROR : ( f ) => `${ labelOf ( f ) } must be ${ minimumOf ( f ) ?? "higher"} or more.` ,
187 MAX_VALUE_ERROR : ( f ) => `${ labelOf ( f ) } must be ${ maximumOf ( f ) ?? "lower"} or less.` ,
188 FORMAT_ERROR : ( f ) => FORMAT_COPY [ validationOf (f).format]?.(f) ?? `Please check ${ labelOf ( f ) }.` ,
189 PATTERN_ERROR : ( f ) => `${ labelOf ( f ) } is not in the expected format.` ,
190 NOT_ALLOWED_VALUE_ERROR : ( f ) => `Choose one of the listed options for ${ labelOf ( f ) }.` ,
191 TYPE_ERROR : ( f ) => `Please check ${ labelOf ( f ) }.` ,
192 UNKNOWN_VALUE_ERROR : ( f ) => `Please check ${ labelOf ( f ) }.` ,
193 };
194
195 /**
196 * Turn a failed `createSubmission` into per-control messages, keyed by input NAME — so each message
197 * lands on its own control instead of only a form-level banner. An address subfield's server path
198 * (`address/subdivision`) is exactly how its control is named, so it maps straight through.
199 *
200 * The copy is written from `errorType` plus the field's own schema; Wix's `errorMessage` is the
201 * validator's internal wording, so it goes to `console.debug` only. An unmapped `errorType` degrades
202 * to safe copy — the enum grows, and MIN/MAX_VALUE_ERROR, MIN/MAX_ITEMS_ERROR and
203 * DISABLED_FORM_ERROR are all reachable without being listed above.
204 *
205 * ⚠️ Two rejections here are SEED bugs, not frontend bugs — do NOT mangle the key or the value to
206 * work around them (fix them in `seed/SEED.md`):
207 * • `UNKNOWN_VALUE_ERROR` on a key that IS in the schema → the field was seeded with no
208 * `validation` block, and that block is what registers the target as an accepted value.
209 * • `NOT_ALLOWED_VALUE_ERROR` on a choice field → the seed's `options[].value` and its validation
210 * enum disagree; the two declarations must match.
211 *
212 * @param {Error & { body?: object }} err
213 * @param {object[]} fields The raw fields being rendered.
214 * @returns {Record<string, string>} Empty when the failure wasn't a validation error.
215 */
216 export function mapSubmissionErrors ( err , fields ) {
217 const byTarget = new Map (fields. map (( f ) => [ targetOf (f), f]));
218 const errors = {};
219 for ( const violation of submissionViolations (err)) {
220 const field = byTarget. get (violation.errorPath. split ( "/" )[ 0 ]);
221 if ( ! field) continue ;
222 console. debug ( "wix-forms: server violation" , violation.errorPath, violation.errorType, violation.errorMessage);
223 errors[violation.errorPath] = ERROR_COPY [violation.errorType]?.(field) ?? `Please check ${ labelOf ( field ) }.` ;
224 }
225 return errors;
226 }
227
228 /**
229 * Move focus to a control by input name. ⚠️ `namedItem` returns a `RadioNodeList` for a radio or
230 * checkbox group and an element for everything else — a guard that checks only for an element
231 * silently skips every choice group.
232 */
233 function focusControl ( formEl , name ) {
234 const control = formEl?.elements?. namedItem ?.(name);
235 const node =
236 typeof RadioNodeList !== "undefined" && control instanceof RadioNodeList ? control[ 0 ] : control;
237 node?. focus ?.();
238 }
239
240 /**
241 * @param {string} formId The form's GUID — read it from `WIX_FORMS` in `rest/wix-forms.config.js`
242 * (written by the seed); never type a literal id into a component.
243 * @returns {{
244 * form: object|null,
245 * // The Form exactly as Wix returned it, once loaded. Nothing is stripped or renamed: the
246 * // fields to render are `read.orderedInputs(form)`, the submit wording
247 * // `read.submitTextOf(form)`. Never hardcode a field list.
248 * values: Record<string, unknown>,
249 * // The form's state, keyed by each field's `target`: a string for text/choice/date, an ARRAY
250 * // for a multi-choice field, an OBJECT for an address. Bind every control's `value` to it.
251 * setValues: (next: object | ((prev: object) => object)) => void,
252 * // The plain React setter — `setValues((v) => ({ ...v, [name]: value }))` from onChange.
253 * bind: (target: string) => object,
254 * // The props a text-ish control needs, ready to spread: name, value, onChange, onBlur and the
255 * // two aria attributes. `<input {...bind(target)} type="email" required />`.
256 * submit: (event?: SubmitEvent) => Promise<boolean>,
257 * // Use as `onSubmit`. Validates, submits, and resolves TRUE when the submission was created —
258 * // that resolved true IS the success signal (a visitor can't read submissions back), so flip
259 * // your own thank-you state on it. FALSE means `errors` now says why.
260 * validate: (target?: string) => boolean,
261 * // `validate("email")` checks that one field, `validate("address/city")` one address subfield,
262 * // `validate()` the whole form. Every rule comes from the schema — `required`,
263 * // `minLength`/`maxLength`, `pattern`, `format` (EMAIL/PHONE/URL) — never from a field's name.
264 * // Writes `errors` (clearing what now passes) and returns whether what it checked passed.
265 * errors: Record<string, string>,
266 * // input name → visitor-facing message. The name is the field's `target`, or `target/sub` for
267 * // an address subfield. `errors[FORM_ERROR]` holds a form-level message (load or submit).
268 * loading: boolean,
269 * // Busy: loading the schema (`form` still null) or submitting (`form` set).
270 * read: typeof import("@/lib/wix-form-schema-utils"),
271 * // The schema accessors — functions, not data: `read.labelOf(field)`, `read.choicesOf(field)`,
272 * // `read.submitTextOf(form)`, … Handed back so the UI needs one import.
273 * }}
274 */
275 export function useWixForm ( formId ) {
276 const [ form , setForm ] = useState ( null );
277 const [ values , setValues ] = useState ({});
278 const [ errors , setErrors ] = useState ({});
279 const [ loading , setLoading ] = useState ( true );
280 // The empty form to fall back to after a successful submit — the schema's own defaults.
281 const emptyRef = useRef ({});
282
283 // Every visible INPUT the schema lays out, in the owner's order — the list the hook seeds values
284 // from, validates and submits. The UI gets it the same way (`read.orderedInputs(form)`), so there
285 // is no second copy to keep in sync and no filtered subset to explain.
286 const fields = useMemo (() => orderedInputs (form), [form]);
287
288 useEffect (() => {
289 let live = true ;
290 if ( ! formId) {
291 setLoading ( false );
292 setErrors ({ [ FORM_ERROR ]: "No formId — pass one from WIX_FORMS (the seed writes it)." });
293 return ;
294 }
295 setLoading ( true );
296 getForm (formId)
297 . then (( loaded ) => {
298 if ( ! live) return ;
299 setForm (loaded);
300 setErrors ({});
301 setLoading ( false );
302 })
303 . catch (( e ) => {
304 if ( ! live) return ;
305 // Fail loudly. A form that can't load is a setup problem (wrong id, Forms app missing) —
306 // never fall back to a hand-built form, which would drop real enquiries silently.
307 setErrors ({ [ FORM_ERROR ]: e.message || "Could not load the form." });
308 setLoading ( false );
309 });
310 return () => {
311 live = false ;
312 };
313 }, [formId]);
314
315 // Seed the controls once the schema is in: every control is controlled from the first render, so
316 // each `target` must hold a value of the right shape before any of them mount.
317 useEffect (() => {
318 const empty = defaultValues (fields);
319 emptyRef.current = empty;
320 setValues (empty);
321 }, [fields]);
322
323 const validate = useCallback (
324 ( target ) => {
325 // No target → the whole form. This is what `submit` runs before it sends anything.
326 if ( ! target) {
327 const all = errorsForForm (fields, values);
328 setErrors (all);
329 return Object. keys (all). length === 0 ;
330 }
331
332 const key = String (target);
333 const field = fields. find (( f ) => targetOf (f) === key. split ( "/" )[ 0 ]);
334 if ( ! field) return true ;
335
336 // The error keys this call owns, so a re-check CLEARS what it fixed as well as flagging what
337 // it didn't: one key for a plain field, or all of an address's subfields (`target/sub`) when
338 // the address itself is named. Naming a single subfield scopes it to that one.
339 const owned = key. includes ( "/" )
340 ? [key]
341 : isAddress (field)
342 ? addressPartsOf (field). map (({ sub }) => `${ targetOf ( field ) }/${ sub }` )
343 : [ targetOf (field)];
344
345 const found = errorsForField (field, values);
346 setErrors (( prev ) => {
347 const next = { ... prev };
348 for ( const k of owned) {
349 delete next[k];
350 if (found[k]) next[k] = found[k];
351 }
352 return next;
353 });
354 return owned. every (( k ) => ! found[k]);
355 },
356 [fields, values],
357 );
358
359 // Everything a text-ish control needs, in one spread: `<input {...bind("email_a1b2")} type="email" />`.
360 // Covers input / textarea / select. A checkbox or radio group carries `checked` instead of `value`
361 // and a file input can't be controlled at all — wire those two by hand (INSTRUCTIONS.md, step 2),
362 // keeping the same `name`, `onBlur` and `aria-*` contract.
363 const bind = useCallback (
364 ( target ) => ({
365 name: target,
366 value: values[target] ?? "" ,
367 onChange : ( event ) => setValues (( prev ) => ({ ... prev, [target]: event.target.value })),
368 onBlur : () => validate (target),
369 "aria-describedby" : `err-${ target }` ,
370 "aria-invalid" : errors[target] ? true : undefined ,
371 }),
372 [values, errors, validate],
373 );
374
375 const submit = useCallback (
376 async ( event ) => {
377 event?. preventDefault ?.();
378 // Capture the <form> now: React clears `currentTarget` once the handler returns, so reading it
379 // after the await below (to focus a server-rejected control) would come back null.
380 const formEl = event?.currentTarget ?? null ;
381 if ( ! form) return false ;
382
383 // Client pass first, so the visitor gets inline feedback in OUR wording before a round trip.
384 const clientErrors = errorsForForm (fields, values);
385 if (Object. keys (clientErrors). length ) {
386 setErrors (clientErrors);
387 // FOCUS the first invalid control — `scrollIntoView` moves the viewport and nothing else,
388 // leaving a keyboard or screen-reader user where they were. The <form> comes from the event,
389 // and each control is found by its `name` (which is its `target`).
390 focusControl (formEl, Object. keys (clientErrors)[ 0 ]);
391 return false ;
392 }
393
394 setLoading ( true );
395 try {
396 // Any picked files go up FIRST — a `File` object is not something the submission API takes,
397 // and its value is the upload URL this hands back. No file fields → nothing happens here.
398 const uploaded = await uploadFormFiles (form.id, fields, values);
399 // Resolves on CONFIRMED, PENDING or PAYMENT_WAITING — all three are created submissions.
400 // There is nothing to read back, so this is the only confirmation there is. (A form that
401 // collects payment leaves the visitor a payment step; call createSubmission directly if you
402 // need its `status` to surface that inside your success state.)
403 await createSubmission (form.id, toSubmissionValues (fields, uploaded));
404 setErrors ({});
405 setValues (emptyRef.current); // back to the schema's defaults, ready for another
406 return true ;
407 } catch (e) {
408 // Per-field violations land on their controls; anything else is a form-level message.
409 const mapped = mapSubmissionErrors (e, fields);
410 if (Object. keys (mapped). length ) {
411 setErrors (mapped);
412 focusControl (formEl, Object. keys (mapped)[ 0 ]);
413 } else {
414 setErrors ({ [ FORM_ERROR ]: e.message || "Could not send the form. Please try again." });
415 }
416 return false ;
417 } finally {
418 setLoading ( false );
419 }
420 },
421 [fields, form, values],
422 );
423
424 return { form, values, setValues, bind, submit, validate, errors, loading, read };
425 }