PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / V3.3.2
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder vV3.3.2
V3.3.2 3.3.1 V-3.3.0 3.2.2 3.2.1 3.2.0 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 V3.0.3 V3.0.2 -3.0.1 V_3.0.0 1.1.1 1.1.8 1.2 1.3 1.4 1.4.18 1.5.2 1.9 2.0 2.10.0 All 139 releases
← All changes | includes/Core/Util/FieldValueHandler.php +480 -26 3.1.0 → V3.3.2 View file →
@@ -6,9 +6,17 @@
6 6 use BitCode\BitForm\Core\Form\FormManager;
7 7
8 8 final class FieldValueHandler
9 9 {
10 - public static function replaceFieldWithValue($stringToReplaceField, $fieldValues, $formID = null)
10 + /**
11 + * @param mixed $stringToReplaceField
12 + * @param mixed $fieldValues
13 + * @param mixed $formID
14 + * @param bool $stripShortcodesFromValues
15 + *
16 + * @return string
17 + */
18 + public static function replaceFieldWithValue($stringToReplaceField, $fieldValues, $formID = null, $stripShortcodesFromValues = false)
11 19 {
12 20 if (empty($stringToReplaceField)) {
13 21 return $stringToReplaceField;
14 22 }
@@ -16,8 +24,12 @@
16 24 $stringToReplaceField = wp_json_encode($stringToReplaceField);
17 25 }
18 26 $fieldValues = $formID ? self::sortValueBasedOnLayout($formID, $fieldValues) : $fieldValues;
19 27
28 + // Must run on the raw template: after substitution an empty field and an empty template
29 + // are the same empty string.
30 + $stringToReplaceField = self::resolveConditionalBlocks($stringToReplaceField, $fieldValues, $formID);
31 +
20 32 if ($formID) {
21 33 $stringToReplaceField = self::replaceValueOfBf_all_data($stringToReplaceField, $fieldValues, $formID);
22 34 $stringToReplaceField = self::replaceRepeaterFieldValue($stringToReplaceField, $fieldValues, $formID);
23 35 }
@@ -35,9 +47,13 @@
35 47 $fieldName = substr($value, 2, strlen($value) - 3);
36 48 $fieldValue = null;
37 49 if (isset($fieldValues[$fieldName])) {
38 50 $targetFieldValue = isset($fieldValues[$fieldName]['value']) ? $fieldValues[$fieldName]['value'] : $fieldValues[$fieldName];
39 - if ('array' === gettype($targetFieldValue) || 'object' === gettype($targetFieldValue)) {
51 + $targetFieldType = self::fieldTypeForSmartTag($fieldValues[$fieldName], $fieldName, $formID);
52 + if (self::isCompositeFieldType($targetFieldType) && ('array' === gettype($targetFieldValue) || 'object' === gettype($targetFieldValue))) {
53 + // Name/Address parts are words of one value: never summed, joined like everywhere else.
54 + $fieldValue = self::joinCompositeFieldValue((array) $targetFieldValue, $targetFieldType);
55 + } elseif ('array' === gettype($targetFieldValue) || 'object' === gettype($targetFieldValue)) {
40 56 foreach (self::stripMetaSubfields((array) $targetFieldValue) as $singleTargetVal) {
41 57 if (isset($fieldValue)) {
42 58 if (is_numeric($fieldValue) && is_numeric($singleTargetVal)) {
43 59 $fieldValue = $fieldValue + $singleTargetVal;
@@ -51,8 +67,12 @@
51 67 // $fieldValue = wp_json_encode($targetFieldValue);
52 68 } else {
53 69 $fieldValue = strval($targetFieldValue);
54 70 }
71 + // Neutralize shortcodes in low-trust submitted values before they are merged into a
72 + if ($stripShortcodesFromValues && is_string($fieldValue)) {
73 + $fieldValue = strip_shortcodes($fieldValue);
74 + }
55 75 $stringToReplaceField = str_replace($value, $fieldValue, $stringToReplaceField);
56 76 } else {
57 77 $stringToReplaceField = str_replace($value, '', $stringToReplaceField);
58 78 }
@@ -198,8 +218,318 @@
198 218 }
199 219 return false;
200 220 }
201 221
222 + /**
223 + * Whether a resolved value renders as nothing.
224 + *
225 + * Stricter than isEmpty(): whitespace-only is blank (some smart-tag resolvers return a
226 + * single space), so is an all-blank array. `0` / `'0'` never are.
227 + *
228 + * @param mixed $val
229 + *
230 + * @return bool
231 + */
232 + public static function isBlank($val)
233 + {
234 + if (null === $val || false === $val) {
235 + return true;
236 + }
237 + if (is_object($val)) {
238 + $val = (array) $val;
239 + }
240 + if (is_array($val)) {
241 + foreach ($val as $key => $item) {
242 + // Composite meta sub-values (_latitude, …) never render on their own.
243 + if (is_string($key) && 0 === strpos($key, '_')) {
244 + continue;
245 + }
246 + if (!self::isBlank($item)) {
247 + return false;
248 + }
249 + }
250 + return true;
251 + }
252 + if (!is_scalar($val)) {
253 + return true;
254 + }
255 +
256 + return '' === trim(str_replace("\xc2\xa0", '', (string) $val));
257 + }
258 +
259 + /**
260 + * Resolve `${bf_if:…}` … `${bf_endif}` template blocks.
261 + *
262 + * Syntax, operators and traps: docs/template-conditional-blocks.md.
263 + *
264 + * @param string $content
265 + * @param array $fieldValues
266 + * @param mixed $formID needed to reach repeater rows; without it a child key reads blank
267 + *
268 + * @return string
269 + */
270 + private static function resolveConditionalBlocks($content, $fieldValues, $formID = null)
271 + {
272 + if (false === strpos($content, '${bf_if') && false === strpos($content, '${bf_unless')) {
273 + return self::stripConditionalBlockTags($content);
274 + }
275 +
276 + $conditionValues = $fieldValues;
277 + if ($formID) {
278 + $formManager = FormManager::getInstance($formID);
279 + // Repeater children have no top-level key; flatten the rows in, real values still win.
280 + $conditionValues = array_merge(self::restructureRepeaterData($fieldValues, $formManager), $fieldValues);
281 + }
282 +
283 + // Matches a block whose body holds no further opener, i.e. the innermost one.
284 + $innerMost = '/\$\{bf_(if|if_any|if_all|unless):([^{}$]*)\}((?:(?!\$\{bf_(?:if|if_any|if_all|unless):)[\s\S])*?)\$\{bf_endif\}/';
285 +
286 + // Bounded so a malformed template can never spin here.
287 + for ($pass = 0; $pass < 200; $pass++) {
288 + $resolved = preg_replace_callback($innerMost, function ($matches) use ($conditionValues) {
289 + $branches = preg_split('/\$\{bf_else\}/', $matches[3], 2);
290 + $truthy = isset($branches[0]) ? $branches[0] : '';
291 + $falsy = isset($branches[1]) ? $branches[1] : '';
292 +
293 + return self::evaluateBlockCondition($matches[1], $matches[2], $conditionValues) ? $truthy : $falsy;
294 + }, $content, -1, $replacedCount);
295 +
296 + if (null === $resolved) {
297 + break; // preg failure (e.g. backtrack limit): leave the content untouched
298 + }
299 + $content = $resolved;
300 + if (!$replacedCount) {
301 + break;
302 + }
303 + }
304 +
305 + return self::stripConditionalBlockTags($content);
306 + }
307 +
308 + /**
309 + * @param string $type if|if_any|if_all|unless
310 + * @param string $rawKeys comma separated conditions
311 + * @param array $fieldValues
312 + *
313 + * @return bool
314 + */
315 + private static function evaluateBlockCondition($type, $rawKeys, $fieldValues)
316 + {
317 + $conditions = array_filter(array_map('trim', explode(',', (string) $rawKeys)), function ($condition) {
318 + return '' !== $condition;
319 + });
320 + if (empty($conditions)) {
321 + return false;
322 + }
323 +
324 + $results = [];
325 + foreach ($conditions as $condition) {
326 + $results[] = self::conditionHolds($condition, $fieldValues);
327 + }
328 +
329 + if ('if_all' === $type) {
330 + return !in_array(false, $results, true);
331 + }
332 + if ('unless' === $type) {
333 + return !in_array(true, $results, true);
334 + }
335 +
336 + return in_array(true, $results, true);
337 + }
338 +
339 + /**
340 + * `key`, or `key operator value`.
341 + *
342 + * @param string $condition
343 + * @param array $fieldValues
344 + *
345 + * @return bool
346 + */
347 + private static function conditionHolds($condition, $fieldValues)
348 + {
349 + $operators = self::blockOperators();
350 + // Longest name first, or `not_equal` reads as `equal`. Field keys never contain a space.
351 + $pattern = '/^(\S+)\s+(' . implode('|', $operators) . ')(?:\s+([\s\S]*))?$/';
352 +
353 + if (!preg_match($pattern, trim($condition), $parts)) {
354 + return !self::isBlank(self::conditionValue(trim($condition), $fieldValues));
355 + }
356 +
357 + $value = self::conditionValue($parts[1], $fieldValues);
358 + $operator = $parts[2];
359 + $expected = isset($parts[3]) ? trim($parts[3]) : '';
360 +
361 + if ('null' === $operator) {
362 + return self::isBlank($value);
363 + }
364 + if ('not_null' === $operator) {
365 + return !self::isBlank($value);
366 + }
367 +
368 + // Multi-value fields and repeater children arrive as a list.
369 + $candidates = is_array($value) || is_object($value) ? self::stripMetaSubfields((array) $value) : [$value];
370 + $negated = in_array($operator, ['not_equal', 'not_contain'], true);
371 + foreach ($candidates as $candidate) {
372 + if (is_array($candidate) || is_object($candidate)) {
373 + continue;
374 + }
375 + // compareValue answers the positive form, so one match settles either case: it satisfies
376 + // `contain` and rules out `not_contain`.
377 + if (self::compareValue($operator, (string) $candidate, $expected)) {
378 + return !$negated;
379 + }
380 + }
381 +
382 + return $negated;
383 + }
384 +
385 + /**
386 + * @return string[] operator names, longest first
387 + */
388 + private static function blockOperators()
389 + {
390 + return [
391 + 'greater_or_equal',
392 + 'less_or_equal',
393 + 'not_contain',
394 + 'start_with',
395 + 'not_equal',
396 + 'not_null',
397 + 'end_with',
398 + 'contain',
399 + 'greater',
400 + 'equal',
401 + 'less',
402 + 'null',
403 + ];
404 + }
405 +
406 + /**
407 + * @param string $key field key, or a `_bf_*` smart tag
408 + * @param array $fieldValues
409 + *
410 + * @return mixed
411 + */
412 + private static function conditionValue($key, $fieldValues)
413 + {
414 + if (0 === strpos($key, '_')) {
415 + return SmartTags::getSmartTagValue($key, false, '');
416 + }
417 + $value = isset($fieldValues[$key]) ? $fieldValues[$key] : null;
418 + if (is_array($value) && isset($value['value'])) {
419 + $value = $value['value'];
420 + }
421 +
422 + return $value;
423 + }
424 +
425 + /**
426 + * @param string $operator
427 + * @param string $value the submitted value
428 + * @param string $expected the value written in the template
429 + *
430 + * @return bool
431 + */
432 + private static function compareValue($operator, $value, $expected)
433 + {
434 + switch ($operator) {
435 + case 'equal':
436 + case 'not_equal':
437 + return 0 === strcasecmp(trim($value), $expected);
438 + case 'contain':
439 + case 'not_contain':
440 + return '' !== $expected && false !== stripos($value, $expected);
441 + case 'start_with':
442 + return '' !== $expected && 0 === stripos($value, $expected);
443 + case 'end_with':
444 + return '' !== $expected && 0 === strcasecmp($expected, (string) substr($value, -strlen($expected)));
445 + case 'greater':
446 + return self::isNumericPair($value, $expected) && (float) $value > (float) $expected;
447 + case 'less':
448 + return self::isNumericPair($value, $expected) && (float) $value < (float) $expected;
449 + case 'greater_or_equal':
450 + return self::isNumericPair($value, $expected) && (float) $value >= (float) $expected;
451 + case 'less_or_equal':
452 + return self::isNumericPair($value, $expected) && (float) $value <= (float) $expected;
453 + default:
454 + return false;
455 + }
456 + }
457 +
458 + /**
459 + * @param string $value
460 + * @param string $expected
461 + *
462 + * @return bool both sides compare as numbers
463 + */
464 + private static function isNumericPair($value, $expected)
465 + {
466 + return is_numeric(trim($value)) && is_numeric($expected);
467 + }
468 +
469 + /**
470 + * Drop leftover block tags so an unbalanced template never leaks them into the output.
471 + *
472 + * @param string $content
473 + *
474 + * @return string
475 + */
476 + private static function stripConditionalBlockTags($content)
477 + {
478 + return preg_replace('/\$\{bf_(?:if|if_any|if_all|unless):[^{}$]*\}|\$\{bf_(?:else|endif)\}/', '', $content);
479 + }
480 +
481 + /**
482 + * Values a field kept on an entry edit, posted as `<fieldKey>_old` instead of resubmitted.
483 + *
484 + * @param mixed $postData submitted data, keyed by field key
485 + * @param string $fieldKey
486 + *
487 + * @return array retained values, empty when the field kept nothing
488 + */
489 + public static function retainedOldValues($postData, $fieldKey)
490 + {
491 + if (!is_array($postData) || !isset($postData[$fieldKey . '_old'])) {
492 + return [];
493 + }
494 + return self::flattenOldValues($postData[$fieldKey . '_old']);
495 + }
496 +
497 + private static function flattenOldValues($value)
498 + {
499 + if (is_object($value)) {
500 + $value = (array) $value;
501 + }
502 + if (!is_array($value)) {
503 + if (!is_string($value) && !is_numeric($value)) {
504 + return [];
505 + }
506 + $value = trim((string) $value);
507 + if ('' === $value) {
508 + return [];
509 + }
510 + // A repeater posts one JSON list per row, so a list can arrive nested.
511 + $decoded = json_decode($value, true);
512 + if (!is_array($decoded)) {
513 + $retained = [];
514 + foreach (explode(',', $value) as $item) {
515 + $item = trim($item);
516 + if ('' !== $item) {
517 + $retained[] = $item;
518 + }
519 + }
520 + return $retained;
521 + }
522 + $value = $decoded;
523 + }
524 +
525 + $retained = [];
526 + foreach ($value as $item) {
527 + $retained = array_merge($retained, self::flattenOldValues($item));
528 + }
529 + return $retained;
530 + }
531 +
202 532 public static function formatFieldValueForMail($fields, $fieldValues = [])
203 533 {
204 534 $formattedFldValues = $fieldValues;
205 535 $file_upload_types = Helpers::$file_upload_types;
@@ -207,9 +537,9 @@
207 537 foreach ($fields as $fldKey => $fldData) {
208 538 if (in_array($fldData->typ, $file_upload_types)) {
209 539 continue;
210 540 }
211 - if (array_key_exists($fldKey, $fieldValues)) {
541 + if (is_array($fieldValues) && array_key_exists($fldKey, $fieldValues)) {
212 542 $value = $fieldValues[$fldKey];
213 543 // if (is_array($value)) {
214 544 // $formattedFldValues[$fldKey] = htmlspecialchars(implode(', ', $value));
215 545 // } else {
@@ -369,13 +699,16 @@
369 699
370 700 public static function sortValueBasedOnLayout($formId, $fieldValues)
371 701 {
372 702 $formManager = FormManager::getInstance($formId);
373 - $layout = $formManager->getFormLayout();
374 703 $formLayout = $formManager->getFlatenFormLayout(); // returns all layouts (lg, md, sm)
375 - $fieldKeyOrderbasedOnLayout = array_map(function ($fld) {
376 - return $fld->i;
377 - }, $formLayout->lg);
704 + // A form saved without a layout (or a minimal/legacy form_content) has no ->lg
705 + $lgLayout = isset($formLayout->lg) ? (array) $formLayout->lg : [];
706 + $fieldKeyOrderbasedOnLayout = array_filter(array_map(function ($fld) {
707 + return isset($fld->i) ? $fld->i : null;
708 + }, $lgLayout), function ($key) {
709 + return !is_null($key);
710 + });
378 711 $ordered = [];
379 712
380 713 foreach ($fieldKeyOrderbasedOnLayout as $key) {
381 714 if (array_key_exists($key, $fieldValues)) {
@@ -402,18 +735,20 @@
402 735 $formManager = FormManager::getInstance($formId);
403 736 $formFields = $formManager->getFields();
404 737 $orderedFormFields = $formManager->getFieldsBasedOnLayout(); // ordered form fields based on layout(lg) order
405 738 foreach ($matchesArray as $match) {
739 + // Each tag binds from the untouched submitted values: reusing a filtered result would
740 + // let the first tag in a template starve the second.
406 741 switch ($match) {
407 742 case '${bf_all_data}':
408 - $fieldValues = self::bindFormData($orderedFormFields, $fieldValues, $formId);
409 - $table = self::generateTable($fieldValues, $orderedFormFields);
743 + $boundValues = self::bindFormData($orderedFormFields, $fieldValues, $formId);
744 + $table = self::generateTable($boundValues, $orderedFormFields, $formId);
410 745 $stringToReplaceField = str_replace('${bf_all_data}', $table, $stringToReplaceField);
411 746 break;
412 747
413 748 case '${bf_all_data.onlyValues}':
414 - $fieldValues = self::bindFormData($orderedFormFields, $fieldValues, $formId, true);
415 - $table = self::generateTable($fieldValues, $orderedFormFields);
749 + $boundValues = self::bindFormData($orderedFormFields, $fieldValues, $formId, true);
750 + $table = self::generateTable($boundValues, $orderedFormFields, $formId);
416 751 $stringToReplaceField = str_replace('${bf_all_data.onlyValues}', $table, $stringToReplaceField);
417 752 break;
418 753 default:
419 754 Log::debug_log([
@@ -516,10 +851,11 @@
516 851 }
517 852
518 853 // Skip processing for hidden or empty fields only when $isOnlyValues is true
519 854 if ($isOnlyValues) {
520 - // Check if the value is strictly an empty string or null, but allow 0
521 - if (!isset($formData[$key]) || '' === $formData[$key] || null === $formData[$key]) {
855 + // Blank means empty string, null, or an array with nothing in it (unchecked
856 + // checkbox group, file field with no upload). 0 is a real value.
857 + if (!isset($formData[$key]) || self::isBlank($formData[$key])) {
522 858 return $fieldNewData;
523 859 }
524 860
525 861 if (isset($field['valid']['hide']) && $field['valid']['hide']) {
@@ -555,9 +891,9 @@
555 891 return $fieldNewData;
556 892 }, []);
557 893 }
558 894
559 - private static function generateTable($fieldValues, $formFields)
895 + private static function generateTable($fieldValues, $formFields, $formId = null)
560 896 {
561 897 if (empty($fieldValues)) {
562 898 Log::debug_log([
563 899 'status' => 'error',
@@ -583,10 +919,12 @@
583 919 if (is_array($value)) {
584 920 if ('repeater' === $fieldType) {
585 921 $table .= "<table style='width: 100%; border-collapse: collapse;'>";
586 922
923 + $subKeys = self::repeaterColumnKeys($value, $fk, $formId);
924 +
587 925 $table .= '<tr>';
588 - foreach (array_keys($value[0]) as $subKey) {
926 + foreach ($subKeys as $subKey) {
589 927 $subLabel = self::getLabel($formFields, $subKey) ?? $subKey;
590 928 $table .= "<th style='border: 1px solid #dddddd; padding: 8px; background-color: #f2f2f2;'>" . $subLabel . '</th>';
591 929 }
592 930 $table .= '</tr>';
@@ -591,10 +929,16 @@
591 929 }
592 930 $table .= '</tr>';
593 931
594 932 foreach ($value as $row) {
933 + if (!is_array($row)) {
934 + continue;
935 + }
595 936 $table .= '<tr>';
596 - foreach ($row as $subKey => $subValue) {
937 + // Walk the shared column list so a row missing a conditionally hidden
938 + // sub-field still lines up with the header.
939 + foreach ($subKeys as $subKey) {
940 + $subValue = array_key_exists($subKey, $row) ? $row[$subKey] : '';
597 941 $subFieldType = self::getFldType($subKey, $formFields);
598 942 if (is_array($subValue)) {
599 943 if (self::isCompositeFieldType($subFieldType)) {
600 944 $subValue = self::joinCompositeFieldValue($subValue, $subFieldType);
@@ -639,12 +983,13 @@
639 983 }
640 984 } elseif (self::isCompositeFieldType($fieldType)) {
641 985 $table .= self::joinCompositeFieldValue($value, $fieldType);
642 986 } elseif ('signature' === $fieldType) {
643 - if ('signature-failed.png' === $subValue) {
644 - $table .= '';
645 - } else {
646 - $table .= self::imgMarkup($value);
987 + // A signature arrives here wrapped in a one-item list; the failed-capture
988 + // placeholder renders nothing.
989 + $signature = reset($value);
990 + if (false !== $signature && 'signature-failed.png' !== $signature) {
991 + $table .= self::imgMarkup($signature);
647 992 }
648 993 }
649 994 } else {
650 995 $table .= $value;
@@ -669,16 +1014,32 @@
669 1014 }
670 1015
671 1016 private static function imgMarkup($filename)
672 1017 {
673 - return "<img src='{$filename}' alt='{$filename}' width='250'/>";
1018 + if (!is_scalar($filename)) {
1019 + return '';
1020 + }
1021 + $filename = (string) $filename;
1022 +
1023 + return "<img src='" . self::escFileHref($filename) . "' alt='" . esc_attr($filename) . "' width='250'/>";
674 1024 }
675 1025
676 1026 private static function anchorMarkup($filename)
677 1027 {
678 - return "<a href='{$filename}' rel='noopener noreferrer' target='_blank' style='color:blue'>{$filename}</a>";
1028 + if (!is_scalar($filename)) {
1029 + return '';
1030 + }
1031 + $filename = (string) $filename;
1032 +
1033 + return "<a href='" . self::escFileHref($filename) . "' rel='noopener noreferrer' target='_blank' style='color:blue'>" . esc_html($filename) . '</a>';
679 1034 }
680 1035
1036 + /** Escape a file reference for an href/src. Not esc_url(): it rewrites a bare file name to `http://<name>`. */
1037 + private static function escFileHref($value)
1038 + {
1039 + return esc_attr(wp_kses_bad_protocol($value, wp_allowed_protocols()));
1040 + }
1041 +
681 1042 public static function replaceRepeaterFieldValue($stringToReplaceField, $fieldValues, $formID)
682 1043 {
683 1044 if (!is_string($stringToReplaceField) || empty($stringToReplaceField)) {
684 1045 return $stringToReplaceField; // Return as-is if nothing to replace
@@ -700,9 +1061,9 @@
700 1061 foreach ($matches[1] as $fk) {
701 1062 $repeaterFieldKey = $fk;
702 1063 $fieldType = isset($formFields[$repeaterFieldKey]['type']) && !empty($formFields[$repeaterFieldKey]['type']) ? $formFields[$repeaterFieldKey]['type'] : null;
703 1064 if ('repeater' === $fieldType) {
704 - $repeaterMarkup = self::repeaterFieldTable($fieldValues[$repeaterFieldKey] ?? [], $formFields, $repeaterFieldKey);
1065 + $repeaterMarkup = self::repeaterFieldTable($fieldValues[$repeaterFieldKey] ?? [], $formFields, $repeaterFieldKey, $formID);
705 1066 $stringToReplaceField = str_replace('${' . $fk . '}', $repeaterMarkup, $stringToReplaceField);
706 1067 } else {
707 1068 if ('signature' === $fieldType) {
708 1069 $stringToReplaceField = self::replaceImgTagForRepeatedSignature($stringToReplaceField, $flatFieldData[$repeaterFieldKey], $repeaterFieldKey);
@@ -917,8 +1278,29 @@
917 1278 return $formFields[$fldKey]['type'];
918 1279 }
919 1280 }
920 1281
1282 + /**
1283 + * The type of the field a Smart Tag points at: from the value itself when it carries
1284 + * one, otherwise from the form.
1285 + *
1286 + * @param mixed $fieldValue Entry of $fieldValues for this field
1287 + * @param string $fieldKey
1288 + * @param int|null $formID
1289 + * @return string|null
1290 + */
1291 + private static function fieldTypeForSmartTag($fieldValue, $fieldKey, $formID)
1292 + {
1293 + if (is_array($fieldValue) && isset($fieldValue['type']) && is_string($fieldValue['type'])) {
1294 + return $fieldValue['type'];
1295 + }
1296 + if (!$formID) {
1297 + return null;
1298 + }
1299 + $formFields = FormManager::getInstance($formID)->getFields();
1300 + return is_array($formFields) && isset($formFields[$fieldKey]['type']) ? $formFields[$fieldKey]['type'] : null;
1301 + }
1302 +
921 1303 private static function isCompositeFieldType($fieldType)
922 1304 {
923 1305 return in_array($fieldType, ['name', 'address'], true);
924 1306 }
@@ -1004,10 +1386,54 @@
1004 1386 return true;
1005 1387 }
1006 1388 }
1007 1389
1008 - private static function repeaterFieldTable($repeaterFieldData, $formFields, $repeaterFieldKey)
1390 + /**
1391 + * Collect the column keys of a repeater table as the union of every row's keys,
1392 + * not just the first row's. Conditional logic can hide a sub-field in one row and
1393 + * show it in the next; keying off row 0 alone drops that column's header and
1394 + * shifts every later row's cells. Ordering follows the repeater's own nested
1395 + * layout when the form id is known, with any leftover keys appended.
1396 + *
1397 + * @param array $rows
1398 + * @param string $repeaterFieldKey
1399 + * @param int|string|null $formId
1400 + * @return array
1401 + */
1402 + private static function repeaterColumnKeys($rows, $repeaterFieldKey, $formId = null)
1009 1403 {
1404 + $present = [];
1405 + foreach ($rows as $row) {
1406 + if (!is_array($row)) {
1407 + continue;
1408 + }
1409 + foreach (array_keys($row) as $subKey) {
1410 + $present[$subKey] = true;
1411 + }
1412 + }
1413 +
1414 + if (empty($present)) {
1415 + return [];
1416 + }
1417 +
1418 + $ordered = [];
1419 + if ($formId) {
1420 + $nestedLayout = FormManager::getInstance($formId)->getFormNestedLayout();
1421 + $repeaterLayout = isset($nestedLayout->{$repeaterFieldKey}->lg) ? $nestedLayout->{$repeaterFieldKey}->lg : [];
1422 + foreach ((array) $repeaterLayout as $fld) {
1423 + $subKey = isset($fld->i) ? $fld->i : null;
1424 + if ($subKey && isset($present[$subKey])) {
1425 + $ordered[] = $subKey;
1426 + unset($present[$subKey]);
1427 + }
1428 + }
1429 + }
1430 +
1431 + return array_merge($ordered, array_keys($present));
1432 + }
1433 +
1434 + private static function repeaterFieldTable($repeaterFieldData, $formFields, $repeaterFieldKey, $formId = null)
1435 + {
1010 1436 $repeaterFieldData = self::decodeIfJson($repeaterFieldData);
1011 1437
1012 1438 if (!is_array($repeaterFieldData) || !isset($repeaterFieldData[0]) || !is_array($repeaterFieldData[0])) {
1013 1439 return ''; // Safely return empty if not a valid repeater structure
@@ -1018,9 +1444,9 @@
1018 1444 // $table .= '</tr>';
1019 1445 // $table .= '<td style="border: 1px solid #dddddd; text-align: left; padding: 8px;">';
1020 1446 // $table .= '<table style="width: 100%; border-collapse: collapse;">';
1021 1447
1022 - $headers = array_keys($repeaterFieldData[0]);
1448 + $headers = self::repeaterColumnKeys($repeaterFieldData, $repeaterFieldKey, $formId);
1023 1449 $table .= '<tr>'; // open tr (for column header)
1024 1450 foreach ($headers as $fk) {
1025 1451 $table .= '<th style="border: 1px solid #dddddd; padding: 8px; ">' . self::getLabel($formFields, $fk) . '</th>';
1026 1452 }
@@ -1026,10 +1452,17 @@
1026 1452 }
1027 1453 $table .= '</tr>'; // close tr (for column header)
1028 1454
1029 1455 foreach ($repeaterFieldData as $row) {
1456 + if (!is_array($row)) {
1457 + continue;
1458 + }
1030 1459 $table .= '<tr>'; // open tr (for table data row)
1031 - foreach ($row as $k=>$value) {
1460 + // Walk the column list, not the row's own keys, so a sub-field hidden by
1461 + // conditional logic in this row renders an empty cell instead of shifting
1462 + // every following cell one column to the left.
1463 + foreach ($headers as $k) {
1464 + $value = array_key_exists($k, $row) ? $row[$k] : '';
1032 1465 $fldTyp = self::getFldType($k, $formFields);
1033 1466 if (is_array($value)) {
1034 1467 if (in_array($fldTyp, ['advanced-file-up', 'file-up'])) {
1035 1468 $newValue = self::unorderedAnchorListMarkup($value);
@@ -1079,8 +1512,29 @@
1079 1512 return $matches[1];
1080 1513 }
1081 1514
1082 1515 return str_replace(['[', ']', (string) $parentFieldName], '', $childFieldName);
1516 + }
1517 +
1518 + /**
1519 + * The HTML name a composite child (Name/Address) renders with: the parent's current name plus
1520 + * the child's own suffix. The child stores a full "parent[suffix]" copy that goes stale when the
1521 + * parent is renamed, and a stale prefix posts the value away from the parent.
1522 + *
1523 + * @param string $childFieldName stored child name, e.g. "address-1[city]"
1524 + * @param string $parentFieldName parent's current name, e.g. "home_address"
1525 + *
1526 + * @return string e.g. "home_address[city]"; the stored name when it has no suffix or the parent has no name
1527 + */
1528 + public static function rebaseChildFieldName($childFieldName, $parentFieldName)
1529 + {
1530 + $childFieldName = (string) $childFieldName;
1531 + $parentFieldName = (string) $parentFieldName;
1532 + if ('' === $parentFieldName || !preg_match('/\[([^\]]+)\]$/', $childFieldName, $matches)) {
1533 + return $childFieldName;
1534 + }
1535 +
1536 + return $parentFieldName . '[' . $matches[1] . ']';
1083 1537 }
1084 1538
1085 1539 /**
1086 1540 * Pull a child value out of a parent composite field's nested submitted value.