Skip to content

Search 3.0: dashboard feature-selection UI (RSM-2116)#48500

Merged
adamwoodnz merged 55 commits intotrunkfrom
rsm-2116-search-dashboard-feature-selection-ui
May 6, 2026
Merged

Search 3.0: dashboard feature-selection UI (RSM-2116)#48500
adamwoodnz merged 55 commits intotrunkfrom
rsm-2116-search-dashboard-feature-selection-ui

Conversation

@adamwoodnz
Copy link
Copy Markdown
Contributor

@adamwoodnz adamwoodnz commented May 4, 2026

Fixes RSM-2116: Search dashboard feature selection UI

Why

Today site owners on the Jetpack Search dashboard juggle two independent toggles to decide what visitors see when they search, and there's no place in the UI for the upcoming block-based "Embedded Search" experience to live. Behind the Search 3.0 feature flag, this PR adds a single radio list — Embedded search, Overlay search, Theme search, or Off — with a Save button, so picking an experience is one explicit decision with the active choice clearly labelled. The legacy toggles continue to render below the new selector so admins can keep managing settings while the back-end work to actually persist the new choice lands.

Status: the new selector itself can't fully save until the back-end PR (#48540, RSM-2291) ships. Until then the legacy <ModuleControl> toggles still render below the new picker so admins aren't blocked. The whole new UI is also gated behind the jetpack_search_blocks_enabled filter, which is off by default in production.

Default

Screenshot 2026-05-05 at 11 29 36 AM

Classic search only

Screenshot 2026-05-05 at 11 51 24 AM

Proposed changes

  • New <FeatureSelector> component on the Search Dashboard: a four-row radio list (Embedded search · Overlay search · Theme search · Off) under a visible <h2> heading "Select a search experience for your visitors", with a Save button that's disabled until a different option is picked.
  • Gated behind the existing jetpack_search_blocks_enabled server-side filter, exposed to the React app as searchBlocksEnabled in the dashboard's initial state. When the flag is on, <FeatureSelector> renders above the legacy <ModuleControl>. When the flag is off, only the legacy toggles render. <ModuleControl> now renders regardless of the flag — until the back-end experience field lands (RSM-2291), the new selector can't actually persist changes, so keeping the legacy toggles visible lets admins continue managing Search settings.
  • Save dispatches a saveExperience thunk that posts a single-field payload — { experience } (one of 'embedded' | 'overlay' | 'inline' | 'off') — to /jetpack/v4/search/settings. Without Search 3.0: backend support for experience field in settings API (RSM-2291) #48540 the endpoint 400s on this payload and the UI handles that path correctly (error notice, pending stays put, no Active-badge promotion). With Search 3.0: backend support for experience field in settings API (RSM-2291) #48540, the round-trip succeeds and the seeded experience value is what getActiveExperience reads.
  • Store: a new experience slot holds the active value (seeded from initial state at boot, also written by setActiveExperience after a successful save as defence-in-depth). getActiveExperience falls back to deriving from the legacy module_active / instant_search_enabled booleans if experience is absent — covers unit tests and the no-back-end period before Search 3.0: backend support for experience field in settings API (RSM-2291) #48540 ships.
  • Plan-gating: on plans that supportsOnlyClassicSearch(), the Embedded search and Overlay search rows render disabled with an "Upgrade your plan to unlock this option." tooltip.
  • Built on @wordpress/ui v0.12.0 primitives — adds the package as a direct dep on the search package and uses its shipped Badge (intent="informational" for Recommended, intent="stable" for Active), Stack (option list, option-row layout, body, title, footer), and Button (Save, with disabled + loading driving aria-disabled / busy state).
  • New analytics event jetpack_search_experience_save (props: previous_experience, new_experience) on the new selector flow. The legacy per-toggle jetpack_search_module_toggle / jetpack_search_instant_toggle events continue to fire from the legacy <ModuleControl> whenever it renders.
  • Styling uses @wordpress/theme design tokens (--wpds-dimension-*, --wpds-color-*, --wpds-border-*, --wpds-typography-*) throughout — no literal px / hex values. The selected-row border uses --wpds-color-stroke-interactive-brand so the selection ring matches the rest of the admin UI.
  • MockedSearch and RecordMeter are unchanged; only the bottom control area gains the new selector above the existing legacy toggles.

Why inline instead of classic?

The wire/storage value for the non-instant non-embedded option is 'inline'. The string classic collides with the older Jetpack Search "classic search" terminology in the package, which refers to a separate (deprecated) experience. The user-facing label remains "Theme search."

Related product discussion/links

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

Yes — adds one new tracks event, jetpack_search_experience_save, on the new selector flow only. Props are two enum strings ('embedded' | 'overlay' | 'inline' | 'off') capturing the previous and newly-saved experience. Legacy per-toggle events still fire from <ModuleControl>. Tagging the PR with [Status] Needs Privacy Updates for review.

Testing instructions

Automated

cd projects/packages/search
pnpm test

Expected: dashboard JS suites pass (selectors + actions including saveExperience success and failure paths, <ExperienceOption>, <FeatureSelector>, dashboard-page branching with both new selector and legacy ModuleControl rendering when the flag is on).

Manual smoke

