Skip to content

Search 3.0: backend support for experience field in settings API (RSM-2291)#48540

Merged
adamwoodnz merged 11 commits intotrunkfrom
copilot/add-backend-support-experience-field
May 6, 2026
Merged

Search 3.0: backend support for experience field in settings API (RSM-2291)#48540
adamwoodnz merged 11 commits intotrunkfrom
copilot/add-backend-support-experience-field

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented May 5, 2026

Fixes RSM-2291: Search dashboard feature selection: backend support for experience field

Why

Site admins using the new feature-selector UI on the Search dashboard (shipped behind jetpack_search_blocks_enabled in #48500) currently can't actually save their choice — the existing settings API only models two booleans (module_active, instant_search_enabled) which can't distinguish the new "Embedded" experience from "Theme search," so picking Embedded silently degrades to Theme search after a save. This PR adds the back-end persistence and read path the new UI needs, so the dashboard reflects and persists the admin's actual choice.

Proposed changes

This branch is rebased on #48500, so the diff includes both the frontend feature-selector and the back-end work below.

  • Data model. The wire format exposes experience as one of embedded | overlay | inline | off. Storage is narrower than the wire format — each fact lives where it's already owned:
    • 'off' is read from jetpack_active_modules (the global Jetpack module-active state) and not stored in this package's option, so flipping Search on/off via the global Jetpack dashboard, wp jetpack module …, or Jetpack::activate_module() doesn't drift.
    • 'inline' is the absence of an opt-in; saving inline deletes jetpack_search_experience rather than writing the value, so existing sites that have never engaged with the new UI are already correctly classified.
    • 'embedded' and 'overlay' are the only values actually written to the new option.
    • Legacy module_active / instant_search_enabled are kept in lockstep so unmigrated readers (Initializer, Options, sidebar registration) keep working until they're migrated to consult get_experience() in a follow-up.
  • Module_Control::get_experience() resolves the wire value: off if the module isn't active, the saved embedded / overlay value if present, otherwise a legacy-boolean fallback (instant_search_enabled=true'overlay', else 'inline') for sites that have never saved via the new UI.
  • Module_Control::update_experience() translates each wire value onto the storage shape. Adds the EXPERIENCE_OVERLAY / EXPERIENCE_EMBEDDED / EXPERIENCE_INLINE / EXPERIENCE_OFF constants and the SEARCH_MODULE_EXPERIENCE_OPTION_KEY (jetpack_search_experience) option key. 'off' deactivates the module and leaves both instant_search_enabled and the experience option untouched, so re-enabling later restores the prior preference (matches legacy ModuleControl behaviour).
  • REST endpoint. POST /jetpack/v4/search/settings accepts an experience-only request body and delegates to update_experience(). validate_search_settings() allows experience-only requests without requiring the legacy booleans. GET /jetpack/v4/search/settings returns the resolved experience value alongside the existing fields.
  • Initial state. Dashboard initial state includes experience so the React app can highlight the active row immediately at boot.
  • Tests. Adds Module_Control_Test cases covering off-when-inactive (with stale option present), saved-value reads, legacy fallback, inline-deletes-option (seeded with 'embedded' to prove the switch clears it), off-preserves-other-state, and invalid-value rejection. Adds matching REST-controller integration tests for each wire value plus an invalid case.

Related product discussion/links

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

No.

Testing instructions

The new UI is behind add_filter( 'jetpack_search_blocks_enabled', '__return_true' ); — set that on a test site so the new dashboard renders alongside the legacy ModuleControl.

  1. Write path — POST /jetpack/v4/search/settings with each wire value:
    • { "experience": "overlay" } → response: module_active=true, instant_search_enabled=true, experience: "overlay". Confirm jetpack_search_experience option is 'overlay'.
    • { "experience": "embedded" } → response: module_active=true, instant_search_enabled=false, experience: "embedded". Confirm jetpack_search_experience option is 'embedded'.
    • { "experience": "inline" } → same booleans as embedded but experience: "inline". Confirm jetpack_search_experience option is deleted (not present in wp_options).
    • { "experience": "off" }module_active=false, experience: "off". Confirm instant_search_enabled is unchanged from before the call, and the jetpack_search_experience option is preserved (so re-enabling later restores the prior overlay-vs-embedded choice).
  2. Read path — GET /jetpack/v4/search/settings on a fresh site (no experience option saved):
    • Module inactive → experience: "off".
    • Module active + instant_search_enabled=trueexperience: "overlay" (legacy fallback).
    • Module active + instant_search_enabled=falseexperience: "inline" (legacy fallback).
  3. After saving via the new UI, GET again — confirm the persisted value is returned, not derived.
  4. Send an invalid experience (e.g. "foo") → confirm 400 response.
  5. Confirm legacy requests (sending only module_active / instant_search_enabled) still work as before.
  6. Toggle Search off via the global Jetpack dashboard or wp jetpack module deactivate search, then GET /search/settingsexperience: "off" even if the jetpack_search_experience option still has a value (the global on/off bit wins).

Copilot AI changed the title [WIP] Add backend support for experience field in search dashboard Search: backend support for experience field in settings API (RSM-2291) May 5, 2026
Copilot AI requested a review from adamwoodnz May 5, 2026 22:05
@adamwoodnz
Copy link
Copy Markdown
Contributor

@copilot follow the PR template please

@adamwoodnz adamwoodnz changed the title Search: backend support for experience field in settings API (RSM-2291) Search 3.0: backend support for experience field in settings API (RSM-2291) May 5, 2026
Copy link
Copy Markdown
Contributor Author

Copilot AI commented May 5, 2026

@copilot follow the PR template please

Updated the PR description to follow the template.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 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 copilot/add-backend-support-experience-field branch.
  • To test on Simple, run the following command on your sandbox:
bin/jetpack-downloader test jetpack copilot/add-backend-support-experience-field
bin/jetpack-downloader test jetpack-mu-wpcom-plugin copilot/add-backend-support-experience-field

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

@github-actions github-actions Bot added [Package] Search Contains core Search functionality for Jetpack and Search plugins [Status] In Progress [Tests] Includes Tests labels May 5, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 5, 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 github-actions Bot added the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label May 5, 2026
@adamwoodnz
Copy link
Copy Markdown
Contributor

@claude review please

@adamwoodnz adamwoodnz removed the [Status] Needs Author Reply We need more details from you. This label will be auto-added until the PR meets all requirements. label May 5, 2026
@claude

This comment was marked as outdated.

@jp-launch-control
Copy link
Copy Markdown

jp-launch-control Bot commented May 5, 2026

Code Coverage Summary

Coverage changed in 4 files.

File Coverage Δ% Δ Uncovered
projects/packages/search/src/class-rest-controller.php 194/231 (83.98%) 1.27% 0 💚
projects/packages/search/src/dashboard/class-initial-state.php 66/77 (85.71%) 0.19% 0 💚
projects/packages/search/src/class-module-control.php 67/74 (90.54%) 12.76% -1 💚
projects/packages/search/src/classic-search/class-classic-search.php 39/870 (4.48%) 0.23% -2 💚

Full summary · PHP report · JS report

@adamwoodnz adamwoodnz force-pushed the copilot/add-backend-support-experience-field branch from 2826973 to afbff56 Compare May 6, 2026 01:17
@adamwoodnz adamwoodnz changed the base branch from trunk to rsm-2116-search-dashboard-feature-selection-ui May 6, 2026 01:18
adamwoodnz added a commit that referenced this pull request May 6, 2026
`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>
adamwoodnz added a commit that referenced this pull request May 6, 2026
…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 adamwoodnz force-pushed the copilot/add-backend-support-experience-field branch from 9a2d7aa to fde14a1 Compare May 6, 2026 02:23
@adamwoodnz adamwoodnz marked this pull request as ready for review May 6, 2026 02:28
@adamwoodnz
Copy link
Copy Markdown
Contributor

@claude fresh review please

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented May 6, 2026

Claude finished @adamwoodnz's task in 2m 27s —— View job


Review

  • Gather context and read changed files
  • Review class-module-control.php
  • Review class-rest-controller.php
  • Review class-initial-state.php
  • Review tests
  • Post review findings

Overall, this is a well-designed implementation. The storage model (off in module state, inline as absence of option, only embedded/overlay written affirmatively) is clean and the legacy fallback path is handled correctly. A few things worth addressing:


Bug / Logic Issues

1. Partial failure leaves inconsistent state in update_experience('overlay')

class-module-control.php lines 270–280

If activate() succeeds but enable_instant_search() returns a WP_Error (e.g., plan doesn't support instant search), the function returns the error without writing the experience option. The module is now active but the experience option is absent, so a subsequent get_experience() falls through to the legacy fallback — it reads instant_search_enabled=false and returns 'inline' rather than surfacing that overlay failed. A caller that expects rollback on error won't get it.

This is a pre-existing pattern in this codebase (other callers also don't roll back), but the new update_experience path is the first place where two side effects need to be atomic. Worth a note or a WP_Error path that at least deactivates the module on failure.


Minor Issues

2. Dead return true after exhaustive switch

class-module-control.php line 283

        }

        return true;  // <-- unreachable: all valid values have case branches, invalid rejected above
    }

The in_array guard at the top ensures only the four known values reach the switch, and all four have return statements. The trailing return true is dead code. Fix this →

3. Comment typo: "classic" should be "inline"

class-rest-controller.php line 259

// which encapsulates the storage shape (off → module deactivate, classic → delete option,

classic was renamed to inline in a prior commit. Should read inline → delete option. Fix this →

4. Silent field-dropping when experience and legacy fields are mixed

class-rest-controller.php lines 260–266

Sending { "experience": "overlay", "module_active": false } silently ignores module_active. The validation short-circuits on experience !== null and the legacy fields are never applied. A consumer could accidentally include both and be surprised. At minimum a doc note on the endpoint behaviour (experience takes full precedence) would be helpful, or the endpoint could return a 400 if legacy booleans are mixed with experience.


Test Coverage Notes

5. test_update_experience_off_preserves_other_state deactivation assertion is trivially true

Module_Control_Test.php lines 281–297

The filter return_search_active_array only intercepts option reads; the module is never actually written as active in the DB. So is_active() → false after the filter is removed regardless of whether deactivate() ran. The meaningful assertions (options preserved) are correct, but the assertFalse(is_active()) doesn't prove the module was deactivated.

6. No test for mixed experience + legacy boolean request

There's no REST test asserting that { "experience": "overlay", "module_active": false } returns overlay (i.e., module_active is ignored). Worth adding to document the behaviour explicitly.


Nits

  • class-module-control.php line 44: SEARCH_MODULE_EXPERIENCE_OPTION_KEY = 'jetpack_search_experience' — the other keys in this class (SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY, etc.) are bare names without a jetpack_ prefix. This one is the full wp_options key rather than a bare slug. Inconsistent naming convention, though it's a constant so the usage is identical. Worth a comment noting the difference, or normalising to just search_experience and prefixing at call sites.
  • get_experience() lines 199–204: checks explicitly for EMBEDDED and OVERLAY but silently falls through for any other stored value (e.g., a corrupt or externally written option). Consider adding a strict check with a fallback comment for clarity.

Summary

The implementation logic is sound. The storage design is well-reasoned and the legacy fallback correctly handles pre-existing sites. Three things worth fixing before merge: the stale "classic" comment typo, the dead return true, and ideally a note or guard on the mixed-field silent-drop behaviour. The partial-failure rollback in the overlay case is the only substantive concern but it's a known pattern in this codebase.

@adamwoodnz adamwoodnz added [Status] Needs Review This PR is ready for review. and removed [Status] In Progress labels May 6, 2026
adamwoodnz added a commit that referenced this pull request May 6, 2026
* Search: expose jetpack_search_blocks_enabled to dashboard initial state (RSM-2116)

Add searchBlocksEnabled field to the siteData array in Initial_State class
to expose the jetpack_search_blocks_enabled filter to the React dashboard
so it can gate the new feature-selection UI on the same flag used by the
backend to register the Interactivity API blocks.

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

* Search: add isSearchBlocksEnabled selector for dashboard gate (RSM-2116)

* Search: add experience selectors to jetpack-settings store (RSM-2116)

* Search: add saveExperience thunk + pending/last-saved actions (RSM-2116)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Search: add EXPERIENCE constants for feature selector (RSM-2116)

* Search: add Badge component for feature selector (RSM-2116)

* Search: add ExperienceOption component for feature selector (RSM-2116)

* Search: add FeatureSelector component (RSM-2116)

* Search: branch dashboard page on searchBlocksEnabled (RSM-2116)

Wire `dashboard-page.jsx` to render `<FeatureSelector>` when
`isSearchBlocksEnabled` is true, falling back to `<ModuleControl>` for
legacy sites. Adds branch tests that confirm both paths render correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Search: style the feature selector to match the design (RSM-2116)

* Search: changelog entry for feature-selection UI (RSM-2116)

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

* Search: refine feature-selector copy and update tests (RSM-2116)

Update the experience labels and descriptions to read more naturally —
sentence-case titles, references to "search-as-you-type" in the
Embedded and Overlay descriptions, and "Theme search" with the
description "Your theme's search layout, with faster results." for the
classic experience. Off becomes "WordPress default search.". Update
the affected component tests to match.

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

* Search: align feature-selector width with dashboard sections (RSM-2116)

Constrain the feature selector to the same 1040px max-width and
auto-centering used by `.jp-search-dashboard-row` above (MockedSearch
and RecordMeter), so the picker no longer goes full-bleed against
those sections.

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

* Search: wrap feature-selector in dashboard-row + add vertical padding (RSM-2116)

Reuse the existing `.jp-search-dashboard-wrap` + `.jp-search-dashboard-row`
grid pattern instead of bespoke max-width CSS, so the selector aligns
with the sections above (MockedSearch, RecordMeter) at every
breakpoint. A new `.jp-search-feature-selector-wrap` class provides
vertical padding around the card without inheriting the bottom border
of the meter wrap.

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

* Search: center-align feature-selector option content (RSM-2116)

Switch the radio row from `align-items: flex-start` to `center` so the
radio, icon, title, description, and ACTIVE badge all line up on a
shared baseline. Drop the now-redundant 2px top offset on the radio
that was needed for first-line alignment under flex-start.

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

* Search: replace literal SCSS values with WPDS tokens (RSM-2116)

Use `@wordpress/theme` design tokens (`--wpds-dimension-*`,
`--wpds-color-*`, `--wpds-border-*`, `--wpds-typography-font-size-*`)
in the feature selector instead of hard-coded px / hex values. Tokens
are loaded by the surrounding admin shell, so no new dependency is
needed in the search package. The pill border-radius on the badge
stays literal because it's a shape, not a token-relevant value.

* Search: simplify save payload to { experience } only (RSM-2116)

The feature-selector UI is gated behind `jetpack_search_blocks_enabled`,
so the front end and back end ship together. There is no reason to
keep sending the legacy `module_active` and `instant_search_enabled`
booleans — the back end is responsible for translating `experience`
into whatever it persists, and for migrating any pre-existing booleans
on first read so the initial active row reflects the user's prior
state.

`experienceToPayload()` now returns `{ experience }` for all four IDs.
Updated the unit test and the EXPERIENCE constant doc comment to match.

* Search: drop experienceToPayload helper (RSM-2116)

Now that the payload is just `{ experience }`, the helper is a no-op
wrapper around its own argument. Inline at the single call site in
`saveExperience` and remove the dedicated unit test.

* Search: promote Badge to a shared dashboard component (RSM-2116)

Move `feature-selector/badge.jsx` to `components/badge/index.jsx` with
its own colocated `style.scss`, generalising the CSS class names from
`jp-search-feature-selector__badge*` to `jp-search-badge*`. The pill's
appearance (font, padding, color, pill shape) lives with the component
itself; the layout-only `margin-inline-start: auto` that right-aligns
the ACTIVE badge inside an option row stays in the feature selector
where that flex layout is defined.

* Search: align Badge styles with the WPDS Badge spec (RSM-2116)

Match the wp-ui-components Badge primitive: rounded rectangle
(`--wpds-border-radius-lg`) instead of pill, body-sm typography
(`--wpds-typography-font-size-sm` / `line-height-xs`), regular weight,
and natural-case labels (no uppercase / letter-spacing). Use
`--wpds-color-fg-content-success` for the foreground to mirror how the
informational variant pairs `bg-surface-info` with `fg-content-info`.

* Search: split Badge intent — recommended → info, active → success (RSM-2116)

Recommended (Embedded row) is a hint, not a state, so it now uses the
WPDS informational color pair (`bg-surface-info` / `fg-content-info`).
Active continues to use the success pair to mark the user's current
running experience.

* Search: use WPDS interactive-brand stroke for selected option (RSM-2116)

The `.is-selected` row was using `--wpds-color-fg-content-success-weak`
(a content/foreground token, semantically Jetpack green) for its border
and inner shadow. Switch to `--wpds-color-stroke-interactive-brand` so
the selection ring picks up the admin's standard interactive colour
rather than baking in a brand-specific green.

* Search: render selector heading as a visible h2 (RSM-2116)

Promote "Pick what visitors see when they search" from a visually
hidden `<legend>` to a visible `<h2>` rendered above the form, matching
the heading level used by sibling dashboard sections (record-meter,
plan-summary). The fieldset references the heading via aria-labelledby
so its group role still has an accessible name; the existing
`getByRole('group', { name: /pick what visitors.../i })` test confirms.

* Search: drop divider line between options and save row (RSM-2116)

Remove the top border (and its companion padding) on the footer so
the Save row sits directly below the option list, separated only by
the existing margin gap.

* Search: drop idle status copy from selector footer (RSM-2116)

The "Currently active. Edits apply when you save." line restated what
the form already conveys — the ACTIVE badge marks the running
experience and the Save button is the obvious commit. Leave the
status `<p>` empty when idle so the footer is a simple Save row, and
keep the `Saving…` message during the in-flight update for the
aria-live region.

* Search: use navigationOverlay icon for the Overlay row (RSM-2116)

The `desktop` icon (a generic monitor) didn't read as "overlay" — it
suggested device targeting. `navigationOverlay` is purpose-built for
this idea in `@wordpress/icons` and matches what the rest of the
WordPress admin uses for overlay-style UI.

* Search: use published icon for the Theme search row (RSM-2116)

The `search` icon was redundant on a tile already labelled "Theme
search" inside a feature called Search. `published` (a document with
a checkmark) reads as "your theme's published search page," matching
what the option actually points users at.

* Search: swap Theme search → trendingUp, Off → notAllowed (RSM-2116)

`published` (a checkmarked document) didn't communicate the value
prop of the Theme search row, which is "faster results from your
existing layout." `trendingUp` reads as acceleration / improvement.

`cancelCircleFilled` was a harsh red-error metaphor for what is just
a "search is off" state. `notAllowed` (the universal circle-with-slash)
is a calmer, more neutral "off" indicator.

* Search: use clsx for option row class names (RSM-2116)

Replace the manual array-filter-join with `clsx`, matching how the rest
of the dashboard composes class strings. Same output, less ceremony.

* Search: Badge prop rename `variant` → `intent` to match WPDS (RSM-2116)

The WPDS Badge primitive uses an `intent` prop with values like
`informational` and `success`. Mirror that vocabulary in the local
Badge so call sites express *what kind of badge* rather than picking
a colour: Recommended renders as `intent="informational"`, Active as
`intent="success"`. Class names follow (`--informational` /
`--success`), and the option-row right-alignment override now selects
the trailing badge by structure (`.option > .badge`) instead of by
intent.

* Search: use @wordpress/ui Badge instead of the local one (RSM-2116)

@wordpress/ui shipped its Badge primitive in v0.12.0. Drop our local
`components/badge/` (8 lines of JSX, 18 lines of SCSS) and use the
shipped component, so the search dashboard inherits future Badge
fixes / token tweaks without us copying them in.

Intent mapping changes from our temporary local component:
- Recommended: `intent="informational"` (unchanged)
- Active: `intent="stable"` (was `intent="success"` locally; @wordpress/ui
  uses `stable` for the green "currently running well" semantic, mapped
  to the same `--wpds-color-bg-surface-success` / `fg-content-success`
  tokens we were already using)

The right-alignment override on the Active badge moves to a new layout
class `jp-search-feature-selector__option-trailing` passed via the
Badge's `className` prop, decoupling layout from intent and from the
shipped Badge's hashed CSS module class names.

Bumps the search package only; other consumers stay on 0.11.0.

* Search: only promote experience on confirmed save success (RSM-2116)

`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.

* Search: drop unused __label rule from selector styles (RSM-2116)

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.

* Search: use @wordpress/ui Stack for the option list (RSM-2116)

The custom `__options` rule was just `display: flex; flex-direction:
column; gap: var(--wpds-dimension-gap-sm)` — exactly what the shipped
`Stack` primitive does. Swap the wrapper `<div>` for `<Stack
direction="column" gap="sm">` and drop the rule.

* Search: use @wordpress/ui Stack inside the option label too (RSM-2116)

The option row's flex layout (radio · icon · body · optional Active
badge) is now a `<Stack direction="row" gap="md" align="center">`
rendered inside the `<label>`. The label keeps its semantics (radio
click target, upgrade-tooltip `title`) and its visual chrome (padding,
border, hover, is-selected, is-disabled); the Stack handles row
alignment and the design-token gap.

Mobile breakpoint's `flex-wrap: wrap` rule moves to the new Stack's
`__option-stack` class so it still wraps when the row is too tight.

* Search: convert option-body, option-title, and footer to Stacks (RSM-2116)

Three more flex containers in the selector were just `display: flex`
+ direction + gap — exactly what `<Stack>` does. Replace each with the
shipped primitive:

- `__option-body`: column gap=xs (description wraps below title)
- `__option-title`: row gap=sm align=center (label + Recommended badge)
- `__footer`: row gap=md align=center justify=space-between (status +
  Save button)

The corresponding SCSS rules now keep only what Stack can't express:
`flex: 1 1 auto` on the body so it fills remaining row space, the
title's typography, and the footer's top margin. The footer keeps
`aria-live="polite"` via prop pass-through.

* Search: drop redundant `direction="row"` from row Stacks (RSM-2116)

`<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.

* Search: use @wordpress/ui Button for the Save action (RSM-2116)

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.

* Search: roll back optimistic `experience` on failed save (RSM-2116)

`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.

* Search: document the embedded gap in deriveExperienceFromBooleans (RSM-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.

* Search: drop redundant pendingExperience check in onSubmit (RSM-2116)

`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.

* Search: combine useSelect calls in ExperienceOption (RSM-2116)

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.

* Search: 2-column grid for option row, content wraps in column 2 (RSM-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.

* Search: keep title beside icon, allow Recommended badge to wrap (RSM-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.

* Search: reset WP Admin's negative margin on the option radio (RSM-2116)

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.

* Search: bump option-radio reset specificity past WP forms.css (RSM-2116)

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.

* Search: stack option title with icon, description below (RSM-2116)

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.

* Search: tighten icon-title gap, loosen title-description gap (RSM-2116)

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.

* Search: give feature-selector heading a WPDS line-height (RSM-2116)

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.

* Search: rephrase feature-selector heading (RSM-2116)

"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.

* Search: align feature-selector tests with new heading copy (RSM-2116)

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.

* Search: replace option grid with @wordpress/ui Stack (RSM-2116)

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.

* Search: align frontend with backend `experience` field name (RSM-2116)

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>

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

`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>

* Search dashboard: keep ModuleControl rendered alongside FeatureSelector (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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Base automatically changed from rsm-2116-search-dashboard-feature-selection-ui to trunk May 6, 2026 05:30
Copilot AI and others added 4 commits May 6, 2026 18:01
- 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>
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>
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>
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>
@adamwoodnz adamwoodnz force-pushed the copilot/add-backend-support-experience-field branch from cac4907 to 860d670 Compare May 6, 2026 06:02
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>
…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>
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.
@adamwoodnz adamwoodnz requested a review from a team as a code owner May 6, 2026 21:34
@adamwoodnz
Copy link
Copy Markdown
Contributor

adamwoodnz commented May 6, 2026

@kangzj — addressed in ed61648. Added jetpack_search_experience to $options_to_sync in projects/packages/sync/src/modules/class-search.php.

Flagging for the next reviewer / for WPcom coordination: per projects/packages/sync/AGENTS.md, whitelisting in this package only controls sending — WPcom's shadow replicastore filters independently and must also whitelist jetpack_search_experience for the value to be retained on the cache site. A separate WPcom-side diff is required before this option will actually be available to readers on the cache site (e.g. for the experience-derivation logic on WPcom).

Tracked as a follow-up: RSM-2535.

// experience option untouched so re-enabling later restores the user's
// prior preference (matches legacy ModuleControl behaviour).
( new Modules() )->deactivate( self::JETPACK_SEARCH_MODULE_SLUG );
return true;
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.

should we return what the previous step's result rather than always successful return?

Similar applies the the following statements as well.

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.

Partially addressed in caa3678 — the OFF branch now propagates Modules::deactivate()'s bool instead of hard-returning true. Added test_update_experience_off_when_module_already_inactive_returns_false to cover the no-op path (3a5e726 stabilizes its setup against state leakage from earlier tests in the class).

For the INLINE / EMBEDDED / OVERLAY branches I left the explicit return true in place. The remaining inner steps are update_option() / delete_option() / disable_instant_search() calls — all of which return false for benign no-change writes (the option already holds the target value). Propagating those would falsely report failure when a caller re-saves the same experience. The actual failure paths in those branches (activate() and enable_instant_search() returning WP_Error) are already short-circuited above. Happy to revisit if I'm reading the intent wrong.

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.

Two suggestions on the new update_settings flow — both quality nits, neither blocking. See inline.

$module_active = isset( $request_body['module_active'] ) ? (bool) $request_body['module_active'] : null;
$instant_search_enabled = isset( $request_body['instant_search_enabled'] ) ? (bool) $request_body['instant_search_enabled'] : null;
$swap_classic_to_inline_search = isset( $request_body['swap_classic_to_inline_search'] ) ? (bool) $request_body['swap_classic_to_inline_search'] : null;
$experience = isset( $request_body['experience'] ) ? sanitize_text_field( $request_body['experience'] ) : null;
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.

sanitize_text_field() is applied without a type check. If a client sends "experience": [] or "experience": {}, this triggers an array-to-string conversion warning (and a deprecation notice on PHP 8.1+). The downstream in_array check in update_experience() catches the bad value, but the warning is unnecessary noise.

Guard with is_string() (or cast (string)) before sanitizing, e.g.:

$experience = isset( $request_body['experience'] ) && is_string( $request_body['experience'] )
    ? sanitize_text_field( $request_body['experience'] )
    : null;

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.

Fixed in 24b58b4 — added an is_string() guard up front so non-string experience payloads resolve to null and skip the experience branch. The downstream in_array() guard in update_experience() is left as defence-in-depth.

Comment on lines +260 to +266
if ( $experience !== null ) {
$result = $this->search_module->update_experience( $experience );
if ( is_wp_error( $result ) ) {
return $result;
}
return rest_ensure_response( $this->get_settings() );
}
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.

swap_classic_to_inline_search is silently dropped when combined with experience.

validate_search_settings() rejects mixing experience with module_active / instant_search_enabled (good), but swap_classic_to_inline_search is allowed through — and then this early return ignores it without applying the value or surfacing an error. The validation error message also doesn't mention this field, so a caller debugging a missing swap update would have no signal.

Today's only caller is the new front-end (which sends { experience } alone), so impact is theoretical. But it's a footgun for any external API consumer that bundles fields, and the asymmetry contradicts the validation's stated intent ("don't silently lose those fields").

Fix options:

  • Extend the validation rejection to also block swap_classic_to_inline_search when experience is set, or
  • Process swap_classic_to_inline_search before the early return (the existing branch below already handles a swap-only update path).

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.

Fixed in 4af42edvalidate_search_settings() now also rejects mixing experience with swap_classic_to_inline_search (returning 400 rest_invalid_arguments). Updated the error message and extended test_update_settings_experience_rejects_mixed_legacy_fields to cover the new combination.

adamwoodnz added 4 commits May 7, 2026 10:52
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.
…(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.
…('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.
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).
@adamwoodnz
Copy link
Copy Markdown
Contributor

All feedback addressed @kangzj, will merge after tests complete. Let me know if you have follow ups.

@adamwoodnz adamwoodnz merged commit cc85234 into trunk May 6, 2026
97 checks passed
@adamwoodnz adamwoodnz deleted the copilot/add-backend-support-experience-field branch May 6, 2026 23:33
@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] Needs Review This PR is ready for review. labels May 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Package] Search Contains core Search functionality for Jetpack and Search plugins [Package] Sync [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