Setting the file. One moment.
Seed Forms · Wix Headless Templates · wix/skills · Skills Docs
ContentsBack to the top of the page async function verifyForm
— line 401
This file
Number 16.427
Position 427 of 828
Type JavaScript
Size 29 KB
Lines 575 forms/seed/ seed-forms.mjs
JavaScript · 575 lines · 29 KB
14
// "perVisitor"?, "requiredIndicator"?,
15 // "fields": [{ "label", "kind", "required"?, "placeholder"?, "description"?, "default"?,
16 // "choices"?, "other"?, "min"?, "max"?, "step"?, "minLength"?, "maxLength"?,
17 // "pattern"?, "patternMessage"?, "fileLimit"?, "formats"?, "countries"?,
18 // "country"?, "buttonText"?, "hidden"? }] }] }
19 //
20 // The plan is PLAIN DATA — labels and kinds. Everything the API demands and the docs bury is
21 // derived here: per-field UUIDs, the two-level options nesting, the validation block that must
22 // exist even when empty, the choice enum that must agree with the component's options, the
23 // per-component prefill key, the snake_case+suffix target, a `steps` layout referencing every
24 // field including the submit button (a field missing from `steps` never appears in the owner's
25 // dashboard), and rules keyed by field id.
26 //
27 // Seeding is ADDITIVE — never deletes or overwrites existing forms.
28 import { seedSiteId } from "../../shared/seed/site-context.mjs" ;
29 import { wixToken } from "../../shared/seed/wix-cli.mjs" ;
30 import { isMain } from "../../shared/seed/main.mjs" ;
31 import { readFileSync } from "node:fs" ;
32 import { randomUUID } from "node:crypto" ;
33
34 const API = "https://www.wixapis.com" ;
35 const FORMS_APP_ID = "225dd912-7dea-4738-8688-4b8c6955ffc2" ;
36 const NAMESPACE = "wix.form_app.form" ;
37
38 export function makeCtx ({ cwd = process. cwd () } = {}) {
39 // The content site: the config's site, or the parent on a migration preview (site-context.mjs stops
40 // a seed there unless --allow-parent is passed after the user confirmed).
41 const siteId = seedSiteId ({ cwd, argv: process.argv });
42 const token = wixToken (siteId, cwd);
43 return { token, siteId };
44 }
45
46 async function req ( ctx , path , { method = "POST" , body } = {}) {
47 const res = await fetch ( API + path, {
48 method,
49 headers: {
50 Authorization: `Bearer ${ ctx . token }` ,
51 "wix-site-id" : ctx.siteId,
52 "Content-Type" : "application/json" ,
53 },
54 body: body ? JSON . stringify (body) : undefined ,
55 });
56 const json = await res. json (). catch (() => ({}));
57 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
58 return json;
59 }
60
61 /** Idempotent. Nothing here works until the app is installed. */
62 export async function installFormsApp ( ctx ) {
63 try {
64 await req (ctx, "/apps-installer-service/v1/app-instance/install" , {
65 body: {
66 tenant: { tenantType: "SITE" , id: ctx.siteId },
67 appInstance: { appDefId: FORMS_APP_ID , enabled: true },
68 },
69 });
70 } catch {
71 /* already installed is fine */
72 }
73 }
74
75 // ---- field expansion -------------------------------------------------------------------------
76
77 // kind → { inputType, componentType, identifier, format?, contact? }
78 // The two block names are what the API nests settings under, and they are named after the
79 // field's own enums — which is why they are looked up here rather than spelled at each call.
80 // `contact` is the per-field contactMapping: it is what makes a submission create or update the
81 // site's CRM contact (the older postSubmissionTriggers.upsertContact is ignored by v4).
82 const KINDS = {
83 text: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "TEXT_INPUT" },
84 textarea: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "TEXT_AREA" },
85 email: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "CONTACTS_EMAIL" , format: "EMAIL" , contact: { contactField: "EMAIL" , emailInfo: { tag: "UNTAGGED" } } },
86 phone: { inputType: "STRING" , componentType: "PHONE_INPUT" , identifier: "CONTACTS_PHONE" , format: "PHONE" , contact: { contactField: "PHONE" , phoneInfo: { tag: "UNTAGGED" } } },
87 url: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "URL_INPUT" , format: "URL" },
88 firstName: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "CONTACTS_FIRST_NAME" , contact: { contactField: "FIRST_NAME" } },
89 lastName: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "CONTACTS_LAST_NAME" , contact: { contactField: "LAST_NAME" } },
90 company: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "CONTACTS_COMPANY" , contact: { contactField: "COMPANY" } },
91 date: { inputType: "STRING" , componentType: "DATE_PICKER" , identifier: "DATE_PICKER" , format: "DATE" , dateBlock: "dateOptions" },
92 time: { inputType: "STRING" , componentType: "TIME_INPUT" , identifier: "TIME_INPUT" , format: "TIME" , dateBlock: "timeOptions" },
93 datetime: { inputType: "STRING" , componentType: "DATE_TIME" , identifier: "DATE_TIME_INPUT" , format: "DATE_TIME" , dateBlock: "dateTimeOptions" },
94 number: { inputType: "NUMBER" , componentType: "NUMBER_INPUT" , identifier: "NUMBER_INPUT" },
95 rating: { inputType: "NUMBER" , componentType: "RATING_INPUT" , identifier: "RATING_INPUT" },
96 select: { inputType: "STRING" , componentType: "DROPDOWN" , identifier: "DROPDOWN" },
97 radio: { inputType: "STRING" , componentType: "RADIO_GROUP" , identifier: "RADIO_GROUP" },
98 multi: { inputType: "ARRAY" , componentType: "CHECKBOX_GROUP" , identifier: "CHECKBOX_GROUP" },
99 tags: { inputType: "ARRAY" , componentType: "TAGS" , identifier: "TAGS" },
100 checkbox: { inputType: "BOOLEAN" , componentType: "CHECKBOX" , identifier: "CHECKBOX" },
101 file: { inputType: "WIX_FILE" , componentType: "FILE_UPLOAD" , identifier: "FILE_UPLOAD" },
102 address: { inputType: "ADDRESS" , componentType: "MULTILINE_ADDRESS" , identifier: "MULTILINE_ADDRESS" },
103 };
104
105 const INPUT_BLOCK = {
106 STRING: "stringOptions" , NUMBER: "numberOptions" , BOOLEAN: "booleanOptions" ,
107 ARRAY: "arrayOptions" , ADDRESS: "addressOptions" , WIX_FILE: "wixFileOptions" ,
108 };
109 const COMPONENT_BLOCK = {
110 TEXT_INPUT: "textInputOptions" , PHONE_INPUT: "phoneInputOptions" , NUMBER_INPUT: "numberInputOptions" ,
111 RATING_INPUT: "ratingInputOptions" , DATE_PICKER: "datePickerOptions" , TIME_INPUT: "timeInputOptions" ,
112 DATE_TIME: "dateTimeOptions" , CHECKBOX: "checkboxOptions" , CHECKBOX_GROUP: "checkboxGroupOptions" ,
113 TAGS: "tagsOptions" , RADIO_GROUP: "radioGroupOptions" , DROPDOWN: "dropdownOptions" ,
114 MULTILINE_ADDRESS: "multilineAddressOptions" , FILE_UPLOAD: "fileUploadOptions" ,
115 };
116
117 // The address subfields the owner can require. `country` is always shown by the runtime; the
118 // others follow the country template. `validation.fields` only carries required-ness.
119 const ADDRESS_PARTS = [ "country" , "addressLine" , "addressLine2" , "city" , "subdivision" , "postalCode" ];
120 const ADDRESS_REQUIRED_WHEN_REQUIRED = [ "country" , "addressLine" , "city" , "postalCode" ];
121
122 /** One paragraph of Ricos rich content — the shape of a checkbox label, a thank-you message. */
123 function richText ( text ) {
124 return {
125 nodes: [
126 {
127 type: "PARAGRAPH" ,
128 id: randomUUID (). slice ( 0 , 8 ),
129 nodes: [{ type: "TEXT" , id: "" , nodes: [], textData: { text: String (text), decorations: [] } }],
130 paragraphData: {},
131 },
132 ],
133 metadata: { version: 1 },
134 };
135 }
136
137 /**
138 * `target` is the IMMUTABLE submission key: starts with a letter, letters/digits/underscore
139 * only, no doubled underscore, unique within the form. The random suffix is what keeps two
140 * fields with the same label apart.
141 */
142 function targetFor ( label , taken ) {
143 const base =
144 String (label)
145 . toLowerCase ()
146 . replace ( / [ ^ a-z0-9] + / g , "_" )
147 . replace ( /_ + / g , "_" )
148 . replace ( / ^ _ | _ $ / g , "" )
149 . replace ( / ^ (?= \d )/ , "f_" )
150 . slice ( 0 , 40 ) || "field" ;
151 let target;
152 do {
153 target = `${ base }_${ Math . random (). toString ( 36 ). slice ( 2 , 8 ) }` ;
154 } while (taken. has (target));
155 taken. add (target);
156 return target;
157 }
158
159 function buildField ( spec , taken ) {
160 const kind = KINDS [spec.kind];
161 if ( ! kind) {
162 throw new Error (
163 `field "${ spec . label }": unknown kind "${ spec . kind }" — one of ${ Object . keys ( KINDS ). join ( ", " ) }` ,
164 );
165 }
166 const isChoice = [ "select" , "radio" , "multi" , "tags" ]. includes (spec.kind);
167 const choices = spec.choices ?? [];
168 if (isChoice && ! choices. length ) {
169 throw new Error ( `field "${ spec . label }": kind "${ spec . kind }" needs a non-empty choices array` );
170 }
171
172 const target = targetFor (spec.label, taken);
173 const defaults = []. concat (spec.default ?? []). map (String);
174 const options = choices. map (( c ) => {
175 const value = typeof c === "string" ? c : c.value;
176 return {
177 id: randomUUID (),
178 label: typeof c === "string" ? c : (c.label ?? c.value),
179 value,
180 // A preselected choice is marked on the OPTION (`options[].default`), not on the component.
181 ... (defaults. includes ( String (value)) ? { default: true } : {}),
182 };
183 });
184 const values = options. map (( o ) => o.value);
185 const other = spec.other
186 ? { label: typeof spec.other === "string" ? spec.other : "Other" , ... (spec.otherPlaceholder ? { placeholder: spec.otherPlaceholder } : {}) }
187 : null ;
188
189 // A choice field declares its options TWICE — here and in the validation enum — and the two
190 // must agree. Disagree and the create still returns 200: the field is created as a plain
191 // text box, losing its choices. Both are derived from the same list, so they cannot drift.
192 // A free-text "Other" entry is anything outside the list, so no enum is written with it (the
193 // validator ignores the enum when a customOption is defined).
194 const validation = {};
195 if (kind.format) validation.format = kind.format;
196 if (kind.dateBlock) {
197 // Date bounds live under the format's options block; `$now`, `$now+2d`, `$now-1M` are accepted.
198 const bounds = { ... (spec.min != null ? { minimum: String (spec.min) } : {}), ... (spec.max != null ? { maximum: String (spec.max) } : {}) };
199 if (Object. keys (bounds). length ) validation[kind.dateBlock] = bounds;
200 } else {
201 if (spec.min != null ) validation.minimum = spec.min;
202 if (spec.max != null ) validation.maximum = spec.max;
203 }
204 if (spec.step != null ) validation.multipleOf = spec.step;
205 if (spec.minLength != null ) validation.minLength = spec.minLength;
206 if (spec.maxLength != null ) validation.maxLength = spec.maxLength;
207 if (spec.pattern) validation.pattern = spec.pattern;
208 if (spec.patternMessage) validation.validationMessages = { pattern: spec.patternMessage };
209 if (spec.minItems != null ) validation.minItems = spec.minItems;
210 if (spec.maxItems != null ) validation.maxItems = spec.maxItems;
211 if (values. length ) {
212 if (kind.inputType === "ARRAY" ) {
213 // `itemType` sits INSIDE `items`, beside the options block; one level up the create is a 400
214 // whose message blames the options block ("itemType cannot be provided with ...").
215 validation.items = { itemType: "STRING" , stringOptions: other ? {} : { enum: values } };
216 } else if ( ! other) {
217 validation.enum = values;
218 }
219 }
220 if (spec.kind === "phone" && spec.countries?. length ) validation.phoneOptions = { allowedCountryCodes: spec.countries };
221 if (spec.kind === "file" ) {
222 validation.fileLimit = spec.fileLimit ?? 1 ;
223 if (spec.formats?. length ) validation.uploadFileFormats = spec.formats;
224 }
225 // "Must be ticked" is the boolean enum [true]; `required` alone only checks that a value is present.
226 if (spec.kind === "checkbox" && spec.required) validation.enum = [ true ];
227 if (spec.kind === "address" ) {
228 if (spec.countries?. length ) validation.allowedCountries = spec.countries;
229 validation.fields = Object. fromEntries (
230 ADDRESS_PARTS . map (( sub ) => [sub, { required: spec.parts?.[sub] ?? ( Boolean (spec.required) && ADDRESS_REQUIRED_WHEN_REQUIRED . includes (sub)) }]),
231 );
232 }
233
234 // A checkbox labels itself with rich content (its label may carry a link to the terms).
235 const component = { label: spec.kind === "checkbox" ? richText (spec.label) : spec.label, showLabel: true };
236 if (spec.placeholder?. length > 100 ) {
237 throw new Error ( `field "${ spec . label }": placeholder is ${ spec . placeholder . length } characters, the API allows 100` );
238 }
239 if (spec.placeholder) component.placeholder = spec.placeholder;
240 if (spec.description) component.description = richText (spec.description);
241 if (options. length ) component.options = options;
242 if (other) component.customOption = other;
243 // The prefill key differs per component: a checkbox has `checked`, a rating `defaultValue`,
244 // a choice field marks its option (above), everything else `default`.
245 if (spec.default != null && ! isChoice) {
246 if (spec.kind === "checkbox" ) component.checked = Boolean (spec.default);
247 else if (spec.kind === "rating" ) component.defaultValue = Number (spec.default);
248 else if (spec.kind === "number" ) component.default = Number (spec.default);
249 else component.default = String (spec.default);
250 }
251 if (spec.kind === "phone" && spec.country) component.defaultCountryCode = spec.country;
252 if (spec.kind === "file" && spec.buttonText) component.buttonText = spec.buttonText;
253 if (spec.kind === "address" && spec.parts && "addressLine2" in spec.parts) component.fieldSettings = { addressLine2: { show: spec.parts.addressLine2 !== false } };
254
255 return {
256 id: randomUUID (),
257 identifier: kind.identifier,
258 fieldType: "INPUT" ,
259 // A field a rule shows starts hidden.
260 hidden: Boolean (spec.hidden),
261 inputOptions: {
262 target,
263 inputType: kind.inputType,
264 // `required` lives HERE, never inside the validation block.
265 required: spec.required ?? false ,
266 // Contact fields carry pii and the CRM mapping; without contactMapping a submission is
267 // stored but no contact is created or updated.
268 ... (kind.contact ? { pii: true , contactMapping: kind.contact } : {}),
269 [ INPUT_BLOCK [kind.inputType]]: {
270 // `validation` is always present, even as {}, and nests under the INPUT-TYPE block —
271 // not the component one. Absent, the target is not registered as an accepted value and
272 // every submission comes back UNKNOWN_VALUE_ERROR on a key that IS in the schema.
273 validation,
274 componentType: kind.componentType,
275 [ COMPONENT_BLOCK [kind.componentType]]: component,
276 },
277 },
278 };
279 }
280
281 // Plan rule → v4 `formRules[]` entry. The condition names a field by LABEL; the override names
282 // the fields it shows / hides / requires. `show` marks those fields hidden in the base schema
283 // and un-hides them while the condition holds — how the dashboard's own "show X when" works.
284 const RULE_OPERATORS = { is: "EQUAL" , isNot: "NOT_EQUAL" , in: "IN" , includes: "CONTAINS" , checked: "CHECKED" , isEmpty: "EMPTY" , isNotEmpty: "NOT_EMPTY" };
285 function buildRule ( rule , fieldByLabel , formName ) {
286 const byLabel = ( label ) => {
287 const f = fieldByLabel. get (label);
288 if ( ! f) throw new Error ( `form "${ formName }": rule refers to a field labelled "${ label }" that is not in the plan` );
289 return f;
290 };
291 const when = rule.when ?? {};
292 const opKey = Object. keys ( RULE_OPERATORS ). find (( k ) => k in when);
293 if ( ! when.field || ! opKey) throw new Error ( `form "${ formName }": a rule needs "when": { "field", and one of ${ Object . keys ( RULE_OPERATORS ). join ( "/" ) } }` );
294 const source = byLabel (when.field);
295 const operator = RULE_OPERATORS [opKey];
296 // EMPTY, NOT_EMPTY and CHECKED carry no value of their own, but the service still requires one
297 // (RULE_CONDITION_VALUE_MISSING without it); `true` is what the dashboard sends.
298 const value = [ "checked" , "isEmpty" , "isNotEmpty" ]. includes (opKey) ? true : when[opKey];
299 const override = ( label , propertyType , options ) => ({
300 entityType: "FIELD" ,
301 fieldOptions: { fieldId: byLabel (label).id, propertyType, ... options },
302 });
303 const overrides = [
304 ... (rule.show ?? []). map (( l ) => override (l, "HIDDEN" , { hiddenOptions: { hidden: false } })),
305 ... (rule.hide ?? []). map (( l ) => override (l, "HIDDEN" , { hiddenOptions: { hidden: true } })),
306 ... (rule.require ?? []). map (( l ) => override (l, "REQUIRED" , { requiredOptions: { required: true } })),
307 ];
308 if ( ! overrides. length ) throw new Error ( `form "${ formName }": a rule needs at least one of show / hide / require` );
309 for ( const l of rule.show ?? []) byLabel (l).hidden = true ;
310 return {
311 id: randomUUID (),
312 name: rule.name ?? `${ when . field } ${ opKey } ${ JSON . stringify ( value ?? "" ) }` . slice ( 0 , 100 ),
313 // The root of a rule expression must be an and/or group, never a bare condition
314 // (UNGROUPED_RULE_EXPRESSION_ROOT). One condition still goes inside an `and`.
315 expression: { and: { conditions: [{ condition: { target: source.inputOptions.target, operator, value } }] } },
316 overrides,
317 };
318 }
319
320 function buildForm ( planForm ) {
321 const taken = new Set ();
322 const fields = (planForm.fields ?? []). map (( f ) => buildField (f, taken));
323 if ( ! fields. length ) throw new Error ( `form "${ planForm . name }": no fields` );
324 const fieldByLabel = new Map (fields. map (( f , i ) => [planForm.fields[i].label, f]));
325
326 const submit = {
327 id: randomUUID (),
328 identifier: "SUBMIT_BUTTON" ,
329 fieldType: "DISPLAY" ,
330 displayOptions: {
331 // The button's identifier is SUBMIT_BUTTON; its display type is PAGE_NAVIGATION (the enum
332 // has no SUBMIT_BUTTON value — the create is a 400 with anything else). One button drives
333 // both the page moves and the final submit, so all three wordings sit on it.
334 displayFieldType: "PAGE_NAVIGATION" ,
335 pageNavigationOptions: {
336 submitText: planForm.submitText ?? "Submit" ,
337 ... (planForm.nextText ? { nextPageText: planForm.nextText } : {}),
338 ... (planForm.previousText ? { previousPageText: planForm.previousText } : {}),
339 },
340 },
341 };
342
343 // `steps` must reference EVERY field, the submit button included — a field missing from the
344 // layout never appears in the owner's dashboard, so they cannot edit what the site renders.
345 // A plan without steps is one page; with steps, a field not named on any step lands on the
346 // last one, and the button always does.
347 const planSteps = planForm.steps?. length ? planForm.steps : [{ name: "" , fields: planForm.fields. map (( f ) => f.label) }];
348 const placed = new Set ();
349 const stepFields = planSteps. map (( s ) =>
350 (s.fields ?? []). map (( label ) => {
351 const f = fieldByLabel. get (label);
352 if ( ! f) throw new Error ( `form "${ planForm . name }": step "${ s . name ?? ""}" names a field labelled "${ label }" that is not in the plan` );
353 placed. add (f.id);
354 return f;
355 }),
356 );
357 const unplaced = fields. filter (( f ) => ! placed. has (f.id));
358 stepFields[stepFields. length - 1 ]. push ( ... unplaced, submit);
359 const layoutOf = ( list ) => {
360 const items = list. map (( f , i ) => ({ fieldId: f.id, row: i, column: 0 , width: 12 }));
361 return { large: { items }, medium: { items }, small: { items } };
362 };
363 const steps = planSteps. map (( s , i ) => ({ id: randomUUID (), ... (s.name ? { name: s.name } : {}), layout: layoutOf (stepFields[i]) }));
364
365 const formRules = (planForm.rules ?? []). map (( r ) => buildRule (r, fieldByLabel, planForm.name));
366
367 // What happens after a successful submit is the owner's setting, so it lives on the form:
368 // the thank-you text (optionally auto-hidden) or a redirect. Absent, the page writes its own.
369 const submitSettings = planForm.redirect
370 ? { submitSuccessAction: "REDIRECT" , redirectOptions: { redirectUrl: planForm.redirect, target: "SELF" } }
371 : planForm.thankYou
372 ? { submitSuccessAction: "THANK_YOU_MESSAGE" , thankYouMessageOptions: { richContent: richText (planForm.thankYou), ... (planForm.thankYouSeconds ? { durationInSeconds: planForm.thankYouSeconds } : {}) } }
373 : null ;
374 const limitationRule = {
375 ... (planForm.deadline ? { dateTimeDeadline: new Date (planForm.deadline). toISOString () } : {}),
376 ... (planForm.maxSubmissions != null ? { maxAllowedSubmissions: planForm.maxSubmissions } : {}),
377 ... (planForm.perVisitor != null ? { submissionLimitPerUser: planForm.perVisitor } : {}),
378 };
379
380 return {
381 name: planForm.name,
382 namespace: NAMESPACE ,
383 formFields: [ ... fields, submit],
384 steps,
385 ... (formRules. length ? { formRules } : {}),
386 ... (submitSettings ? { submitSettings } : {}),
387 ... (Object. keys (limitationRule). length ? { limitationRule } : {}),
388 ... (planForm.requiredIndicator
389 ? { requiredIndicatorProperties: { requiredIndicator: planForm.requiredIndicator, requiredIndicatorPlacement: "AFTER_FIELD_TITLE" } }
390 : {}),
391 };
392 }
393
394 // ---- operations ------------------------------------------------------------------------------
395
396 /**
397 * Read the form back and confirm each field kept its componentType, and that the steps and rules
398 * arrived. A create returns 200 even when a choice field degraded to a plain text box, so this is
399 * the only check that catches it.
400 */
401 async function verifyForm ( ctx , formId , expected , body ) {
402 const { form } = await req (ctx, `/form-schema-service/v4/forms/${ formId }` , { method: "GET" });
403 const live = new Map (
404 (form?.formFields ?? [])
405 . filter (( f ) => f.fieldType === "INPUT" )
406 . map (( f ) => {
407 const block = f.inputOptions?.[ INPUT_BLOCK [f.inputOptions?.inputType]] ?? {};
408 return [f.inputOptions?.target, block.componentType];
409 }),
410 );
411 const degraded = expected
412 . filter (( e ) => live. get (e.target) !== e.componentType)
413 . map (( e ) => `${ e . target }: expected ${ e . componentType }, got ${ live . get ( e . target ) ?? "MISSING"}` );
414 const stepsLive = (form?.steps ?? []). length ;
415 const rulesLive = (form?.formRules ?? []). length ;
416 if (stepsLive !== body.steps. length ) degraded. push ( `steps: expected ${ body . steps . length }, got ${ stepsLive }` );
417 if (rulesLive !== (body.formRules ?? []). length ) degraded. push ( `rules: expected ${ ( body . formRules ?? []). length }, got ${ rulesLive }` );
418 return { fieldsLive: live.size, steps: stepsLive, rules: rulesLive, degraded };
419 }
420
421 // ---- plan limits -----------------------------------------------------------------------------
422
423 /**
424 * The caps the site's plan puts on forms, read live (Get Restrictions). A free site: 4 forms,
425 * 10 input fields per form, 3 steps, 3 rules. A paid plan lifts them, so they are never
426 * hardcoded here. Null when the call fails; the create then reports the cap itself.
427 */
428 async function readLimits ( ctx ) {
429 try {
430 const { restrictions , totalFormCount } = await req (ctx, "/form-app-service/v4/restrictions" , { method: "GET" });
431 const limit = ( key ) => restrictions?.[key]?.limit;
432 return {
433 forms: limit ( "formsLimit" ),
434 fields: limit ( "fieldsLimit" ),
435 steps: limit ( "stepsLimit" ),
436 rules: limit ( "rulesLimit" ),
437 totalFormCount: totalFormCount ?? 0 ,
438 };
439 } catch {
440 return null ;
441 }
442 }
443
444 /**
445 * Checks the whole plan against the site's caps BEFORE any create, so the agent fixes the plan
446 * in one step instead of decoding the service's message one form at a time. The service rejects
447 * the whole form (FIELDS_COUNT_RESTRICTIONS_ERROR and friends); nothing would be created either
448 * way, but a local check costs no round trip.
449 */
450 function checkPlanAgainstLimits ( ctx , plan , byName , limits ) {
451 if ( ! limits) return ;
452 const over = ( n , cap ) => cap != null && n > cap;
453 const problems = [];
454 let newForms = 0 ;
455 for ( const f of plan.forms ?? []) {
456 if (byName. has (f.name)) continue ; // left as is, never counts twice
457 newForms += 1 ;
458 const fields = (f.fields ?? []). length ;
459 const steps = (f.steps ?? []). length ;
460 const rules = (f.rules ?? []). length ;
461 if ( over (fields, limits.fields)) problems. push ( `form "${ f . name }": ${ fields } fields, this site's plan allows ${ limits . fields } per form` );
462 if ( over (steps, limits.steps)) problems. push ( `form "${ f . name }": ${ steps } steps, this site's plan allows ${ limits . steps } per form` );
463 if ( over (rules, limits.rules)) problems. push ( `form "${ f . name }": ${ rules } rules, this site's plan allows ${ limits . rules } per form` );
464 }
465 if ( over (limits.totalFormCount + newForms, limits.forms)) {
466 problems. push (
467 `${ newForms } new form(s) on top of the site's ${ limits . totalFormCount } would exceed this site's plan limit of ${ limits . forms } forms` ,
468 );
469 }
470 if ( ! problems. length ) return ;
471 throw new Error (
472 `The plan exceeds this site's plan limits; nothing was created: \n - ${ problems . join ( " \n - " ) } \n ` +
473 `Either trim the plan to the limits, or the owner upgrades the site's plan at ` +
474 `https://www.wix.com/upgrade/website?metaSiteId=${ ctx . siteId } and the same plan runs unchanged. ` +
475 `Do not split one form in two to fit: it spends a form slot and splits a visitor's answers across two records. ` +
476 `Say which way you went in the closing message.` ,
477 );
478 }
479
480 /**
481 * Create every form in the plan. Existing forms are left alone — matching by name, since a
482 * re-run must not create a second copy of the same form.
483 */
484 export async function setupForms ( ctx , plan ) {
485 await installFormsApp (ctx);
486
487 const existing = await req (
488 ctx,
489 `/form-schema-service/v4/forms?namespace=${ encodeURIComponent ( NAMESPACE ) }` ,
490 { method: "GET" },
491 ). catch (() => ({ forms: [] }));
492 const byName = new Map ((existing.forms ?? []). map (( f ) => [f.name, f]));
493
494 // Every form is built first, so a plan mistake (a bad kind, a rule naming an unknown field, a
495 // placeholder over 100 characters) surfaces before any call; then the site's caps are checked.
496 const bodies = new Map ((plan.forms ?? []). filter (( f ) => ! byName. has (f.name)). map (( f ) => [f.name, buildForm (f)]));
497 checkPlanAgainstLimits (ctx, plan, byName, await readLimits (ctx));
498
499 const out = [];
500 for ( const planForm of plan.forms ?? []) {
501 const already = byName. get (planForm.name);
502 if (already) {
503 out. push ({
504 name: planForm.name,
505 formId: already.id ?? already._id,
506 created: false ,
507 fields: (already.formFields ?? [])
508 . filter (( f ) => f.fieldType === "INPUT" )
509 . map (( f ) => ({ target: f.inputOptions?.target, label: planForm.name })),
510 });
511 continue ;
512 }
513
514 const body = bodies. get (planForm.name);
515 let form;
516 try {
517 ({ form } = await req (ctx, "/form-schema-service/v4/forms" , { body: { form: body } }));
518 } catch (e) {
519 // Verified live: a `file` field on a site whose plan does not include file uploads fails the
520 // whole create with this code — nothing is created. There is no API switch for it; the owner
521 // upgrades the site's plan, or the form drops the `file` kind (see SEED.md, "Kinds").
522 if ( String (e.message). includes ( "FILE_UPLOAD_RESTRICTIONS_ERROR" )) {
523 const names = planForm.fields. filter (( f ) => f.kind === "file" ). map (( f ) => `"${ f . label }"` ). join ( ", " );
524 throw new Error (
525 `form "${ planForm . name }": this site's plan does not allow file-upload fields (${ names }) — ` +
526 `FILE_UPLOAD_RESTRICTIONS_ERROR; Wix Forms gates the file field behind a paid site plan and nothing was created. ` +
527 `Either the owner upgrades the site in the dashboard, or replace the field with a "url" kind and let the site ` +
528 `upload the file itself through the shared mediaUpload capability (plan.capabilities.mediaUpload, Astro only), ` +
529 `submitting the uploaded file's URL. Tell the owner which one you did.` ,
530 );
531 }
532 throw e;
533 }
534 const formId = form?.id ?? form?._id;
535 if ( ! formId) throw new Error ( `form "${ planForm . name }": created but no id returned` );
536
537 const expected = body.formFields
538 . filter (( f ) => f.fieldType === "INPUT" )
539 . map (( f ) => ({
540 target: f.inputOptions.target,
541 componentType: f.inputOptions[ INPUT_BLOCK [f.inputOptions.inputType]].componentType,
542 }));
543 const check = await verifyForm (ctx, formId, expected, body);
544
545 out. push ({
546 name: planForm.name,
547 formId,
548 created: true ,
549 fields: body.formFields
550 . filter (( f ) => f.fieldType === "INPUT" )
551 . map (( f , i ) => ({ target: f.inputOptions.target, label: planForm.fields[i].label })),
552 ... check,
553 });
554 }
555 return { forms: out };
556 }
557
558 // ---- CLI entry -------------------------------------------------------------------------------
559
560 const invokedDirectly = isMain ( import . meta .url);
561 if (invokedDirectly) {
562 const planPath = process.argv[ 2 ];
563 if ( ! planPath) {
564 console. error ( "usage: node seed-forms.mjs <plan.json> (run from the project root)" );
565 process. exit ( 1 );
566 }
567 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
568 const ctx = makeCtx ();
569 setupForms (ctx, plan)
570 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
571 . catch (( e ) => {
572 console. error (e.message);
573 process. exit ( 1 );
574 });
575 }