The path that's testable end-to-end depends on whether #48540 is also installed on the test site:

  1. Open the Search Dashboard at WP Admin → Jetpack → Search.
  2. Without overriding the filter, confirm the legacy <ModuleControl> (two toggles) renders. No new selector.
  3. Add a mu-plugin with add_filter( 'jetpack_search_blocks_enabled', '__return_true' );. Refresh the dashboard.
  4. Confirm the visible <h2> "Select a search experience for your visitors" sits above a card containing four radio rows in order: Embedded search (Recommended badge) · Overlay search · Theme search · Off. Below the new selector, the legacy <ModuleControl> is also rendered.
  5. Confirm the Active badge sits on the row that matches your current settings (or the seeded experience value if Search 3.0: backend support for experience field in settings API (RSM-2291) #48540 is installed and a value was previously saved).
  6. Save is disabled (aria-disabled="true") until you pick a different option, and remains focusable.
  7. Pick Embedded search → Save. In DevTools Network: the POST to /jetpack/v4/search/settings body is exactly {"experience":"embedded"}.
  8. Refresh the page. Without Search 3.0: backend support for experience field in settings API (RSM-2291) #48540, state derives from the legacy booleans (no seeded experience). With Search 3.0: backend support for experience field in settings API (RSM-2291) #48540, the seeded experience from initial state drives the Active badge.
  9. Cycle the selection (Embedded → Overlay → Theme search → Off) and verify each click sends the corresponding {"experience":"…"} request body. Note that the wire value for "Theme search" is inline, not classic.
  10. On a free / classic-only plan, Embedded search and Overlay search rows render disabled with the upgrade tooltip; clicking them is a no-op.
  11. Toggle the legacy <ModuleControl> toggles below the new selector — confirm they still drive module_active / instant_search_enabled independently and the Active badge updates to match (via the legacy-boolean fallback in getActiveExperience).

@adamwoodnz adamwoodnz added [Status] Needs Review This PR is ready for review. [Status] Needs Privacy Updates Our support docs will need to be updated to take this change into account labels May 4, 2026
@adamwoodnz adamwoodnz self-assigned this May 4, 2026
@github-actions github-actions Bot added [Package] Search Contains core Search functionality for Jetpack and Search plugins [Tests] Includes Tests labels May 4, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

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!

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 4, 2026

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 or WordPress.com Site Helper), and enable the rsm-2116-search-dashboard-feature-selection-ui branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack rsm-2116-search-dashboard-feature-selection-ui
bin/jetpack-downloader test jetpack-mu-wpcom-plugin rsm-2116-search-dashboard-feature-selection-ui

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

@adamwoodnz adamwoodnz added [Status] In Progress and removed [Status] Needs Review This PR is ready for review. labels May 4, 2026
@adamwoodnz adamwoodnz marked this pull request as draft May 4, 2026 21:27
@jp-launch-control
Copy link
Copy Markdown

jp-launch-control Bot commented May 4, 2026

Code Coverage Summary

Coverage changed in 22 files. Only the first 5 are listed here.

File Coverage Δ% Δ Uncovered
projects/packages/search/src/class-module-control.php 28/36 (77.78%) -11.11% 4 💔
projects/packages/search/src/class-package.php 0/12 (0.00%) -8.33% 1 ❤️‍🩹
projects/packages/search/src/class-plan.php 38/49 (77.55%) -2.04% 1 ❤️‍🩹
projects/packages/search/src/dashboard/store/actions/jetpack-settings.js 24/28 (85.71%) 6.77% 0 💚
projects/packages/search/src/class-helper.php 312/377 (82.76%) 0.27% -1 💚

3 files are newly checked for coverage.

File Coverage
projects/packages/search/src/dashboard/components/feature-selector/index.jsx 12/16 (75.00%) 💚
projects/packages/search/src/dashboard/components/feature-selector/constants.js 17/20 (85.00%) 💚
projects/packages/search/src/dashboard/components/feature-selector/experience-option.jsx 11/11 (100.00%) 💚

Full summary · PHP report · JS report

Coverage check overridden by I don't care about code coverage for this PR Use this label to ignore the check for insufficient code coveage. .

@adamwoodnz adamwoodnz marked this pull request as ready for review May 4, 2026 23:42
@adamwoodnz adamwoodnz requested a review from Copilot May 4, 2026 23:43
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a Search 3.0–gated “feature-selection” control on the Jetpack Search dashboard, replacing the legacy two-toggle UI with a single radio-list experience picker and a Save action, and wires the feature flag through the dashboard’s initial state/store.

Changes:

  • Add a new <FeatureSelector> (radio list + Save) and supporting components/styles (including a new <Badge>).
  • Extend the dashboard store with “experience” selectors/actions and a searchBlocksEnabled initial-state flag.
  • Add JS + PHP tests for the new store behavior and UI branching, plus a Search package changelog entry.

