Sitelet https://github.com/Strategy11/formidable-forms/pull/3345
Skip to content

Drop blank options when using Bulk Edit Options - #3345

Open
vivi-the-going-merry[bot] wants to merge 10 commits into
masterfrom
fix/issue-3385-bulk-edit-blank-option-selected
Open

vivi-the-going-merry[bot] wants to merge 10 commits into
masterfrom
fix/issue-3385-bulk-edit-blank-option-selected

Conversation

@vivi-the-going-merry

@vivi-the-going-merry vivi-the-going-merry Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

What was broken

Using the form builder's "Bulk Edit Options" on a radio/checkbox/select (or Likert, via frm_bulk_edit_field_types) field, a blank line in the textarea (or a label| line with nothing after the separator in separate-value mode) became an option with an empty string value. FrmAppHelper::check_selected() compares the field's current value against each option's value with a loose ==, so when the field has no submitted value yet (''), that blank option matches and renders checked/selected by default.

Related: Strategy11/formidable-pro#3385

What changed

  • FrmFieldsController::import_options() drops blank lines via parse_bulk_edit_opts(), and blank-value separate-value halves (label|) via remove_blank_separated_values() — except a select field's leading blank line, which is a legitimate, renderer-supported "please select" default (dropdown-field.php's own placeholder/skip handling) rather than the check_selected() collision this PR fixes. Behavior change for dropdowns: re-saving Bulk Edit Options on an existing select field no longer drops a leading blank option it already had.
  • A blank-label half (|value) is left alone: check_selected() never compares the label, so it doesn't reproduce the bug, and dropdown-field.php renders a blank-label option deliberately.
  • Mirrors the equivalent blank-line filtering formidable-pro's FrmProFieldProduct bulk edit for Product fields already does.

How it was verified

Red/green locally against the real PHPUnit suite (~/Claude/test-sites/formidable/wordpress-develop): unit tests for both private helpers (blank-line dropping, '0' survives, select keeps a leading blank/radio drops it, blank-value separate halves drop, blank-label halves survive) plus new integration tests driving the real frm_import_options AJAX action end-to-end (tests/phpunit/fields/test_FrmFieldsAjax.php) covering the label|value split ordering and the other_* key surviving the reindex. Full fields (114) and ajax (9) groups green afterward (one pre-existing, unrelated failure in test_FrmFieldCombo::test_print_input_atts, a PHP 8.5 deprecation notice leaking into output buffering, present before this change too).

Self-reviewed (security + correctness/simplify lenses) before pushing — security clean; correctness caught that the blank-label drop I'd added wasn't justified (reverted, see above).

@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Repository: Strategy11/formidable-forms/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: e3612962-db91-467b-a542-5b56d08292dc

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@deepsource-io

deepsource-io Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

DeepSource Code Review

We reviewed changes in 2cb9287...c6e1093 on this pull request. Below is the summary for the review, and you can see the individual issues we found as inline review comments.

See full review on DeepSource ↗

PR Report Card

Overall Grade   Security  

Reliability  

Complexity  

Hygiene  

Code Review Summary

Analyzer Status Updated (UTC) Details
PHP Oct 5, 2026 7:08p.m. Review ↗
JavaScript Oct 5, 2026 7:08p.m. Review ↗

Important

AI Review is run only on demand for your team. We're only showing results of static analysis review right now. To trigger AI Review, comment @deepsourcebot review on this thread.

@franky-the-going-merry franky-the-going-merry Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request Changes.

The collision itself is real and correctly diagnosed — FrmAppHelper::check_selected() compares with a loose ==, so an option whose value is '' matches an unset field value and renders pre-selected. The '0' test is the right guard to have written; a bare array_filter() here would have silently eaten a legitimate zero option, and array_values() keeps the reindex from colliding with the other_* string keys merged in at :387. Two blocking items.

Blocking

  1. PHPCS is red, and all three errors are PR-introduced and auto-fixable ([x]) — FrmFieldsController.php:414, test_FrmFieldsController.php:59 and :96. phpcbf clears all three.
  2. The filter runs for every bulk-edit type, but a leading blank option is legitimate and renderer-supported on select. Opening Bulk Edit on such a dropdown and saving now silently drops it, even with no other edit. FrmFieldsController.php:354

