diff --git a/projects/packages/search/changelog/search-219-filter-static-helper b/projects/packages/search/changelog/search-219-filter-static-helper new file mode 100644 index 00000000000..f59c25bcdbc --- /dev/null +++ b/projects/packages/search/changelog/search-219-filter-static-helper @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Search: internal — new `Filter_Static` helper class, plus a `staticFilterSelections` seed slot on the shared Interactivity state. Foundation for the upcoming `jetpack-search/filter-static` block; no user-visible change on its own. diff --git a/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php b/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php new file mode 100644 index 00000000000..e3a596a2a9b --- /dev/null +++ b/projects/packages/search/src/search-blocks/blocks/filter-static/class-filter-static.php @@ -0,0 +1,304 @@ +>|null + */ + private static $config_cache = null; + + /** + * Read the site-configured static-filter list (memoised per request). + * + * Composes three steps: read raw entries from the host-site filter hooks, + * normalise each one, dedupe by `filter_id` (last write wins). + * + * @return array> + */ + public static function get_static_filters_config(): array { + if ( null !== self::$config_cache ) { + return self::$config_cache; + } + self::$config_cache = self::dedupe_by_filter_id( + self::normalize_entries( self::read_raw_entries() ) + ); + return self::$config_cache; + } + + /** + * Reset the per-request memo. Tests only. + */ + public static function reset_cache_for_testing(): void { + self::$config_cache = null; + } + + /** + * Read raw static-filter entries from the host-site filter hooks. Resolves + * the union of two seams so sites already wired up for the legacy overlay + * pick up the new block for free: + * + * 1. `jetpack_instant_search_options` — the legacy options blob's + * `staticFilters` key. Used by the overlay today. + * 2. `jetpack_search_static_filters` — narrower sibling whose payload is + * just the static-filter array. New, blocks-only callers register here. + * + * @return array + */ + private static function read_raw_entries(): array { + if ( ! function_exists( 'apply_filters' ) ) { + return array(); + } + $options = apply_filters( 'jetpack_instant_search_options', array() ); + $from_legacy = is_array( $options['staticFilters'] ?? null ) + ? $options['staticFilters'] + : array(); + return (array) apply_filters( 'jetpack_search_static_filters', $from_legacy ); + } + + /** + * Normalise a raw entry list, dropping ones that don't pass validation. + * + * @param array $raw Raw entries from the filter hooks. + * @return array> + */ + private static function normalize_entries( array $raw ): array { + $out = array(); + foreach ( $raw as $entry ) { + $normalized = self::normalize_entry( $entry ); + if ( null !== $normalized ) { + $out[] = $normalized; + } + } + return $out; + } + + /** + * Collapse entries with duplicate `filter_id` to a single entry per id, + * keeping the last registration (matches PHP `apply_filters` semantics). + * The duplicate's position is preserved so iteration order stays + * deterministic regardless of how many duplicates were registered. + * + * @param array> $entries Normalised entries. + * @return array> + */ + private static function dedupe_by_filter_id( array $entries ): array { + $position = array(); + $out = array(); + foreach ( $entries as $entry ) { + $filter_id = $entry['filter_id']; + if ( isset( $position[ $filter_id ] ) ) { + self::warn_duplicate_filter_id( $filter_id ); + $out[ $position[ $filter_id ] ] = $entry; + continue; + } + $position[ $filter_id ] = count( $out ); + $out[] = $entry; + } + return $out; + } + + /** + * Surface a duplicate-registration via `_doing_it_wrong()` so abuse is + * visible in debug. Silent in production. + * + * @param string $filter_id The colliding filter id. + */ + private static function warn_duplicate_filter_id( string $filter_id ): void { + if ( ! function_exists( '_doing_it_wrong' ) ) { + return; + } + $message = sprintf( + /* translators: %s: duplicate filter ID. */ + esc_html__( 'Duplicate static filter "%s" — last registration wins.', 'jetpack-search-pkg' ), + esc_html( $filter_id ) + ); + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- $message is sprintf() of esc_html__() with esc_html()-wrapped arg. + _doing_it_wrong( __METHOD__, $message, 'jetpack-search-pkg 7.0.0' ); + } + + /** + * Narrow the configured list by `variation` and (optionally) `filter_id`. + * + * @param string $variation Either 'sidebar' or 'tabbed'. + * @param string $filter_id When non-empty, return only the matching entry. + * @return array> + */ + public static function filters_for_variation( string $variation, string $filter_id = '' ): array { + $variation = self::normalize_variation( $variation ); + $out = array(); + foreach ( self::get_static_filters_config() as $entry ) { + if ( self::normalize_variation( $entry['variation'] ?? '' ) !== $variation ) { + continue; + } + if ( '' !== $filter_id && $entry['filter_id'] !== $filter_id ) { + continue; + } + $out[] = $entry; + } + return $out; + } + + /** + * Normalize the variation value. Anything other than 'tabbed' collapses to + * 'sidebar' — matches the legacy `getAvailableStaticFilters()` default. + * + * @param mixed $value Raw value. + * @return string Either 'sidebar' or 'tabbed'. + */ + public static function normalize_variation( $value ): string { + return 'tabbed' === $value ? 'tabbed' : 'sidebar'; + } + + /** + * Sanitize and validate a single configured entry. Returns null when the + * entry is missing required fields or its `filter_id` collides with a + * reserved URL param — the block then renders nothing for that entry + * rather than silently failing on round-trip. + * + * @param mixed $entry Raw entry. + * @return array|null + */ + private static function normalize_entry( $entry ): ?array { + if ( ! is_array( $entry ) ) { + return null; + } + $filter_id = sanitize_key( (string) ( $entry['filter_id'] ?? '' ) ); + if ( '' === $filter_id || in_array( $filter_id, Search_Blocks::RESERVED_QUERY_PARAMS, true ) ) { + return null; + } + $values = self::normalize_values( $entry['values'] ?? array() ); + if ( empty( $values ) ) { + return null; + } + return array( + 'filter_id' => $filter_id, + 'name' => sanitize_text_field( (string) ( $entry['name'] ?? '' ) ), + 'type' => 'group', + 'variation' => self::normalize_variation( $entry['variation'] ?? '' ), + 'selected' => sanitize_text_field( (string) ( $entry['selected'] ?? '' ) ), + 'values' => $values, + ); + } + + /** + * Sanitize the per-entry `values` array. Drops entries that aren't + * objects, entries with an empty `value`, and any non-array element. + * A missing display `name` falls back to the value so the radio still + * has a visible label rather than rendering blank. + * + * @param mixed $raw Raw values list. + * @return array + */ + private static function normalize_values( $raw ): array { + $out = array(); + foreach ( (array) $raw as $entry ) { + if ( ! is_array( $entry ) ) { + continue; + } + $value = sanitize_text_field( (string) ( $entry['value'] ?? '' ) ); + if ( '' === $value ) { + continue; + } + $name = sanitize_text_field( (string) ( $entry['name'] ?? '' ) ); + $out[] = array( + 'value' => $value, + 'name' => '' === $name ? $value : $name, + ); + } + return $out; + } + + /** + * Build the filterConfig entry this block contributes to the shared + * Interactivity state. The `kind => 'static'` flag is what the JS store + * keys off to decide URL serialization (scalar vs array shape) and the + * single-select vs toggle action path. + * + * @param array $entry Normalized server-config entry. + * @param array $attributes Block attributes. + * @return array + */ + public static function build_config( array $entry, array $attributes ): array { + return array( + 'filterKey' => $entry['filter_id'], + 'kind' => 'static', + 'filterType' => 'static', + 'label' => self::derive_label( $entry, $attributes ), + 'values' => $entry['values'], + 'selected' => $entry['selected'], + 'variation' => $entry['variation'], + ); + } + + /** + * Block-attribute label override beats server name; empty falls back. + * + * @param array $entry Normalized server-config entry. + * @param array $attributes Block attributes. + * @return string + */ + public static function derive_label( array $entry, array $attributes ): string { + $override = sanitize_text_field( (string) ( $attributes['label'] ?? '' ) ); + if ( '' !== $override ) { + return $override; + } + return (string) ( $entry['name'] ?? '' ); + } + + /** + * Parse scalar URL params that match a configured static-filter key into a + * `{ filter_id => value }` map. Called from the seed path so a deep link + * pre-checks the right radio and the SSR pass shows the filtered count. + * + * Iterates the configured filters rather than the entire `$_GET` so + * arbitrary plugin-emitted params can't end up in the map. + * + * @return array + */ + public static function parse_url_selections(): array { + if ( ! function_exists( 'wp_unslash' ) ) { + return array(); + } + // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- read-only URL state; sanitized per-value below. + $raw = wp_unslash( $_GET ); + if ( ! is_array( $raw ) ) { + return array(); + } + $out = array(); + foreach ( self::get_static_filters_config() as $entry ) { + $filter_id = $entry['filter_id']; + $value = $raw[ $filter_id ] ?? null; + // `! is_string` drops both missing values and array-shaped misuse + // (e.g. a stray `?section[]=…` under a static-filter key). + if ( ! is_string( $value ) ) { + continue; + } + $clean = sanitize_text_field( $value ); + if ( '' !== $clean ) { + $out[ $filter_id ] = $clean; + } + } + return $out; + } +} diff --git a/projects/packages/search/src/search-blocks/class-search-blocks.php b/projects/packages/search/src/search-blocks/class-search-blocks.php index 406264ec640..92e65cfbea9 100644 --- a/projects/packages/search/src/search-blocks/class-search-blocks.php +++ b/projects/packages/search/src/search-blocks/class-search-blocks.php @@ -1716,6 +1716,13 @@ public static function build_initial_state() { 'activeFilters' => $active_filters, 'filterLogic' => $filter_logic, 'priceRange' => $price_range, + // Static filters (`jetpack-search/filter-static`) round-trip as + // scalar `?filter_id=value` URL params. The block's render.php + // merges the URL-seeded selections in alongside its filterConfig + // entry so a deep link pre-checks the right radio. Seeded as an + // empty object here so JS readers always see a defined shape on + // pages without the block. + 'staticFilterSelections' => (object) array(), // filterConfigs: each filter-checkbox block's render.php merges its // own entry here. Shape: { [filterKey]: { filterKey, filterType, diff --git a/projects/packages/search/tests/php/Filter_Static_Test.php b/projects/packages/search/tests/php/Filter_Static_Test.php new file mode 100644 index 00000000000..eaa473b5f98 --- /dev/null +++ b/projects/packages/search/tests/php/Filter_Static_Test.php @@ -0,0 +1,598 @@ + 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + } + ); + + $configs = Filter_Static::get_static_filters_config(); + + $this->assertCount( 1, $configs ); + $this->assertSame( 'section', $configs[0]['filter_id'] ); + $this->assertSame( 'Section', $configs[0]['name'] ); + $this->assertSame( 'group', $configs[0]['type'] ); + // Variation defaults to 'sidebar' when unset on the entry. + $this->assertSame( 'sidebar', $configs[0]['variation'] ); + $this->assertSame( 'news', $configs[0]['values'][0]['value'] ); + } + + /** + * Sites that registered static filters for the legacy instant-search + * overlay via `jetpack_instant_search_options` should get the blocks for + * free — Filter_Static must pick the same payload up without a second + * registration. + */ + public function test_get_static_filters_config_reads_legacy_instant_search_options_hook() { + add_filter( + 'jetpack_instant_search_options', + static function ( $options ) { + $options['staticFilters'] = array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'variation' => 'tabbed', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + return $options; + } + ); + + $configs = Filter_Static::get_static_filters_config(); + + $this->assertCount( 1, $configs ); + $this->assertSame( 'section', $configs[0]['filter_id'] ); + $this->assertSame( 'tabbed', $configs[0]['variation'] ); + } + + /** + * Result is memoised per request — re-registering after the first read + * shouldn't change subsequent reads until the cache is reset. + */ + public function test_get_static_filters_config_memoises_per_request() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'a', + 'name' => 'A', + 'values' => array( + array( + 'name' => 'One', + 'value' => 'one', + ), + ), + ), + ); + } + ); + $first = Filter_Static::get_static_filters_config(); + + // Re-register so the underlying hook returns a different list. The + // memoised first read should still win. + remove_all_filters( 'jetpack_search_static_filters' ); + add_filter( + 'jetpack_search_static_filters', + static function () { + return array(); + } + ); + + $second = Filter_Static::get_static_filters_config(); + $this->assertSame( $first, $second ); + } + + /** + * Entries whose `filter_id` collides with a reserved query param (`s`, + * `q`, `orderby`, `min_price`, `max_price`) round-trip through + * `parse_url_filters()` as something other than a filter and would + * silently fail. Reject them upfront so misconfiguration is visible. + */ + public function test_normalize_entry_rejects_reserved_filter_id() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 's', + 'name' => 'Search hijack', + 'values' => array( + array( + 'name' => 'One', + 'value' => 'one', + ), + ), + ), + array( + 'filter_id' => 'orderby', + 'name' => 'Sort hijack', + 'values' => array( + array( + 'name' => 'One', + 'value' => 'one', + ), + ), + ), + ); + } + ); + + $this->assertSame( array(), Filter_Static::get_static_filters_config() ); + } + + /** + * An entry with no usable values would render an empty fieldset. Drop it + * so render.php has nothing to emit rather than a labeled empty list. + */ + public function test_normalize_entry_rejects_empty_values() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array(), + ), + array( + 'filter_id' => 'topic', + 'name' => 'Topic', + 'values' => 'not-an-array', + ), + ); + } + ); + + $this->assertSame( array(), Filter_Static::get_static_filters_config() ); + } + + /** + * Values missing a `value` field are filtered out, and a missing display + * `name` falls back to the value. An entry left with no usable values + * after this is rejected. + */ + public function test_normalize_entry_sanitises_individual_values() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + // Missing value — dropped. + array( 'name' => 'Orphan' ), + // Missing name — falls back to the value. + array( 'value' => 'guides' ), + // Not an array — skipped. + 'garbage', + ), + ), + ); + } + ); + + $values = Filter_Static::get_static_filters_config()[0]['values']; + $this->assertSame( + array( + array( + 'value' => 'news', + 'name' => 'News', + ), + array( + 'value' => 'guides', + 'name' => 'guides', + ), + ), + $values + ); + } + + /** + * Two entries with the same `filter_id` create an ambiguous URL contract + * (which radio set owns `?section=...`?). Adopt the last write so the + * behaviour mirrors PHP `apply_filters` semantics, and surface the + * collision via `_doing_it_wrong()` so abuse is visible in debug. + */ + public function test_get_static_filters_config_dedupes_last_wins_on_duplicate_filter_id() { + add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'First registration', + 'values' => array( + array( + 'name' => 'One', + 'value' => 'one', + ), + ), + ), + array( + 'filter_id' => 'section', + 'name' => 'Second registration', + 'values' => array( + array( + 'name' => 'Two', + 'value' => 'two', + ), + ), + ), + ); + } + ); + + $configs = Filter_Static::get_static_filters_config(); + $this->assertCount( 1, $configs ); + $this->assertSame( 'Second registration', $configs[0]['name'] ); + $this->assertSame( 'two', $configs[0]['values'][0]['value'] ); + } + + /** + * `filters_for_variation` returns only entries whose `variation` matches. + * Defaults coerce 'sidebar' so an entry with the field unset shows under + * the sidebar variation and not the tabbed one. + */ + public function test_filters_for_variation_scopes_by_variation() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + // Variation omitted — defaults to sidebar. + 'values' => array( + array( + 'name' => 'A', + 'value' => 'a', + ), + ), + ), + array( + 'filter_id' => 'audience', + 'name' => 'Audience', + 'variation' => 'tabbed', + 'values' => array( + array( + 'name' => 'B', + 'value' => 'b', + ), + ), + ), + ); + } + ); + + $this->assertCount( 1, Filter_Static::filters_for_variation( 'sidebar' ) ); + $this->assertSame( 'section', Filter_Static::filters_for_variation( 'sidebar' )[0]['filter_id'] ); + + $this->assertCount( 1, Filter_Static::filters_for_variation( 'tabbed' ) ); + $this->assertSame( 'audience', Filter_Static::filters_for_variation( 'tabbed' )[0]['filter_id'] ); + } + + /** + * When the block scopes to a specific `filter_id`, only that one entry + * (intersected with the variation) is returned. + */ + public function test_filters_for_variation_filters_by_specific_filter_id() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'A', + 'value' => 'a', + ), + ), + ), + array( + 'filter_id' => 'topic', + 'name' => 'Topic', + 'values' => array( + array( + 'name' => 'B', + 'value' => 'b', + ), + ), + ), + ); + } + ); + + $narrowed = Filter_Static::filters_for_variation( 'sidebar', 'topic' ); + $this->assertCount( 1, $narrowed ); + $this->assertSame( 'topic', $narrowed[0]['filter_id'] ); + } + + /** + * Anything other than the literal string 'tabbed' collapses to 'sidebar', + * matching `getAvailableStaticFilters()` on the JS side. + */ + public function test_normalize_variation_defaults_to_sidebar() { + $this->assertSame( 'tabbed', Filter_Static::normalize_variation( 'tabbed' ) ); + $this->assertSame( 'sidebar', Filter_Static::normalize_variation( 'sidebar' ) ); + $this->assertSame( 'sidebar', Filter_Static::normalize_variation( '' ) ); + $this->assertSame( 'sidebar', Filter_Static::normalize_variation( 'garbage' ) ); + $this->assertSame( 'sidebar', Filter_Static::normalize_variation( null ) ); + } + + /** + * `build_config` produces the filterConfig entry that render.php pushes + * into the shared Interactivity state. The `kind => 'static'` marker is + * what the JS store keys off to decide URL serialization (scalar vs + * array shape) — locking it down here prevents silent drift between PHP + * and JS contracts. + */ + public function test_build_config_shape() { + $entry = array( + 'filter_id' => 'section', + 'name' => 'Section', + 'type' => 'group', + 'variation' => 'sidebar', + 'selected' => 'news', + 'values' => array( + array( + 'value' => 'news', + 'name' => 'News', + ), + ), + ); + + $config = Filter_Static::build_config( $entry, array() ); + + $this->assertSame( 'section', $config['filterKey'] ); + $this->assertSame( 'static', $config['kind'] ); + $this->assertSame( 'static', $config['filterType'] ); + $this->assertSame( 'Section', $config['label'] ); + $this->assertSame( 'news', $config['selected'] ); + $this->assertSame( 'sidebar', $config['variation'] ); + $this->assertSame( + array( + array( + 'value' => 'news', + 'name' => 'News', + ), + ), + $config['values'] + ); + } + + /** + * The block's `label` attribute overrides the server-supplied `name`. + * Empty falls back so a site that never set the override sees the + * registered display name. + */ + public function test_derive_label_attribute_override_beats_server_name() { + $entry = array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array(), + ); + + $this->assertSame( + 'Custom Heading', + Filter_Static::derive_label( $entry, array( 'label' => 'Custom Heading' ) ) + ); + $this->assertSame( 'Section', Filter_Static::derive_label( $entry, array( 'label' => '' ) ) ); + $this->assertSame( 'Section', Filter_Static::derive_label( $entry, array() ) ); + } + + /** + * `parse_url_selections` pulls scalar `?filter_id=value` URL params into a + * `{ filter_id => value }` map. Only keys that match a configured static + * filter get through — arbitrary scalar params from other plugins never + * reach `staticFilterSelections`. + */ + public function test_parse_url_selections_pulls_only_configured_keys() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + } + ); + $_GET = array( + 'section' => 'news', + 'unrelated' => 'ignored', + 's' => 'search query', + ); + + $this->assertSame( array( 'section' => 'news' ), Filter_Static::parse_url_selections() ); + } + + /** + * Array-shaped params under a static-filter key are misuse (the URL + * contract for static filters is scalar). Drop them rather than guess + * which array element to use. + */ + public function test_parse_url_selections_skips_array_shaped_values() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + } + ); + $_GET = array( 'section' => array( 'news', 'guides' ) ); + + $this->assertSame( array(), Filter_Static::parse_url_selections() ); + } + + /** + * Empty-string values mean "no selection" — drop them so the gate stays + * empty rather than seeding an empty string into staticFilterSelections. + */ + public function test_parse_url_selections_drops_empty_strings() { + add_filter( + 'jetpack_search_static_filters', + static function () { + return array( + array( + 'filter_id' => 'section', + 'name' => 'Section', + 'values' => array( + array( + 'name' => 'News', + 'value' => 'news', + ), + ), + ), + ); + } + ); + $_GET = array( 'section' => '' ); + + $this->assertSame( array(), Filter_Static::parse_url_selections() ); + } + + /** + * The new `jetpack_search_static_filters` hook receives whatever the legacy + * `jetpack_instant_search_options.staticFilters` produced as its initial + * value, so callbacks on the new hook can see, override, or extend a + * site's legacy static-filter registration. This is the integration + * point sites would use to migrate without dropping the existing overlay. + */ + public function test_new_hook_receives_legacy_payload_as_input() { + add_filter( + 'jetpack_instant_search_options', + static function ( $options ) { + $options['staticFilters'] = array( + array( + 'filter_id' => 'section', + 'name' => 'Legacy section', + 'values' => array( + array( + 'name' => 'A', + 'value' => 'a', + ), + ), + ), + ); + return $options; + } + ); + // Override the same filter_id on the new hook — last-wins semantics + // mean the new hook's entry replaces the legacy one. + add_filter( + 'jetpack_search_static_filters', + static function ( $list ) { + // The list passed in MUST contain the legacy entry. + if ( ! is_array( $list ) || count( $list ) !== 1 || ( $list[0]['filter_id'] ?? '' ) !== 'section' ) { + return $list; + } + $list[] = array( + 'filter_id' => 'section', + 'name' => 'Blocks-side override', + 'values' => array( + array( + 'name' => 'B', + 'value' => 'b', + ), + ), + ); + return $list; + } + ); + add_filter( 'doing_it_wrong_trigger_error', '__return_false' ); + + $configs = Filter_Static::get_static_filters_config(); + $this->assertCount( 1, $configs ); + $this->assertSame( 'Blocks-side override', $configs[0]['name'] ); + $this->assertSame( 'b', $configs[0]['values'][0]['value'] ); + } +}