Reviewed changes

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
projects/packages/search/src/dashboard/class-initial-state.php Exposes searchBlocksEnabled (mirrors jetpack_search_blocks_enabled) to the React initial state.
projects/packages/search/src/dashboard/store/selectors/site-data.js Adds selector for the new searchBlocksEnabled initial-state flag.
projects/packages/search/src/dashboard/store/selectors/jetpack-settings.js Adds experience derivation + new “pending/selected/active/dirty” selectors.
projects/packages/search/src/dashboard/store/actions/jetpack-settings.js Adds saveExperience + pending/last-saved experience actions and analytics event.
projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx Switches bottom controls to <FeatureSelector> when searchBlocksEnabled is true.
projects/packages/search/src/dashboard/components/feature-selector/index.jsx New feature-selection form (fieldset of radio rows + Save).
projects/packages/search/src/dashboard/components/feature-selector/experience-option.jsx New radio-row component with badges + plan-gating disabled state.
projects/packages/search/src/dashboard/components/feature-selector/constants.js Defines experience IDs, order, labels/descriptions, and icons.
projects/packages/search/src/dashboard/components/feature-selector/style.scss WPDS-token-based styling for the selector card and rows.
projects/packages/search/src/dashboard/components/badge/index.jsx New local Badge primitive for “Recommended”/“Active” labels.
projects/packages/search/src/dashboard/components/badge/style.scss Badge styling aligned to WPDS tokens.
projects/packages/search/tests/php/Initial_State_Test.php PHPUnit coverage for the new searchBlocksEnabled initial-state field.
projects/packages/search/tests/js/dashboard/store/jetpack-settings.test.js Jest coverage for new selectors/actions and saveExperience behavior.
projects/packages/search/tests/js/dashboard/pages/dashboard-page.test.jsx Jest coverage for FeatureSelector vs ModuleControl branching.
projects/packages/search/tests/js/dashboard/feature-selector/index.test.jsx Jest coverage for FeatureSelector rendering/disabled-save/plan gating.
projects/packages/search/tests/js/dashboard/feature-selector/experience-option.test.jsx Jest coverage for ExperienceOption badges, checked state, and disabled rows.
projects/packages/search/changelog/rsm-2116-search-dashboard-feature-selection-ui Changelog entry for the new dashboard UI (gated).

Comment thread projects/packages/search/src/dashboard/store/actions/jetpack-settings.js Outdated
Comment thread projects/packages/search/src/dashboard/components/feature-selector/style.scss Outdated
Comment thread projects/packages/search/src/dashboard/components/badge/index.jsx Outdated
adamwoodnz added a commit that referenced this pull request May 5, 2026
`updateJetpackSettings` catches its own errors and returns an
error-notice action instead of throwing, which meant `saveExperience`
was unconditionally calling `setLastSavedExperience` and clearing
`pending_experience` even when the API call failed — moving the
Active badge to the failed selection and removing the user's pending
choice so they couldn't retry without re-clicking.

Capture the inner generator's return value and gate the promotion
on `notice.status === 'is-success'`. On failure we exit early and
pending stays put, so the user can retry. Added a test for the
failure path; updated the existing success-path test to use the
real notice shape rather than an `{ ok: true }` placeholder.

Caught by Copilot review on PR #48500.
adamwoodnz added a commit that referenced this pull request May 5, 2026
Leftover from when the heading was a visually-hidden legend with a
separate visible label. No JSX renders an element with this class
anymore, so the rule was dead CSS.

Caught by Copilot review on PR #48500.
Comment on lines +174 to +176
<div className="jp-search-dashboard-wrap jp-search-feature-selector-wrap">
<div className="jp-search-dashboard-row">
<div className="lg-col-span-12 md-col-span-8 sm-col-span-4">
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.

This can all be removed when we move to the tabs

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 17 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • pnpm-lock.yaml: Language not supported

Comment thread projects/packages/search/src/dashboard/components/feature-selector/index.jsx Outdated
@adamwoodnz
Copy link
Copy Markdown
Contributor Author

@claude review please

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 5, 2026

Claude finished @adamwoodnz's task in 3m 55s —— View job


Review

  • Gather context and understand request
  • Read changed files and diff
  • Analyze store actions/selectors
  • Analyze new components (FeatureSelector, Badge)
  • Analyze PHP changes and tests
  • Post comprehensive review

Overall this is a well-structured, carefully-gated feature with good separation of concerns and solid test coverage. No blockers found. A few things worth discussing before merge:


Store — actions (jetpack-settings.js)

Error rollback is incomplete for the new experience field

updateJetpackSettings optimistically calls setJetpackSettings({ experience }), but the catch block only restores module_active and instant_search_enabled:

// class-initial-state.php line ~36-43
const oldSettings = Object.fromEntries(
    Object.entries( select( STORE_ID ).getSearchModuleStatus() ).filter(
        ( [ k ] ) => k === 'module_active' || k === 'instant_search_enabled'
    )
);
yield setJetpackSettings( oldSettings );

On a failed save, jetpackSettings.experience in the store is left with the attempted (unconfirmed) value. This doesn't break the UI today because no selector reads jetpackSettings.experience directly (they read last_saved_experience), but it's a latent inconsistency. Fix this →


Store — selectors (jetpack-settings.js)

deriveExperienceFromBooleans can never return 'embedded' (acknowledged in PR description)

The derivation path maps booleans to 'off'/'overlay'/'classic' but has no 'embedded' path. After a page refresh (before RSM-2291 lands), a user who just saved embedded will see classic (Theme search) as active. This is documented, but consider making the gap explicit in the function itself with a comment so future contributors don't accidentally "fix" it:

// NOTE: 'embedded' is not derivable from booleans — it can only be set by
// setLastSavedExperience() within a session, or seeded from the backend once
// RSM-2291 persists the 'experience' field.

Component — FeatureSelector

!pendingExperience guard in onSubmit is unreachable

// index.jsx line 37
if ( isSaveDisabled || ! pendingExperience ) {

isSaveDisabled = !isDirty || isUpdating. When isDirty is true, pending_experience is guaranteed non-null by isDirty's implementation. The !pendingExperience branch can never be reached when isSaveDisabled is false. Harmless, but it adds a false impression that pendingExperience can be falsy when the save is enabled. Fix this →


Component — ExperienceOption

<fieldset> without <legend>

The selector uses <fieldset aria-labelledby="jp-search-feature-selector-heading"> pointing to the <h2> that lives outside the <form>. This is valid ARIA but <legend> has broader screen reader coverage. Since the heading IS a visible label, consider wrapping the heading text in a visually-styled <legend> element inside the fieldset (NVDA and older JAWS versions handle <legend> more reliably than aria-labelledby for fieldsets). If keeping the current pattern for layout reasons, that's understandable.

Two separate useSelect calls for selected and active

const selected = useSelect( select => select( STORE_ID ).getSelectedExperience(), [] );
const active = useSelect( select => select( STORE_ID ).getActiveExperience(), [] );

These can be combined into one useSelect call, reducing the number of store subscriptions per rendered row (4 rows × 2 subscriptions = 8 vs 4):

const { selected, active } = useSelect( select => ( {
    selected: select( STORE_ID ).getSelectedExperience(),
    active: select( STORE_ID ).getActiveExperience(),
} ), [] );

Fix this →


Analytics

jetpack_search_experience_save fires before the save result is known

// jetpack-settings.js line 121-124
analytics.tracks.recordEvent( 'jetpack_search_experience_save', { ... } );
const result = yield updateJetpackSettings( { experience } );

The event fires synchronously before the API call completes, so save failures are counted as successful intent-to-save events. The PR body acknowledges this matches legacy behavior — but the legacy toggle events fire after the toggle state is visually reflected, not before the request is issued. If failed saves are measurable (e.g. connectivity issues on mobile), this inflates the event count. Worth considering whether moving the recordEvent call after the success check is worth diverging from the legacy pattern.


Tests

fireEvent vs userEvent

Both test files suppress testing-library/prefer-user-event with a disable comment. userEvent more accurately simulates real browser interactions (e.g. it dispatches pointer events, triggers focus, etc.). For radio buttons specifically, userEvent.click behaves closer to a real click than fireEvent.click. Fine to defer, but should be tracked as a follow-up.

saveExperience failure test drives the generator manually

// jetpack-settings.test.js line ~221
const next = gen.next( { notice: { status: 'is-error' } } );
expect( next.done ).toBe( true );

This verifies the generator terminates after an error result, but it doesn't assert that setPendingExperience and setLastSavedExperience were NOT called. Adding negative assertions would make the intent explicit and guard against future regressions.


PHP (class-initial-state.php)

Looks clean. The (bool) cast on apply_filters('jetpack_search_blocks_enabled', false) is correct. Teardown in Initial_State_Test properly calls remove_all_filters before parent::tearDown(). No issues.


Positive notes

  • The generator pattern (yielding an inner generator via plain yield) is correctly used and consistent with the rest of the package — adamwoodnz's explanation of rungen's iterator control is accurate.
  • aria-disabled="true" on the Save button (via @wordpress/ui Button's focusableWhenDisabled default) correctly preserves focus order while visually disabling.
  • aria-live="polite" on the footer Stack ensures "Saving…" is announced without interrupting screen reader users.
  • WPDS tokens throughout — no literal hex/px values.
  • The pending → last_saved promotion only on confirmed success is the right model.
  • Feature flag architecture is clean: single PHP filter, single initial-state key, single React branch.

export const EXPERIENCE = Object.freeze( {
EMBEDDED: 'embedded',
OVERLAY: 'overlay',
CLASSIC: 'classic',
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.

we are retiring Classic search if I'm not mistaken. The experience should be 'inline' search? @gibrown

Copy link
Copy Markdown
Contributor Author

@adamwoodnz adamwoodnz May 5, 2026

Choose a reason for hiding this comment

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

This string corresponds to the 'Your theme' option. So Jetpack Search enabled but not using overlay or blocks. I used the classic term because it was already used in the code, but if that's confusing perhaps we could change it to theme, or inline.

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.

@kangzj is this decision blocking? Keen to merge and get the backend going too. Happy to switch to inline if you think that's best.

Copy link
Copy Markdown
Contributor Author

@adamwoodnz adamwoodnz May 6, 2026

Choose a reason for hiding this comment

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

I can see other references to classic (eg. is_swap_classic_to_inline_search) so I've renamed it to inline in 60a349e

* dashboard React app can gate the new feature-selection UI on the
* same flag the back end uses to register the blocks themselves.
*/
'searchBlocksEnabled' => (bool) apply_filters( 'jetpack_search_blocks_enabled', false ),
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.

👍

kangzj
kangzj previously approved these changes May 5, 2026
Copy link
Copy Markdown
Contributor

@kangzj kangzj left a comment

Choose a reason for hiding this comment

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

This looks like a good start and is not affecting current dashboard. 🚢

Would be good to improve mobile view:

Image

adamwoodnz added a commit that referenced this pull request May 5, 2026
`updateJetpackSettings` writes the optimistic settings into the store
before yielding the API control. On failure the catch block restored
only `module_active` / `instant_search_enabled` — the new `experience`
field was left at the unconfirmed value. No selector reads
`jetpackSettings.experience` today (we read `last_saved_experience`),
so this was latent — but cheap to fix and keeps the store consistent
for whenever consumers do start reading it.

Caught by claude[bot] review on PR #48500.
adamwoodnz added a commit that referenced this pull request May 5, 2026
…M-2116)

The booleans returned by the REST endpoint can't distinguish Embedded
from Theme search — both are `module_active=true,
instant_search_enabled=false`. Add an inline NOTE so future
contributors don't add an `'embedded'` branch and silently change the
fallback behaviour for sites with that boolean pair.

Caught by claude[bot] review on PR #48500.
adamwoodnz added 16 commits May 6, 2026 10:59
`<Stack>` with no `direction` defaults to row (it sets
`flexDirection: undefined`, so the browser falls back to the CSS
default). Drop the explicit `direction="row"` from the option-stack,
title, and footer Stacks; the column Stacks (outer options list,
option-body) keep `direction="column"` since that's a real override.
Now that `@wordpress/ui` is a direct dep (Stack, Badge), use its Button
for the Save action too. Maps the previous `@wordpress/components` API
across:

- `variant="primary"` → drop (defaults `variant="solid" tone="brand"`
  give the same primary-brand button)
- `isBusy` → `loading`
- `aria-disabled={...}` → `disabled={...}`. `@wordpress/ui` Button has
  `focusableWhenDisabled=true` by default, so it still renders
  `aria-disabled="true"` rather than the native `disabled` attribute,
  preserving focus order. Existing tests that assert
  `aria-disabled="true"`/`"false"` continue to pass unchanged.

Update the docblock to reflect the new behaviour and drop the stale
"would pull a new runtime dep" justification.
`updateJetpackSettings` writes the optimistic settings into the store
before yielding the API control. On failure the catch block restored
only `module_active` / `instant_search_enabled` — the new `experience`
field was left at the unconfirmed value. No selector reads
`jetpackSettings.experience` today (we read `last_saved_experience`),
so this was latent — but cheap to fix and keeps the store consistent
for whenever consumers do start reading it.

Caught by claude[bot] review on PR #48500.
…M-2116)

The booleans returned by the REST endpoint can't distinguish Embedded
from Theme search — both are `module_active=true,
instant_search_enabled=false`. Add an inline NOTE so future
contributors don't add an `'embedded'` branch and silently change the
fallback behaviour for sites with that boolean pair.

Caught by claude[bot] review on PR #48500.
`isSaveDisabled = !isDirty || isUpdating`, and `isDirty` already
guarantees `pending_experience` is non-null when it's true. So when
`isSaveDisabled` is false, `pendingExperience` is guaranteed
non-null — the `! pendingExperience` branch is dead code that
implied the value could be falsy when the save is enabled.

Caught by claude[bot] review on PR #48500.
Two `useSelect` subscriptions per row × four rows was eight store
listeners; collapse to one per row. Same observable values, half the
subscription churn on store updates.

Caught by claude[bot] review on PR #48500.
…2116)

Improve mobile / narrow-viewport behavior (kangzj review on PR #48500).
Previously the option row was a single flex row that wrapped on phone-
width, which let the radio drop to its own line below the title. The
new layout pins the radio to its own grid column on the left, vertically
centered against the content; everything else (icon · title · description
· Recommended badge · Active badge) lives in a wrapping Stack in column
2 and wraps among itself when the viewport is narrow.

The mobile-specific `flex-wrap: wrap` override is no longer needed —
the column-2 Stack always wraps — so the phone-down rule shrinks back
to just the smaller card padding.
…2116)

Move the wrap point from the column-2 Stack to the title Stack:

- column-2 (icon · body · active badge) no longer wraps, so the icon
  always shares a row with the body's title and the description sits
  directly beneath it. The active badge stays vertically centered to
  the body's right edge.
- the title Stack (label text + Recommended badge) now wraps, so on
  narrow viewports the Recommended badge drops onto a second line
  beneath the label text rather than pushing the rest of the row out
  of shape.
WP Admin's `wp-admin/css/forms.css` ships `margin: -4px 4px 0 0` on
`input[type=radio]` to fudge alignment with adjacent label baselines
in classic forms. That same negative margin was leaking into the
feature-selector option row, pulling the radio about 2px above the
icon's vertical centre.

Reset the margin to 0 on `.jp-search-feature-selector__option-radio`
so the grid's `align-items: center` puts the radio on the same
baseline as the icon. Verified in Chrome: previous deltaY between
radio centre and icon centre was -2px, now 0.
WP Admin's `input[type="checkbox"], input[type="radio"]` rule (with
the bad `margin: -4px 4px 0 0`) has specificity (0,1,1), the same as
our previous `.jp-search-feature-selector__option-radio` reset
(0,1,0) — except the WP rule wins because of the attribute selector.
Qualify ours with `input[type="radio"]` so the reset lands at (0,2,1)
and reliably overrides.
Restructure each feature-selector row so the icon sits next to the
title, the description wraps full width below them, and the Active
badge anchors a third grid column. The Recommended badge stays on
the title line when there's room and wraps to its own line on narrow
viewports without dropping below the description.

Previously the row was a 2-column grid (radio | flex row of icon,
body, badge) with the description nested under the title. The new
shape is a 3-column grid (radio | content stack | active badge),
which keeps the radio and Active badge centred against the whole
content block and lets the headline Stack handle its own wrap.
The headline row's `md` gap left the icon floating noticeably away
from its title; dropping to `sm` brings them visibly together as a
single label unit. The content stack's `xs` gap, by contrast, made
the description crowd the title — `sm` gives the description the
breathing room it needs to read as secondary copy.
The h2 was inheriting WP Admin's tight default line-height, which
left the descenders crowding the card below. Apply
`--wpds-typography-line-height-md` so the heading breathes in line
with the rest of the WPDS-tokenised dashboard typography.
"Pick what visitors see when they search" framed the choice as a
content question, but the four options are really four different
search products. "Select a search experience for your visitors"
matches the language used in the option labels and badges (Embedded
search / Overlay search / Theme search) and reads as a configuration
choice, which is what this section actually is.
37987ec reworded the feature-selector h2 from "Pick what visitors
see when they search" to "Select a search experience for your
visitors" but left two tests that look up the fieldset by its old
accessible name. Update both name patterns so the suite reflects the
shipping copy.
Each feature-selector row was a CSS grid (auto 1fr auto) that
hand-rolled the same flex layout @wordpress/ui's Stack already
provides. Swap to a row Stack that renders as the wrapping <label>
via Base UI's `render` prop, so the radio | content | active-badge
layout, gap, and vertical centering all come from design-system
tokens instead of bespoke grid CSS.

The middle column now grows via `flex: 1 1 auto` on the content
Stack (the row Stack's other two children — the radio and the
trailing Active badge — keep their intrinsic widths). A
`flex-shrink: 0` on the radio prevents the description's wrapping
content from squashing it on narrow viewports, where grid's `auto`
column had been doing that work for free.
@adamwoodnz adamwoodnz force-pushed the rsm-2116-search-dashboard-feature-selection-ui branch from decb802 to cacf057 Compare May 5, 2026 23:24
adamwoodnz and others added 3 commits May 6, 2026 14:10
Backend (RSM-2291) settled on `experience` as the field name in the REST
response and initial state, replacing the temporary `last_saved_experience`
forward-compat name. Update the dashboard store to match:

- Rename the store slot `last_saved_experience` → `experience`.
- Drop the redundant `getLastSavedExperience` selector — `getActiveExperience`
  is the only consumer of that slot, and it now reads `experience` directly
  with the legacy-boolean derivation as defence-in-depth.
- Rename `setLastSavedExperience` → `setActiveExperience` (paired with
  `getActiveExperience`); `saveExperience` writes through it after a
  successful save as a belt-and-braces complement to the post-save
  `fetchJetpackSettings` round-trip in `updateJetpackSettings`.
- Update test fixtures (`baseSettings` / `settings` blocks) and the
  jetpack-settings store test to the new field/action names.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`classic` collides with the older Jetpack Search "classic search"
terminology, which refers to a deprecated experience. Rename the new
non-instant non-embedded option to `inline` everywhere it appears:
the `EXPERIENCE` enum, `EXPERIENCE_ORDER`, the label/description/icon
switches, the legacy-fallback derivation in the selectors, and the
test fixtures that exercise the value.

