← 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) { |