Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Search: Add backend support for the `experience` field in the search settings REST endpoint. `POST /jetpack/v4/search/settings` accepts `experience` (`embedded`, `overlay`, `inline`, or `off`) and updates the package state in lockstep. `GET /jetpack/v4/search/settings` returns the active `experience`, derived from the legacy settings for sites that have not yet saved via the new UI.
113 changes: 113 additions & 0 deletions projects/packages/search/src/class-module-control.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ class Module_Control {
const JETPACK_SEARCH_MODULE_SLUG = 'search';
const SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY = 'instant_search_enabled';
const SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY = 'swap_classic_to_inline_search';
const SEARCH_MODULE_EXPERIENCE_OPTION_KEY = 'jetpack_search_experience';

/**
* Valid experience values.
*/
const EXPERIENCE_OVERLAY = 'overlay';
const EXPERIENCE_EMBEDDED = 'embedded';
const EXPERIENCE_INLINE = 'inline';
const EXPERIENCE_OFF = 'off';

/**
* Contructor
Expand Down Expand Up @@ -170,6 +179,110 @@ public function update_swap_classic_to_inline_search( bool $swap_classic_to_inli
return update_option( self::SEARCH_MODULE_SWAP_CLASSIC_TO_INLINE_OPTION_KEY, $swap_classic_to_inline_search );
}

/**
* Get the active search experience.
*
* The wire format always resolves to one of the four values, but storage is narrower:
* `'off'` is read from the global Jetpack module-active state (not stored in this
* package's option), and `'inline'` is the absence of an opt-in (the option is
* deleted, not written as `'inline'`). Only `'embedded'` and `'overlay'` are
* actually written to `jetpack_search_experience`.
*
* @return string One of 'embedded', 'overlay', 'inline', 'off'.
*/
public function get_experience() {
if ( ! $this->is_active() ) {
return self::EXPERIENCE_OFF;
}

$saved = get_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false );
if ( self::EXPERIENCE_EMBEDDED === $saved ) {
return self::EXPERIENCE_EMBEDDED;
}
if ( self::EXPERIENCE_OVERLAY === $saved ) {
return self::EXPERIENCE_OVERLAY;
}

// Legacy fallback for sites that have never saved via the new UI: a true
// `instant_search_enabled` boolean reads as overlay; otherwise inline.
if ( $this->is_instant_search_enabled() ) {
return self::EXPERIENCE_OVERLAY;
}

return self::EXPERIENCE_INLINE;
}

/**
* Update the search experience.
*
* Storage is narrower than the wire format: `'off'` only deactivates the global
* module (no write to the experience option), and `'inline'` deletes the
* experience option (the absence of an opt-in *is* inline). Only `'embedded'`
* and `'overlay'` write affirmative values.
*
* Legacy `module_active` / `instant_search_enabled` are kept in lockstep so
* unmigrated readers (Initializer, Options, sidebar registration) continue to
* see the right state until they're migrated to consult get_experience().
*
* @param string $experience One of 'embedded', 'overlay', 'inline', 'off'.
* @return bool|WP_Error WP_Error on failure; true on success for the affirmative
* branches; the bool from Modules::deactivate() for `'off'`
* (false signals the module was already inactive — a benign
* no-op the REST controller treats as success).
*/
public function update_experience( string $experience ) {
$valid_values = array( self::EXPERIENCE_OVERLAY, self::EXPERIENCE_EMBEDDED, self::EXPERIENCE_INLINE, self::EXPERIENCE_OFF );
if ( ! in_array( $experience, $valid_values, true ) ) {
return new WP_Error(
'invalid_experience',
esc_html__( 'Invalid experience value.', 'jetpack-search-pkg' ),
array( 'status' => 400 )
);
}

switch ( $experience ) {
case self::EXPERIENCE_OFF:
// Off lives in the global jetpack_active_modules option, not in this
// package's experience option. Leave instant_search_enabled and the
// experience option untouched so re-enabling later restores the user's
// prior preference (matches legacy ModuleControl behaviour).
return ( new Modules() )->deactivate( self::JETPACK_SEARCH_MODULE_SLUG );

case self::EXPERIENCE_INLINE:
$result = $this->activate();
if ( is_wp_error( $result ) ) {
return $result;
}
$this->disable_instant_search();
// Inline is the absence of an opt-in — delete the option rather than
// writing 'inline'. Pre-existing sites that have never saved are
// already in this state, so this also normalises after a switch.
delete_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY );
return true;

case self::EXPERIENCE_EMBEDDED:
$result = $this->activate();
if ( is_wp_error( $result ) ) {
return $result;
}
$this->disable_instant_search();
update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, self::EXPERIENCE_EMBEDDED );
return true;

case self::EXPERIENCE_OVERLAY:
$result = $this->activate();
if ( is_wp_error( $result ) ) {
return $result;
}
$result = $this->enable_instant_search();
if ( is_wp_error( $result ) ) {
return $result;
}
update_option( self::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, self::EXPERIENCE_OVERLAY );
return true;
}
}