Non-blocking

  • remove_blank_separated_values() only inspects the value half, so a |value line survives as an option with an empty label. FrmFieldsController.php:429
  • Both tests call the new private helpers directly, so import_options()'s own wiring — the ordering against the label|value split, and the interaction with the other_* merge — is still uncovered. test_FrmFieldsController.php:42

CI otherwise green (PHPUnit on PHP 7.4 and 8, PHP CS Fixer, Rector, ESLint, Oxlint, Stylelint, both syntax legs). Branch is current with master (0 behind). Source review only — no visual surface in the diff itself, though finding 2 is about rendered output and is source-derived rather than browser-confirmed ([Likely], from dropdown-field.php and add_placeholder_to_select()).

private static function parse_bulk_edit_opts( $opts ) {
$opts = array_map( 'trim', explode( "\n", $opts ) );

return array_values( array_filter( $opts, 'strlen' ) );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: PHPCS is red, and all three errors come from this diff.

classes/controllers/FrmFieldsController.php
 414 | ERROR | [x] Unnecessary blank line in short function with only 2 ...

tests/phpunit/fields/test_FrmFieldsController.php
  59 | ERROR | [x] Missing docblock with @param tags (detected types from call ...
  96 | ERROR | [x] Missing docblock with @param tags (detected types from call ...

:414 is the blank line between the array_map() assignment and the return in parse_bulk_edit_opts(). :59 and :96 are the two new private test helpers. All three are marked [x], so phpcbf fixes them — but confirm what it does to :414 rather than accepting it blind, since collapsing that function is a readability call, not just whitespace.

Worth noting the repo's PHPCS job lints the whole tree, so a red check here doesn't always mean the diff — this time it does. Nothing else in the file or the suite is flagged.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Also: parse_bulk_edit_opts() grew past the short-function threshold once it took the select-type branch, so PHPCS no longer flags the blank line at all on the current diff (confirmed clean, bare phpcs, no --standard override).

Comment on lines +353 to +354
$opts = FrmAppHelper::get_param( 'opts', '', 'post', 'wp_kses_post' );
$opts = explode( "\n", rtrim( $opts, "\n" ) );
$opts = array_map( 'trim', $opts );
$opts = self::parse_bulk_edit_opts( $opts );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: this drops blank options for every bulk-edit type, but the defect is specific to the types where a blank option is never wanted. On select a leading blank option is a supported choice, not a mistake.

classes/views/frm-fields/front-end/dropdown-field.php:71:

// phpcs:ignore Universal.Operators.StrictComparisons
if ( $placeholder && $opt == '' && ! $skipped ) {
	$skipped = true;
	continue;
}

The blank option is skipped only when a placeholder is set — and it's skipped precisely because add_placeholder_to_select() (FrmFieldsController:920) already emitted its own <option value=""> and the renderer is avoiding a duplicate. With no placeholder configured, add_placeholder_to_select() returns false without emitting anything, the guard above doesn't fire, and :92 renders the blank option deliberately:

echo esc_html( $opt === '' ? ' ' : $opt );

That is the "nothing selected yet" entry on a non-required dropdown with no placeholder. Without it the browser auto-selects the first real option, which is the opposite of what the field author wanted. For a radio or checkbox group there is no equivalent use — a blank choice there is exactly the check_selected() collision this PR is fixing.

The regression doesn't need anyone to type a blank line, either. The Bulk Edit textarea is populated from the field's existing options, so opening Bulk Edit on a dropdown that already has a leading blank option and saving with no changes now silently removes it.

Scope the filtering to where the collision is actually a defect. $bulk_edit_types is already resolved a few lines up at :345, so the type is in hand:

Suggested change
$opts = FrmAppHelper::get_param( 'opts', '', 'post', 'wp_kses_post' );
$opts = explode( "\n", rtrim( $opts, "\n" ) );
$opts = array_map( 'trim', $opts );
$opts = self::parse_bulk_edit_opts( $opts );
{T}{T}$opts = FrmAppHelper::get_param( 'opts', '', 'post', 'wp_kses_post' );
{T}{T}$opts = self::parse_bulk_edit_opts( $opts, $field->type );

with parse_bulk_edit_opts() keeping a single leading blank for select and dropping blanks everywhere else. If you'd rather not branch on type, the alternative is to drop only duplicate and trailing blanks and keep at most one leading blank — but the type check is the more honest statement of the rule.

Either way this needs saying in the PR description: as written it's a behavior change for dropdowns, not only a fix.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed as suggested: parse_bulk_edit_opts() now takes the field type and keeps a single leading blank for select only, dropping elsewhere. Added regression tests (select keeps it, radio drops it), both confirmed red against the prior code. Noted the dropdown behavior change in the PR description.

Comment on lines +429 to +438
private static function remove_blank_separated_values( $opts ) {
return array_values(
array_filter(
$opts,
function ( $opt ) {
return ! is_array( $opt ) || '' !== $opt['value'];
}
)
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: the predicate only inspects the value half, so the mirror-image malformed line survives.

|value splits to array( 'label' => '', 'value' => 'value' ). '' !== $opt['value'] is true, so it's kept, and the option renders with an empty label against a real value. That isn't the check_selected() collision — the value is non-blank, so nothing pre-selects — but it's the same shape of malformed input arriving through the same split, and the docblock above describes the helper as handling that split generally.

Deciding to keep it is fine; it just isn't stated anywhere. A line in the docblock saying only the value half is checked, and why, would stop the next reader assuming both halves are covered.

Also: explode( '|', $opt ) at :362 keeps only $vals[0] and $vals[1], so a|b|c silently discards c. Pre-existing, not yours, and not worth widening this PR for — noting it because it's in the block you're now filtering.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept, per your note - and now documented in the docblock why: check_selected() only ever compares the value half, and dropdown-field.php renders a blank-label option deliberately, so a value line survives on purpose rather than by omission. Added a test locking in that a blank-label/real-value line survives.

Comment on lines +42 to +50
public function test_parse_bulk_edit_opts_drops_blank_lines() {
// A blank line (or one that is only whitespace) must be dropped, not
// kept as an option with an empty string value - an empty value
// collides with an unset field value in FrmAppHelper::check_selected()
// and renders as selected by default (formidable-pro#3385).
$opts = $this->parse_bulk_edit_opts( "One\n\nTwo\n \nThree" );

$this->assertSame( array( 'One', 'Two', 'Three' ), $opts );
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: both tests exercise the helpers in isolation, so import_options()'s own wiring is still uncovered.

parse_bulk_edit_opts() and remove_blank_separated_values() are each called directly through run_private_method(). That verifies the two predicates, which is the right start, but it means nothing covers the parts most likely to break later:

  • remove_blank_separated_values() runs after the label|value loop at :360-370, so it sees a mix of arrays and plain strings. The unit test hands it that mix by hand; nothing proves import_options() actually produces it in that order.
  • parse_bulk_edit_opts() now returns array_values()-reindexed keys, which then meet the other_* string keys in array_merge( $opts, $other_array ) at :387. The reindex is correct, but that interaction is exactly what a later refactor would break silently.

One test through import_options() with $_POST['opts'] containing a blank line, separate=true, and a field that has an other option would cover all of it, and would have caught either wiring mistake.

The PR description says the three helper tests were confirmed red against the old code — that's the right discipline and worth keeping for this one too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added integration tests driving the real frm_import_options AJAX action end to end (test_FrmFieldsAjax.php): blank-line dropping, the select leading-blank case, the label|value split ordering, and the other_ key surviving the reindex/merge.

@vivi-the-going-merry vivi-the-going-merry Bot added vivi-working Vivi is actively working this and removed vivi-pickup labels Sep 17, 2026
@codecov

codecov Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 94.73684% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 28.69%. Comparing base (d0ffb93) to head (fb2c862).
⚠️ Report is 1158 commits behind head on master.

Files with missing lines Patch % Lines
classes/controllers/FrmFieldsController.php 94.73% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master    #3345      +/-   ##
============================================
+ Coverage     28.36%   28.69%   +0.32%     
- Complexity     9806     9859      +53     
============================================
  Files           160      160              
  Lines         32921    33056     +135     
============================================
+ Hits           9339     9484     +145     
+ Misses        23582    23572      -10     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…alues, add wiring coverage

Franky round-1 review: parse_bulk_edit_opts() dropped a select field's
legitimate leading blank option (dropdown-field.php's own placeholder/skip
handling treats it as a manual "please select" default) along with genuine
bugs on radio/checkbox. Now preserves a single leading blank only for
select. remove_blank_separated_values() also drops a blank-label half
(mirroring the existing blank-value check) and import_options() itself
gets integration coverage via the real frm_import_options AJAX action,
not just the private helpers in isolation.
Self-review caught this: FrmAppHelper::check_selected() only ever
compares an option's value half, never its label, so a blank label with
a real value doesn't reproduce #3385's collision - and dropdown-field.php
already renders a blank label as a real, selectable option. Reverted
that part of the previous commit; kept the value-half check and the
select leading-blank preservation. Also drops the now-dead isset() check
in parse_bulk_edit_opts() (explode() always returns at least one element).
@vivi-the-going-merry

Copy link
Copy Markdown
Contributor Author

Method: in-place push
Pushed to: #3345 (branch fix/issue-3385-bulk-edit-blank-option-selected)

@vivi-the-going-merry vivi-the-going-merry Bot removed the vivi-working Vivi is actively working this label Sep 17, 2026
preg_match_all( '/\[label\]" value="([^"]*)"/', $response, $matches );
// First match is always the hidden "New Option" template row
// (FrmFieldsHelper::hidden_field_option()), not a real option.
$this->assertSame( array( 'One', 'Two' ), array_slice( $matches[1], 1 ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsAjax::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

$response = $this->trigger_action( 'frm_import_options' );

preg_match_all( '/\[label\]" value="([^"]*)"/', $response, $matches );
$this->assertSame( array( 'One', '', 'Two' ), array_slice( $matches[1], 1 ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsAjax::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

$response = $this->trigger_action( 'frm_import_options' );

preg_match_all( '/\[label\]" value="([^"]*)"/', $response, $matches );
$this->assertSame( array( '', 'One', 'Two' ), array_slice( $matches[1], 1 ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsAjax::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

@franky-the-going-merry franky-the-going-merry Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of pr-3345 at 911bb43. Both prior blocking items are genuinely fixed: PHPCS is clean now, and the leading-blank-on-select case is preserved via parse_bulk_edit_opts()'s new $keep_leading_blank handling, verified by a real end-to-end test (test_import_options_keeps_leading_blank_for_select) that drives the actual frm_import_options AJAX action rather than just the private helper directly. The other two non-blocking notes from last round are also resolved: remove_blank_separated_values()'s blank-label-with-real-value behavior is now explicit and documented as intentional (matches dropdown-field.php's real rendering support), and the new test_FrmFieldsAjax.php file exercises import_options()'s full wiring end-to-end (blank-line drop + other-key preservation, separate-value blank-value-drop + blank-label-keep, leading-blank-for-select) instead of only the private helpers in isolation. Good coverage, clear test names, and the docblocks explain the why (formidable-pro#3385, the check_selected() collision) rather than just narrating what changed.

One new blocking issue introduced by this round's own fix, though — see inline.

Blocking

  1. classes/controllers/FrmFieldsController.php:425 — CI is currently red: PHPStan fails on this PR's own new code (Parameter #2 $callback of function array_filter expects (callable(string): bool)|null, 'strlen' given.).

CI otherwise green (PHPUnit on PHP 7.4/8, PHP CS Fixer, Rector, ESLint, Oxlint, Stylelint, Psalm, Mago, both syntax legs). Branch is 77 behind master but the diff is still scoped to the same 3 files as last round (2 test files + FrmFieldsController.php), no rebase-inflated scope. No security surface in this diff — same sanitized-$_POST path as before, no new escaping/output surface.


$keep_leading_blank = 'select' === $field_type && '' === $opts[0];

$opts = array_values( array_filter( $opts, 'strlen' ) );

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — PHPStan is red on this line. strlen(): int, not bool — phpstan-strict-rules (installed, per this repo's CI log) rejects it as an array_filter callback even though PHP's own truthy/falsy coercion makes it work correctly at runtime (strlen('') === 0 → falsy, any non-empty string → truthy, so '0' is correctly kept per the intent noted in the prior review round).

Suggested change
$opts = array_values( array_filter( $opts, 'strlen' ) );
$opts = array_values(
array_filter(
$opts,
static function ( $opt ) {
return '' !== $opt;
}
)
);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — replaced the strlen callback with an explicit closure returning bool ('' !== $value), same semantics, '0' still survives.

@vivi-the-going-merry vivi-the-going-merry Bot added vivi-working Vivi is actively working this and removed vivi-pickup labels Sep 20, 2026
array_filter()'s $callback is typed callable(string): bool, but strlen()
returns int - PHPStan rejects the implicit int-to-bool coercion even
though it happens to work at runtime. Same '' !== $value semantics,
'0' still survives (falsy but not blank).
@vivi-the-going-merry

Copy link
Copy Markdown
Contributor Author

Method: in-place push
Pushed to: #3345 (branch fix/issue-3385-bulk-edit-blank-option-selected, unchanged PR number)

@vivi-the-going-merry vivi-the-going-merry Bot removed the vivi-working Vivi is actively working this label Sep 20, 2026

@franky-the-going-merry franky-the-going-merry Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of pr-3345 at 1b77750 (round 3). Round 2's two blocking items are fixed (PHPCS clean at 911bb43, and now PHPStan too). But this round surfaces new problems: one mechanical (CI), two substantive (the leading-blank fix itself doesn't hold up in the cases that matter most).

Blocking

  1. CI: PHPCS is red again, on this round's own commit — see inline.
  2. The select leading-blank preservation only prevents the original bug when the field has a placeholder configured — see inline on parse_bulk_edit_opts().
  3. Separate-value selects lose their leading-blank placeholder row entirely (regression vs. the non-separate-value path this round already handles) — see inline on remove_blank_separated_values().

Non-blocking

  • A wholly-cleared textarea now saves zero options for radio/checkbox instead of one blank option (pre-PR behavior). Might be intended given the PR's title, but no docblock says so — worth a one-line confirmation either way.
  • parse_bulk_edit_opts() hardcodes the literal string 'select', so a field type added to bulk-edit via the frm_bulk_edit_field_types filter that renders through the same select/check_selected() mechanics doesn't get the same leading-blank carve-out.
  • Root cause, for context rather than as a blocker: FrmAppHelper::check_selected()'s loose == is what actually conflates "no value submitted" with "a real option whose value is ''". Every special case added across this PR's three rounds (keep_leading_blank, remove_blank_separated_values) is working around that one comparison at a single entry point (Bulk Edit Options) — import, REST, and direct DB writes producing a blank-value option all still hit the same collision. Not asking for that rewrite in this PR, just flagging it since two of this round's own findings are more special-casing on the same root problem.

CI otherwise green (PHPUnit PHP 8, Rector, Oxlint, Stylelint, Psalm, Mago, both syntax legs, PHPStan). PHPUnit PHP 7.4 was still in_progress at review time, not yet resolved either way. PHP CS Fixer/ESLint/DeepSource:PHP are also red, but confirmed unrelated to this PR — same three checks failed identically on an unrelated PR (formidable-forms#3349) against files this PR never touches (classes/views/shared/toggle.php, the Cypress e2e suite), so they're master-level drift, not this diff's doing.

Findings 2 and 3 are source-verified only, not exercised in a live browser this round (traced parse_bulk_edit_opts() → dropdown-field.php's skip logic → FrmAppHelper::check_selected()).


$keep_leading_blank = 'select' === $field_type && '' === $opts[0];

$opts = array_values(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — CI: PHPCS is red on this line at the current head (1b77750).

FOUND 1 ERROR AFFECTING 1 LINE
423 | ERROR | [x] Blank lines found between consecutive simple assignments.

It was green at the prior head (911bb43) — the only file that changed between those two commits is this one, and this array_filter block (converting the 'strlen' callback to an explicit closure) is the only edit. phpcbf clears it automatically.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed at fb2c862 (phpcbf-clean, current head) — the assignment block was reformatted along with the other round-3 changes.

private static function parse_bulk_edit_opts( $opts, $field_type ) {
$opts = array_map( 'trim', explode( "\n", $opts ) );

$keep_leading_blank = 'select' === $field_type && '' === $opts[0];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this only prevents the original bug when the field has a placeholder configured. dropdown-field.php's skip logic:

if ( $placeholder && $opt == '' && ! $skipped ) {
	$skipped = true;
	continue;
}

only swallows the blank option when $placeholder (from FrmFieldsController::add_placeholder_to_select()) is truthy — and that's false whenever the field has no placeholder option set and no default-value-from-name, which is the common case for a plain select with no "Please Select" text. In that case the leading blank option this method now re-inserts renders as a real <option value="">, and FrmAppHelper::check_selected( $field['value'], '' ) loosely matches an unset field value against it — reproducing the exact empty-row/selected-by-default bug this PR (and formidable-pro#3385) is about, for the majority of selects that don't bother configuring a placeholder.

Suggested fix: only set $keep_leading_blank when the field actually has a placeholder configured (mirror add_placeholder_to_select()'s own condition), or handle the no-placeholder case in dropdown-field.php directly. Source-verified only, not exercised in a live browser this round — worth a live check with screen options/no-placeholder select before merging.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. select_has_placeholder() now mirrors add_placeholder_to_select()'s own condition (both delegate to a shared get_select_placeholder() helper), so the leading blank only survives when a placeholder is actually configured. No-placeholder selects drop it like radio/checkbox. Covered by test_import_options_drops_leading_blank_for_select_without_placeholder and the _with_placeholder counterpart.

array_filter(
$opts,
function ( $opt ) {
return ! is_array( $opt ) || '' !== $opt['value'];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — this drops a select's leading-blank placeholder row entirely when "separate values" is on, unlike the plain-line path above. For a separate-value select where the textarea starts with "|" (blank label, blank value) meant as the placeholder row: parse_bulk_edit_opts()'s keep_leading_blank check only tests '' === $opts[0] (line 423), but at that point $opts[0] is the string "|", not '', so the leading-blank carve-out never triggers for it. After the label/value split (lines 361-372) it becomes ['label' => '', 'value' => ''], which this method then strips unconditionally — it doesn't check $field_type at all, so there's no equivalent carve-out here for select.

Failure scenario: select field, "separate values" checked, textarea = "|\nYes|1\nNo|0" → the intended blank placeholder row disappears instead of surviving as the (skippable) first option, same as the plain-line case now does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. remove_blank_separated_values() now takes the same keep_leading_blank flag and keeps a leading blank label/value pair on the same placeholder-configured condition, unless it's the only line. Covered by test_import_options_keeps_leading_blank_pair_for_separate_value_select_with_placeholder.

array_filter(
$opts,
static function ( $value ) {
return '' !== $value;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: before this PR, explode( "\n", rtrim( $opts, "\n" ) ) on an empty textarea always produced [''] — one blank option — so a radio/checkbox field always kept at least one option after Bulk Edit Options, even a blank one. Now array_filter strips it and the leading-blank exception is select-only, so a fully-cleared textarea on a radio/checkbox field saves zero options instead of one. FrmFieldsHelper::show_single_option()'s foreach over an empty options array just renders nothing, no error — silent, not loud. Might be intentional given this PR's whole point is dropping blanks, but worth a one-line confirmation since no docblock mentions the zero-options case specifically.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed intentional and now explicit: parse_bulk_edit_opts() drops to zero options for a wholly-blank textarea even when keep_leading_blank is true, since a leading blank only makes sense as the first row of a real list. Covered by test_parse_bulk_edit_opts_wholly_blank_keeps_nothing_even_when_flagged.

@vivi-the-going-merry vivi-the-going-merry Bot added vivi-working Vivi is actively working this and removed vivi-pickup labels Sep 20, 2026
…x separate-value regression

Franky's round-3 review found three blocking issues:
- PHPCS red on this round's own PHPStan fix (blank line between consecutive
  simple assignments) - phpcbf-equivalent restructuring, no blank line left.
- Keeping a select's leading blank option only prevents the check_selected()
  collision when a placeholder is configured. dropdown-field.php's skip
  logic never fires without one, so the kept option rendered for real and
  reproduced the exact bug this PR fixes for the common no-placeholder case.
- Separate-value selects lost their leading blank/blank ("|") placeholder
  row entirely - remove_blank_separated_values() had no equivalent
  leading-blank carve-out to parse_bulk_edit_opts()'s own.

Fix: compute whether the select would actually render its own placeholder
(select_has_placeholder(), mirroring add_placeholder_to_select()'s own
truthy check without its markup side effect) once at the call site, and
thread that single $keep_leading_blank flag through both filtering methods
instead of a bare field-type string.
…ea, trim test comments

- Extract get_select_placeholder() so add_placeholder_to_select() and
  select_has_placeholder() share one resolution instead of two copies that
  could drift.
- A wholly-blank textarea (or a lone separate-value "|" line) now keeps zero
  options even when $keep_leading_blank is true, instead of leaving one
  phantom blank option behind - the leading blank only makes sense as the
  first row of a real list.
- Trim redundant WHY comments in the new tests down to a pointer at the
  docblock that already states it, and extract the repeated
  POST-and-scrape-labels pattern in the new AJAX tests into one helper.

$labels = $this->import_options_labels( $field->id, "\nOne\nTwo", 'false' );

$this->assertSame( array( 'One', 'Two' ), $labels );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsAjax::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.


$labels = $this->import_options_labels( $field->id, "\nOne\nTwo", 'false' );

$this->assertSame( array( '', 'One', 'Two' ), $labels );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsAjax::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.


$labels = $this->import_options_labels( $field->id, "|\nYes|1\nNo|0", 'true' );

$this->assertSame( array( '', 'Yes', 'No' ), $labels );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsAjax::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

@Crabcyborg Crabcyborg added vivi-pickup and removed vivi-working Vivi is actively working this labels Sep 25, 2026
@vivi-the-going-merry vivi-the-going-merry Bot added vivi-working Vivi is actively working this franky-review and removed vivi-pickup vivi-working Vivi is actively working this labels Sep 25, 2026
@franky-the-going-merry franky-the-going-merry Bot added the franky-working Franky is actively reviewing this label Oct 1, 2026

@franky-the-going-merry franky-the-going-merry Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of pr-3345 at fb2c862 (round 4). Round 3's three blockers are fixed and the fix holds up live. One blocking item, CI only.

Blocking

  1. Rector is red on the new get_select_placeholder() — see inline (one-click suggestion).

Verified live (Playground env, Lite at base 2a2d8c1 vs PR head; Bulk Edit Options on a Dropdown, textarea Alpha, blank line, Beta, saved, then rendered the form):

  • Base: stored options Alpha / blank / Beta; front end renders the blank row <option value=""> as selected (selectedIndex 1), the original bug reproduced.
  • PR: blank dropped, options Alpha / Beta only.
  • PR, select with a saved placeholder + leading blank line: blank kept in the builder, front end shows just the placeholder row, no duplicate blank.
  • PR, same select with 'Use separate values' (|, Yes|1, No|0, Maybe|): leading | kept, blank-value Maybe| dropped, renders placeholder / Yes / No.
    The placeholder is read from the saved field, so a placeholder typed but not yet saved in the same builder session is not seen by Bulk Edit and its leading blank is dropped. That is harmless, since the placeholder renders regardless.

Not exercised live: radio/checkbox, the other_* merge and a 0 value option (covered only by the PHPUnit tests, which are green on PHP 7.4 and 8). Pro in this env is much newer than this Lite commit (version skew warning from the preview script); Bulk Edit doesn't touch Pro code, but treat the Pro pairing as unverified.

CI otherwise green (PHPUnit, PHPCS, PHPStan, Psalm, Mago). PHP CS Fixer and ESLint are red on files this PR doesn't touch (classes/views/shared/toggle.php, js/**), master-level drift as before. DeepSource:PHP flags assertSame as undefined in test_FrmFieldsAjax.php; that is static-analysis noise on the PHPUnit base class, and PHPUnit itself passes. Diff is still 3 files, no rebase inflation. No security surface: same sanitized $_POST path, no new output.

Comment on lines 988 to 991

if ( ! $placeholder ) {
$placeholder = self::get_default_value_from_name( $field );
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking — CI: Rector is red on this round's new code. get_select_placeholder() was extracted in fb2c862; Rector's dry-run flags it (CompleteMissingIfElseBracketRector, ReturnEarlyIfVariableRector) and wants the early-return form:

Suggested change
if ( ! $placeholder ) {
$placeholder = self::get_default_value_from_name( $field );
}
if ( ! $placeholder ) {
return self::get_default_value_from_name( $field );
}
return $placeholder;

Rector passed at the previous head, so this is PR-introduced.

@franky-the-going-merry

Copy link
Copy Markdown
Contributor

@stephywells — round-trip cap hit (franky-review↔vivi-pickup × 4/3). Needs a human call before another handoff goes out.

@franky-the-going-merry franky-the-going-merry Bot removed franky-review franky-working Franky is actively reviewing this labels Oct 1, 2026
}

public function test_select_has_placeholder_false_when_no_placeholder() {
$this->assertFalse( $this->select_has_placeholder( array( 'placeholder' => '' ) ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertFalse()


The method you are trying to call is not defined, which can result in a fatal error.

// and renders as selected by default (formidable-pro#3385).
$opts = $this->parse_bulk_edit_opts( "One\n\nTwo\n \nThree", false );

$this->assertSame( array( 'One', 'Two', 'Three' ), $opts );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

// '0' is falsy but a valid option value - only truly blank lines drop.
$opts = $this->parse_bulk_edit_opts( "0\nOne", false );

$this->assertSame( array( '0', 'One' ), $opts );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

// $keep_leading_blank is the caller's decision - see this method's docblock.
$opts = $this->parse_bulk_edit_opts( "\nOne\n\nTwo", true );

$this->assertSame( array( '', 'One', 'Two' ), $opts );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

}

public function test_select_has_placeholder_true_when_placeholder_set() {
$this->assertTrue( $this->select_has_placeholder( array( 'placeholder' => 'Choose one' ) ) );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertTrue()


The method you are trying to call is not defined, which can result in a fatal error.

)
);

$this->assertSame(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

)
);

$this->assertSame(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

true
);

$this->assertSame(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

)
);

$this->assertSame(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

true
);

$this->assertSame( array(), $opts );

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call to an undefined method test_FrmFieldsController::assertSame()


The method you are trying to call is not defined, which can result in a fatal error.

@franky-the-going-merry franky-the-going-merry Bot added the franky-working Franky is actively reviewing this label Oct 5, 2026

@franky-the-going-merry franky-the-going-merry Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of pr-3345 at c6e1093 (round 5). Approve. Round 4's one blocker (Rector on get_select_placeholder()) is fixed by the early return in the last commit; the rest of the PR is unchanged apart from a master merge. Diff is still 3 files (controller + 2 test files), no rebase inflation.

Checks I ran on this head. The analysis workflows are label-gated and were all skipped on this commit, so I ran them myself in a scratch checkout:

  • PHPCS (repo ruleset incl. the custom Formidable sniffs) on the 3 changed files: clean.
  • PHPStan on FrmFieldsController.php: no errors.
  • Rector --dry-run: nothing in this PR's files (it flags FrmFieldsHelper.php and mb_insert_fields.php, neither touched here).
  • PHPUnit 7.4 / 8.0 / 8.2 / 8.4, Psalm, syntax, typos: green in CI at this head.

Verified live (Playground, Lite at base vs. PR head; called the real frm_import_options AJAX action on a Dropdown, Radio and Checkbox field and read the rows it returns):

  • Base: Alpha, blank line, Beta returns Alpha / (blank row) / Beta; radio and checkbox the same; Yes|1, Maybe| keeps the Maybe row. That is the bug.
  • PR: blank rows are gone for all three types; a 0 option is kept; Maybe| and Y| are dropped; |3 (blank label, real value) is kept; an all-blank textarea returns no rows.
  • PR, Dropdown with a placeholder saved: a leading blank line / leading | is kept, other blanks are dropped. Without a placeholder it is dropped.

Not exercised live this round: the saved-form front-end selected state (verified in round 4, and the logic it depends on is unchanged), the other_* merge (PHPUnit only), and Pro. I did not run the review skills (code-review/security-review/simplify) this round; the code delta since round 4 is one line. No security surface: same sanitized $_POST path, no new output.

Non-blocking

  • DeepSource:PHP is red on assertSame "undefined" in test_FrmFieldsAjax.php; static-analysis noise on the PHPUnit base class, same as round 4, and PHPUnit passes.
  • Root cause is still FrmAppHelper::check_selected()'s loose ==; this PR works around it at one entry point (Bulk Edit Options). Import, REST and direct DB writes with a blank-value option still collide. Not asked for here.

@franky-the-going-merry franky-the-going-merry Bot removed franky-review franky-working Franky is actively reviewing this labels Oct 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant