PluginProbe
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder / 3.3.1
Bit Form – Contact Form, Payment Forms, Multi Step Forms, Calculator & Custom Form Builder v3.3.1
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 2.10.1 All 138 releases
← All changes | includes/Core/Util/FieldValueHandler.php +433 -25 3.1.23.3.1 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 }
@@ -51,8 +63,12 @@
51 63 // $fieldValue = wp_json_encode($targetFieldValue);
52 64 } else {
53 65 $fieldValue = strval($targetFieldValue);
54 66 }
67 + // Neutralize shortcodes in low-trust submitted values before they are merged into a
68 + if ($stripShortcodesFromValues && is_string($fieldValue)) {
69 + $fieldValue = strip_shortcodes($fieldValue);
70 + }
55 71 $stringToReplaceField = str_replace($value, $fieldValue, $stringToReplaceField);
56 72 } else {
57 73 $stringToReplaceField = str_replace($value, '', $stringToReplaceField);
58 74 }
@@ -198,8 +214,318 @@
198 214 }
199 215 return false;
200 216 }
201 217
218 + /**
219 + * Whether a resolved value renders as nothing.
220 + *
221 + * Stricter than isEmpty(): whitespace-only is blank (some smart-tag resolvers return a
222 + * single space), so is an all-blank array. `0` / `'0'` never are.
223 + *
224 + * @param mixed $val
225 + *
226 + * @return bool
227 + */
228 + public static function isBlank($val)
229 + {
230 + if (null === $val || false === $val) {
231 + return true;
232 + }
233 + if (is_object($val)) {
234 + $val = (array) $val;
235 + }
236 + if (is_array($val)) {
237 + foreach ($val as $key => $item) {
238 + // Composite meta sub-values (_latitude, …) never render on their own.
239 + if (is_string($key) && 0 === strpos($key, '_')) {
240 + continue;
241 + }
242 + if (!self::isBlank($item)) {
243 + return false;
244 + }
245 + }
246 + return true;
247 + }
248 + if (!is_scalar($val)) {
249 + return true;
250 + }
251 +
252 + return '' === trim(str_replace("\xc2\xa0", '', (string) $val));
253 + }
254 +
255 + /**
256 + * Resolve `${bf_if:…}` … `${bf_endif}` template blocks.
257 + *
258 + * Syntax, operators and traps: docs/template-conditional-blocks.md.
259 + *
260 + * @param string $content
261 + * @param array $fieldValues
262 + * @param mixed $formID needed to reach repeater rows; without it a child key reads blank
263 + *
264 + * @return string
265 + */
266 + private static function resolveConditionalBlocks($content, $fieldValues, $formID = null)
267 + {
268 + if (false === strpos($content, '${bf_if') && false === strpos($content, '${bf_unless')) {
269 + return self::stripConditionalBlockTags($content);
270 + }
271 +
272 + $conditionValues = $fieldValues;
273 + if ($formID) {
274 + $formManager = FormManager::getInstance($formID);
275 + // Repeater children have no top-level key; flatten the rows in, real values still win.
276 + $conditionValues = array_merge(self::restructureRepeaterData($fieldValues, $formManager), $fieldValues);
277 + }
278 +
279 + // Matches a block whose body holds no further opener, i.e. the innermost one.
280 + $innerMost = '/\$\{bf_(if|if_any|if_all|unless):([^{}$]*)\}((?:(?!\$\{bf_(?:if|if_any|if_all|unless):)[\s\S])*?)\$\{bf_endif\}/';
281 +
282 + // Bounded so a malformed template can never spin here.
283 + for ($pass = 0; $pass < 200; $pass++) {
284 + $resolved = preg_replace_callback($innerMost, function ($matches) use ($conditionValues) {
285 + $branches = preg_split('/\$\{bf_else\}/', $matches[3], 2);
286 + $truthy = isset($branches[0]) ? $branches[0] : '';
287 + $falsy = isset($branches[1]) ? $branches[1] : '';
288 +
289 + return self::evaluateBlockCondition($matches[1], $matches[2], $conditionValues) ? $truthy : $falsy;
290 + }, $content, -1, $replacedCount);
291 +
292 + if (null === $resolved) {
293 + break; // preg failure (e.g. backtrack limit): leave the content untouched
294 + }
295 + $content = $resolved;
296 + if (!$replacedCount) {
297 + break;
298 + }
299 + }
300 +
301 + return self::stripConditionalBlockTags($content);
302 + }
303 +
304 + /**
305 + * @param string $type if|if_any|if_all|unless
306 + * @param string $rawKeys comma separated conditions
307 + * @param array $fieldValues
308 + *
309 + * @return bool
310 + */
311 + private static function evaluateBlockCondition($type, $rawKeys, $fieldValues)
312 + {
313 + $conditions = array_filter(array_map('trim', explode(',', (string) $rawKeys)), function ($condition) {
314 + return '' !== $condition;
315 + });
316 + if (empty($conditions)) {
317 + return false;
318 + }
319 +
320 + $results = [];
321 + foreach ($conditions as $condition) {
322 + $results[] = self::conditionHolds($condition, $fieldValues);
323 + }
324 +
325 + if ('if_all' === $type) {
326 + return !in_array(false, $results, true);
327 + }
328 + if ('unless' === $type) {
329 + return !in_array(true, $results, true);
330 + }
331 +
332 + return in_array(true, $results, true);
333 + }
334 +
335 + /**
336 + * `key`, or `key operator value`.
337 + *
338 + * @param string $condition
339 + * @param array $fieldValues
340 + *
341 + * @return bool
342 + */
343 + private static function conditionHolds($condition, $fieldValues)
344 + {
345 + $operators = self::blockOperators();
346 + // Longest name first, or `not_equal` reads as `equal`. Field keys never contain a space.
347 + $pattern = '/^(\S+)\s+(' . implode('|', $operators) . ')(?:\s+([\s\S]*))?$/';
348 +
349 + if (!preg_match($pattern, trim($condition), $parts)) {
350 + return !self::isBlank(self::conditionValue(trim($condition), $fieldValues));
351 + }
352 +
353 + $value = self::conditionValue($parts[1], $fieldValues);
354 + $operator = $parts[2];
355 + $expected = isset($parts[3]) ? trim($parts[3]) : '';
356 +
357 + if ('null' === $operator) {
358 + return self::isBlank($value);
359 + }
360 + if ('not_null' === $operator) {
361 + return !self::isBlank($value);
362 + }
363 +
364 + // Multi-value fields and repeater children arrive as a list.
365 + $candidates = is_array($value) || is_object($value) ? self::stripMetaSubfields((array) $value) : [$value];
366 + $negated = in_array($operator, ['not_equal', 'not_contain'], true);
367 + foreach ($candidates as $candidate) {
368 + if (is_array($candidate) || is_object($candidate)) {
369 + continue;
370 + }
371 + // compareValue answers the positive form, so one match settles either case: it satisfies
372 + // `contain` and rules out `not_contain`.
373 + if (self::compareValue($operator, (string) $candidate, $expected)) {
374 + return !$negated;
375 + }
376 + }
377 +
378 + return $negated;
379 + }
380 +
381 + /**
382 + * @return string[] operator names, longest first
383 + */
384 + private static function blockOperators()
385 + {
386 + return [
387 + 'greater_or_equal',
388 + 'less_or_equal',
389 + 'not_contain',
390 + 'start_with',
391 + 'not_equal',
392 + 'not_null',
393 + 'end_with',
394 + 'contain',
395 + 'greater',
396 + 'equal',
397 + 'less',
398 + 'null',
399 + ];
400 + }
401 +
402 + /**
403 + * @param string $key field key, or a `_bf_*` smart tag
404 + * @param array $fieldValues
405 + *
406 + * @return mixed
407 + */
408 + private static function conditionValue($key, $fieldValues)
409 + {
410 + if (0 === strpos($key, '_')) {
411 + return SmartTags::getSmartTagValue($key, false, '');
412 + }
413 + $value = isset($fieldValues[$key]) ? $fieldValues[$key] : null;
414 + if (is_array($value) && isset($value['value'])) {
415 + $value = $value['value'];
416 + }
417 +
418 + return $value;
419 + }
420 +
421 + /**
422 + * @param string $operator
423 + * @param string $value the submitted value
424 + * @param string $expected the value written in the template
425 + *
426 + * @return bool
427 + */
428 + private static function compareValue($operator, $value, $expected)
429 + {
430 + switch ($operator) {
431 + case 'equal':
432 + case 'not_equal':
433 + return 0 === strcasecmp(trim($value), $expected);
434 + case 'contain':
435 + case 'not_contain':
436 + return '' !== $expected && false !== stripos($value, $expected);
437 + case 'start_with':
438 + return '' !== $expected && 0 === stripos($value, $expected);
439 + case 'end_with':
440 + return '' !== $expected && 0 === strcasecmp($expected, (string) substr($value, -strlen($expected)));
441 + case 'greater':
442 + return self::isNumericPair($value, $expected) && (float) $value > (float) $expected;
443 + case 'less':
444 + return self::isNumericPair($value, $expected) && (float) $value < (float) $expected;
445 + case 'greater_or_equal':
446 + return self::isNumericPair($value, $expected) && (float) $value >= (float) $expected;
447 + case 'less_or_equal':
448 + return self::isNumericPair($value, $expected) && (float) $value <= (float) $expected;
449 + default:
450 + return false;
451 + }
452 + }
453 +
454 + /**
455 + * @param string $value
456 + * @param string $expected
457 + *
458 + * @return bool both sides compare as numbers
459 + */
460 + private static function isNumericPair($value, $expected)
461 + {
462 + return is_numeric(trim($value)) && is_numeric($expected);
463 + }
464 +
465 + /**
466 + * Drop leftover block tags so an unbalanced template never leaks them into the output.
467 + *
468 + * @param string $content
469 + *
470 + * @return string
471 + */
472 + private static function stripConditionalBlockTags($content)
473 + {
474 + return preg_replace('/\$\{bf_(?:if|if_any|if_all|unless):[^{}$]*\}|\$\{bf_(?:else|endif)\}/', '', $content);
475 + }
476 +
477 + /**
478 + * Values a field kept on an entry edit, posted as `<fieldKey>_old` instead of resubmitted.
479 + *
480 + * @param mixed $postData submitted data, keyed by field key
481 + * @param string $fieldKey
482 + *
483 + * @return array retained values, empty when the field kept nothing
484 + */
485 + public static function retainedOldValues($postData, $fieldKey)
486 + {
487 + if (!is_array($postData) || !isset($postData[$fieldKey . '_old'])) {
488 + return [];
489 + }
490 + return self::flattenOldValues($postData[$fieldKey . '_old']);
491 + }
492 +
493 + private static function flattenOldValues($value)
494 + {
495 + if (is_object($value)) {
496 + $value = (array) $value;
497 + }
498 + if (!is_array($value)) {
499 + if (!is_string($value) && !is_numeric($value)) {
500 + return [];
501 + }
502 + $value = trim((string) $value);
503 + if ('' === $value) {
504 + return [];
505 + }
506 + // A repeater posts one JSON list per row, so a list can arrive nested.
507 + $decoded = json_decode($value, true);
508 + if (!is_array($decoded)) {
509 + $retained = [];
510 + foreach (explode(',', $value) as $item) {
511 + $item = trim($item);
512 + if ('' !== $item) {
513 + $retained[] = $item;
514 + }
515 + }
516 + return $retained;
517 + }
518 + $value = $decoded;
519 + }
520 +
521 + $retained = [];
522 + foreach ($value as $item) {
523 + $retained = array_merge($retained, self::flattenOldValues($item));
524 + }
525 + return $retained;
526 + }
527 +
202 528 public static function formatFieldValueForMail($fields, $fieldValues = [])
203 529 {
204 530 $formattedFldValues = $fieldValues;
205 531 $file_upload_types = Helpers::$file_upload_types;
@@ -207,9 +533,9 @@
207 533 foreach ($fields as $fldKey => $fldData) {
208 534 if (in_array($fldData->typ, $file_upload_types)) {
209 535 continue;
210 536 }
211 - if (array_key_exists($fldKey, $fieldValues)) {
537 + if (is_array($fieldValues) && array_key_exists($fldKey, $fieldValues)) {
212 538 $value = $fieldValues[$fldKey];
213 539 // if (is_array($value)) {
214 540 // $formattedFldValues[$fldKey] = htmlspecialchars(implode(', ', $value));
215 541 // } else {
@@ -369,13 +695,16 @@
369 695
370 696 public static function sortValueBasedOnLayout($formId, $fieldValues)
371 697 {
372 698 $formManager = FormManager::getInstance($formId);
373 - $layout = $formManager->getFormLayout();
374 699 $formLayout = $formManager->getFlatenFormLayout(); // returns all layouts (lg, md, sm)
375 - $fieldKeyOrderbasedOnLayout = array_map(function ($fld) {
376 - return $fld->i;
377 - }, $formLayout->lg);
700 + // A form saved without a layout (or a minimal/legacy form_content) has no ->lg
701 + $lgLayout = isset($formLayout->lg) ? (array) $formLayout->lg : [];
702 + $fieldKeyOrderbasedOnLayout = array_filter(array_map(function ($fld) {
703 + return isset($fld->i) ? $fld->i : null;
704 + }, $lgLayout), function ($key) {
705 + return !is_null($key);
706 + });
378 707 $ordered = [];
379 708
380 709 foreach ($fieldKeyOrderbasedOnLayout as $key) {
381 710 if (array_key_exists($key, $fieldValues)) {
@@ -402,18 +731,20 @@
402 731 $formManager = FormManager::getInstance($formId);
403 732 $formFields = $formManager->getFields();
404 733 $orderedFormFields = $formManager->getFieldsBasedOnLayout(); // ordered form fields based on layout(lg) order
405 734 foreach ($matchesArray as $match) {
735 + // Each tag binds from the untouched submitted values: reusing a filtered result would
736 + // let the first tag in a template starve the second.
406 737 switch ($match) {
407 738 case '${bf_all_data}':
408 - $fieldValues = self::bindFormData($orderedFormFields, $fieldValues, $formId);
409 - $table = self::generateTable($fieldValues, $orderedFormFields);
739 + $boundValues = self::bindFormData($orderedFormFields, $fieldValues, $formId);
740 + $table = self::generateTable($boundValues, $orderedFormFields, $formId);
410 741 $stringToReplaceField = str_replace('${bf_all_data}', $table, $stringToReplaceField);
411 742 break;
412 743
413 744 case '${bf_all_data.onlyValues}':
414 - $fieldValues = self::bindFormData($orderedFormFields, $fieldValues, $formId, true);
415 - $table = self::generateTable($fieldValues, $orderedFormFields);
745 + $boundValues = self::bindFormData($orderedFormFields, $fieldValues, $formId, true);
746 + $table = self::generateTable($boundValues, $orderedFormFields, $formId);
416 747 $stringToReplaceField = str_replace('${bf_all_data.onlyValues}', $table, $stringToReplaceField);
417 748 break;
418 749 default:
419 750 Log::debug_log([
@@ -516,10 +847,11 @@
516 847 }
517 848
518 849 // Skip processing for hidden or empty fields only when $isOnlyValues is true
519 850 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]) {
851 + // Blank means empty string, null, or an array with nothing in it (unchecked
852 + // checkbox group, file field with no upload). 0 is a real value.
853 + if (!isset($formData[$key]) || self::isBlank($formData[$key])) {
522 854 return $fieldNewData;
523 855 }
524 856
525 857 if (isset($field['valid']['hide']) && $field['valid']['hide']) {
@@ -555,9 +887,9 @@
555 887 return $fieldNewData;
556 888 }, []);
557 889 }
558 890
559 - private static function generateTable($fieldValues, $formFields)
891 + private static function generateTable($fieldValues, $formFields, $formId = null)
560 892 {
561 893 if (empty($fieldValues)) {
562 894 Log::debug_log([
563 895 'status' => 'error',
@@ -583,10 +915,12 @@
583 915 if (is_array($value)) {
584 916 if ('repeater' === $fieldType) {
585 917 $table .= "<table style='width: 100%; border-collapse: collapse;'>";
586 918
919 + $subKeys = self::repeaterColumnKeys($value, $fk, $formId);
920 +
587 921 $table .= '<tr>';
588 - foreach (array_keys($value[0]) as $subKey) {
922 + foreach ($subKeys as $subKey) {
589 923 $subLabel = self::getLabel($formFields, $subKey) ?? $subKey;
590 924 $table .= "<th style='border: 1px solid #dddddd; padding: 8px; background-color: #f2f2f2;'>" . $subLabel . '</th>';
591 925 }
592 926 $table .= '</tr>';
@@ -591,10 +925,16 @@
591 925 }
592 926 $table .= '</tr>';
593 927
594 928 foreach ($value as $row) {
929 + if (!is_array($row)) {
930 + continue;
931 + }
595 932 $table .= '<tr>';
596 - foreach ($row as $subKey => $subValue) {
933 + // Walk the shared column list so a row missing a conditionally hidden
934 + // sub-field still lines up with the header.
935 + foreach ($subKeys as $subKey) {
936 + $subValue = array_key_exists($subKey, $row) ? $row[$subKey] : '';
597 937 $subFieldType = self::getFldType($subKey, $formFields);
598 938 if (is_array($subValue)) {
599 939 if (self::isCompositeFieldType($subFieldType)) {
600 940 $subValue = self::joinCompositeFieldValue($subValue, $subFieldType);
@@ -639,12 +979,13 @@
639 979 }
640 980 } elseif (self::isCompositeFieldType($fieldType)) {
641 981 $table .= self::joinCompositeFieldValue($value, $fieldType);
642 982 } elseif ('signature' === $fieldType) {
643 - if ('signature-failed.png' === $subValue) {
644 - $table .= '';
645 - } else {
646 - $table .= self::imgMarkup($value);
983 + // A signature arrives here wrapped in a one-item list; the failed-capture
984 + // placeholder renders nothing.
985 + $signature = reset($value);
986 + if (false !== $signature && 'signature-failed.png' !== $signature) {
987 + $table .= self::imgMarkup($signature);
647 988 }
648 989 }
649 990 } else {
650 991 $table .= $value;
@@ -669,16 +1010,32 @@
669 1010 }
670 1011
671 1012 private static function imgMarkup($filename)
672 1013 {
673 - return "<img src='{$filename}' alt='{$filename}' width='250'/>";
1014 + if (!is_scalar($filename)) {
1015 + return '';
1016 + }
1017 + $filename = (string) $filename;
1018 +
1019 + return "<img src='" . self::escFileHref($filename) . "' alt='" . esc_attr($filename) . "' width='250'/>";
674 1020 }
675 1021
676 1022 private static function anchorMarkup($filename)
677 1023 {
678 - return "<a href='{$filename}' rel='noopener noreferrer' target='_blank' style='color:blue'>{$filename}</a>";
1024 + if (!is_scalar($filename)) {
1025 + return '';
1026 + }
1027 + $filename = (string) $filename;
1028 +
1029 + return "<a href='" . self::escFileHref($filename) . "' rel='noopener noreferrer' target='_blank' style='color:blue'>" . esc_html($filename) . '</a>';
679 1030 }
680 1031
1032 + /** Escape a file reference for an href/src. Not esc_url(): it rewrites a bare file name to `http://<name>`. */
1033 + private static function escFileHref($value)
1034 + {
1035 + return esc_attr(wp_kses_bad_protocol($value, wp_allowed_protocols()));
1036 + }
1037 +
681 1038 public static function replaceRepeaterFieldValue($stringToReplaceField, $fieldValues, $formID)
682 1039 {
683 1040 if (!is_string($stringToReplaceField) || empty($stringToReplaceField)) {
684 1041 return $stringToReplaceField; // Return as-is if nothing to replace
@@ -700,9 +1057,9 @@
700 1057 foreach ($matches[1] as $fk) {
701 1058 $repeaterFieldKey = $fk;
702 1059 $fieldType = isset($formFields[$repeaterFieldKey]['type']) && !empty($formFields[$repeaterFieldKey]['type']) ? $formFields[$repeaterFieldKey]['type'] : null;
703 1060 if ('repeater' === $fieldType) {
704 - $repeaterMarkup = self::repeaterFieldTable($fieldValues[$repeaterFieldKey] ?? [], $formFields, $repeaterFieldKey);
1061 + $repeaterMarkup = self::repeaterFieldTable($fieldValues[$repeaterFieldKey] ?? [], $formFields, $repeaterFieldKey, $formID);
705 1062 $stringToReplaceField = str_replace('${' . $fk . '}', $repeaterMarkup, $stringToReplaceField);
706 1063 } else {
707 1064 if ('signature' === $fieldType) {
708 1065 $stringToReplaceField = self::replaceImgTagForRepeatedSignature($stringToReplaceField, $flatFieldData[$repeaterFieldKey], $repeaterFieldKey);
@@ -1004,10 +1361,54 @@
1004 1361 return true;
1005 1362 }
1006 1363 }
1007 1364
1008 - private static function repeaterFieldTable($repeaterFieldData, $formFields, $repeaterFieldKey)
1365 + /**
1366 + * Collect the column keys of a repeater table as the union of every row's keys,
1367 + * not just the first row's. Conditional logic can hide a sub-field in one row and
1368 + * show it in the next; keying off row 0 alone drops that column's header and
1369 + * shifts every later row's cells. Ordering follows the repeater's own nested
1370 + * layout when the form id is known, with any leftover keys appended.
1371 + *
1372 + * @param array $rows
1373 + * @param string $repeaterFieldKey
1374 + * @param int|string|null $formId
1375 + * @return array
1376 + */
1377 + private static function repeaterColumnKeys($rows, $repeaterFieldKey, $formId = null)
1009 1378 {
1379 + $present = [];
1380 + foreach ($rows as $row) {
1381 + if (!is_array($row)) {
1382 + continue;
1383 + }
1384 + foreach (array_keys($row) as $subKey) {
1385 + $present[$subKey] = true;
1386 + }
1387 + }
1388 +
1389 + if (empty($present)) {
1390 + return [];
1391 + }
1392 +
1393 + $ordered = [];
1394 + if ($formId) {
1395 + $nestedLayout = FormManager::getInstance($formId)->getFormNestedLayout();
1396 + $repeaterLayout = isset($nestedLayout->{$repeaterFieldKey}->lg) ? $nestedLayout->{$repeaterFieldKey}->lg : [];
1397 + foreach ((array) $repeaterLayout as $fld) {
1398 + $subKey = isset($fld->i) ? $fld->i : null;
1399 + if ($subKey && isset($present[$subKey])) {
1400 + $ordered[] = $subKey;
1401 + unset($present[$subKey]);
1402 + }
1403 + }
1404 + }
1405 +
1406 + return array_merge($ordered, array_keys($present));
1407 + }
1408 +
1409 + private static function repeaterFieldTable($repeaterFieldData, $formFields, $repeaterFieldKey, $formId = null)
1410 + {
1010 1411 $repeaterFieldData = self::decodeIfJson($repeaterFieldData);
1011 1412
1012 1413 if (!is_array($repeaterFieldData) || !isset($repeaterFieldData[0]) || !is_array($repeaterFieldData[0])) {
1013 1414 return ''; // Safely return empty if not a valid repeater structure
@@ -1018,9 +1419,9 @@
1018 1419 // $table .= '</tr>';
1019 1420 // $table .= '<td style="border: 1px solid #dddddd; text-align: left; padding: 8px;">';
1020 1421 // $table .= '<table style="width: 100%; border-collapse: collapse;">';
1021 1422
1022 - $headers = array_keys($repeaterFieldData[0]);
1423 + $headers = self::repeaterColumnKeys($repeaterFieldData, $repeaterFieldKey, $formId);
1023 1424 $table .= '<tr>'; // open tr (for column header)
1024 1425 foreach ($headers as $fk) {
1025 1426 $table .= '<th style="border: 1px solid #dddddd; padding: 8px; ">' . self::getLabel($formFields, $fk) . '</th>';
1026 1427 }
@@ -1026,10 +1427,17 @@
1026 1427 }
1027 1428 $table .= '</tr>'; // close tr (for column header)
1028 1429
1029 1430 foreach ($repeaterFieldData as $row) {
1431 + if (!is_array($row)) {
1432 + continue;
1433 + }
1030 1434 $table .= '<tr>'; // open tr (for table data row)
1031 - foreach ($row as $k=>$value) {
1435 + // Walk the column list, not the row's own keys, so a sub-field hidden by
1436 + // conditional logic in this row renders an empty cell instead of shifting
1437 + // every following cell one column to the left.
1438 + foreach ($headers as $k) {
1439 + $value = array_key_exists($k, $row) ? $row[$k] : '';
1032 1440 $fldTyp = self::getFldType($k, $formFields);
1033 1441 if (is_array($value)) {
1034 1442 if (in_array($fldTyp, ['advanced-file-up', 'file-up'])) {
1035 1443 $newValue = self::unorderedAnchorListMarkup($value);