Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
71 / 71 |
|
100.00% |
11 / 11 |
CRAP | |
100.00% |
1 / 1 |
| No_Results | |
100.00% |
71 / 71 |
|
100.00% |
11 / 11 |
36 | |
100.00% |
1 / 1 |
| render_self_contained | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| collect_coverage | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| walk_coverage | |
100.00% |
17 / 17 |
|
100.00% |
1 / 1 |
10 | |||
| normalize_condition | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| seed_coverage | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| visibility_getter | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| legacy_no_results_getter | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
4 | |||
| legacy_error_getter | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| live_region_attribute | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
2 | |||
| default_messages | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| render_default_copy | |
100.00% |
12 / 12 |
|
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 | |
| 8 | namespace 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 | */ |
| 18 | class 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 | } |