/**
* Get a list of activated modules as an array of module slugs.
*
Expand Down
40 changes: 35 additions & 5 deletions projects/packages/search/src/class-rest-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -246,13 +246,27 @@ public function update_settings( $request ) {
$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'] ) && is_string( $request_body['experience'] )
? sanitize_text_field( $request_body['experience'] )
: null;

$error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search );
$error = $this->validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience );

if ( is_wp_error( $error ) ) {
return $error;
}

// If an experience value was provided, delegate to Module_Control::update_experience(),
// which encapsulates the storage shape (off → module deactivate, inline → delete option,
// embedded/overlay → write affirmative value) and keeps the legacy booleans in lockstep.
if ( $experience !== null ) {
$result = $this->search_module->update_experience( $experience );
if ( is_wp_error( $result ) ) {
return $result;
}
return rest_ensure_response( $this->get_settings() );
}
Comment on lines +262 to +268
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.


// Enabling instant search should enable the module too.
if ( true === $instant_search_enabled && true !== $module_active ) {
$module_active = true;
Expand Down Expand Up @@ -298,11 +312,26 @@ public function update_settings( $request ) {
/**
* Validate $module_active and $instant_search_enabled. Returns an WP_Error instance if invalid.
*
* @param boolean $module_active - Module status.
* @param boolean $instant_search_enabled - Instant Search status.
* @param boolean $swap_classic_to_inline_search - New inline search status.
* @param boolean $module_active - Module status.
* @param boolean $instant_search_enabled - Instant Search status.
* @param boolean $swap_classic_to_inline_search - New inline search status.
* @param string|null $experience - Experience value.
*/
protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search ) {
protected function validate_search_settings( $module_active, $instant_search_enabled, $swap_classic_to_inline_search, $experience = null ) {
// `experience` is the canonical source of truth and writes the legacy booleans in lockstep.
// Reject requests that mix it with any other settings field so callers don't silently
// lose those fields — the `experience` branch in update_settings() early-returns and
// would otherwise drop them.
if ( $experience !== null ) {
if ( $module_active !== null || $instant_search_enabled !== null || $swap_classic_to_inline_search !== null ) {
return new WP_Error(
'rest_invalid_arguments',
esc_html__( 'The `experience` field cannot be combined with `module_active`, `instant_search_enabled`, or `swap_classic_to_inline_search`.', 'jetpack-search-pkg' ),
array( 'status' => 400 )
);
}
return true;
}
if ( $module_active === null && $instant_search_enabled === null && $swap_classic_to_inline_search !== null ) {
// allow updating 'swap_classic_to_inline_search' without updating/validating other settings.
return true;
Expand All @@ -326,6 +355,7 @@ public function get_settings() {
'module_active' => $this->search_module->is_active(),
'instant_search_enabled' => $this->search_module->is_instant_search_enabled(),
'swap_classic_to_inline_search' => $this->search_module->is_swap_classic_to_inline_search(),
'experience' => $this->search_module->get_experience(),
)
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ public function get_initial_state() {
'jetpackSettings' => array(
'search' => $this->module_control->is_active(),
'instant_search_enabled' => $this->module_control->is_instant_search_enabled(),
'experience' => $this->module_control->get_experience(),
),
'features' => array_map(
'sanitize_text_field',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -170,34 +170,31 @@ export default function DashboardPage( { isLoading = false } ) {
/>
) }
<div className="jp-search-dashboard-bottom">
{ isSearchBlocksEnabled && (
{ isSearchBlocksEnabled ? (
<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">
<FeatureSelector />
</div>
</div>
</div>
) : (
<ModuleControl
siteAdminUrl={ siteAdminUrl }
updateOptions={ updateOptions }
domain={ domain }
isDisabledFromOverLimit={ isOverLimit }
isInstantSearchPromotionActive={ isInstantSearchPromotionActive }
supportsOnlyClassicSearch={ supportsOnlyClassicSearch }
supportsSearch={ supportsSearch }
supportsInstantSearch={ supportsInstantSearch }
isModuleEnabled={ isModuleEnabled }
isInstantSearchEnabled={ isInstantSearchEnabled }
isSavingEitherOption={ isSavingEitherOption }
isTogglingModule={ isTogglingModule }
isTogglingInstantSearch={ isTogglingInstantSearch }
/>
) }
{ /* ModuleControl renders regardless of the feature flag for now —
Comment thread
adamwoodnz marked this conversation as resolved.
until the back-end `experience` field lands (RSM-2291), the new
FeatureSelector can't actually persist changes. Keeping the legacy
toggles visible lets admins continue managing Search settings. */ }
<ModuleControl
siteAdminUrl={ siteAdminUrl }
updateOptions={ updateOptions }
domain={ domain }
isDisabledFromOverLimit={ isOverLimit }
isInstantSearchPromotionActive={ isInstantSearchPromotionActive }
supportsOnlyClassicSearch={ supportsOnlyClassicSearch }
supportsSearch={ supportsSearch }
supportsInstantSearch={ supportsInstantSearch }
isModuleEnabled={ isModuleEnabled }
isInstantSearchEnabled={ isInstantSearchEnabled }
isSavingEitherOption={ isSavingEitherOption }
isTogglingModule={ isTogglingModule }
isTogglingInstantSearch={ isTogglingInstantSearch }
/>
</div>
<NoticesList
notices={ notices }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,17 +64,15 @@ const settings = {
};

describe( '<DashboardPage> branch', () => {
test( 'renders FeatureSelector and ModuleControl when searchBlocksEnabled is true', () => {
test( 'renders FeatureSelector when searchBlocksEnabled is true', () => {
renderWith( { searchBlocksEnabled: true, jetpackSettings: settings } );
expect(
screen.getByRole( 'group', { name: /select a search experience for your visitors/i } )
).toBeInTheDocument();
// ModuleControl renders unconditionally so admins can still change
// settings until the back-end `experience` field lands.
expect( screen.getByTestId( 'module-control' ) ).toBeInTheDocument();
expect( screen.queryByTestId( 'module-control' ) ).not.toBeInTheDocument();
} );

test( 'renders only ModuleControl when searchBlocksEnabled is false', () => {
test( 'renders ModuleControl when searchBlocksEnabled is false', () => {
renderWith( { searchBlocksEnabled: false, jetpackSettings: settings } );
expect(
screen.queryByRole( 'group', { name: /select a search experience for your visitors/i } )
Expand Down
Loading
Loading