PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / 6.2.14
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder v6.2.14
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 3.6.65 All 196 releases
fluentform / app / Modules / MCP / Tools / FieldTools.php

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

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