diff --git a/projects/packages/search/changelog/add-rsm-2291-experience-field-backend b/projects/packages/search/changelog/add-rsm-2291-experience-field-backend new file mode 100644 index 000000000000..ba4bd08e86ef --- /dev/null +++ b/projects/packages/search/changelog/add-rsm-2291-experience-field-backend @@ -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. diff --git a/projects/packages/search/src/class-module-control.php b/projects/packages/search/src/class-module-control.php index 4044765be1cc..8db94f1e9549 100644 --- a/projects/packages/search/src/class-module-control.php +++ b/projects/packages/search/src/class-module-control.php @@ -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 @@ -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. * diff --git a/projects/packages/search/src/class-rest-controller.php b/projects/packages/search/src/class-rest-controller.php index 0b5b144bb3b6..42c714bb50af 100644 --- a/projects/packages/search/src/class-rest-controller.php +++ b/projects/packages/search/src/class-rest-controller.php @@ -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() ); + } + // Enabling instant search should enable the module too. if ( true === $instant_search_enabled && true !== $module_active ) { $module_active = true; @@ -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; @@ -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(), ) ); } diff --git a/projects/packages/search/src/dashboard/class-initial-state.php b/projects/packages/search/src/dashboard/class-initial-state.php index 0235176af93a..f0393f3fc160 100644 --- a/projects/packages/search/src/dashboard/class-initial-state.php +++ b/projects/packages/search/src/dashboard/class-initial-state.php @@ -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', diff --git a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx index 68a79fa80459..404c9d3d7bf8 100644 --- a/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx +++ b/projects/packages/search/src/dashboard/components/pages/dashboard-page.jsx @@ -170,7 +170,7 @@ export default function DashboardPage( { isLoading = false } ) { /> ) }
- { isSearchBlocksEnabled && ( + { isSearchBlocksEnabled ? (
@@ -178,26 +178,23 @@ export default function DashboardPage( { isLoading = false } ) {
+ ) : ( + ) } - { /* ModuleControl renders regardless of the feature flag for now — - 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. */ } -
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 } ) diff --git a/projects/packages/search/tests/php/Module_Control_Test.php b/projects/packages/search/tests/php/Module_Control_Test.php index 32e4bc184e2f..8d927880d3ae 100644 --- a/projects/packages/search/tests/php/Module_Control_Test.php +++ b/projects/packages/search/tests/php/Module_Control_Test.php @@ -167,6 +167,223 @@ public function test_disable_instant_search() { $this->assertFalse( get_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY ) ); } + /** + * Inactive module always reads as 'off' regardless of any saved experience + * option — off lives in jetpack_active_modules, not in the package's option. + */ + public function test_get_experience_off_when_module_inactive() { + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + $this->assertEquals( Module_Control::EXPERIENCE_OFF, static::$search_module->get_experience() ); + + // Even with a stale 'embedded' value in the option, an inactive module is off. + update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_EMBEDDED ); + $this->assertEquals( Module_Control::EXPERIENCE_OFF, static::$search_module->get_experience() ); + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + } + + /** + * Saved 'embedded' / 'overlay' values are returned when the module is active. + */ + public function test_get_experience_returns_saved_value() { + add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 ); + + update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_EMBEDDED ); + $this->assertEquals( Module_Control::EXPERIENCE_EMBEDDED, static::$search_module->get_experience() ); + + update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_OVERLAY ); + $this->assertEquals( Module_Control::EXPERIENCE_OVERLAY, static::$search_module->get_experience() ); + + remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) ); + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + } + + /** + * Legacy fallback: active module + instant_search_enabled=true with no saved + * value resolves to 'overlay'. + */ + public function test_get_experience_legacy_fallback_overlay() { + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 ); + update_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY, true ); + $this->assertEquals( Module_Control::EXPERIENCE_OVERLAY, static::$search_module->get_experience() ); + remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) ); + delete_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY ); + } + + /** + * Active module + instant_search_enabled=false with no saved value resolves to + * 'inline' — inline is the absence of an opt-in. + */ + public function test_get_experience_inline_when_no_opt_in() { + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 ); + update_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY, false ); + $this->assertEquals( Module_Control::EXPERIENCE_INLINE, static::$search_module->get_experience() ); + remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) ); + delete_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY ); + } + + /** + * Overlay activates the module, enables instant search, and writes 'overlay' + * to the experience option. + */ + public function test_update_experience_overlay() { + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + // is_active() needs to return true during enable_instant_search(). + add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 ); + static::$search_module->update_experience( Module_Control::EXPERIENCE_OVERLAY ); + remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) ); + + $this->assertTrue( static::$search_module->is_instant_search_enabled() ); + $this->assertEquals( Module_Control::EXPERIENCE_OVERLAY, get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ) ); + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + } + + /** + * Embedded activates the module, disables instant search, and writes + * 'embedded' to the experience option. + */ + public function test_update_experience_embedded() { + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + add_filter( 'jetpack_options', array( $this, 'return_active_modules_array_without_search' ), 10, 2 ); + static::$search_module->update_experience( Module_Control::EXPERIENCE_EMBEDDED ); + $active_modules = get_option( 'jetpack_' . Module_Control::JETPACK_ACTIVE_MODULES_OPTION_KEY, array() ); + remove_filter( 'jetpack_options', array( $this, 'return_active_modules_array_without_search' ) ); + + $this->assertContains( Module_Control::JETPACK_SEARCH_MODULE_SLUG, $active_modules ); + $this->assertFalse( static::$search_module->is_instant_search_enabled() ); + $this->assertEquals( Module_Control::EXPERIENCE_EMBEDDED, get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ) ); + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + } + + /** + * Inline activates the module, disables instant search, and deletes the + * experience option (inline is the absence of an opt-in). + */ + public function test_update_experience_inline_deletes_option() { + // Seed an existing 'embedded' to prove the switch to inline clears it. + update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_EMBEDDED ); + add_filter( 'jetpack_options', array( $this, 'return_active_modules_array_without_search' ), 10, 2 ); + static::$search_module->update_experience( Module_Control::EXPERIENCE_INLINE ); + $active_modules = get_option( 'jetpack_' . Module_Control::JETPACK_ACTIVE_MODULES_OPTION_KEY, array() ); + remove_filter( 'jetpack_options', array( $this, 'return_active_modules_array_without_search' ) ); + + $this->assertContains( Module_Control::JETPACK_SEARCH_MODULE_SLUG, $active_modules ); + $this->assertFalse( static::$search_module->is_instant_search_enabled() ); + $this->assertFalse( get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false ) ); + } + + /** + * Off deactivates the module and leaves the experience option and + * instant_search_enabled untouched, so re-enabling later restores the user's + * prior preference. + */ + public function test_update_experience_off_preserves_other_state() { + // Start with module active, overlay saved, instant search on. The filter + // has to stay active across update_experience() so deactivate() has a + // real active-modules option to remove 'search' from — see test_deactivate_module + // for the same pattern. + add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 ); + update_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, Module_Control::EXPERIENCE_OVERLAY ); + update_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY, true ); + + $result = static::$search_module->update_experience( Module_Control::EXPERIENCE_OFF ); + + // Read the actual option (not via the filter) to prove deactivate() ran. + $active_modules = get_option( 'jetpack_' . Module_Control::JETPACK_ACTIVE_MODULES_OPTION_KEY, array() ); + remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) ); + + // Propagated from Modules::deactivate(): true when the module was actually removed. + $this->assertTrue( $result ); + $this->assertNotContains( Module_Control::JETPACK_SEARCH_MODULE_SLUG, $active_modules ); + // experience option preserved (still 'overlay' for later re-enable). + $this->assertEquals( Module_Control::EXPERIENCE_OVERLAY, get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ) ); + // instant_search_enabled preserved. + $this->assertTrue( (bool) get_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY ) ); + delete_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY ); + delete_option( Module_Control::SEARCH_MODULE_INSTANT_SEARCH_OPTION_KEY ); + } + + /** + * When the module is already inactive, Modules::deactivate() is a no-op and + * returns false. update_experience('off') propagates that bool — it's not an + * error, just a signal that nothing changed. The REST controller (which only + * branches on is_wp_error()) still treats it as success. + */ + public function test_update_experience_off_when_module_already_inactive_returns_false() { + // Earlier tests in this class activate the search module via update_experience() + // and persist 'search' into the real jetpack_active_modules option. Set it to an + // empty array so deactivate() really is a no-op (`update_option` with the same + // value returns false). + update_option( 'jetpack_' . Module_Control::JETPACK_ACTIVE_MODULES_OPTION_KEY, array() ); + + $result = static::$search_module->update_experience( Module_Control::EXPERIENCE_OFF ); + + $this->assertFalse( $result ); + $this->assertNotInstanceOf( \WP_Error::class, $result ); + } + + /** + * Invalid input returns WP_Error. + */ + public function test_update_experience_invalid_value() { + $result = static::$search_module->update_experience( 'invalid_value' ); + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'invalid_experience', $result->get_error_code() ); + } + + /** + * Each experience that calls activate() must propagate its WP_Error rather than + * fall through and write the experience option in an inconsistent state. + * + * @param string $experience One of 'inline', 'embedded', 'overlay'. + * @dataProvider experiences_requiring_activation + */ + #[\PHPUnit\Framework\Attributes\DataProvider( 'experiences_requiring_activation' )] + public function test_update_experience_propagates_activate_error( $experience ) { + $plan = $this->createStub( Plan::class ); + $plan->method( 'supports_search' )->willReturn( false ); + $plan->method( 'supports_instant_search' )->willReturn( false ); + $module = new Module_Control( $plan ); + + $result = $module->update_experience( $experience ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'not_supported', $result->get_error_code() ); + // On failure, the experience option must not be written. + $this->assertFalse( get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false ) ); + } + + /** + * @return array> + */ + public static function experiences_requiring_activation() { + return array( + 'inline' => array( Module_Control::EXPERIENCE_INLINE ), + 'embedded' => array( Module_Control::EXPERIENCE_EMBEDDED ), + 'overlay' => array( Module_Control::EXPERIENCE_OVERLAY ), + ); + } + + /** + * Overlay propagates the WP_Error from enable_instant_search() (e.g. plan + * doesn't support instant search) and does not write the experience option. + */ + public function test_update_experience_overlay_propagates_enable_instant_search_error() { + // $search_module_no_instant has supports_search=true but supports_instant_search=false, + // so activate() succeeds and enable_instant_search() returns 'not_supported'. + // Filter is on so is_active() returns true inside enable_instant_search(). + add_filter( 'jetpack_options', array( $this, 'return_search_active_array' ), 10, 2 ); + + $result = static::$search_module_no_instant->update_experience( Module_Control::EXPERIENCE_OVERLAY ); + + remove_filter( 'jetpack_options', array( $this, 'return_search_active_array' ) ); + + $this->assertInstanceOf( \WP_Error::class, $result ); + $this->assertEquals( 'not_supported', $result->get_error_code() ); + $this->assertFalse( get_option( Module_Control::SEARCH_MODULE_EXPERIENCE_OPTION_KEY, false ) ); + } + /** * Returns an empty array */ diff --git a/projects/packages/search/tests/php/REST_Controller_Test.php b/projects/packages/search/tests/php/REST_Controller_Test.php index a95c2cbd143f..4a4c1b05ea56 100644 --- a/projects/packages/search/tests/php/REST_Controller_Test.php +++ b/projects/packages/search/tests/php/REST_Controller_Test.php @@ -119,13 +119,14 @@ public function test_update_search_settings_success_both_enable() { 'instant_search_enabled' => true, 'swap_classic_to_inline_search' => false, ); + $expected = array_merge( $new_settings, array( 'experience' => 'overlay' ) ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); $request->set_header( 'content-type', 'application/json' ); $request->set_body( wp_json_encode( $new_settings, JSON_UNESCAPED_SLASHES ) ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( $new_settings, $response->get_data() ); + $this->assertEquals( $expected, $response->get_data() ); } /** @@ -170,13 +171,14 @@ public function test_update_search_settings_success_both_disable() { 'instant_search_enabled' => false, 'swap_classic_to_inline_search' => false, ); + $expected = array_merge( $new_settings, array( 'experience' => 'off' ) ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); $request->set_header( 'content-type', 'application/json' ); $request->set_body( wp_json_encode( $new_settings, JSON_UNESCAPED_SLASHES ) ); $response = $this->server->dispatch( $request ); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( $new_settings, $response->get_data() ); + $this->assertEquals( $expected, $response->get_data() ); } /** @@ -191,6 +193,7 @@ public function test_update_search_settings_success_disable_module_only() { 'module_active' => false, 'instant_search_enabled' => false, 'swap_classic_to_inline_search' => false, + 'experience' => 'off', ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -213,6 +216,7 @@ public function test_update_search_settings_success_disable_instant_only() { 'module_active' => true, 'instant_search_enabled' => true, 'swap_classic_to_inline_search' => false, + 'experience' => 'overlay', ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -235,6 +239,7 @@ public function test_update_search_settings_success_enable_inline_search() { 'module_active' => false, 'instant_search_enabled' => false, 'swap_classic_to_inline_search' => true, + 'experience' => 'off', ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -257,6 +262,7 @@ public function test_update_search_settings_success_disable_inline_search() { 'module_active' => false, 'instant_search_enabled' => false, 'swap_classic_to_inline_search' => false, + 'experience' => 'off', ); $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); @@ -292,6 +298,7 @@ public function test_get_search_settings_success() { $this->assertEquals( 200, $response->get_status() ); $this->assertArrayHasKey( 'module_active', $response->get_data() ); $this->assertArrayHasKey( 'instant_search_enabled', $response->get_data() ); + $this->assertArrayHasKey( 'experience', $response->get_data() ); } /** @@ -332,6 +339,156 @@ public function test_get_search_results_success_admin() { $this->assertEquals( 6, $response->get_data()['total'] ); } + /** + * Testing the `POST /jetpack/v4/search/settings` with experience=overlay. + */ + public function test_update_settings_experience_overlay() { + wp_set_current_user( $this->admin_id ); + + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'experience' => 'overlay' ), JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['module_active'] ); + $this->assertTrue( $data['instant_search_enabled'] ); + $this->assertEquals( 'overlay', $data['experience'] ); + } + + /** + * Testing the `POST /jetpack/v4/search/settings` with experience=embedded. + */ + public function test_update_settings_experience_embedded() { + wp_set_current_user( $this->admin_id ); + + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'experience' => 'embedded' ), JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['module_active'] ); + $this->assertFalse( $data['instant_search_enabled'] ); + $this->assertEquals( 'embedded', $data['experience'] ); + } + + /** + * Testing the `POST /jetpack/v4/search/settings` with experience=inline. + */ + public function test_update_settings_experience_inline() { + wp_set_current_user( $this->admin_id ); + + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'experience' => 'inline' ), JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertTrue( $data['module_active'] ); + $this->assertFalse( $data['instant_search_enabled'] ); + $this->assertEquals( 'inline', $data['experience'] ); + } + + /** + * Testing the `POST /jetpack/v4/search/settings` with experience=off. + */ + public function test_update_settings_experience_off() { + wp_set_current_user( $this->admin_id ); + + // Pre-activate the module and enable instant search via the legacy path so we can verify + // that `experience=off` deactivates the module but preserves instant_search_enabled. + $activate_request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $activate_request->set_header( 'content-type', 'application/json' ); + $activate_request->set_body( + wp_json_encode( + array( + 'module_active' => true, + 'instant_search_enabled' => true, + ), + JSON_UNESCAPED_SLASHES + ) + ); + $this->server->dispatch( $activate_request ); + + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'experience' => 'off' ), JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertFalse( $data['module_active'] ); + $this->assertEquals( 'off', $data['experience'] ); + // instant_search_enabled should be preserved (not changed to false by deactivation). + $this->assertTrue( $data['instant_search_enabled'] ); + } + + /** + * Testing the `POST /jetpack/v4/search/settings` with an invalid experience value. + */ + public function test_update_settings_experience_invalid() { + wp_set_current_user( $this->admin_id ); + + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'experience' => 'invalid_value' ), JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Mixing `experience` with `module_active` or `instant_search_enabled` is rejected + * so callers don't silently drop fields. `experience` writes the legacy booleans + * in lockstep — there's no scenario where the caller needs both. + */ + public function test_update_settings_experience_rejects_mixed_legacy_fields() { + wp_set_current_user( $this->admin_id ); + + foreach ( + array( + array( + 'experience' => 'overlay', + 'module_active' => false, + ), + array( + 'experience' => 'embedded', + 'instant_search_enabled' => true, + ), + array( + 'experience' => 'inline', + 'swap_classic_to_inline_search' => true, + ), + ) as $body + ) { + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( $body, JSON_UNESCAPED_SLASHES ) ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + $this->assertEquals( 'rest_invalid_arguments', $response->get_data()['code'] ); + } + } + + /** + * Testing that the persisted experience is returned from `GET /jetpack/v4/search/settings`. + */ + public function test_get_settings_returns_persisted_experience() { + wp_set_current_user( $this->admin_id ); + + // Save experience=embedded. + $request = new WP_REST_Request( 'POST', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $request->set_body( wp_json_encode( array( 'experience' => 'embedded' ), JSON_UNESCAPED_SLASHES ) ); + $this->server->dispatch( $request ); + + // Read back and check persisted value is returned. + $request = new WP_REST_Request( 'GET', '/jetpack/v4/search/settings' ); + $request->set_header( 'content-type', 'application/json' ); + $response = $this->server->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'embedded', $response->get_data()['experience'] ); + } + /** * Testing the `POST /jetpack/v4/search/plan/activate` endpoint with no user. */ diff --git a/projects/packages/sync/changelog/add-rsm-2291-search-experience-option b/projects/packages/sync/changelog/add-rsm-2291-search-experience-option new file mode 100644 index 000000000000..aa81ee636112 --- /dev/null +++ b/projects/packages/sync/changelog/add-rsm-2291-search-experience-option @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Sync: Whitelist the new jetpack_search_experience option so it propagates to WPcom. diff --git a/projects/packages/sync/src/modules/class-search.php b/projects/packages/sync/src/modules/class-search.php index 112da68a27ca..0691174bc5fe 100644 --- a/projects/packages/sync/src/modules/class-search.php +++ b/projects/packages/sync/src/modules/class-search.php @@ -1771,6 +1771,7 @@ public function __construct() { 'jetpack_search_enable_sort', 'jetpack_search_inf_scroll', 'jetpack_search_show_powered_by', + 'jetpack_search_experience', 'instant_search_enabled', ); // end options.