Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
11 / 11
CRAP
100.00% covered (success)
100.00%
1 / 1
No_Results
100.00% covered (success)
100.00%
71 / 71
100.00% covered (success)
100.00%
11 / 11
36
100.00% covered (success)
100.00%
1 / 1
 render_self_contained
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 collect_coverage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 walk_coverage
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
10
 normalize_condition
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 seed_coverage
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 visibility_getter
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 legacy_no_results_getter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 legacy_error_getter
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 live_region_attribute
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 default_messages
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 render_default_copy
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Empty-state logic shared by the `no-results` block and `results-list`.
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10/**
11 * The one implementation of what the results region shows instead of results.
12 *
13 * Two renderers can emit an empty state — the `no-results` block's variants and
14 * `results-list`'s legacy regions — and they have to agree on which of them
15 * covers each condition, on the copy, and on the store getter that reveals it.
16 * Condition logic belongs here rather than in either `render.php`.
17 */
18class No_Results {
19
20    /**
21     * Conditions a variant can be scoped to.
22     */
23    const CONDITIONS = array( 'any', 'filtered', 'error' );
24
25    /**
26     * Coverage of the markup currently being rendered on its own, set only for
27     * the duration of `render_self_contained()`. Null during a page render.
28     *
29     * @var array<string,bool>|null
30     */
31    private static $self_contained_coverage = null;
32
33    /**
34     * Render block markup as a region of its own, resolving the empty-state
35     * hand-off from its own composition rather than the page-global flags.
36     *
37     * The block-template overlay is pre-rendered on every front-end request,
38     * whether or not a visitor opens it. Seeding coverage from that pass would
39     * retire the legacy regions of unrelated in-page results markup, blanking a
40     * message the site showed yesterday. Its composition is known up front, so
41     * it doesn't need the flags at all.
42     *
43     * @param string $content Block markup.
44     * @return string Rendered HTML.
45     */
46    public static function render_self_contained( string $content ): string {
47        // Restored, not cleared: a nested call that reset to null would drop the
48        // outer render back onto the page-global getters *and* let it resume
49        // seeding — silently reintroducing the leak this exists to prevent.
50        // Nothing nests today; this keeps that cheap to add.
51        $previous                      = self::$self_contained_coverage;
52        self::$self_contained_coverage = self::collect_coverage( $content );
53        try {
54            return do_blocks( $content );
55        } finally {
56            self::$self_contained_coverage = $previous;
57        }
58    }
59
60    /**
61     * Which conditions the `no-results` blocks in a chunk of block markup cover.
62     *
63     * @param string $content Block markup.
64     * @return array<string,bool> Keyed by condition.
65     */
66    public static function collect_coverage( string $content ): array {
67        $coverage = array_fill_keys( self::CONDITIONS, false );
68        self::walk_coverage( parse_blocks( $content ), $coverage );
69        return $coverage;
70    }
71
72    /**
73     * Accumulate coverage across a parsed block tree.
74     *
75     * @param array              $blocks   Parsed blocks.
76     * @param array<string,bool> $coverage Coverage accumulator, by reference.
77     * @return void
78     */
79    private static function walk_coverage( array $blocks, array &$coverage ): void {
80        foreach ( $blocks as $block ) {
81            $inner_blocks = $block['innerBlocks'] ?? array();
82            if ( 'jetpack-search/no-results' !== ( $block['blockName'] ?? '' ) ) {
83                self::walk_coverage( $inner_blocks, $coverage );
84                continue;
85            }
86
87            $conditions = array();
88            $has_strays = false;
89            foreach ( $inner_blocks as $inner_block ) {
90                $inner_name = $inner_block['blockName'] ?? '';
91                if ( 'jetpack-search/no-results-slot' === $inner_name ) {
92                    $conditions[] = self::normalize_condition( $inner_block['attrs']['condition'] ?? '' );
93                } elseif ( ! empty( $inner_name ) || '' !== trim( (string) ( $inner_block['innerHTML'] ?? '' ) ) ) {
94                    $has_strays = true;
95                }
96            }
97            // Mirrors the renderer: it wraps stray children as an unscoped
98            // variant, and a container with nothing else stands in for one. If
99            // this disagreed, both the wrapped strays and the legacy region
100            // would bind to the same getter and show together.
101            if ( $has_strays ) {
102                $conditions[] = 'any';
103            }
104            foreach ( $conditions ? $conditions : array( 'any' ) as $condition ) {
105                $coverage[ $condition ] = true;
106            }
107        }
108    }
109
110    /**
111     * Normalize a saved `no-results-slot` condition.
112     *
113     * @param mixed $condition Saved attribute value.
114     * @return string One of `any`, `filtered`, `error`.
115     */
116    public static function normalize_condition( $condition ): string {
117        return in_array( $condition, array( 'filtered', 'error' ), true ) ? $condition : 'any';
118    }
119
120    /**
121     * Seed which empty states a variant covers.
122     *
123     * `results-list`'s legacy regions stand down only for the cases actually
124     * covered, so a lone variant scoped to one condition doesn't leave the
125     * others with no message at all. `wp_interactivity_state()` deep-merges and
126     * nothing ever seeds `false`, so variants compose into full coverage.
127     *
128     * A `filtered` variant additionally claims that state, and an unscoped
129     * (`any`) one yields where it did — otherwise the two would stack on a
130     * filtered empty search. `error` is disjoint from both: it retires only the
131     * legacy error region, which keeps `any` the safe default.
132     *
133     * @param string $condition One of `any`, `filtered`, `error`.
134     * @return void
135     */
136    public static function seed_coverage( string $condition ): void {
137        if ( ! function_exists( 'wp_interactivity_state' ) || null !== self::$self_contained_coverage ) {
138            return;
139        }
140        if ( 'error' === $condition ) {
141            $coverage = array( 'hasErrorBlock' => true );
142        } else {
143            $coverage = array( 'hasNoResultsFiltered' => true );
144            if ( 'filtered' === $condition ) {
145                $coverage['hasScopedNoResultsFiltered'] = true;
146            } else {
147                $coverage['hasNoResultsUnfiltered'] = true;
148            }
149        }
150        wp_interactivity_state( 'jetpack-search', $coverage );
151    }
152
153    /**
154     * Store getter that decides whether a condition is showing.
155     *
156     * `data-wp-bind` only evaluates simple property paths, so each condition
157     * needs its own getter rather than an inline expression.
158     *
159     * @param string $condition One of `any`, `filtered`, `error`.
160     * @return string Getter path.
161     */
162    public static function visibility_getter( string $condition ): string {
163        $coverage = self::$self_contained_coverage;
164
165        // `showNoResultsAny` yields to whichever variant claimed the filtered
166        // state, which it reads from flags a self-contained render never
167        // writes. That render knows the answer outright.
168        if ( 'any' === $condition && null !== $coverage ) {
169            return $coverage['filtered'] ? 'state.showNoResultsUnfiltered' : 'state.showNoResults';
170        }
171
172        $getters = array(
173            'any'      => 'state.showNoResultsAny',
174            'filtered' => 'state.showNoResultsFiltered',
175            'error'    => 'state.showError',
176        );
177        return $getters[ $condition ] ?? $getters['any'];
178    }
179
180    /**
181     * Store getter that shows `results-list`'s legacy empty-state region, or an
182     * empty string when nothing is left for it to cover.
183     *
184     * @return string Getter path, or '' to drop the region.
185     */
186    public static function legacy_no_results_getter(): string {
187        $coverage = self::$self_contained_coverage;
188        if ( null === $coverage ) {
189            return 'state.showLegacyNoResults';
190        }
191        if ( $coverage['any'] ) {
192            return '';
193        }
194        return $coverage['filtered'] ? 'state.showNoResultsUnfiltered' : 'state.showNoResults';
195    }
196
197    /**
198     * Store getter that shows `results-list`'s legacy error region, or an empty
199     * string when a variant covers the failure case.
200     *
201     * @return string Getter path, or '' to drop the region.
202     */
203    public static function legacy_error_getter(): string {
204        $coverage = self::$self_contained_coverage;
205        if ( null === $coverage ) {
206            return 'state.showLegacyError';
207        }
208        return $coverage['error'] ? '' : 'state.showError';
209    }
210
211    /**
212     * Live-region attribute for a condition's default copy.
213     *
214     * A failure is assertive, an empty result set is not — the same split
215     * `results-list` has always emitted between its two regions. Only the
216     * default copy gets one: announcing an author's whole composition verbatim
217     * on every empty search is worse than not announcing it.
218     *
219     * Every message gets one, authored or not. `results-list` announced its
220     * custom copy too, so wrapping only the fallback would leave a screen
221     * reader silent on the transition to empty for exactly the authors who
222     * cared enough to write their own message.
223     *
224     * @param string $condition One of `any`, `filtered`, `error`.
225     * @return string Attribute markup.
226     */
227    public static function live_region_attribute( string $condition ): string {
228        return 'error' === $condition ? 'role="alert"' : 'role="status"';
229    }
230
231    /**
232     * Default copy for the states the results region can show instead of
233     * results, keyed by state.
234     *
235     * Single source for the two renderers that can emit them, so the strings
236     * can't drift into near-identical translator entries that disagree. The
237     * editor-side mirror lives in `blocks/no-results/slot/edit.jsx`.
238     *
239     * @return array{unfiltered:string, filtered:string, error:string}
240     */
241    public static function default_messages(): array {
242        return array(
243            'unfiltered' => __( 'No results found. Try a different search.', 'jetpack-search-pkg' ),
244            'filtered'   => __( 'No results match these filters. Try clearing some, or searching for something else.', 'jetpack-search-pkg' ),
245            'error'      => __( 'Something went wrong. Please try again.', 'jetpack-search-pkg' ),
246        );
247    }
248
249    /**
250     * Emit the localized default copy for a condition.
251     *
252     * The unscoped case keeps the filter-aware pair `results-list` has always
253     * rendered, so a stock install reads the same as before the block existed.
254     * Neither `<p>` carries an initial `hidden` — the wrapper's covers the SSR
255     * path and the Interactivity runtime resolves the inner binds atomically on
256     * reveal; adding one here makes the other flash.
257     *
258     * @param string $condition One of `any`, `filtered`, `error`.
259     * @return void
260     */
261    public static function render_default_copy( string $condition ): void {
262        $defaults = self::default_messages();
263        if ( 'filtered' === $condition ) {
264            printf( '<p>%s</p>', esc_html( $defaults['filtered'] ) );
265            return;
266        }
267        if ( 'error' === $condition ) {
268            printf( '<p>%s</p>', esc_html( $defaults['error'] ) );
269            return;
270        }
271        printf(
272            '<p data-wp-bind--hidden="state.hasActiveFilters">%s</p><p data-wp-bind--hidden="!state.hasActiveFilters">%s</p>',
273            esc_html( $defaults['unfiltered'] ),
274            esc_html( $defaults['filtered'] )
275        );
276    }
277}