User-facing labels are unchanged ("Theme search", "Your theme's search
layout, with faster results.") — only the wire/storage value changes.

Backend (PR #48540) needs a matching rename in `Module_Control`
constants (EXPERIENCE_CLASSIC → EXPERIENCE_INLINE) and validation list
so the wire format stays consistent end-to-end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…or (RSM-2116)

The new feature selector can't actually persist changes until the back-end
`experience` field lands (RSM-2291 / PR #48540), so until then the
dashboard would be effectively read-only when `jetpack_search_blocks_enabled`
is on. Render `ModuleControl` regardless of the feature flag for now so
admins can keep managing Search settings. The `FeatureSelector` still
appears above it when the flag is on, giving a preview of the new UI.

Updated the dashboard-page branching test to assert both components
render together when the flag is on, and that ModuleControl always
renders.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
adamwoodnz added a commit that referenced this pull request May 6, 2026
Match the frontend rename in PR #48500 — `classic` collided with the
deprecated original Jetpack Search "classic search" terminology. Wire
value is now `'inline'`; storage shape is unchanged (still deleted on
write, since inline is the absence of an opt-in).

- `Module_Control::EXPERIENCE_CLASSIC` → `EXPERIENCE_INLINE` (value `'inline'`)
- `update_experience()` switch case + validation list
- `get_experience()` legacy-fallback return value and docblock
- Test names + values in `Module_Control_Test` and `REST_Controller_Test`
- Changelog entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@adamwoodnz adamwoodnz merged commit 3930033 into trunk May 6, 2026
91 checks passed
@adamwoodnz adamwoodnz deleted the rsm-2116-search-dashboard-feature-selection-ui branch May 6, 2026 05:30
@github-actions github-actions Bot added [Status] UI Changes Add this to PRs that change the UI so documentation can be updated. and removed [Status] Ready to Merge Go ahead, you can push that green button! labels May 6, 2026
adamwoodnz added a commit that referenced this pull request May 6, 2026
Match the frontend rename in PR #48500 — `classic` collided with the
deprecated original Jetpack Search "classic search" terminology. Wire
value is now `'inline'`; storage shape is unchanged (still deleted on
write, since inline is the absence of an opt-in).

- `Module_Control::EXPERIENCE_CLASSIC` → `EXPERIENCE_INLINE` (value `'inline'`)
- `update_experience()` switch case + validation list
- `get_experience()` legacy-fallback return value and docblock
- Test names + values in `Module_Control_Test` and `REST_Controller_Test`
- Changelog entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
adamwoodnz added a commit that referenced this pull request May 6, 2026
…is on (RSM-2291)

The frontend PR (#48500) shipped a transitional state where ModuleControl
rendered alongside FeatureSelector even with the feature flag on, so
admins could still manage Search settings while the back-end `experience`
field was unimplemented. With this PR adding that back end, the new
selector can fully persist the user's choice on its own — the legacy
toggles below it become redundant.

Reverts the dashboard-page bottom-area to the either/or shape: when
`jetpack_search_blocks_enabled` is on, render only `<FeatureSelector>`;
otherwise render only `<ModuleControl>`. Updates the branching test to
assert ModuleControl is *absent* when the flag is on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
adamwoodnz added a commit that referenced this pull request May 6, 2026
…SM-2291) (#48540)

* feat(search): add backend support for experience field (RSM-2291)

- Add SEARCH_MODULE_EXPERIENCE_OPTION_KEY constant and experience value constants to Module_Control
- Add get_experience() method that returns persisted value or derives from legacy booleans
- Add update_experience() method that writes experience and legacy booleans in lockstep
- Update REST update_settings() to accept experience-only requests
- Update REST get_settings() to include last_saved_experience in response
- Update validate_search_settings() to allow experience-only requests
- Update Initial_State to include last_saved_experience
- Update existing tests to include last_saved_experience in expected responses
- Add new tests for experience read/write paths
- Add changelog entry"

Agent-Logs-Url: https://github.com/Automattic/jetpack/sessions/40f5e762-e4d0-4932-b4ac-5bd0ec01f26b

Co-authored-by: adamwoodnz <1017872+adamwoodnz@users.noreply.github.com>

* Search: narrow `experience` storage shape (RSM-2291)

Off and classic are no longer stored in `jetpack_search_experience`. Off
is read from `Modules::is_active()` because that bit lives in the global
`jetpack_active_modules` option; storing `'off'` here would drift the
moment any non-Search path (Jetpack dashboard module toggle, WP-CLI,
`Jetpack::activate_module()`) flipped the global. Classic is the absence
of an opt-in — `update_experience('classic')` deletes the option so
existing classic sites stay at zero writes and don't need a migration.

The wire format still exposes all four values (`embedded | overlay |
classic | off`), so JS callers and external consumers see a clean enum;
`get_experience()` resolves them from the on/off bit + saved value +
legacy `instant_search_enabled` fallback.

Renames the REST/initial-state field from `last_saved_experience` to
`experience` to match the JS selector (`getActiveExperience`) and the
request-body field name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Search: rename `classic` experience to `inline` (RSM-2291)

Match the frontend rename in PR #48500 — `classic` collided with the
deprecated original Jetpack Search "classic search" terminology. Wire
value is now `'inline'`; storage shape is unchanged (still deleted on
write, since inline is the absence of an opt-in).

- `Module_Control::EXPERIENCE_CLASSIC` → `EXPERIENCE_INLINE` (value `'inline'`)
- `update_experience()` switch case + validation list
- `get_experience()` legacy-fallback return value and docblock
- Test names + values in `Module_Control_Test` and `REST_Controller_Test`
- Changelog entry

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Search: address backend PR review feedback (RSM-2291)

From the bot review on #48540:

- Drop the unreachable `return true` after the exhaustive switch in
  `update_experience()`. The four valid values each return inside their
  case and the in_array guard rejects everything else, so the trailing
  return was dead code.
- Fix the stale "classic" mention in the REST controller's delegation
  comment — it should read "inline → delete option" after the rename.
- Reject requests that mix `experience` with `module_active` or
  `instant_search_enabled` rather than silently dropping the legacy
  fields. `experience` writes the legacy booleans in lockstep, so there
  is no scenario where the caller needs both. Returns 400
  `rest_invalid_arguments` with a clear message; new REST test covers
  the two mixing combinations.
- Tighten `test_update_experience_off_preserves_other_state` so it
  proves deactivation actually ran. Previously the active-modules
  filter was removed before the assertion, so `is_active() === false`
  was trivially true regardless of whether `deactivate()` had been
  called. Now reads `jetpack_active_modules` directly while the filter
  is still active, matching the pattern in `test_deactivate_module`.

Two items intentionally not changed: the partial-failure rollback in
the overlay branch (acknowledged as a known package-wide pattern by
the reviewer; "rolling back" by deactivating the module would itself
be unrequested state mutation), and the `SEARCH_MODULE_EXPERIENCE_OPTION_KEY`
prefix nit (the option name is what's stored in `wp_options` and
prefixing it is the correct namespace for cross-plugin coexistence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Search: cover update_experience() error-propagation branches (RSM-2291)

The four `if ( is_wp_error( $result ) ) { return $result; }` branches in
`update_experience()`'s INLINE / EMBEDDED / OVERLAY cases were the only
new lines in this PR not exercised by tests (per the PR's coverage
report). They're behavioral — a typo in any of them would silently
swallow a partial-failure error and write the experience option in an
inconsistent state. Adds:

- A data-provider-driven test asserting each of the three experiences
  that calls activate() propagates the WP_Error from a plan-without-
  search and leaves the experience option unwritten.
- A test asserting overlay propagates the WP_Error from
  enable_instant_search() (plan supports search but not instant search)
  and leaves the experience option unwritten.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Search dashboard: stop rendering ModuleControl when feature-selector is on (RSM-2291)

The frontend PR (#48500) shipped a transitional state where ModuleControl
rendered alongside FeatureSelector even with the feature flag on, so
admins could still manage Search settings while the back-end `experience`
field was unimplemented. With this PR adding that back end, the new
selector can fully persist the user's choice on its own — the legacy
toggles below it become redundant.

Reverts the dashboard-page bottom-area to the either/or shape: when
`jetpack_search_blocks_enabled` is on, render only `<FeatureSelector>`;
otherwise render only `<ModuleControl>`. Updates the branching test to
assert ModuleControl is *absent* when the flag is on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Sync: whitelist jetpack_search_experience option (RSM-2291)

Per review feedback on PR #48540, add the new
`jetpack_search_experience` option to the Search sync module's
options whitelist so writes propagate to the WPcom shadow
replicastore. Without this, the dashboard saves locally but the
WPcom-side experience derivation can't see the user's choice.

Note: requires a coordinated WPcom-side whitelist entry for the
option to actually be retained on the cache site.

* Search: type-guard `experience` before sanitize_text_field() (RSM-2291)

Per PR #48540 review: passing an array or object payload through
sanitize_text_field() triggers a PHP 8.1+ array-to-string deprecation
notice. The downstream in_array() check in update_experience() already
rejects non-string values, but the warning is unnecessary noise.

Add an is_string() guard so non-string values resolve to null up front
and skip the experience branch entirely.

* Search: reject `experience` + `swap_classic_to_inline_search` mixing (RSM-2291)

Per PR #48540 review: validate_search_settings() rejected mixing
`experience` with `module_active` / `instant_search_enabled`, but let
`swap_classic_to_inline_search` through. The early return in the
`experience` branch of update_settings() then dropped that field
silently — same footgun the existing rejection was meant to prevent.

Extend the rejection to cover all three legacy fields and update the
error message accordingly. Today's only caller (the new front end)
sends `{ experience }` alone, so this is a defensive fix for external
API consumers.

Add the new mixing combination to the existing rejection test.

* Search: propagate Modules::deactivate() result from update_experience('off') (RSM-2291)

Per PR #48540 review: the OFF branch was discarding Modules::deactivate()'s
bool and hard-returning true. Propagate the bool instead — false signals
the module was already inactive (a benign no-op), true signals it was
removed from jetpack_active_modules.

The REST controller only branches on is_wp_error(), so a propagated false
still falls through to the success response — but direct callers of
update_experience() can now distinguish the two cases.

INLINE / EMBEDDED / OVERLAY are unchanged: their inner update_option()
and delete_option() calls return false on no-change writes (option already
at the target value), which can't be safely conflated with failure.
WP_Error paths in those branches are already propagated.

Update test_update_experience_off_preserves_other_state to assert the
new return value, and add coverage for the no-op deactivate path.

* Search: stabilize update_experience('off') no-op test (RSM-2291)

The new test added in caa3678 assumed an empty
jetpack_active_modules option, but earlier tests in the same class
activate the module via update_experience() and leak 'search' into
the persisted option. Seed the option with an empty array up front
so deactivate() truly is a no-op (update_option returns false when
the stored value already matches).

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: adamwoodnz <1017872+adamwoodnz@users.noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gibrown added a commit that referenced this pull request May 7, 2026
This is a squashed commit from developing the feature and multiple experiments with the interface.

It also includes the original plan for building the feature.

This was merged with the new experience selection logic from #48500
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

I don't care about code coverage for this PR Use this label to ignore the check for insufficient code coveage. [Package] Search Contains core Search functionality for Jetpack and Search plugins [Status] Needs Privacy Updates Our support docs will need to be updated to take this change into account [Status] UI Changes Add this to PRs that change the UI so documentation can be updated. [Tests] Includes Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants