Repository navigation
Fix repeatable fields dropping 0 values on save - #1574
Conversation
jtsternberg
left a comment
There was a problem hiding this comment.
Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.
PR #1574: Fix repeatable fields dropping 0 values on save
Head 65d78ab, base develop. Reviewed in a worktree (cmb2-pr-1574) with its own wp-env (port 2634, PHP 8.3, WP latest).
Briefing
- Where it comes from: the same bug class as #1535 (fixed by #1572), but in the repeatable save path. No issue was filed.
- Root cause:
CMB2_Field::sanitization_cb()strips empty repeatable entries with a callback-lessarray_filter(), which also drops0/'0'.CMB2_Sanitize::_check_repeat()drops them a second time with a truthiness check. The diagnosis is correct for the save path. - The fix: use
CMB2_Utils::filter_empty()/notempty()in those two places and in thetext_moneyrepeatable loop, and add convention entry C14. - Trade-offs: a
'0'entry now reaches each type's sanitizer, the customsanitization_cb, andcmb2_sanitize_{$type}. Some types turn it into a junk entry (false,'',0). The render path still drops0, so in the admin UI the saved value disappears again on the next save.
Verdict: REQUEST CHANGES
One blocker (F1). The PR's own repro ("add rows a, 0, b, save, reload") still shows only a and b after the fix. The 0 is in the database, but the next save of the post deletes it. The other findings are should-fix and nit items that can land in the same pass.
Findings
F1 — Scope/correctness · High · blocking — includes/CMB2_Types.php:358, :364
CMB2_Types::repeatable_rows() builds the edit-screen rows with two callback-less array_filter() calls, so a stored '0' row is never rendered. The fix therefore stores the 0 only until someone next saves the post through the admin UI, and that save removes it. Programmatic and REST writes keep the 0, but the admin round trip still loses it. The description's repro is not fixed.
Evidence: probe Test_Probe_1574::test_probe_render_round_trip (save, render, re-submit the rendered rows):
[PROBE] stored after save 1: array ( 0 => 'a', 1 => '0', 2 => 'b', )
[PROBE] rows rendered on edit screen: array ( 0 => 'a', 1 => 'b', )
[PROBE] stored after re-saving rendered form: array ( 0 => 'a', 1 => 'b', )
1) Test_Probe_1574::test_probe_render_round_trip — Failed asserting that an array contains '0'.
This affects repeatable sub-fields in groups too, because they render through the same method. The repeatable text_date_timestamp epoch-0 row (F4) is also hidden by it.
Recommendation: switch both filters in repeatable_rows() to CMB2_Utils::filter_empty(). Add a regression test that saves array( 'a', '0', 'b' ), renders the field, and asserts that a value="0" row is present outside the hidden empty-row. Note: repeatable_rows() probably needs the same "only entry is '0'" decision as F2 (today array( '0' ) renders as one default row).
F2 — Design · Medium · should-fix — CONVENTIONS.md (C14 canonical examples), includes/CMB2_Sanitize.php:125, :616
C14 and the PR description say "a repeatable field whose only entry is '0' is still removed by _is_empty_array()". That is true only for types that go through default_sanitization() (text, select, hidden, …). Types with their own sanitizer never reach that check, so with this PR alone a '0'-only field behaves differently by type:
[PROBE] ['0'] only, text: exists=false: ''
[PROBE] ['0'] only, select: exists=false: ''
[PROBE] ['0'] only, textarea: exists=true: array ( 0 => '0', )
[PROBE] ['0'] only, textarea_code: exists=true: array ( 0 => '0', )
[PROBE] ['0'] only, text_money: exists=true: array ( 0 => '0.00', )
[PROBE] ['0'] only, text_date_timestamp: exists=true: array ( 0 => 0, )
[PROBE] ['0'] only, text_url: exists=true: array ( 0 => false, )
[PROBE] ['0'] only, text_email: exists=true: array ( 0 => '', )
On develop every one of these is exists=false. #1572 changes _is_empty_array() to filter_empty(), which removes the split.
Recommendation: #1572 is now merged into develop, so rebase onto it — that removes the split. Then correct the C14 sentence so it describes what the code actually does, and add a zero-only repeatable test (array( '0' ) → array( '0' )).
F3 — Correctness · Medium · should-fix / question — includes/CMB2_Sanitize.php:259
text_money keeps the scalar early return if ( ! $this->value ) return '';. With this PR, a non-repeatable '0' money field is removed while a repeatable '0' entry is saved as '0.00':
[PROBE] non-repeatable money "0" metadata_exists: false
[PROBE] repeatable money ["0"] stored: array ( 0 => '0.00', )
That contradicts C14's "Not staged" reasoning that "non-repeatable fields already keep it". For text_money they don't. History: jrfnl's 2167d89 (PR #577) guarded only '' === $this->value, to stop empty money fields being saved as 0.00. The later commit 29e3248 (@jtsternberg, "A little cleanup and give props") widened that guard to truthiness, which also catches '0'. Neither the commit nor PR #577 gives a reason to drop 0.
Question for the author: should scalar money become CMB2_Utils::isempty(), which keeps jrfnl's empty guard and saves '0' as '0.00'? Or should repeatable money match the scalar behavior? Either choice is defensible, but the two paths shouldn't disagree. Whatever is chosen, fix the C14 claim.
F4 — Back-compat · Low · should-fix — CONVENTIONS.md C14 "Blast radius" / missing CHANGELOG
Who now sees a '0' that they didn't see before (confirmed by probe on head vs develop):
- Custom
sanitization_cbandcmb2_sanitize_textnow receivearray( 0=>'a', 1=>'0', 2=>'b' )instead ofarray( 0=>'a', 2=>'b' ). sanitization_cb => falsenow storesarray( 'a', '0', 'b' ).- Stored data shape: junk entries now appear where nothing used to be. Repeatable
text_urlgetsfalse,text_emailgets'', andtext_date_timestampgets0(epoch). For example[ 'http://a.test', false ]. Anything that reads the data directly sees them:get_post_meta()callers, REST output, and themetadata_exists()result for'0'-only fields. C14 lists these, but the CHANGELOG has to say so too. - Not affected: hook firing counts and database write counts.
save_field()makes the same singleupdate_data()/remove_data()call.
A claim from an earlier private review, that this PR moved the filter ahead of those seams, is refuted: The filter already ran before sanitization_cb, the false path, and cmb2_sanitize_{$type} on develop (array_filter sits in the same place, from 106f91e in 2014). Only which entries survive it changed. The contract half of the claim holds.
C3: Justin confirmed this doesn't need staging. It restores data the user typed, and the observers are filters, not a stored-value reinterpretation. But C14's "No configuration relies on a saved 0 vanishing" can't be verified. Reword it to the actual argument: silent data loss, plus the group precedent in dba5776.
Recommendation: add the CHANGELOG entry before merge (the author deferred it until #1572 landed; it has now). It should name the three sanitizer seams that now receive '0'. Also consider whether a repeatable text_url/text_email should drop entries that sanitize to false/'' instead of storing them.
F5 — Testing · Medium · should-fix — tests/test-cmb-field.php:302-336
The PR's tests are real: all three fail with includes/ reverted to develop. With only CMB2_Sanitize.php reverted, two fail (textarea_code and text_money). The description mentions only textarea_code. Coverage that's missing:
- Render round trip (F1).
- Empty-only repeatable money. Probe:
array( '', '' )→metadata_exists false, so 2167d89's fence holds. Pin it with a test next to the explicit-zero money test. sanitization_cb => falseand a custom callback receiving'0'(F4).- Datetime: a repeatable
text_datetime_timestampepoch-0 row is now stored as0. My probe gotarray( 0 => 0, 1 => 1577959200 ); ondevelopthat row is dropped. No test covers it.
The fcb112c repeatable timezone-in-group fence is still covered by its own Test_CMB2_Types::test_save_group, which passes on head.
F6 — Clarity · nit — CONVENTIONS.md:415
#1571 also adds ## C14 (taxonomy value_field). Whichever PR lands second needs to be renumbered to C15.
F7 — Clarity · nit — includes/CMB2_Sanitize.php:275
After filter_empty() runs upstream, the notempty( $val ) guard in the money loop only matters for callers that build CMB2_Sanitize directly. That's fine to keep. No change needed unless you want to note it inline.
Fences checked
array_filter( $meta_value )insanitization_cb()→ 106f91e (2014, original tables-to-classes conversion). Never revisited for0: 7e521ed (#135) moved the scalar checks tois_null, and dba5776 (2.2.2) fixed groups only. Changed. Its empty-removal need is kept.if ( $this->value )/! empty( $val )in_check_repeat()→ fcb112c (#330: repeatable datetime saving the current time). It exists to skip blank rows and failed parses (''/false). Losing0was collateral: I checked the commit message, the diff, and the #330 body, and none of them mention zero. Changed. The skip for blank rows and failed parses is kept. The UTC sidecar stays aligned, because a'0'entry returns''before$utc_valuesis pushed.if ( $val )in thetext_moneyrepeatable loop → 2167d89 (jrfnl, PR #577,'' === $val), widened to truthiness in 29e3248 (cleanup). Changed back toward the original intent. The empty guard is kept (probe:['','']stores nothing).if ( ! $this->value ) return '';scalar money early return → same pair of commits. Kept, which creates the F3 inconsistency.array_filter()inCMB2_Types::repeatable_rows()→ 934078f / 4d11c11 (2014, and older code before that). It skips blank rows and falls back todefault. Not touched (F1)._is_empty_array()'sarray_filter→ 2.0.6 era. Not touched. #1572 changes it (F2).
Not covered: PHP 7.4 wasn't run locally (C7). I read the diff by eye and it uses no syntax newer than 5.x. Full suite on head: OK (331 tests, 1076 assertions).
Justin's agent here (Claude Code), reviewing on behalf of @jtsternberg.
Repeatable text, textarea_code and text_money fields drop or skip a '0' entry on save. These tests fail until the fix lands.
sanitization_cb() used array_filter() and _check_repeat() used truthiness checks, so a '0' entry was removed like an empty one. Use CMB2_Utils::filter_empty() and notempty() instead, the same way group fields keep '0' since 2.2.2. A repeatable text_money '0' entry is now formatted as '0.00' like its siblings.
repeatable_rows() used array_filter(), so a saved 0 row was hidden on the edit screen and dropped on the next save. Use filter_empty() there too. A single text_money field now saves '0' as '0.00', the same as a repeatable entry. An empty value is still removed. Add tests for 0-only values, empty money rows, sanitization_cb false, a custom sanitization_cb, and an epoch-0 datetime row.
The hidden empty-row template was submitted with every form save. Its value (a '0' default, or the first select option) was saved as an extra row. Render the template inputs as disabled with data-cmb2-template, so the browser does not submit them. addAjaxRow enables them when a new row is added. A field that sets its own disabled attribute is left as is.
65d78ab to
71a0094
Compare
|
Thanks for the detailed review. I rebased onto F1 (render path). Fixed. This fix showed one more bug. The hidden So the template row inputs now render with F2 ( F3 (scalar money). I picked F4 (CHANGELOG and C14 wording). Added a CHANGELOG entry. It names I did not change how One limit I found: a repeatable date timestamp row stored as F5 (tests). Added tests for empty-only money ( F6 (C14 number). No change for now. #1571 is still open. Whichever PR lands second will rename its entry to C15. F7. No change. Full suite in wp-env (PHP 8.3, WP latest): 340 tests, 1096 assertions, all pass. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## develop #1574 +/- ##
=============================================
+ Coverage 72.16% 73.08% +0.91%
- Complexity 1911 1912 +1
=============================================
Files 53 53
Lines 5034 5041 +7
=============================================
+ Hits 3633 3684 +51
+ Misses 1401 1357 -44
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Justin's agent here (Claude Code), on behalf of @jtsternberg. @faisalahammad, Justin is merging this now. No further changes are needed from you. In a follow-up commit on Justin's agent here (Claude Code), on behalf of @jtsternberg. |
#1574 also made a single text_money field save '0' as '0.00'. Reverted here: the history on #457 and #577 is that an empty money field must not be saved as 0.00. Repeatable '0' entries still format as '0.00'. The test, changelog and C14 now say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KpfVcxMH9VE8CRmGRvjJPS Co-Authored-By: jtsternberg-bot <me+jtbot@jtsternberg.com>
…-row-reset Merged locally: CHANGELOG.md and js/cmb2.min.js conflicted with #1574. Kept both changelog entries; rebuilt cmb2.min.js from the merged cmb2.js with build:js:minify.
Description
CMB2_Field::sanitization_cb()removes empty entries from a repeatable field witharray_filter().array_filter()also drops0and'0', so a repeatable field saved witharray( 'a', '0', 'b' )is stored asarray( 'a', 'b' ). Fortextarea_code,text_datetime_timestampandtext_datetime_timestamp_timezonethe entry is dropped a second time inCMB2_Sanitize::_check_repeat(), which checks each entry withif ( $this->value )and! empty( $val ). The edit screen has the same problem:CMB2_Types::repeatable_rows()usesarray_filter()too, so a saved0row is not shown, and the next admin save removes it.This PR uses the existing
CMB2_Utils::filter_empty(),notempty()andisempty()helpers in these places. They keep0and'0'and still dropnull,'',falseand empty arrays. Group rows already usefilter_empty()since dba5776 (2.2.2), which was added "so group fields can have '0' as a value". The repeatable path was not changed at that time.CMB2_Field::sanitization_cb()Before:
After:
CMB2_Sanitize::_check_repeat()Before:
After:
CMB2_Sanitize::text_money()Now that
'0'reaches it, the repeatable branch formats it like the other entries ('0.00') instead of storing a raw'0'. The single (non-repeatable) field now does the same, so both paths agree. An empty money field is still removed, not saved as0.00.CMB2_Types::repeatable_rows()Both
array_filter()calls now usefilter_empty(), so a saved0row is shown on the edit screen and survives the next save.This change showed a second bug. The hidden
empty-rowtemplate is part of the form, so its inputs are posted on every save. Before this PR,array_filter()dropped the template value only when it was'0'. With other values it was already saved as an extra row ondevelop(for example a repeatableselectwith optionsa/bgains an'a'row on each save). Withfilter_empty()the'0'case would show up too.The template row inputs now render with
disabled="disabled"anddata-cmb2-template='1', so the browser does not post them.addAjaxRowinjs/cmb2.jsremoves both when it turns the template into a real row. If a field sets its owndisabledattribute, CMB2 leaves it as is.The PR also adds a C14 entry to
CONVENTIONS.mdand two CHANGELOG entries.Motivation and Context
There is no open issue for this. It is the same root cause as #1535 (fixed in #1572), but in the repeatable save and render paths.
Repro: register a repeatable
textfield, add three rowsa,0andb, and save. After the page reloads onlyaandbare left.Risk Level
Low to medium. The PHP change is small. The template row change touches JS that every repeatable field uses.
sanitization_cb()has three callers:CMB2_Field::save_field()(form saves on every object type, options pages and the REST API) andCMB2::save_group_field()(group rows and each sub-field). The changed line only runs for repeatable fields.repeatable_rows()renders every repeatable field and repeatable group sub-field.What changes: a
0or'0'entry in a repeatable field is now kept and passed to the field type's sanitizer, like any other entry. I saved'0'through each repeatable type before and after the fix:'0':text,text_small,hidden,textarea,textarea_small,textarea_code,select,oembed,text_date,text_time, and repeatable sub-fields in a group.text_money: saved as'0.00', repeatable or not.text_url: its sanitizer treats'0'as an invalid URL, so the entry becomes the fielddefault, orfalsewhen there is no default.text_email: the entry becomes'', same as any other invalid email.text_date_timestamp: saved as timestamp0, same as a non-repeatable field.text_datetime_timestamp(_timezone): a'0'entry is still dropped as an invalid date. A real epoch0is now kept on save.colorpickerandfile_list: no change, their own checks still drop it.Custom
sanitization_cbcallbacks,cmb2_sanitize_{$type}filters andsanitization_cb => falsenow get'0'entries in repeatable arrays. There are no extra database writes and no hook fires a different number of times. Data that is already saved is not touched.Known limit: a repeatable date timestamp row stored as
0renders blank, becauseget_timestamp_format()treats0as empty. So the next admin save drops that row. Before this PR the row was never stored. Fixing the render would change how every date field shows a stored0, so I left it out and noted it in C14 and the CHANGELOG.Back-compat for the template row: custom JS that copies
.empty-rowby itself (not throughaddAjaxRow) has to removedisabledfrom the copied inputs. The CHANGELOG says this.Testing procedure
Test_CMB2_Field. All of them go throughsave_field():test_repeatable_field_keeps_zero_value: atextfield savesarray( 'a', '0', '', 'b' )and getsarray( 'a', '0', 'b' ). This also checks that''is still dropped.test_repeatable_textarea_code_keeps_zero_value: the same fortextarea_code, which covers_check_repeat().test_repeatable_text_money_formats_zero_value:array( '1', '0', '', '2' )givesarray( '1.00', '0.00', '2.00' ).test_repeatable_field_keeps_zero_only_value:array( '0' )givesarray( '0' ).test_repeatable_text_money_empty_rows_are_removed:array( '', '' )stores nothing.test_repeatable_field_without_sanitization_keeps_zero_value:sanitization_cb => falsekeeps'0'.test_repeatable_field_sanitization_cb_receives_zero_value: a custom callback gets the'0'entry.test_repeatable_text_datetime_timestamp_keeps_epoch_zero: an epoch0row is stored as0.Test_CMB2_Types:test_repeatable_field_renders_zero_value_row: savesarray( 'a', '0', 'b' ), renders the field, and checks thevalue="0"row.test_repeatable_field_form_save_does_not_add_template_row: renders the field, posts the inputs a browser would post, saves again, and checks nothing was added. It covers aNo/Yesselect, ana/bselect, a text field withdefault'0', and a field with its owndisabledattribute.test_text_money_field_value_update: a single money field with'0'now gives'0.00'. An empty value still removes the meta.disabledanddata-cmb2-templateattributes on the template row.CMB2_Sanitize.phpreverted, both thetextarea_codeand thetext_moneytests fail.composer phpcsand ESLint are clean.js/cmb2.min.jsis rebuilt.a,0,b, save, reload, save again, reload. All three rows stay. A repeatable select with one row keeps one row after each save. Add Row still gives a working, enabled row.Types of changes
Checklist:
Screenshots
No UI changes.