Skip to content

Forms: conditional logic (5/5) — the editor panel - #50980

Open
enejb wants to merge 8 commits into
add/forms-conditional-logic-serverfrom
add/forms-conditional-logic-editor
Open

Forms: conditional logic (5/5) — the editor panel#50980
enejb wants to merge 8 commits into
add/forms-conditional-logic-serverfrom
add/forms-conditional-logic-editor

Conversation

@enejb

@enejb enejb commented Jul 31, 2026

Copy link
Copy Markdown
Member

Fixes FORMS-749

Part 5 of 5. This series splits #50938 into reviewable pieces. Each PR is based on the previous one, so the diff shown here is incremental.

  1. Forms: conditional logic (1/5) — vocabulary and feature flag #50976 — vocabulary and feature flag
  2. Forms: conditional logic (2/5) — the resolver, in JS and PHP #50977 — the resolver, in JS and PHP
  3. Forms: conditional logic (3/5) — apply in the browser #50978 — apply in the browser
  4. Forms: conditional logic (4/5) — server-side render and validation #50979 — server-side render and validation
  5. Forms: conditional logic (5/5) — the editor panel #50980 — the editor panel

Proposed changes

Part 5 of 5 splitting #50938 — the authoring UI. Pick an action, pick whether any or all conditions must hold, and build rules against any other field in the form.

  • Added by an editor.BlockEdit filter rather than per-block wiring. The field blocks share no single inspector component — four of them build their own — so a filter is the only way to cover all of them without touching nineteen edit files, and new field types inherit the panel automatically.
  • Any field in the form can be a rule's subject, with operators and a value input chosen from that field's declared comparison behaviour. Fields without an explicit id get one assigned when first referenced, since most authors never set one.
  • The panel loads through a lazy boundary, so a site with the feature off never fetches it: the filter is not registered, the component never renders, and the browser never requests the chunk.

Bundle impact

Measured on a clean build of each bundle:

before after
dist/blocks/editor.js 2,133,660 1,399,432 (−717 KB)
dist/form-editor/jetpack-form-editor.js 2,855,112 2,122,253 (−733 KB)
panel chunk (on demand) ~52 KB

Most of that is @wordpress/icons, which sat in the entry only because the panel reached it statically. Total emitted JS is unchanged — this is about what loads, not what ships. No enqueue change is needed: the editor entrypoint still lists exactly editor.js plus its stylesheets, and every new chunk is initial=false, so the webpack runtime fetches them itself.

Related product discussion/links

Does this pull request change what data or activity we track or use?

No.

Testing instructions

Enable the flag in a mu-plugin:

add_filter( 'jetpack_feature_flag_enabled_forms-conditional-logic', '__return_true' );
  • Add a form with several field types. Select any field → a Conditional logic panel appears in the inspector.
  • Enable it, add a condition, and confirm the field dropdown lists every other field in the form with its type in brackets, that the operator list changes with the subject field's type, and that a choice field offers its own options as values.
  • Confirm a field with no label still identifies itself in the dropdown.
  • Publish and check the front end honours the rules.

With the flag off, open the editor with DevTools → Network filtered to conditional-logic: it must stay empty, and no panel should appear on any field.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Are you an Automattician? Please test your changes on all WordPress.com environments to help mitigate accidental explosions.

  • To test on WoA, go to the Plugins menu on a WoA dev site. Click on the "Upload" button and follow the upgrade flow to be able to upload, install, and activate the Jetpack Beta plugin. Once the plugin is active, go to Jetpack > Jetpack Beta, select your plugin (Jetpack), and enable the add/forms-conditional-logic-editor branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack add/forms-conditional-logic-editor

Interested in more tips and information?

  • In your local development environment, use the jetpack rsync command to sync your changes to a WoA dev blog.
  • Read more about our development workflow here: PCYsg-eg0-p2
  • Figure out when your changes will be shipped to customers here: PCYsg-eg5-p2

@jp-launch-control

jp-launch-control Bot commented Jul 31, 2026

Copy link
Copy Markdown

Code Coverage Summary

No summary data is available for parent commit 888f5da, so cannot calculate coverage changes. 😴

If that commit is a feature branch rather than a trunk commit, this is expected. Otherwise, this should be updated once coverage for 888f5da is available.

