PluginProbe
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder / trunk
Fluent Forms – Customizable Contact Forms, Survey, Quiz, & Conversational Form Builder vtrunk
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
← All changes | app/Services/FluentConversational/Classes/Converter/Converter.php +176 -14 6.2.2 → trunk View file →
@@ -5,12 +5,14 @@
5 5 defined('ABSPATH') or die;
6 6
7 7 use FluentForm\App\Helpers\Helper;
8 8 use FluentForm\App\Modules\Payments\PaymentHelper;
9 -use FluentForm\Framework\Helpers\ArrayHelper;
10 9 use FluentForm\App\Modules\Component\Component;
10 +use FluentForm\App\Services\FormBuilder\AutocompleteTokens;
11 +use FluentForm\App\Services\FormBuilder\RatingIcon;
11 12 use FluentForm\App\Services\FormBuilder\Components\DateTime;
12 13 use FluentForm\App\Modules\Form\FormFieldsParser;
14 +use FluentForm\Framework\Helpers\ArrayHelper;
13 15
14 16 class Converter
15 17 {
16 18 /**
@@ -200,8 +202,16 @@
200 202
201 203 if (!$hasSaveAndResume) {
202 204 $item['attributes']['value'] = self::setDefaultValue(ArrayHelper::get($item, 'attributes.value', ''), $item, $form);;
203 205 }
206 + // Per-sub-field token, resolved exactly as the classic renderer
207 + // does, so both renderers agree on the same saved form.
208 + $item['attributes']['autocomplete'] = ArrayHelper::get(AutocompleteTokens::normalizeAttributes(['autocomplete' => AutocompleteTokens::forSubField(
209 + ArrayHelper::get($item, 'attributes.name'),
210 + ArrayHelper::get($field, 'attributes.autocomplete'),
211 + ArrayHelper::get($item, 'attributes.autocomplete')
212 + )]), 'autocomplete');
213 +
204 214 $question['fields'][] = wp_parse_args($itemQuestion, $item);
205 215 }
206 216 }
207 217 } elseif ('input_name' === $field['element']) {
@@ -231,8 +241,16 @@
231 241 }
232 242
233 243 $item['attributes']['value'] = self::getComponent()->replaceEditorSmartCodes(ArrayHelper::get($item, 'attributes.value'), $form);
234 244
245 + // Per-sub-field token, resolved exactly as the classic renderer
246 + // does, so both renderers agree on the same saved form.
247 + $item['attributes']['autocomplete'] = ArrayHelper::get(AutocompleteTokens::normalizeAttributes(['autocomplete' => AutocompleteTokens::forSubField(
248 + ArrayHelper::get($item, 'attributes.name'),
249 + ArrayHelper::get($field, 'attributes.autocomplete'),
250 + ArrayHelper::get($item, 'attributes.autocomplete')
251 + )]), 'autocomplete');
252 +
235 253 $question['fields'][] = wp_parse_args($itemQuestion, $item);
236 254 }
237 255 }
238 256 } elseif ('input_text' === $field['element']) {
@@ -256,10 +274,17 @@
256 274
257 275 if ($isMultiple) {
258 276 $question['multiple'] = true;
259 277 $question['placeholder'] = self::getComponent()->replaceEditorSmartCodes(ArrayHelper::get($field, 'attributes.placeholder', false), $form);
260 - $question['max_selection'] = ArrayHelper::get($field, 'settings.max_selection');
261 - $question['max_selection'] = $question['max_selection'] ? intval($question['max_selection']) : 0;
278 + $maxSelection = Helper::resolveMaxSelection($field);
279 + $question['max_selection'] = $maxSelection ? intval($maxSelection) : 0;
280 +
281 + // Resolved here so a form on the legacy setting — which has no
282 + // rule to read a message from — still has wording.
283 + $question['max_selection_message'] = Helper::getSelectionLimitMessage(
284 + ArrayHelper::get($field, 'settings.validation_rules', []),
285 + 'max_selection'
286 + );
262 287 }
263 288 } elseif ('select_country' === $field['element']) {
264 289 $countryComponent = new \FluentForm\App\Services\FormBuilder\Components\SelectCountry();
265 290 $field = $countryComponent->loadCountries($field);
@@ -287,9 +312,9 @@
287 312 $question['searchable'] = ArrayHelper::get($field, 'settings.enable_select_2');
288 313 } elseif ('dynamic_field' === $field['element']) {
289 314 $dynamicFetchValue = 'yes' == ArrayHelper::get($field, 'settings.dynamic_fetch');
290 315 if ($dynamicFetchValue) {
291 - $field = apply_filters('fluentform/dynamic_field_re_fetch_result_and_resolve_value', $field);
316 + $field = apply_filters('fluentform/dynamic_field_re_fetch_result_and_resolve_value', $field, $form->id);
292 317 $question['answer'] = self::getComponent()->replaceEditorSmartCodes(ArrayHelper::get($field, 'attributes.value'), $form);
293 318 }
294 319 $type = ArrayHelper::get($field, 'settings.field_type', 'select');
295 320 if (in_array($type, ['checkbox', 'radio'])) {
@@ -316,8 +341,18 @@
316 341 $question['options'] = self::getAdvancedOptions($field, $form);
317 342 $question['nextStepOnAnswer'] = true;
318 343 $question = static::hasPictureMode($field, $question);
319 344 $question = static::maybeAddOtherOption($field, $question);
345 + } elseif ('input_ranking' === $field['element']) {
346 + $question['options'] = self::getAdvancedOptions($field, $form);
347 + $question['type'] = 'FlowFormRankingType';
348 + $question['multiple'] = true;
349 + $question['allowImages'] = (bool) ArrayHelper::get($field, 'settings.enable_image_input');
350 + $question['rankingDisplayType'] = ArrayHelper::get($field, 'settings.ranking_display_type', 'list');
351 + $question['rankingGridColumns'] = ArrayHelper::get($field, 'settings.ranking_grid_columns', '3');
352 + $question['showResetIcon'] = ArrayHelper::get($field, 'settings.show_reset_icon', 'no') === 'yes';
353 + $question['showPositionSerial'] = ArrayHelper::get($field, 'settings.show_position_serial', 'yes');
354 + $question['accentColor'] = ArrayHelper::get($field, 'settings.accent_color', '');
320 355 } elseif ('custom_html' === $field['element']) {
321 356 $question['content'] = self::getComponent()->replaceEditorSmartCodes(ArrayHelper::get($field, 'settings.html_codes', ''), $form);
322 357 } elseif ('section_break' === $field['element']) {
323 358 $question['content'] = self::getComponent()->replaceEditorSmartCodes(ArrayHelper::get($field, 'settings.description', ''), $form);
@@ -387,10 +422,19 @@
387 422 if ('gdpr_agreement' === $field['element']) {
388 423 $question['required'] = true;
389 424 }
390 425 } elseif ('ratings' === $field['element']) {
426 + $iconSettings = RatingIcon::resolveSettings($field);
391 427 $question['show_text'] = ArrayHelper::get($field, 'settings.show_text');
392 428 $question['rateOptions'] = ArrayHelper::get($field, 'options', []);
429 + $question['iconSource'] = $iconSettings['icon_source'];
430 + $question['iconType'] = $iconSettings['icon_type'];
431 + $question['customIconSvg'] = $iconSettings['custom_icon_svg'];
432 + $question['inactiveColor'] = $iconSettings['inactive_color'];
433 + $question['activeColor'] = $iconSettings['active_color'];
434 + $question['iconSvg'] = RatingIcon::getResolvedIconSvg($field, [
435 + 'class' => 'ff-rating-icon-svg',
436 + ]);
393 437 $question['nextStepOnAnswer'] = true;
394 438 } elseif ('input_date' === $field['element']) {
395 439 $app = wpFluentForm();
396 440 $dateField = new DateTime();
@@ -401,8 +445,9 @@
401 445 $question['dateConfig'] = json_decode($dateField->getDateFormatConfigJSON($field['settings'], $form));
402 446 $question['dateCustomConfig'] = $dateField->getCustomConfig($field['settings']);
403 447 } elseif (in_array($field['element'], ['input_image', 'input_file'])) {
404 448 $question['multiple'] = true;
449 + $question['btn_text'] = ArrayHelper::get($field, 'settings.btn_text', __('Choose File', 'fluentform'));
405 450
406 451 $maxFileCount = ArrayHelper::get($field, 'settings.validation_rules.max_file_count');
407 452 $maxFileSize = ArrayHelper::get($field, 'settings.validation_rules.max_file_size');
408 453
@@ -707,9 +752,9 @@
707 752 }
708 753 }
709 754 } elseif ('payment_summary_component' === $field['element']) {
710 755 $question['title'] = self::getComponent()->replaceEditorSmartCodes(__('Payment Summary', 'fluentform'), $form);
711 - $question['emptyText'] = $field['settings']['cart_empty_text'];
756 + $question['emptyText'] = fluentform_sanitize_html(ArrayHelper::get($field, 'settings.cart_empty_text', ''));
712 757 } elseif ('net_promoter_score' === $field['element']) {
713 758 if (!ArrayHelper::exists($question, 'answer')) {
714 759 $question['answer'] = (int) $field['attributes']['value'];
715 760 }
@@ -850,9 +895,9 @@
850 895
851 896 public static function convertExistingForm($form)
852 897 {
853 898 $formFields = json_decode($form->form_fields, true);
854 - $fields = $formFields['fields'];
899 + $fields = static::flattenLayoutContainers(ArrayHelper::get($formFields, 'fields', []));
855 900 $formattedFields = [];
856 901
857 902 if (is_array($fields) && ! empty($fields)) {
858 903 foreach ($fields as $field) {
@@ -879,8 +924,58 @@
879 924 $formFields['fields'] = $formattedFields;
880 925
881 926 return json_encode($formFields);
882 927 }
928 +
929 + /**
930 + * Lift fields out of layout containers so they survive conversion.
931 + *
932 + * A conversational form is a flat sequence of questions, so `container` has
933 + * no equivalent and is not in fieldTypes(). Dropping it wholesale used to
934 + * take every input nested inside it as well — silent, irreversible data loss
935 + * on a one-click action, and column layouts are common.
936 + *
937 + * Only `columns` is unwrapped. Composite fields (input_name, address) keep
938 + * their sub-inputs under `fields` and have their own conversational type, so
939 + * they must stay whole. Nested containers are handled recursively.
940 + *
941 + * @param array $fields
942 + * @return array
943 + */
944 + protected static function flattenLayoutContainers($fields)
945 + {
946 + if (! is_array($fields)) {
947 + return [];
948 + }
949 +
950 + $flattened = [];
951 +
952 + foreach ($fields as $field) {
953 + if (! is_array($field)) {
954 + continue;
955 + }
956 +
957 + $columns = ArrayHelper::get($field, 'columns');
958 +
959 + if ('container' !== ArrayHelper::get($field, 'element') || ! is_array($columns)) {
960 + $flattened[] = $field;
961 +
962 + continue;
963 + }
964 +
965 + foreach ($columns as $column) {
966 + $columnFields = static::flattenLayoutContainers(
967 + ArrayHelper::get($column, 'fields', [])
968 + );
969 +
970 + foreach ($columnFields as $columnField) {
971 + $flattened[] = $columnField;
972 + }
973 + }
974 + }
975 +
976 + return $flattened;
977 + }
883 978
884 979 private static function buildBaseQuestion($field, $validationsRules, $form)
885 980 {
886 981 return [
@@ -889,8 +984,9 @@
889 984 'title' => self::getComponent()->replaceEditorSmartCodes(ArrayHelper::get($field, 'settings.label'), $form),
890 985 'type' => ArrayHelper::get(static::fieldTypes(), $field['element']),
891 986 'ff_input_type' => ArrayHelper::get($field, 'element'),
892 987 'container_class' => ArrayHelper::get($field, 'settings.container_class'),
988 + 'autocomplete' => ArrayHelper::get(AutocompleteTokens::normalizeAttributes((array) ArrayHelper::get($field, 'attributes', [])), 'autocomplete'),
893 989 'placeholder' => self::getComponent()->replaceEditorSmartCodes(ArrayHelper::get($field, 'attributes.placeholder'), $form),
894 990 'maxLength' => ArrayHelper::get($field, 'attributes.maxlength'),
895 991 'required' => ArrayHelper::get($validationsRules, 'required.value'),
896 992 'requiredMsg' => ArrayHelper::get($validationsRules, 'required.message'),
@@ -962,8 +1058,9 @@
962 1058 $fieldTypes['rangeslider'] = 'FlowFormRangesliderType';
963 1059 $fieldTypes['save_progress_button'] = 'FlowFormSaveAndResumeType';
964 1060 $fieldTypes['dynamic_field'] = 'FlowFormDynamicFieldType';
965 1061 $fieldTypes['net_promoter_score'] = 'FlowFormNetPromoterScoreType';
1062 + $fieldTypes['input_ranking'] = 'FlowFormRankingType';
966 1063 }
967 1064
968 1065 return apply_filters('fluentform/conversational_field_types', $fieldTypes);
969 1066 }
@@ -1215,12 +1312,22 @@
1215 1312
1216 1313 private static function getAdvancedOptions($field, $form)
1217 1314 {
1218 1315 $options = ArrayHelper::get($field, 'settings.advanced_options', []);
1219 -
1316 +
1220 1317 foreach ($options as &$option) {
1318 + if (\FluentForm\App\Helpers\Helper::isOptionGroup($option)) {
1319 + foreach ($option['options'] as &$groupOption) {
1320 + $groupOption['label'] = self::getComponent()->replaceEditorSmartCodes($groupOption['label'], $form);
1321 + }
1322 + unset($groupOption);
1323 + $option['label'] = self::getComponent()->replaceEditorSmartCodes($option['label'], $form);
1324 + continue;
1325 + }
1326 +
1221 1327 $option['label'] = self::getComponent()->replaceEditorSmartCodes($option['label'], $form);
1222 1328 }
1329 + unset($option);
1223 1330
1224 1331 if ($options && 'yes' == ArrayHelper::get($field, 'settings.randomize_options')) {
1225 1332 shuffle($options);
1226 1333 }
@@ -1273,8 +1380,10 @@
1273 1380 if ($perStepSave || $hasSaveProgressButton) {
1274 1381 $saveAndResume = false;
1275 1382 $hash = '';
1276 1383 $form->save_state = false;
1384 + $userId = (int)get_current_user_id();
1385 + $viaLink = false;
1277 1386
1278 1387 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public form state parameter for save & resume functionality, verified by hash comparison in database
1279 1388 $key = isset($_GET['fluent_state']) ? sanitize_text_field(wp_unslash($_GET['fluent_state'])) : false;
1280 1389
@@ -1280,17 +1389,47 @@
1280 1389
1281 1390 if ($key) {
1282 1391 $hash = base64_decode($key);
1283 1392 $form->save_state = true;
1393 + $viaLink = true;
1284 1394 } else {
1285 1395 $cookieName = 'fluentform_step_form_hash_' . $form->id;
1286 - $hash = ArrayHelper::get($_COOKIE, $cookieName, wp_generate_uuid4());
1396 + $hash = isset($_COOKIE[$cookieName])
1397 + ? sanitize_text_field(wp_unslash($_COOKIE[$cookieName]))
1398 + : wp_generate_uuid4();
1287 1399 }
1288 1400
1289 1401 \FluentFormPro\classes\DraftSubmissionsManager::migrate();
1290 1402
1291 - $draftForm = \FluentFormPro\classes\DraftSubmissionsManager::get($hash);
1403 + $draftForm = null;
1292 1404
1405 + // Logged-in users: prefer their own latest draft for this form so
1406 + // cross-device restore works and a stale cookie can't pin them to
1407 + // an older row. Link-share path is explicit and skips this.
1408 + if (!$viaLink && $userId) {
1409 + $draftForm = wpFluent()->table('fluentform_draft_submissions')
1410 + ->where('user_id', $userId)
1411 + ->where('form_id', $form->id)
1412 + ->orderBy('updated_at', 'desc')
1413 + ->first();
1414 + }
1415 +
1416 + if (!$draftForm) {
1417 + $candidate = \FluentFormPro\classes\DraftSubmissionsManager::get($hash);
1418 + // Privacy guard: a draft owned by a real user (user_id > 0)
1419 + // may only be loaded by that same user. Link-share path is
1420 + // explicit sharing and is exempt.
1421 + if ($candidate && !$viaLink) {
1422 + $entryUserId = (int)$candidate->user_id;
1423 + if ($entryUserId > 0 && $entryUserId !== $userId) {
1424 + $candidate = null;
1425 + }
1426 + }
1427 + if ($candidate) {
1428 + $draftForm = $candidate;
1429 + }
1430 + }
1431 +
1293 1432 if ($draftForm) {
1294 1433 $saveAndResume = true;
1295 1434 }
1296 1435
@@ -1332,25 +1471,48 @@
1332 1471 {
1333 1472 $draftForm = null;
1334 1473 $data = [];
1335 1474 $formId = $form->id;
1475 + $userId = (int)get_current_user_id();
1476 + $viaLink = false;
1336 1477 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public form state parameter for save & resume functionality, verified by hash comparison in database
1337 1478 $key = isset($_GET['fluent_state']) ? sanitize_text_field(wp_unslash($_GET['fluent_state'])) : false;
1338 1479 if ($key) {
1339 1480 $hash = base64_decode($key);
1481 + $viaLink = true;
1340 1482 } else {
1341 1483 $cookieName = 'fluentform_step_form_hash_' . $formId;
1342 - $hash = ArrayHelper::get($_COOKIE, $cookieName, wp_generate_uuid4());
1484 + $hash = isset($_COOKIE[$cookieName])
1485 + ? sanitize_text_field(wp_unslash($_COOKIE[$cookieName]))
1486 + : wp_generate_uuid4();
1343 1487 }
1344 1488
1345 - if ($hash) {
1346 - $draftForm = \FluentFormPro\classes\DraftSubmissionsManager::get($hash, $formId);
1347 - } elseif (!$draftForm && $userId = get_current_user_id()) {
1489 + // Logged-in users: prefer their own latest draft (cross-device sync).
1490 + // Link-share path stays explicit and skips this.
1491 + if (!$viaLink && $userId) {
1348 1492 $draftForm = wpFluent()->table('fluentform_draft_submissions')
1349 1493 ->where('user_id', $userId)
1350 1494 ->where('form_id', $formId)
1495 + ->orderBy('updated_at', 'desc')
1351 1496 ->first();
1352 - } else {
1497 + }
1498 +
1499 + if (!$draftForm && $hash) {
1500 + $candidate = \FluentFormPro\classes\DraftSubmissionsManager::get($hash, $formId);
1501 + // Privacy guard: a draft owned by a real user (user_id > 0) may
1502 + // only be loaded by that same user. Link-share path is exempt.
1503 + if ($candidate && !$viaLink) {
1504 + $entryUserId = (int)$candidate->user_id;
1505 + if ($entryUserId > 0 && $entryUserId !== $userId) {
1506 + $candidate = null;
1507 + }
1508 + }
1509 + if ($candidate) {
1510 + $draftForm = $candidate;
1511 + }
1512 + }
1513 +
1514 + if (!$draftForm) {
1353 1515 return $data;
1354 1516 }
1355 1517
1356 1518 if ($draftForm) {