Setting the file. One moment.
Seed Forms · Wix Headless Fast · wix/skills · Skills Docs
ContentsBack to the top of the page references/forms/seed/ seed-forms.mjs
JavaScript · 320 lines · 13 KB
//
15 // The plan is PLAIN DATA — labels and kinds. Everything the API demands and the docs bury is
16 // derived here: per-field UUIDs, the two-level options nesting, the validation block that must
17 // exist even when empty, the choice enum that must agree with the component's options, the
18 // snake_case+suffix target, and a `steps` layout referencing every field including the submit
19 // button (a field missing from `steps` never appears in the owner's dashboard).
20 //
21 // Seeding is ADDITIVE — never deletes or overwrites existing forms.
22 import { execFileSync } from "node:child_process" ;
23 import { readFileSync } from "node:fs" ;
24 import { randomUUID } from "node:crypto" ;
25
26 const API = "https://www.wixapis.com" ;
27 const FORMS_APP_ID = "225dd912-7dea-4738-8688-4b8c6955ffc2" ;
28 const NAMESPACE = "wix.form_app.form" ;
29
30 export function makeCtx ({ cwd = process. cwd () } = {}) {
31 const config = JSON . parse ( readFileSync ( `${ cwd }/wix.config.json` , "utf8" ));
32 const siteId = config.siteId ?? config.projectId;
33 if ( ! siteId) throw new Error ( "wix.config.json has no siteId — is this a Wix CLI project?" );
34 const token = execFileSync ( "npx" , [ "@wix/cli@latest" , "token" , "--site" , siteId], {
35 encoding: "utf8" ,
36 cwd,
37 }). trim ();
38 if ( ! token) throw new Error ( "The Wix CLI returned no token — run `npx @wix/cli@latest login` first." );
39 return { token, siteId };
40 }
41
42 async function req ( ctx , path , { method = "POST" , body } = {}) {
43 const res = await fetch ( API + path, {
44 method,
45 headers: {
46 Authorization: `Bearer ${ ctx . token }` ,
47 "wix-site-id" : ctx.siteId,
48 "Content-Type" : "application/json" ,
49 },
50 body: body ? JSON . stringify (body) : undefined ,
51 });
52 const json = await res. json (). catch (() => ({}));
53 if ( ! res.ok) throw new Error ( `${ method } ${ path } -> ${ res . status }: ${ JSON . stringify ( json ). slice ( 0 , 400 ) }` );
54 return json;
55 }
56
57 /** Idempotent. Nothing here works until the app is installed. */
58 export async function installFormsApp ( ctx ) {
59 try {
60 await req (ctx, "/apps-installer-service/v1/app-instance/install" , {
61 body: {
62 tenant: { tenantType: "SITE" , id: ctx.siteId },
63 appInstance: { appDefId: FORMS_APP_ID , enabled: true },
64 },
65 });
66 } catch {
67 /* already installed is fine */
68 }
69 }
70
71 // ---- field expansion -------------------------------------------------------------------------
72
73 // kind → { inputType, componentType, identifier, block, componentBlock, format? }
74 // The two block names are what the API nests settings under, and they are named after the
75 // field's own enums — which is why they are looked up here rather than spelled at each call.
76 const KINDS = {
77 text: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "TEXT_INPUT" },
78 textarea: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "TEXT_AREA" },
79 email: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "CONTACTS_EMAIL" , format: "EMAIL" },
80 phone: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "CONTACTS_PHONE" , format: "PHONE" },
81 url: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "URL_INPUT" , format: "URL" },
82 firstName: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "CONTACTS_FIRST_NAME" },
83 lastName: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "CONTACTS_LAST_NAME" },
84 company: { inputType: "STRING" , componentType: "TEXT_INPUT" , identifier: "CONTACTS_COMPANY" },
85 date: { inputType: "STRING" , componentType: "DATE_PICKER" , identifier: "DATE_PICKER" , format: "DATE" },
86 number: { inputType: "NUMBER" , componentType: "NUMBER_INPUT" , identifier: "NUMBER_INPUT" },
87 rating: { inputType: "NUMBER" , componentType: "RATING_INPUT" , identifier: "RATING_INPUT" },
88 select: { inputType: "STRING" , componentType: "DROPDOWN" , identifier: "DROPDOWN" },
89 radio: { inputType: "STRING" , componentType: "RADIO_GROUP" , identifier: "RADIO_GROUP" },
90 multi: { inputType: "ARRAY" , componentType: "CHECKBOX_GROUP" , identifier: "CHECKBOX_GROUP" },
91 checkbox: { inputType: "BOOLEAN" , componentType: "CHECKBOX" , identifier: "CHECKBOX" },
92 file: { inputType: "WIX_FILE" , componentType: "FILE_UPLOAD" , identifier: "FILE_UPLOAD" },
93 address: { inputType: "ADDRESS" , componentType: "MULTILINE_ADDRESS" , identifier: "MULTILINE_ADDRESS" },
94 };
95
96 const INPUT_BLOCK = {
97 STRING: "stringOptions" , NUMBER: "numberOptions" , BOOLEAN: "booleanOptions" ,
98 ARRAY: "arrayOptions" , ADDRESS: "addressOptions" , WIX_FILE: "wixFileOptions" ,
99 };
100 const COMPONENT_BLOCK = {
101 TEXT_INPUT: "textInputOptions" , NUMBER_INPUT: "numberInputOptions" ,
102 RATING_INPUT: "ratingInputOptions" , DATE_PICKER: "datePickerOptions" ,
103 CHECKBOX: "checkboxOptions" , CHECKBOX_GROUP: "checkboxGroupOptions" ,
104 RADIO_GROUP: "radioGroupOptions" , DROPDOWN: "dropdownOptions" ,
105 MULTILINE_ADDRESS: "multilineAddressOptions" , FILE_UPLOAD: "fileUploadOptions" ,
106 };
107
108 /**
109 * `target` is the IMMUTABLE submission key: starts with a letter, letters/digits/underscore
110 * only, no doubled underscore, unique within the form. The random suffix is what keeps two
111 * fields with the same label apart.
112 */
113 function targetFor ( label , taken ) {
114 const base =
115 String (label)
116 . toLowerCase ()
117 . replace ( / [ ^ a-z0-9] + / g , "_" )
118 . replace ( /_ + / g , "_" )
119 . replace ( / ^ _ | _ $ / g , "" )
120 . replace ( / ^ (?= \d )/ , "f_" )
121 . slice ( 0 , 40 ) || "field" ;
122 let target;
123 do {
124 target = `${ base }_${ Math . random (). toString ( 36 ). slice ( 2 , 8 ) }` ;
125 } while (taken. has (target));
126 taken. add (target);
127 return target;
128 }
129
130 function buildField ( spec , taken ) {
131 const kind = KINDS [spec.kind];
132 if ( ! kind) {
133 throw new Error (
134 `field "${ spec . label }": unknown kind "${ spec . kind }" — one of ${ Object . keys ( KINDS ). join ( ", " ) }` ,
135 );
136 }
137 const choices = spec.choices ?? [];
138 if ((spec.kind === "select" || spec.kind === "radio" || spec.kind === "multi" ) && ! choices. length ) {
139 throw new Error ( `field "${ spec . label }": kind "${ spec . kind }" needs a non-empty choices array` );
140 }
141
142 const target = targetFor (spec.label, taken);
143 const options = choices. map (( c ) => {
144 const value = typeof c === "string" ? c : c.value;
145 return { id: randomUUID (), label: typeof c === "string" ? c : (c.label ?? c.value), value };
146 });
147 const values = options. map (( o ) => o.value);
148
149 // A choice field declares its options TWICE — here and in the validation enum — and the two
150 // must agree. Disagree and the create still returns 200: the field is created as a plain
151 // text box, losing its choices. Both are derived from the same list, so they cannot drift.
152 const validation = {};
153 if (kind.format) validation.format = kind.format;
154 if (spec.min != null ) validation.minimum = spec.min;
155 if (spec.max != null ) validation.maximum = spec.max;
156 if (spec.maxLength != null ) validation.maxLength = spec.maxLength;
157 if (values. length ) {
158 if (kind.inputType === "ARRAY" ) {
159 validation.itemType = "STRING" ;
160 validation.items = { stringOptions: { enum: values } };
161 } else {
162 validation.enum = values;
163 }
164 }
165 if (spec.kind === "file" ) validation.fileLimit = spec.fileLimit ?? 1 ;
166
167 const component = { label: spec.label, showLabel: true };
168 if (spec.placeholder) component.placeholder = spec.placeholder;
169 if (options. length ) component.options = options;
170 if (spec.kind === "textarea" ) component.numberOfLines = spec.lines ?? 4 ;
171 if (spec.default != null ) component.default = spec.default;
172
173 return {
174 id: randomUUID (),
175 identifier: kind.identifier,
176 fieldType: "INPUT" ,
177 inputOptions: {
178 target,
179 inputType: kind.inputType,
180 // `required` lives HERE, never inside the validation block.
181 required: spec.required ?? false ,
182 [ INPUT_BLOCK [kind.inputType]]: {
183 // `validation` is always present, even as {}, and nests under the INPUT-TYPE block —
184 // not the component one. Absent, the target is not registered as an accepted value and
185 // every submission comes back UNKNOWN_VALUE_ERROR on a key that IS in the schema.
186 validation,
187 componentType: kind.componentType,
188 [ COMPONENT_BLOCK [kind.componentType]]: component,
189 },
190 },
191 };
192 }
193
194 function buildForm ( planForm ) {
195 const taken = new Set ();
196 const fields = (planForm.fields ?? []). map (( f ) => buildField (f, taken));
197 if ( ! fields. length ) throw new Error ( `form "${ planForm . name }": no fields` );
198
199 const submit = {
200 id: randomUUID (),
201 identifier: "SUBMIT_BUTTON" ,
202 fieldType: "DISPLAY" ,
203 displayOptions: {
204 displayFieldType: "SUBMIT_BUTTON" ,
205 pageNavigationOptions: { submitText: planForm.submitText ?? "Submit" },
206 },
207 };
208
209 // `steps` must reference EVERY field, the submit button included — a field missing from the
210 // layout never appears in the owner's dashboard, so they cannot edit what the site renders.
211 const items = [ ... fields, submit]. map (( f , i ) => ({
212 fieldId: f.id,
213 row: i,
214 column: 0 ,
215 width: 12 ,
216 }));
217
218 return {
219 name: planForm.name,
220 namespace: NAMESPACE ,
221 formFields: [ ... fields, submit],
222 steps: [{ id: randomUUID (), layout: { large: { items }, medium: { items }, small: { items } } }],
223 };
224 }
225
226 // ---- operations ------------------------------------------------------------------------------
227
228 /**
229 * Read the form back and confirm each field kept its componentType. A create returns 200 even
230 * when a choice field degraded to a plain text box, so this is the only check that catches it.
231 */
232 async function verifyForm ( ctx , formId , expected ) {
233 const { form } = await req (ctx, `/form-schema-service/v4/forms/${ formId }` , { method: "GET" });
234 const live = new Map (
235 (form?.formFields ?? [])
236 . filter (( f ) => f.fieldType === "INPUT" )
237 . map (( f ) => {
238 const block = f.inputOptions?.[ INPUT_BLOCK [f.inputOptions?.inputType]] ?? {};
239 return [f.inputOptions?.target, block.componentType];
240 }),
241 );
242 const degraded = expected
243 . filter (( e ) => live. get (e.target) !== e.componentType)
244 . map (( e ) => `${ e . target }: expected ${ e . componentType }, got ${ live . get ( e . target ) ?? "MISSING"}` );
245 return { fieldsLive: live.size, degraded };
246 }
247
248 /**
249 * Create every form in the plan. Existing forms are left alone — matching by name, since a
250 * re-run must not create a second copy of the same form.
251 */
252 export async function setupForms ( ctx , plan ) {
253 await installFormsApp (ctx);
254
255 const existing = await req (
256 ctx,
257 `/form-schema-service/v4/forms?namespace=${ encodeURIComponent ( NAMESPACE ) }` ,
258 { method: "GET" },
259 ). catch (() => ({ forms: [] }));
260 const byName = new Map ((existing.forms ?? []). map (( f ) => [f.name, f]));
261
262 const out = [];
263 for ( const planForm of plan.forms ?? []) {
264 const already = byName. get (planForm.name);
265 if (already) {
266 out. push ({
267 name: planForm.name,
268 formId: already.id ?? already._id,
269 created: false ,
270 fields: (already.formFields ?? [])
271 . filter (( f ) => f.fieldType === "INPUT" )
272 . map (( f ) => ({ target: f.inputOptions?.target, label: planForm.name })),
273 });
274 continue ;
275 }
276
277 const body = buildForm (planForm);
278 const { form } = await req (ctx, "/form-schema-service/v4/forms" , { body: { form: body } });
279 const formId = form?.id ?? form?._id;
280 if ( ! formId) throw new Error ( `form "${ planForm . name }": created but no id returned` );
281
282 const expected = body.formFields
283 . filter (( f ) => f.fieldType === "INPUT" )
284 . map (( f ) => ({
285 target: f.inputOptions.target,
286 componentType: f.inputOptions[ INPUT_BLOCK [f.inputOptions.inputType]].componentType,
287 }));
288 const check = await verifyForm (ctx, formId, expected);
289
290 out. push ({
291 name: planForm.name,
292 formId,
293 created: true ,
294 fields: body.formFields
295 . filter (( f ) => f.fieldType === "INPUT" )
296 . map (( f , i ) => ({ target: f.inputOptions.target, label: planForm.fields[i].label })),
297 ... check,
298 });
299 }
300 return { forms: out };
301 }
302
303 // ---- CLI entry -------------------------------------------------------------------------------
304
305 const invokedDirectly = process.argv[ 1 ] && import . meta .url. endsWith (process.argv[ 1 ]. split ( "/" ). pop ());
306 if (invokedDirectly) {
307 const planPath = process.argv[ 2 ];
308 if ( ! planPath) {
309 console. error ( "usage: node seed-forms.mjs <plan.json> (run from the project root)" );
310 process. exit ( 1 );
311 }
312 const plan = JSON . parse ( readFileSync (planPath, "utf8" ));
313 const ctx = makeCtx ();
314 setupForms (ctx, plan)
315 . then (( result ) => console. log ( JSON . stringify (result, null , 2 )))
316 . catch (( e ) => {
317 console. error (e.message);
318 process. exit ( 1 );
319 });
320 }