Full summary · PHP report · JS report

@github-actions

Copy link
Copy Markdown
Contributor

Thank you for your PR!

When contributing to Jetpack, we have a few suggestions that can help us test and review your patch:

  • ✅ Include a description of your PR changes.
  • ✅ Add a "[Status]" label (In Progress, Needs Review, ...).
  • ✅ Add testing instructions.
  • ✅ Specify whether this PR includes any changes to data or privacy.
  • ✅ Add changelog entries to affected projects

This comment will be updated as you work on your PR and make changes. If you think that some of those checks are not needed for your PR, please explain why you think so. Thanks for cooperation 🤖


Follow this PR Review Process:

  1. Ensure all required checks appearing at the bottom of this PR are passing.
  2. Make sure to test your changes on all platforms that it applies to. You're responsible for the quality of the code you ship.
  3. You can use GitHub's Reviewers functionality to request a review.
  4. When it's reviewed and merged, you will be pinged in Slack to deploy the changes to WordPress.com simple once the build is done.

If you have questions about anything, reach out in #jetpack-developers for guidance!

@enejb
enejb force-pushed the add/forms-conditional-logic-editor branch from c0f0b60 to af5ffb3 Compare August 4, 2026 18:36
enejb added a commit that referenced this pull request Aug 10, 2026
Any field can be shown or hidden based on another field's answer. Disabled by
default: the whole feature sits behind the `forms-conditional-logic` flag,
registered with the jetpack-feature-flags package, while it is in testing.

Five pieces, reviewable separately as #50976-#50980:

- the shared vocabulary both sides speak -- the comparison behaviour each field
  type has, the operators it offers, the value input each operator needs -- plus
  a `conditional_logic` declaration on each field block, beside `form_editor`
  and outside the registered block settings so it cannot collide with core
  block metadata
- the resolver, written once in JS and once in PHP, because the browser decides
  what the visitor sees while the server decides what it accepts, and the
  browser's answer cannot be trusted at submit time. Resolution is a fixed point,
  since a field's visibility can depend on a field that is itself conditional;
  a cycle fails open, because hiding a field the visitor cannot reveal is a dead
  end
- the front-end runtime, which shows and hides fields as the visitor answers and
  skips hidden fields when validating
- the server side: hidden fields are skipped by validation, so a required field
  the visitor cannot see can never block submission; initial visibility is
  resolved during render so a hidden field never flashes into view; and a hidden
  field's answer reaches nothing downstream
- the editor panel, added by an `editor.BlockEdit` filter rather than per-block
  wiring, loaded through a lazy boundary so a site with the feature off never
  fetches it

Rebased onto trunk by content rather than by replaying commits: the branch had
36 commits against 103 of drift, and the conflicts landed in intermediate states
that later work removed outright, so replaying them would have produced commits
that never existed in a working form. The tree here is byte-identical to the
verified pre-rebase branch merged with trunk.
@enejb
enejb force-pushed the add/forms-conditional-logic-editor branch from af5ffb3 to d162c28 Compare August 10, 2026 22:29
The authoring UI: pick an action, pick whether any or all conditions must hold,
and build rules against any other field in the form.

Added by an `editor.BlockEdit` filter rather than per-block wiring. The field
blocks share no single inspector component -- four of them build their own -- so
a filter is the only way to cover all of them without touching nineteen edit
files, and new field types inherit the panel automatically. It mounts only for
the selected block: the panel walks the whole form tree to build its subject
list, and mounting it on every field made that walk run per field on every
block-editor store change.

The panel loads through a lazy boundary, so a site with the feature off never
fetches it: the filter is not registered, the component never renders, and the
browser never requests the chunk. That keeps roughly 700KB out of the editor
bundle that loads on every site.

Any field in the form can be a rule's subject, with operators and a value input
chosen from that field's declared comparison behaviour. Fields without an
explicit id get one assigned when first referenced, checked against the ids
already in use including the panel's own field -- otherwise two same-labelled
fields could end up sharing an id and the rule would evaluate the wrong one.
@kraftbj
kraftbj force-pushed the add/forms-conditional-logic-editor branch from d162c28 to 012ee9f Compare August 12, 2026 21:43

