PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.15
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.15
6.2.15 6.2.14 6.2.13 6.2.12 6.2.10 6.2.11 6.2.9 6.2.8 6.2.7 6.2.6 6.2.5 6.2.4 6.2.3 6.2.2 3.6.22 3.6.31 3.6.40 3.6.41 3.6.42 3.6.50 3.6.51 3.6.60 3.6.61 3.6.62 3.6.64 All 197 releases
fluentform / app / Modules / MCP / Tools / FieldTools.php

FieldTools.php in Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder 6.2.15, at app/Modules/MCP/Tools/FieldTools.php

255 lines 12.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentForm\App\Modules\MCP\Tools;
4
5 defined('ABSPATH') || exit;
6
7 use FluentForm\App\Helpers\Helper;
8 use FluentForm\App\Models\Form;
9 use FluentForm\App\Modules\MCP\Support\ErrorCodes;
10 use FluentForm\App\Modules\MCP\Support\FormAccess;
11 use FluentForm\App\Modules\MCP\Support\FormCreator;
12 use FluentForm\App\Modules\MCP\Support\MCPHelper;
13 use FluentForm\App\Modules\MCP\Support\Mutation;
14 use FluentForm\App\Modules\MCP\Support\WriteGuard;
15 use FluentForm\App\Services\Form\FormService;
16 use FluentForm\Framework\Support\Arr;
17
18 /**
19 * Field-editing tool (write, destructive).
20 *
21 * The update-form-fields tool replaces an existing form's whole field set from a
22 * simple spec (the same shape create-form accepts), reusing FormService::update so the
23 * agent path shares the admin editor's duplicate-name validation and field
24 * sanitization. It is destructive: a submission's answers are keyed by a field's
25 * attributes.name, so removing or renaming a field orphans its stored values.
26 * The dry-run preview lists exactly which stored field keys would disappear, and
27 * a confirm_token bound to the form's current field state must round-trip before
28 * anything is written. Multi-step forms are refused — their step wrappers make a
29 * blind full-replace unsafe; edit those in the form builder.
30 */
31 class FieldTools
32 {
33 public static function definitions()
34 {
35 return [
36 'fluentform/update-form-fields' => [
37 'label' => __('Update Form Fields', 'fluentform'),
38 'group' => __('Forms', 'fluentform'),
39 'description' => __('Replace a form\'s fields from a spec (same shape as create-form: a list of {element, attributes, settings}). This overwrites the ENTIRE field set — include every field you want to keep, not just new ones. Entries are keyed by a field\'s name, so dropping or renaming a field orphans its stored answers. Call once with dry_run:true to preview which stored field keys would be removed and get a confirm_token, then call again with the same fields plus confirm_token to execute. If your new list drops any existing field you must ALSO pass allow_field_removal:true. Multi-step forms are not editable here. Requires form_id and fields.', 'fluentform'),
40 'input_schema' => [
41 'type' => 'object',
42 'properties' => array_merge([
43 'form_id' => ['type' => 'integer', 'description' => 'Required. The form to edit.'],
44 'fields' => ['type' => 'array', 'items' => ['type' => 'object'], 'description' => 'Required. The full replacement field list (create-form spec shape).'],
45 'allow_field_removal' => ['type' => 'boolean', 'description' => 'Required true to execute if your new list drops any existing field key (which orphans that field\'s stored entries).'],
46 ], WriteGuard::schemaProps()),
47 'required' => ['form_id', 'fields'],
48 ],
49 'execute_callback' => [self::class, 'updateFields'],
50 'capability' => 'fluentform_forms_manager',
51 'annotations' => ['destructive' => true],
52 ],
53 ];
54 }
55
56 public static function updateFields($params = [])
57 {
58 $form = FormAccess::resolveForm($params);
59 if (is_wp_error($form)) {
60 return $form;
61 }
62 $formId = (int) $form->id;
63
64 $spec = isset($params['fields']) ? $params['fields'] : null;
65 if (!is_array($spec) || empty($spec)) {
66 return MCPHelper::error(ErrorCodes::MISSING_PARAM, __('fields must be a non-empty array of field definitions.', 'fluentform'), ['fields' => ['fields']]);
67 }
68
69 $existing = json_decode($form->form_fields, true);
70 if (!is_array($existing)) {
71 $existing = ['fields' => [], 'submitButton' => []];
72 }
73
74 if (!empty($existing['stepsWrapper'])) {
75 return MCPHelper::error(ErrorCodes::INVALID_PARAM, __('This form is multi-step; edit its fields in the form builder. update-form-fields does not support step wrappers.', 'fluentform'), ['fields' => ['form_id']]);
76 }
77
78 try {
79 $newFields = (new FormCreator())->formatFields($spec);
80 } catch (\Throwable $e) {
81 return MCPHelper::error(ErrorCodes::INVALID_PARAM, $e->getMessage(), ['fields' => ['fields']]);
82 }
83 if (empty($newFields)) {
84 return MCPHelper::error(ErrorCodes::INVALID_PARAM, __('None of the supplied fields resolved to a valid FluentForm element.', 'fluentform'), ['fields' => ['fields']]);
85 }
86
87 $oldNames = self::fieldNames(Arr::get($existing, 'fields', []));
88 $newNames = self::fieldNames($newFields);
89 $removed = array_values(array_diff($oldNames, $newNames));
90
91 // Dropping a field orphans its stored entries. dry_run still previews the
92 // removed keys so the agent can learn what's at stake; execution is refused
93 // unless the caller explicitly opts into removal.
94 if (empty($params['dry_run']) && $removed && empty($params['allow_field_removal'])) {
95 return MCPHelper::error(
96 ErrorCodes::INVALID_PARAM,
97 __('This change removes existing fields, which orphans their stored entries. Re-send with allow_field_removal:true (plus the confirm_token) to proceed.', 'fluentform'),
98 ['fields' => ['allow_field_removal'], 'removed_field_keys' => $removed, 'next_step' => 'set allow_field_removal:true']
99 );
100 }
101
102 $fingerprint = 'fields:' . md5((string) $form->form_fields);
103
104 return Mutation::runGuarded(
105 'fluentform/update-form-fields',
106 $params,
107 'form_fields:' . $formId,
108 $fingerprint,
109 function () use ($removed, $oldNames, $newNames) {
110 return [
111 'removed_field_keys' => $removed,
112 'kept_field_keys' => array_values(array_intersect($oldNames, $newNames)),
113 'new_field_keys' => array_values(array_diff($newNames, $oldNames)),
114 'warning' => $removed
115 ? __('Entries stored under the removed field keys will no longer display against those fields. To execute, re-send with confirm_token AND allow_field_removal:true.', 'fluentform')
116 : __('No stored field keys are removed.', 'fluentform'),
117 ];
118 },
119 function () use ($formId, $newFields, $removed, $fingerprint) {
120 global $wpdb;
121
122 // Serialize against concurrent form saves (editor autosave, another
123 // agent): lock the form row, then read-modify-write inside one
124 // transaction so no edit lands between our read and the Updater
125 // write (lost update). FOR UPDATE degrades to a plain fresh read on
126 // engines without row locks. Mirrors the pattern the field-conditions
127 // tool used before it was folded away.
128 $wpdb->query('START TRANSACTION');
129
130 try {
131 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name from $wpdb->prefix, id is %d-prepared
132 $wpdb->query($wpdb->prepare("SELECT id FROM {$wpdb->prefix}fluentform_forms WHERE id = %d FOR UPDATE", $formId));
133
134 $fresh = Form::query()->find($formId);
135 if (!$fresh) {
136 $wpdb->query('ROLLBACK');
137 return MCPHelper::error(ErrorCodes::STATE_CHANGED, __('The form was deleted while this update was in flight.', 'fluentform'));
138 }
139
140 // Re-validate the locked row against the exact state the caller
141 // previewed and confirmed. The confirm_token check ran against a
142 // pre-lock read; an edit landing between that read and this lock
143 // would make the removed-keys preview wrong (a concurrently-added
144 // field would be dropped without consent). Refuse and force a
145 // fresh dry_run so removal is always previewed against what ships.
146 if ('fields:' . md5((string) $fresh->form_fields) !== $fingerprint) {
147 $wpdb->query('ROLLBACK');
148 return MCPHelper::error(ErrorCodes::STATE_CHANGED, __('The form changed while this update was in flight. Run a fresh dry_run to re-preview which fields would be removed, then execute.', 'fluentform'), ['next_step' => 'set dry_run:true']);
149 }
150
151 $decoded = json_decode($fresh->form_fields, true);
152 if (!is_array($decoded)) {
153 $decoded = ['fields' => [], 'submitButton' => []];
154 }
155 if (!empty($decoded['stepsWrapper'])) {
156 $wpdb->query('ROLLBACK');
157 return MCPHelper::error(ErrorCodes::STATE_CHANGED, __('The form became multi-step while this update was in flight; edit it in the form builder.', 'fluentform'), ['fields' => ['form_id']]);
158 }
159
160 // Replace only the fields; keep the fresh submitButton and any
161 // other top-level keys as they stand right now.
162 $decoded['fields'] = Helper::isConversionForm($formId)
163 ? self::withStylePrefs($newFields, Arr::get($decoded, 'fields', []))
164 : $newFields;
165
166 (new FormService())->update([
167 'form_id' => $formId,
168 'formFields' => wp_json_encode($decoded),
169 'title' => $fresh->title,
170 'status' => $fresh->status,
171 ]);
172
173 $wpdb->query('COMMIT');
174 } catch (\FluentForm\Framework\Validator\ValidationException $e) {
175 $wpdb->query('ROLLBACK');
176 return MCPHelper::error(ErrorCodes::INVALID_PARAM, $e->getMessage(), ['fields' => ['fields']]);
177 } catch (\Throwable $e) {
178 $wpdb->query('ROLLBACK');
179 throw $e;
180 }
181
182 return MCPHelper::envelope(
183 sprintf(
184 /* translators: %s: form title */
185 __('Fields for "%s" updated.', 'fluentform'),
186 $fresh->title
187 ),
188 ['form_id' => $formId, 'removed_field_keys' => $removed]
189 );
190 },
191 ['form_id' => $formId]
192 );
193 }
194
195 /**
196 * create-form gets style_pref from Converter::convertExistingForm; this path
197 * never reaches it, and the editor reads style_pref.layout unguarded. Kept
198 * fields keep their stored block so an added question resets no layout.
199 */
200 private static function withStylePrefs($fields, $existing)
201 {
202 $stored = [];
203 foreach ($existing as $field) {
204 $name = Arr::get($field, 'attributes.name');
205 if ($name && isset($field['style_pref'])) {
206 $stored[$name] = $field['style_pref'];
207 }
208 }
209
210 foreach ($fields as $index => $field) {
211 $name = Arr::get($field, 'attributes.name');
212 $fields[$index]['style_pref'] = isset($stored[$name]) ? $stored[$name] : [
213 'layout' => 'default',
214 'media' => fluentFormGetRandomPhoto(),
215 'brightness' => 0,
216 'alt_text' => '',
217 'media_x_position' => 50,
218 'media_y_position' => 50,
219 ];
220 }
221
222 return $fields;
223 }
224
225 /**
226 * Every stored field key (attributes.name) in a fields array, recursing into
227 * container columns — the keys entries are stored against.
228 */
229 private static function fieldNames($fields)
230 {
231 $names = [];
232 if (!is_array($fields)) {
233 return $names;
234 }
235
236 foreach ($fields as $field) {
237 if (!is_array($field)) {
238 continue;
239 }
240 $name = Arr::get($field, 'attributes.name');
241 if ($name) {
242 $names[] = $name;
243 }
244 $columns = Arr::get($field, 'columns', []);
245 if (is_array($columns)) {
246 foreach ($columns as $column) {
247 $names = array_merge($names, self::fieldNames(Arr::get($column, 'fields', [])));
248 }
249 }
250 }
251
252 return array_values(array_unique($names));
253 }
254 }
255