PluginProbe ʕ •ᴥ•ʔ
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI / 3.5.2
Everest Forms – Contact Form, Payment Form, Quiz, Survey & Custom Form Builder with AI v3.5.2
3.5.2 3.5.1 3.5.0 3.4.8 3.4.7 3.4.6 1.1.0 1.1.1 1.1.2 1.1.3 1.1.4 1.1.5 1.1.5.1 1.1.6 1.1.7 1.1.8 1.1.9 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.4.5 1.4.6 1.4.7 1.4.8 1.4.9 1.5.0 1.5.1 1.5.10 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.5.9 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 1.6.6 1.6.6.1 1.6.7 1.7.0 1.7.0.1 1.7.0.2 1.7.0.3 1.7.1 1.7.2 1.7.2.1 1.7.2.2 1.7.3 1.7.4 1.7.5 1.7.5.1 1.7.5.2 1.7.6 1.7.7 1.7.7.1 1.7.7.2 1.7.8 1.7.9 1.8.0 1.8.0.1 1.8.1 1.8.2 1.8.2.1 1.8.2.2 1.8.2.3 1.8.3 1.8.4 1.8.5 1.8.6 1.8.7 1.8.8 1.8.9 1.9.0 1.9.0.1 1.9.1 1.9.2 1.9.3 1.9.4 1.9.4.1 1.9.5 1.9.6 1.9.7 1.9.8 1.9.9 2.0.0 2.0.0.1 2.0.1 2.0.2 2.0.3 2.0.3.1 2.0.4 2.0.4.1 2.0.5 2.0.6 2.0.7 2.0.8 2.0.8.1 2.0.9 3.0.0 3.0.0.1 3.0.1 3.0.2 3.0.3 3.0.3.1 3.0.4 3.0.4.1 3.0.4.2 3.0.5 3.0.5.1 3.0.5.2 3.0.6 3.0.6.1 3.0.7.1 3.0.8 3.0.8.1 3.0.9 3.0.9.1 3.0.9.2 3.0.9.3 3.0.9.4 3.0.9.5 3.1.0 3.1.1 3.1.2 3.2.0 3.2.1 3.2.2 3.2.3 3.2.4 3.2.5 3.2.6 3.3.0 3.4.0 3.4.1 3.4.2 3.4.2.1 3.4.3 3.4.4 3.4.5 trunk 1.0 1.0.1 1.0.2 1.0.3
everest-forms / includes / abilities / class-evf-form-builder.php
everest-forms / includes / abilities Last commit date
class-evf-abilities-handlers.php 4 weeks ago class-evf-abilities-registry.php 4 weeks ago class-evf-abilities.php 4 weeks ago class-evf-field-schemas.php 4 weeks ago class-evf-form-builder.php 4 weeks ago class-evf-mcp-server.php 4 weeks ago
class-evf-form-builder.php
812 lines
1 <?php
2 /**
3 * High-level form builder used by the create-form/update-form abilities.
4 *
5 * Translates a human/LLM-friendly descriptor:
6 *
7 * [
8 * 'title' => 'Contact 2026',
9 * 'settings' => [ 'form_desc' => '...' ],
10 * 'layout' => [
11 * [ 'row' => [
12 * [ 'type' => 'first-name', 'label' => 'First', 'grid' => 1 ],
13 * [ 'type' => 'last-name', 'label' => 'Last', 'grid' => 2 ],
14 * ] ],
15 * [ 'row' => [ [ 'type' => 'email', 'label' => 'Email' ] ] ],
16 * ],
17 * ]
18 *
19 * ...into the EVF form-data shape persisted to `post_content` (form_fields,
20 * structure.row_N.grid_M, form_field_id, settings, id).
21 *
22 * Also accepts the flat `fields => [...]` shape; that auto-stacks into rows.
23 *
24 * Validation happens against {@see EVF_Field_Schemas}: unknown types emit a
25 * structured error pointing the caller at the addon that ships them; missing
26 * required attributes (e.g. choices on a select) are flagged before any DB
27 * write. Build is in-memory; the caller does the single update() call, which
28 * keeps multi-field operations atomic.
29 *
30 * @package EverestForms\Abilities
31 */
32
33 defined( 'ABSPATH' ) || exit;
34
35 /**
36 * Builder.
37 */
38 class EVF_Form_Builder {
39
40 /**
41 * Build / mutate an EVF form data array from a high-level descriptor.
42 *
43 * @param array $existing Existing form-data array (decoded post_content) or empty for a fresh build.
44 * @param array $descriptor High-level descriptor (see file docblock).
45 * @return array|WP_Error Updated form-data array, or WP_Error on validation failure.
46 */
47 public static function build( array $existing, array $descriptor ) {
48 $data = $existing;
49
50 // --- Multi-Part addon gate ------------------------------------------
51 // If the descriptor asks for multi-part behavior, the Multi-Part Forms
52 // addon must be installed *and* active. Surface the same structured
53 // error the field-type validator uses, so the LLM can route the user
54 // into the activate-addon flow before any DB write happens.
55 $wants_conversational = ! empty( $descriptor['conversational']['enabled'] );
56
57 $wants_multipart = ! empty( $descriptor['multi_part']['enabled'] )
58 || ( isset( $descriptor['multi_part']['parts'] ) && is_array( $descriptor['multi_part']['parts'] ) && count( $descriptor['multi_part']['parts'] ) > 0 )
59 || self::layout_has_parts( $descriptor );
60
61 // Conversational + Multi-Part are mutually exclusive (same UI surface).
62 if ( $wants_multipart && $wants_conversational ) {
63 return new WP_Error(
64 'evf_incompatible_features',
65 'Multi-Part and Conversational layouts cannot be enabled on the same form. Pick one.',
66 array( 'status' => 400 )
67 );
68 }
69
70 if ( $wants_multipart ) {
71 $mp_check = self::check_multipart_available();
72 if ( $mp_check ) {
73 return new WP_Error(
74 'evf_addon_required',
75 $mp_check['message'],
76 array( 'status' => 400, 'errors' => array( $mp_check ) )
77 );
78 }
79 }
80
81 if ( $wants_conversational ) {
82 $cf_check = self::check_conversational_available();
83 if ( $cf_check ) {
84 return new WP_Error(
85 'evf_addon_required',
86 $cf_check['message'],
87 array( 'status' => 400, 'errors' => array( $cf_check ) )
88 );
89 }
90 }
91
92 // --- Top-level settings ---------------------------------------------
93 if ( ! isset( $data['settings'] ) || ! is_array( $data['settings'] ) ) {
94 $data['settings'] = array();
95 }
96
97 if ( isset( $descriptor['title'] ) ) {
98 $data['settings']['form_title'] = sanitize_text_field( (string) $descriptor['title'] );
99 }
100 if ( isset( $descriptor['description'] ) ) {
101 $data['settings']['form_desc'] = wp_kses_post( (string) $descriptor['description'] );
102 }
103 if ( isset( $descriptor['settings'] ) && is_array( $descriptor['settings'] ) ) {
104 // Before deep-merging caller-supplied settings, verify any addon-
105 // gated toggle (enable_save_and_continue, pdf_enabled, etc.) has
106 // the matching addon active. Otherwise the option lands in
107 // post_content silently and the feature is broken without the
108 // caller knowing — exactly the footgun Save and Continue hit.
109 $settings_check = self::check_settings_addons_available( $descriptor['settings'] );
110 if ( $settings_check ) {
111 return new WP_Error(
112 'evf_addon_required',
113 $settings_check['message'],
114 array( 'status' => 400, 'errors' => array( $settings_check ) )
115 );
116 }
117 $data['settings'] = self::deep_merge( $data['settings'], $descriptor['settings'] );
118 }
119
120 // --- Existing fields / structure / counter --------------------------
121 $existing_fields = isset( $data['form_fields'] ) && is_array( $data['form_fields'] ) ? $data['form_fields'] : array();
122 $structure = isset( $data['structure'] ) && is_array( $data['structure'] ) ? $data['structure'] : array();
123 $counter = isset( $data['form_field_id'] ) ? (int) $data['form_field_id'] : count( $existing_fields );
124
125 // Normalize layout descriptor: support both `layout` and flat `fields`.
126 $layout = self::normalize_layout( $descriptor );
127
128 // Validate first (no DB writes yet).
129 $registered = EVF_Field_Schemas::registered_types();
130 $errors = array();
131 $row_idx = 0;
132 foreach ( $layout as $row ) {
133 $row_idx++;
134 $col_idx = 0;
135 foreach ( $row['row'] as $field ) {
136 $col_idx++;
137 $err = self::validate_field( $field, $registered, $row_idx, $col_idx );
138 if ( $err ) {
139 $errors[] = $err;
140 }
141 }
142 }
143 if ( ! empty( $errors ) ) {
144 return new WP_Error(
145 'evf_field_validation_failed',
146 sprintf( '%d field validation error(s).', count( $errors ) ),
147 array( 'status' => 400, 'errors' => $errors )
148 );
149 }
150
151 // --- Walk layout and produce field defs + structure rows ------------
152 // Track which rows belong to which part as we emit them.
153 $row_keys_by_part = array(); // part_idx (1-based) => [ row_key, row_key, ... ]
154
155 $row_idx = 0;
156 foreach ( $layout as $row ) {
157 $row_idx++;
158 $row_key = 'row_' . self::next_row_key( $structure );
159
160 // Capture the row's part assignment if any.
161 $part_idx = isset( $row['part'] ) ? max( 1, (int) $row['part'] ) : 0;
162 if ( $part_idx > 0 ) {
163 if ( ! isset( $row_keys_by_part[ $part_idx ] ) ) {
164 $row_keys_by_part[ $part_idx ] = array();
165 }
166 $row_keys_by_part[ $part_idx ][] = $row_key;
167 }
168
169 // Default each row to a single full-width grid. Only allocate
170 // additional grids (grid_2, grid_3, ...) when the row actually
171 // holds multiple fields or fields specify explicit grid numbers.
172 // This avoids producing a two-column row with one empty column
173 // for the single-field case.
174 $explicit_grids = self::row_has_explicit_grids( $row['row'] );
175 $auto_grid = 1;
176 $auto_count = count( $row['row'] );
177
178 if ( ! isset( $structure[ $row_key ] ) ) {
179 $structure[ $row_key ] = array( 'grid_1' => array() );
180 }
181
182 foreach ( $row['row'] as $field ) {
183 $counter++;
184 $field_id = ( function_exists( 'evf_get_random_string' ) ? evf_get_random_string() : wp_generate_password( 10, false ) ) . '-' . $counter;
185 $field_data = self::compose_field( $field, $field_id, $counter );
186
187 $existing_fields[ $field_id ] = $field_data;
188
189 $grid_no = $explicit_grids
190 ? max( 1, (int) ( isset( $field['grid'] ) ? $field['grid'] : 1 ) )
191 : ( $auto_count > 1 ? $auto_grid++ : 1 );
192
193 $grid_key = 'grid_' . $grid_no;
194 if ( ! isset( $structure[ $row_key ][ $grid_key ] ) ) {
195 $structure[ $row_key ][ $grid_key ] = array();
196 }
197 $structure[ $row_key ][ $grid_key ][] = $field_id;
198 }
199 }
200
201 // --- Direct form_fields patches (update path) -----------------------
202 if ( isset( $descriptor['form_fields_patch'] ) && is_array( $descriptor['form_fields_patch'] ) ) {
203 foreach ( $descriptor['form_fields_patch'] as $fid => $patch ) {
204 if ( isset( $existing_fields[ $fid ] ) && is_array( $patch ) ) {
205 $existing_fields[ $fid ] = array_merge( $existing_fields[ $fid ], $patch );
206 }
207 }
208 }
209
210 $data['form_fields'] = $existing_fields;
211 $data['structure'] = $structure;
212 $data['form_field_id'] = (string) $counter;
213
214 // --- Multi-Part block -----------------------------------------------
215 if ( $wants_multipart ) {
216 $data = self::apply_multipart( $data, $descriptor, $row_keys_by_part );
217 }
218
219 // --- Conversational block -------------------------------------------
220 if ( $wants_conversational ) {
221 $data = self::apply_conversational( $data, $descriptor );
222 }
223
224 return $data;
225 }
226
227 /**
228 * Whether the layout descriptor uses per-row `part` assignments.
229 *
230 * @param array $descriptor Descriptor.
231 * @return bool
232 */
233 protected static function layout_has_parts( $descriptor ) {
234 if ( ! isset( $descriptor['layout'] ) || ! is_array( $descriptor['layout'] ) ) {
235 return false;
236 }
237 foreach ( $descriptor['layout'] as $row ) {
238 if ( isset( $row['part'] ) && (int) $row['part'] > 0 ) {
239 return true;
240 }
241 }
242 return false;
243 }
244
245 /**
246 * Check whether the Multi-Part Forms addon is usable.
247 *
248 * @return array|null Error array (matching field-validator shape) if not usable, null otherwise.
249 */
250 protected static function check_multipart_available() {
251 $plugin = 'everest-forms-multi-part/everest-forms-multi-part.php';
252
253 if ( ! function_exists( 'is_plugin_active' ) ) {
254 include_once ABSPATH . 'wp-admin/includes/plugin.php';
255 }
256 $plugins = function_exists( 'get_plugins' ) ? (array) get_plugins() : array();
257 $installed = isset( $plugins[ $plugin ] );
258 $active = function_exists( 'is_plugin_active' ) ? is_plugin_active( $plugin ) : false;
259
260 if ( $active ) {
261 return null;
262 }
263
264 $action = $installed ? 'activate' : 'install_or_upgrade';
265 return array(
266 'code' => 'addon_required',
267 'feature' => 'multi_part',
268 'addon' => array( 'addon' => 'Multi-Part Forms', 'plugin' => $plugin ),
269 'installed' => $installed,
270 'active' => false,
271 'action' => $action,
272 'message' => $installed
273 ? sprintf( 'Multi-step (page break) layout requires the "Multi-Part Forms" addon, which is installed but not active. Ask the user to confirm, then call the "activate-addon" ability with plugin="%s". If they decline, omit `multi_part`/row parts and proceed with a single-page form.', $plugin )
274 : 'Multi-step (page break) layout requires the "Multi-Part Forms" addon, which is not installed on this site. The user must install/purchase it from their Everest Forms account, or upgrade their plan if it isn\'t included. If they decline, omit `multi_part`/row parts and proceed with a single-page form.',
275 );
276 }
277
278 /**
279 * Scan caller-supplied settings for addon-gated toggle keys whose
280 * underlying addon isn't active. Returns the first offending entry
281 * in the same error envelope as the field validator so the chat UI
282 * can render the standard activation prompt.
283 *
284 * @param array $settings Caller-supplied settings block (the descriptor's
285 * `settings`, not the post_content `settings`).
286 * @return array|null
287 */
288 protected static function check_settings_addons_available( array $settings ) {
289 $gated = EVF_Field_Schemas::setting_addon_hints();
290 if ( ! function_exists( 'is_plugin_active' ) ) {
291 include_once ABSPATH . 'wp-admin/includes/plugin.php';
292 }
293 $plugins = function_exists( 'get_plugins' ) ? (array) get_plugins() : array();
294
295 foreach ( $gated as $key => $hint ) {
296 if ( ! array_key_exists( $key, $settings ) ) {
297 continue;
298 }
299 $value = $settings[ $key ];
300 // Skip falsy values — the caller is *disabling* the toggle, which
301 // is safe regardless of addon state.
302 if ( empty( $value ) || '0' === (string) $value || 'false' === strtolower( (string) $value ) ) {
303 continue;
304 }
305
306 $plugin = $hint['plugin'];
307 $installed = isset( $plugins[ $plugin ] );
308 $active = function_exists( 'is_plugin_active' ) ? is_plugin_active( $plugin ) : false;
309 if ( $active ) {
310 continue;
311 }
312
313 $action = $installed ? 'activate' : 'install_or_upgrade';
314 return array(
315 'code' => 'addon_required',
316 'feature' => $key,
317 'addon' => $hint,
318 'installed' => $installed,
319 'active' => false,
320 'action' => $action,
321 'message' => $installed
322 ? sprintf( 'Setting "%s" needs the "%s" addon, which is installed but not active. Ask the user to confirm, then call the "activate-addon" ability with plugin="%s". If they decline, omit `%s` from settings and proceed.', $key, $hint['addon'], $plugin, $key )
323 : sprintf( 'Setting "%s" needs the "%s" addon, which is not installed on this site. The user must install/purchase it from their Everest Forms account, or upgrade their plan if it isn\'t included. If they decline, omit `%s` from settings and proceed.', $key, $hint['addon'], $key ),
324 );
325 }
326 return null;
327 }
328
329 /**
330 * Check whether the Conversational Forms addon is usable.
331 *
332 * @return array|null Error array (matching field-validator shape) if not usable, null otherwise.
333 */
334 protected static function check_conversational_available() {
335 $plugin = 'everest-forms-conversational-forms/everest-forms-conversational-forms.php';
336
337 if ( ! function_exists( 'is_plugin_active' ) ) {
338 include_once ABSPATH . 'wp-admin/includes/plugin.php';
339 }
340 $plugins = function_exists( 'get_plugins' ) ? (array) get_plugins() : array();
341 $installed = isset( $plugins[ $plugin ] );
342 $active = function_exists( 'is_plugin_active' ) ? is_plugin_active( $plugin ) : false;
343
344 if ( $active ) {
345 return null;
346 }
347
348 $action = $installed ? 'activate' : 'install_or_upgrade';
349 return array(
350 'code' => 'addon_required',
351 'feature' => 'conversational',
352 'addon' => array( 'addon' => 'Conversational Forms', 'plugin' => $plugin ),
353 'installed' => $installed,
354 'active' => false,
355 'action' => $action,
356 'message' => $installed
357 ? sprintf( 'Conversational (one-question-at-a-time) layout requires the "Conversational Forms" addon, which is installed but not active. Ask the user to confirm, then call the "activate-addon" ability with plugin="%s". If they decline, omit `conversational` and proceed with a standard form.', $plugin )
358 : 'Conversational (one-question-at-a-time) layout requires the "Conversational Forms" addon, which is not installed on this site. The user must install/purchase it from their Everest Forms account, or upgrade their plan if it isn\'t included. If they decline, omit `conversational` and proceed with a standard form.',
359 );
360 }
361
362 /**
363 * Write the conversational settings block.
364 *
365 * Data shape EVF expects:
366 * $form_data['settings']['enable_conversational_forms'] = '1'
367 * $form_data['settings']['conversational_forms']['conversational_forms_url'] = '<slug>' (optional)
368 * $form_data['settings']['conversational_forms']['conversational_forms_title'] = '<title>' (optional)
369 * $form_data['settings']['conversational_forms']['conversational_forms_description']= '<desc>' (optional)
370 *
371 * @param array $data Form data so far.
372 * @param array $descriptor Descriptor.
373 * @return array Updated form data.
374 */
375 protected static function apply_conversational( $data, $descriptor ) {
376 $data['settings']['enable_conversational_forms'] = '1';
377
378 $existing_cf = isset( $data['settings']['conversational_forms'] ) && is_array( $data['settings']['conversational_forms'] )
379 ? $data['settings']['conversational_forms']
380 : array();
381
382 $cf = $existing_cf;
383 $src = isset( $descriptor['conversational'] ) && is_array( $descriptor['conversational'] ) ? $descriptor['conversational'] : array();
384
385 if ( isset( $src['slug'] ) ) {
386 $cf['conversational_forms_url'] = sanitize_title( (string) $src['slug'] );
387 }
388 if ( isset( $src['title'] ) ) {
389 $cf['conversational_forms_title'] = sanitize_text_field( (string) $src['title'] );
390 }
391 if ( isset( $src['description'] ) ) {
392 $cf['conversational_forms_description'] = wp_kses_post( (string) $src['description'] );
393 }
394 // Allow opaque pass-through of any other conversational_forms_* keys.
395 foreach ( $src as $k => $v ) {
396 if ( in_array( $k, array( 'enabled', 'slug', 'title', 'description' ), true ) ) {
397 continue;
398 }
399 $key = sanitize_key( (string) $k );
400 if ( is_scalar( $v ) ) {
401 $cf[ $key ] = sanitize_text_field( (string) $v );
402 } elseif ( is_array( $v ) ) {
403 $cf[ $key ] = $v;
404 }
405 }
406
407 $data['settings']['conversational_forms'] = $cf;
408 return $data;
409 }
410
411 /**
412 * Write the multi_part block and enable_multi_part toggle into form data.
413 *
414 * @param array $data Form data so far.
415 * @param array $descriptor Descriptor.
416 * @param array $row_keys_by_part Map of part_idx => [row_key, ...].
417 * @return array Updated form data.
418 */
419 protected static function apply_multipart( $data, $descriptor, $row_keys_by_part ) {
420 $data['settings']['enable_multi_part'] = '1';
421
422 $declared = isset( $descriptor['multi_part']['parts'] ) && is_array( $descriptor['multi_part']['parts'] )
423 ? $descriptor['multi_part']['parts']
424 : array();
425
426 // Derive the part list: prefer explicit declarations, otherwise fall back
427 // to a "Step N" series sized to cover whichever part index any row uses.
428 $max_used_part = 0;
429 foreach ( $row_keys_by_part as $p => $_rows ) {
430 $max_used_part = max( $max_used_part, (int) $p );
431 }
432 $part_count = max( count( $declared ), $max_used_part, 1 );
433
434 $existing_parts = isset( $data['multi_part'] ) && is_array( $data['multi_part'] ) ? array_values( $data['multi_part'] ) : array();
435 $built = array();
436 for ( $i = 1; $i <= $part_count; $i++ ) {
437 $idx_zero = $i - 1;
438 $prior = isset( $existing_parts[ $idx_zero ] ) && is_array( $existing_parts[ $idx_zero ] ) ? $existing_parts[ $idx_zero ] : array();
439 $declared_entry = isset( $declared[ $idx_zero ] ) && is_array( $declared[ $idx_zero ] ) ? $declared[ $idx_zero ] : array();
440
441 $name = '';
442 if ( isset( $declared_entry['name'] ) ) {
443 $name = sanitize_text_field( (string) $declared_entry['name'] );
444 } elseif ( isset( $prior['name'] ) ) {
445 $name = (string) $prior['name'];
446 } else {
447 $name = sprintf( 'Step %d', $i );
448 }
449
450 // Build the rows map for this part (1-based index → row_key).
451 $rows_for_part = isset( $row_keys_by_part[ $i ] ) ? $row_keys_by_part[ $i ] : array();
452 $rows_map = array();
453 $slot = 1;
454 foreach ( $rows_for_part as $row_key ) {
455 $rows_map[ $slot ] = $row_key;
456 $slot++;
457 }
458
459 $built[ $idx_zero ] = array_merge(
460 $prior,
461 array(
462 'id' => $i,
463 'name' => $name,
464 ),
465 $rows_map ? array( 'rows' => $rows_map ) : array()
466 );
467 }
468
469 $data['multi_part'] = $built;
470 return $data;
471 }
472
473 /* ----------------------------------------------------------------------
474 * Internals
475 * ---------------------------------------------------------------------- */
476
477 /**
478 * Normalize the layout from the descriptor.
479 *
480 * Returns a list of rows, each `{ row: [field, field, ...] }`. Accepts:
481 * - 'layout' => [{row: [...]}]
482 * - 'fields' => [field, field, ...] (auto-stacked one per row)
483 *
484 * @param array $descriptor Descriptor.
485 * @return array
486 */
487 protected static function normalize_layout( array $descriptor ) {
488 if ( isset( $descriptor['layout'] ) && is_array( $descriptor['layout'] ) ) {
489 $out = array();
490 foreach ( $descriptor['layout'] as $row ) {
491 if ( isset( $row['row'] ) && is_array( $row['row'] ) ) {
492 $entry = array( 'row' => array_values( $row['row'] ) );
493 // Preserve per-row metadata the walker needs (multi-part step
494 // assignment in particular). Without this, `part: 2/3` was
495 // silently dropped here and every field ended up in step 1.
496 if ( isset( $row['part'] ) ) {
497 $entry['part'] = max( 1, (int) $row['part'] );
498 }
499 $out[] = $entry;
500 }
501 }
502 return $out;
503 }
504 if ( isset( $descriptor['fields'] ) && is_array( $descriptor['fields'] ) ) {
505 $out = array();
506 foreach ( $descriptor['fields'] as $field ) {
507 if ( is_array( $field ) ) {
508 $entry = array( 'row' => array( $field ) );
509 // Allow flat `fields` entries to carry a `part` too, so a
510 // caller can do single-field-per-row multi-step layouts
511 // without using the richer `layout` shape.
512 if ( isset( $field['part'] ) ) {
513 $entry['part'] = max( 1, (int) $field['part'] );
514 }
515 $out[] = $entry;
516 }
517 }
518 return $out;
519 }
520 return array();
521 }
522
523 /**
524 * Validate a field definition against the schema registry.
525 *
526 * @param array $field Field def.
527 * @param array $registered Map of currently-registered type ids.
528 * @param int $row_idx Row index (for error reporting).
529 * @param int $col_idx Column index (for error reporting).
530 * @return array|null Error or null.
531 */
532 protected static function validate_field( $field, $registered, $row_idx, $col_idx ) {
533 if ( ! is_array( $field ) || empty( $field['type'] ) ) {
534 return array(
535 'row' => $row_idx,
536 'col' => $col_idx,
537 'code' => 'missing_type',
538 'message' => 'Field is missing required "type".',
539 );
540 }
541 $type = (string) $field['type'];
542
543 $hint = EVF_Field_Schemas::addon_for( $type );
544 $addon_inactive = false;
545 $installed = false;
546 $active = false;
547 if ( $hint && ! empty( $hint['plugin'] ) ) {
548 if ( ! function_exists( 'is_plugin_active' ) ) {
549 include_once ABSPATH . 'wp-admin/includes/plugin.php';
550 }
551 $plugins = function_exists( 'get_plugins' ) ? (array) get_plugins() : array();
552 $installed = isset( $plugins[ $hint['plugin'] ] );
553 $active = function_exists( 'is_plugin_active' ) ? is_plugin_active( $hint['plugin'] ) : false;
554 $addon_inactive = ! $active;
555 }
556
557 // A field type is unusable if either:
558 // (a) it isn't registered at all (no class in core), OR
559 // (b) it has an addon hint AND that addon plugin is inactive.
560 // Case (b) catches types like `likert`, `yes-no`, `rating` whose field
561 // class lives in core but whose real implementation ships in an addon.
562 if ( ! isset( $registered[ $type ] ) || $addon_inactive ) {
563 $action = $hint
564 ? ( $installed ? 'activate' : 'install_or_upgrade' )
565 : 'unknown';
566
567 $message = $hint
568 ? ( 'activate' === $action
569 ? sprintf( 'Field type "%s" needs the "%s" addon, which is installed but not active. Ask the user to confirm, then call the "activate-addon" ability with plugin="%s". If the user declines, omit this field and proceed with the rest.', $type, $hint['addon'], $hint['plugin'] )
570 : sprintf( 'Field type "%s" needs the "%s" addon, which is not installed on this site. The user must install/purchase it from their Everest Forms account (or upgrade their plan if it isn\'t in their tier). If the user declines, omit this field and proceed with the rest.', $type, $hint['addon'] )
571 )
572 : sprintf( 'Field type "%s" is not registered on this site.', $type );
573
574 return array(
575 'row' => $row_idx,
576 'col' => $col_idx,
577 'code' => 'unregistered_type',
578 'type' => $type,
579 'addon' => $hint,
580 'installed' => $installed,
581 'active' => $active,
582 'action' => $action, // 'activate' | 'install_or_upgrade' | 'unknown'
583 'message' => $message,
584 );
585 }
586
587 $schema = EVF_Field_Schemas::for_type( $type );
588 if ( $schema && $schema['requires_choices'] && empty( $field['choices'] ) ) {
589 return array(
590 'row' => $row_idx,
591 'col' => $col_idx,
592 'code' => 'missing_choices',
593 'type' => $type,
594 'message' => sprintf( 'Field type "%s" requires a non-empty "choices" array.', $type ),
595 );
596 }
597 return null;
598 }
599
600 /**
601 * Compose the final EVF field array (id, type, label, meta-key, plus
602 * normalized choices / sublabels / common props).
603 *
604 * @param array $field Input field def.
605 * @param string $field_id Generated field id.
606 * @param int $counter Position counter (for unique meta-key).
607 * @return array
608 */
609 protected static function compose_field( $field, $field_id, $counter ) {
610 $type = sanitize_key( (string) $field['type'] );
611 $label = isset( $field['label'] ) && '' !== $field['label']
612 ? sanitize_text_field( (string) $field['label'] )
613 : ucfirst( str_replace( array( '-', '_' ), ' ', $type ) );
614 $meta_key = sanitize_key( $label . '_' . $counter );
615
616 // Seed with the field type's registered defaults so complex fields
617 // (likert, scale-rating, address, etc.) carry every key their render
618 // method expects. Without this, e.g. a likert field saves with no
619 // rows/columns and renders blank on the frontend even though it shows
620 // in the builder. Caller-supplied values below override these.
621 $out = self::field_type_defaults( $type );
622
623 $out['id'] = $field_id;
624 $out['type'] = $type;
625 $out['label'] = $label;
626 $out['meta-key'] = $meta_key;
627
628 if ( ! empty( $field['required'] ) ) {
629 $out['required'] = '1';
630 }
631 if ( isset( $field['placeholder'] ) ) {
632 $out['placeholder'] = sanitize_text_field( (string) $field['placeholder'] );
633 }
634 if ( isset( $field['description'] ) ) {
635 $out['description'] = wp_kses_post( (string) $field['description'] );
636 }
637 if ( isset( $field['default_value'] ) ) {
638 $out['default_value'] = is_scalar( $field['default_value'] ) ? (string) $field['default_value'] : $field['default_value'];
639 }
640 if ( isset( $field['css'] ) ) {
641 $out['css'] = sanitize_text_field( (string) $field['css'] );
642 }
643
644 // Choices for select/radio/checkbox/country.
645 if ( isset( $field['choices'] ) && is_array( $field['choices'] ) ) {
646 $out['choices'] = self::normalize_choices( $field['choices'] );
647 }
648
649 // First/last name sublabels.
650 if ( isset( $field['sublabels'] ) && is_array( $field['sublabels'] ) ) {
651 foreach ( $field['sublabels'] as $sk => $sv ) {
652 $out[ 'sublabel_' . sanitize_key( (string) $sk ) ] = sanitize_text_field( (string) $sv );
653 }
654 }
655
656 // Likert: the field expects `likert_rows` / `likert_columns` as flat
657 // {index: "label"} maps and an `input_type`. Accept caller-friendly
658 // `rows` / `columns` (arrays of strings or {label} objects) and map them.
659 if ( 'likert' === $type ) {
660 if ( isset( $field['rows'] ) ) {
661 $out['likert_rows'] = self::flatten_label_map( $field['rows'] );
662 }
663 if ( isset( $field['columns'] ) ) {
664 $out['likert_columns'] = self::flatten_label_map( $field['columns'] );
665 }
666 if ( empty( $out['input_type'] ) ) {
667 $out['input_type'] = 'radio';
668 }
669 }
670
671 // Pass-through for anything else (numeric mins, formats, etc.) — sanitized scalars.
672 // `rows`/`columns` are consumed above for likert; skip them so we don't
673 // also write raw keys the field doesn't understand.
674 $skip = array( 'type', 'label', 'required', 'placeholder', 'description', 'default_value', 'css', 'choices', 'sublabels', 'grid', 'rows', 'columns' );
675 foreach ( $field as $k => $v ) {
676 if ( in_array( $k, $skip, true ) ) {
677 continue;
678 }
679 $key = sanitize_key( (string) $k );
680 if ( is_scalar( $v ) ) {
681 $out[ $key ] = sanitize_text_field( (string) $v );
682 } elseif ( is_array( $v ) ) {
683 $out[ $key ] = $v; // arrays passed through; EVF expects them for some advanced types
684 }
685 }
686
687 return $out;
688 }
689
690 /**
691 * Pull a registered field type's `defaults` array (e.g. likert_rows,
692 * likert_columns, drop_down_choices). These seed the composed field so it
693 * renders correctly even when the caller doesn't specify every key.
694 *
695 * @param string $type Field type id.
696 * @return array
697 */
698 protected static function field_type_defaults( $type ) {
699 $registry = function_exists( 'evf' ) ? evf()->form_fields : null;
700 $groups = ( is_object( $registry ) && isset( $registry->form_fields ) ) ? (array) $registry->form_fields : array();
701 foreach ( $groups as $fields ) {
702 foreach ( (array) $fields as $f ) {
703 if ( is_object( $f ) && isset( $f->type ) && $f->type === $type && isset( $f->defaults ) && is_array( $f->defaults ) ) {
704 return $f->defaults;
705 }
706 }
707 }
708 return array();
709 }
710
711 /**
712 * Flatten a caller-supplied label list into a 1-based {index: "label"} map.
713 *
714 * Accepts ["A","B"], {1:"A",2:"B"}, or [{label:"A"},{label:"B"}] and always
715 * returns {1:"A", 2:"B"}. Used for likert rows/columns.
716 *
717 * @param mixed $input Input.
718 * @return array
719 */
720 protected static function flatten_label_map( $input ) {
721 $out = array();
722 $i = 0;
723 foreach ( (array) $input as $v ) {
724 $i++;
725 if ( is_string( $v ) ) {
726 $out[ $i ] = sanitize_text_field( $v );
727 } elseif ( is_array( $v ) && isset( $v['label'] ) ) {
728 $out[ $i ] = sanitize_text_field( (string) $v['label'] );
729 }
730 }
731 return $out;
732 }
733
734 /**
735 * Normalize a choices array.
736 *
737 * Accepts either an indexed list of strings (`["Red","Green","Blue"]`) or
738 * a list of `{label, value?, default?}` objects, and produces the
739 * `{1: {label, value, default}, 2: {...}}` shape EVF expects.
740 *
741 * @param array $choices Input.
742 * @return array
743 */
744 protected static function normalize_choices( array $choices ) {
745 $out = array();
746 $i = 0;
747 foreach ( $choices as $choice ) {
748 $i++;
749 if ( is_string( $choice ) ) {
750 $out[ $i ] = array( 'label' => sanitize_text_field( $choice ), 'value' => '', 'default' => '' );
751 } elseif ( is_array( $choice ) ) {
752 $out[ $i ] = array(
753 'label' => isset( $choice['label'] ) ? sanitize_text_field( (string) $choice['label'] ) : '',
754 'value' => isset( $choice['value'] ) ? sanitize_text_field( (string) $choice['value'] ) : '',
755 'default' => ! empty( $choice['default'] ) ? '1' : '',
756 );
757 }
758 }
759 return $out;
760 }
761
762 /**
763 * Pick the next free row key index, scanning existing structure to keep
764 * row ordering stable across multiple builder calls.
765 *
766 * @param array $structure Structure map.
767 * @return int
768 */
769 protected static function next_row_key( $structure ) {
770 $max = 0;
771 foreach ( array_keys( $structure ) as $k ) {
772 if ( preg_match( '/^row_(\d+)$/', (string) $k, $m ) ) {
773 $max = max( $max, (int) $m[1] );
774 }
775 }
776 return $max + 1;
777 }
778
779 /**
780 * Whether every field in a row carries an explicit `grid` value.
781 *
782 * @param array $row_fields Row fields.
783 * @return bool
784 */
785 protected static function row_has_explicit_grids( $row_fields ) {
786 foreach ( $row_fields as $f ) {
787 if ( ! isset( $f['grid'] ) ) {
788 return false;
789 }
790 }
791 return ! empty( $row_fields );
792 }
793
794 /**
795 * Recursive merge that overwrites scalar values but merges associative arrays.
796 *
797 * @param array $base Base.
798 * @param array $overlay Overlay.
799 * @return array
800 */
801 protected static function deep_merge( array $base, array $overlay ) {
802 foreach ( $overlay as $k => $v ) {
803 if ( is_array( $v ) && isset( $base[ $k ] ) && is_array( $base[ $k ] ) ) {
804 $base[ $k ] = self::deep_merge( $base[ $k ], $v );
805 } else {
806 $base[ $k ] = $v;
807 }
808 }
809 return $base;
810 }
811 }
812