@kraftbj kraftbj 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.

Reviewed the panel layer on its own. The round-1 id-collision fix is genuinely reused rather than reimplemented — ownFieldId is folded into usedIds and it goes through the same generateUniqueFormFieldId helper the Name/ID control uses. The hasFilter guard is correct across the two bundles because @wordpress/hooks is externalized to the shared wp.hooks global, and the isSelected gate keeps the block-tree walk to one panel instance. Deleted subjects surface a Notice, label edits don't break rules since they reference id, and i18n and the icon-only control's accessible name are handled.

Not approving yet, on the rating issue — but the fix belongs in 50976 where the declaration lives, so I've filed it there rather than duplicating it here. This PR is what makes it reachable: block-types.js builds the subject map from every block's conditional_logic.type, so Rating shows up in the dropdown looking like any other numeric field, the author sets "equals 4", and it never fires.

Two things inline.

Comment thread projects/packages/forms/tests/js/blocks/shared/conditional-logic/panel.test.jsx Outdated
* @param {object} field - Subject field descriptor.
* @return {string} A value unique within the dropdown.
*/
const selectionValue = field => field.id || `clientId:${ field.clientId }`;

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.

Duplicating a field that already has an explicit id gives two dropdown entries with the same value, and both lookups resolve to the first one.

WordPress's Duplicate and paste copy attributes verbatim, id included, and nothing here re-derives a fresh one. walk() lists both blocks, both produce the same selectionValue, and the two fields.find(...) lookups — the subject resolution in handleFieldChange and the one in RuleRow — return the first match regardless of which option the author clicked. So the author can't point a rule at the second copy; the UI silently substitutes the first.

Transient (it resolves once either id is edited, or once PHP's dedupe renames one at render), and not a crash, so I'd take it as a follow-up rather than a blocker. Keying options by clientId and disambiguating the label when two fields share an id would settle it.

While you're in here: choosing an id-less field as a subject writes a permanent id onto a field the author didn't otherwise touch, changing it from render-derived (g{form_id}-{label}) to the JS slug. The comment a few lines down reasons through the risk, but nothing tells the author it happened. A one-line notice near the Field dropdown would cover it, since this panel's whole purpose is being pointed at existing fields on existing forms.

enejb added a commit that referenced this pull request Aug 13, 2026
Any field can be shown or hidden based on another field's answer. Disabled by
default: the whole feature sits behind the `forms-conditional-logic` flag,
registered with the jetpack-feature-flags package, while it is in testing.

Five pieces, reviewable separately as #50976-#50980:

- the shared vocabulary both sides speak -- the comparison behaviour each field
  type has, the operators it offers, the value input each operator needs -- plus
  a `conditional_logic` declaration on each field block, beside `form_editor`
  and outside the registered block settings so it cannot collide with core
  block metadata
- the resolver, written once in JS and once in PHP, because the browser decides
  what the visitor sees while the server decides what it accepts, and the
  browser's answer cannot be trusted at submit time. Resolution is a fixed point,
  since a field's visibility can depend on a field that is itself conditional;
  a cycle fails open, because hiding a field the visitor cannot reveal is a dead
  end
- the front-end runtime, which shows and hides fields as the visitor answers and
  skips hidden fields when validating
- the server side: hidden fields are skipped by validation, so a required field
  the visitor cannot see can never block submission; initial visibility is
  resolved during render so a hidden field never flashes into view; and a hidden
  field's answer reaches nothing downstream
- the editor panel, added by an `editor.BlockEdit` filter rather than per-block
  wiring, loaded through a lazy boundary so a site with the feature off never
  fetches it

Rebased onto trunk by content rather than by replaying commits: the branch had
36 commits against 103 of drift, and the conflicts landed in intermediate states
that later work removed outright, so replaying them would have produced commits
that never existed in a working form. The tree here is byte-identical to the
verified pre-rebase branch merged with trunk.
enejb added 4 commits August 13, 2026 08:01
The attribute kept its rules in a map keyed by condition kind, which cannot
express "any of these AND all of those" -- so supporting more than one grouping
later meant reshaping what is already stored. It is an array of groups now, each
combining its own rules with its own operator, combined with each other by the
top-level one.

Both evaluators handle several groups already, even though the V1 panel writes
exactly one: if only the storage changed, the second group would still arrive
needing an evaluator change. With a single group the outer reduction is a no-op,
so behaviour is unchanged.

Rules now carry their own type, so further condition kinds become new rule types
inside a group rather than another reshape. A rule of an unknown kind is
ignored, so a form saved by a newer editor degrades to its remaining conditions.
A rating was declared as a `number`, and every rule against one was silently
dead. The field submits `selected/max` -- `4/5`, not `4` -- so is_numeric() and
Number() both refuse it, and every numeric operator returns false. "Show when
the rating is at least 4" hid its field permanently. Only is_empty and
is_not_empty worked, because they never reach the numeric comparison.

Rating is its own type key rather than a special case inside `number`, because
two things differ. The submitted value needs unpacking before it can be compared
-- only the submitted side, since the rule stores a bare number. And the values
worth offering are the field own scale, so the rule builder lists 1..max from
the block max attribute instead of a free number box that would accept 6 stars
out of 5. The operators are the numeric set, which is why sharing the key looked
reasonable.
The id a rule points at is the load-bearing part of the feature: a wrong one
silently retargets a rule at another field, or renames a field that may already
have responses. That path ran in no test. panel.test.jsx and
with-conditional-logic.test.jsx both mocked use-subject-fields.js wholesale,
including a hand-rolled copy of the collision logic, and the one assertion meant
to catch the regression used arrayContaining against an always-undefined
ownFieldId, so deleting the ownFieldId wiring left the suite green.

Add use-subject-fields.test.jsx exercising the real useEnsureFieldId against the
real generateUniqueFormFieldId: minting a slug id, keeping an explicit id,
de-duplicating, and the round-1 owner collision where a sibling's label matches
the panel's own field id. It also covers the real useSubjectFields walk (owner
exclusion, id-less fields, multi-step numbering).

Strengthen panel.test.jsx to assert the exact usedIds array including ownFieldId,
so removing ownFieldId from the panel now fails the suite instead of passing.

@kraftbj kraftbj 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.

Approving. The rating-field blocker from my last review is fixed, and the id-collision guard now has real coverage.

6597845 adds use-subject-fields.test.jsx, which exercises the actual useEnsureFieldId / generateUniqueFormFieldId path (mint, keep-existing, de-dupe, and the round-1 owner collision) plus the real useSubjectFields walk, and strengthens panel.test.jsx so the uniqueness check asserts the exact usedIds array including ownFieldId. Verified the test does its job: dropping ownFieldId={ attributes.id } from the panel turns the suite red.

Two items I'd treat as potential follow-ups rather than blockers:

  • No notice when choosing an id-less field mints a permanent id. The minted bare slug differs from the renderer's derived g{form_id}-{label} key, so this can change a field's submission key on a form that may already have responses — worth a one-line notice near the Field dropdown.
  • Duplicating a field (WordPress copies the id verbatim) yields two dropdown entries with the same value, and both lookups resolve to the first. Keying options by clientId would settle it.

Neither blocks this PR.

enejb added a commit that referenced this pull request Aug 14, 2026
panel.test.jsx mocks useSubjectFields and useEnsureFieldId so it can drive the
rule builder without a block editor. That left what they actually implement
untested: which fields are offered as subjects, and what id a chosen one is
given. The id assignment is the part worth pinning -- getting it wrong silently
repoints a rule at another field, or renames a field that may already have
responses stored against its old id.

Covers minting an id from a label, keeping an explicit one, de-duplicating
against ids already in the form, the empty-slug fallback, and a missing field.
For useSubjectFields: sibling fields listed, the panel's own excluded, id-less
fields kept, step numbering, the label fallbacks, and a field outside any form.

The panel suite gains the one assertion it could not make with a fixture that
had no id of its own -- that the panel's own field id reaches the uniqueness
check. Without it an unnamed sibling whose label slugifies the same way is
handed the owner's id.

Both guards verified by breaking them: dropping the used-id list fails the
de-duplication tests, and dropping ownFieldId fails the panel one.

Equivalent ground to kraftbj's tests on #50980, written against the UI as it
now stands rather than cherry-picked -- the panel those assertions drove has
since moved into a dialog.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants