Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.47% covered (danger)
49.47%
47 / 95
29.41% covered (danger)
29.41%
5 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard
49.47% covered (danger)
49.47%
47 / 95
29.41% covered (danger)
29.41%
5 / 17
246.38
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 init_hooks
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_load_wp_build
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
4.25
 require_wp_build_with_screen_alias
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
2.06
 wp_build_index
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wp_build_render_function
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_search_admin_request
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 alias_screen_id_for_wp_build
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 restore_screen_id_after_wp_build
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 add_wp_admin_submenu
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
12
 render
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 should_add_search_submenu
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 remove_search_submenu_if_exists
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 admin_init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 load_admin_scripts
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 should_enqueue_tracking_script
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 check_plan_deactivate_search_module
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * A class that adds a search dashboard to wp-admin.
4 *
5 * @package automattic/jetpack
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
12use Automattic\Jetpack\Connection\Manager as Connection_Manager;
13use Automattic\Jetpack\Status;
14use Automattic\Jetpack\Tracking;
15use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
16use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
17/**
18 * Responsible for adding a search dashboard to wp-admin.
19 *
20 * @package Automattic\Jetpack\Search
21 */
22class Dashboard {
23    /**
24     * Slug emitted by `@wordpress/build` (`wpPlugin.pages[0].id`). Must differ from the
25     * `jetpack-search` menu slug: the generated page.php takes over, and exits, any request
26     * whose `page` matches this id.
27     */
28    const WP_BUILD_PAGE_ID = 'jetpack-search-dashboard';
29
30    /**
31     * Render function generated by `@wordpress/build` into
32     * `build/pages/jetpack-search-dashboard/page-wp-admin.php`. Naming convention:
33     * `{wpPlugin.name}_{page-with-underscores}_wp_admin_render_page`.
34     */
35    const WP_BUILD_RENDER_FN = 'jetpack_search_jetpack_search_dashboard_wp_admin_render_page';
36
37    /**
38     * Classic script handle with no source, registered only so the dashboard has
39     * something to hang {@see Initial_State} and the connection initial state on.
40     */
41    const DATA_SCRIPT_HANDLE = 'jetpack-search-dashboard-data';
42
43    /**
44     * Whether the class has been initialized
45     *
46     * @var boolean
47     */
48    private static $initialized = false;
49
50    /**
51     * The screen ID {@see self::alias_screen_id_for_wp_build()} replaced, until it is restored.
52     *
53     * @var string|null
54     */
55    private static $wp_build_original_screen_id = null;
56
57    /**
58     * Plan instance
59     *
60     * @var \Automattic\Jetpack\Search\Plan
61     */
62    protected $plan;
63
64    /**
65     * Connection manager instance
66     *
67     * @var \Automattic\Jetpack\Connection\Manager
68     */
69    protected $connection_manager;
70
71    /**
72     * Module_Control instance
73     *
74     * @var \Automattic\Jetpack\Search\Module_Control
75     */
76    protected $module_control;
77
78    /**
79     * Priority for the dashboard menu
80     * For Jetpack sites: Akismet uses 4, so we use 1 to ensure both menus are added when only they exist.
81     * For Simple sites: the value is overriden in a child class with value 100000 to wait for all menus to be registered.
82     *
83     * @var int
84     */
85    protected $search_menu_priority = 1;
86
87    /**
88     * Contructor
89     *
90     * @param \Automattic\Jetpack\Search\Plan           $plan - Plan instance.
91     * @param \Automattic\Jetpack\Connection\Manager    $connection_manager - Connection Manager instance.
92     * @param \Automattic\Jetpack\Search\Module_Control $module_control - Module_Control instance.
93     */
94    public function __construct( $plan = null, $connection_manager = null, $module_control = null ) {
95        $this->plan               = $plan ? $plan : new Plan();
96        $this->connection_manager = $connection_manager ? $connection_manager : new Connection_Manager( Package::SLUG );
97        $this->module_control     = $module_control ? $module_control : new Module_Control( $this->plan );
98        $this->plan->init_hooks();
99    }
100
101    /**
102     * Initialise hooks.
103     *
104     * We use the `config` package to initialize the search package, which ensures the package is
105     * only initialized once. However earlier versions of Jetpack would still forcely initialize the
106     * dashboard. As a result, there would be two `Search` submenus if we don't ensure the dashboard
107     * is initialized only once. So we use `$initialized` to ensure the class is only initialized once.
108     *
109     * Ref: https://github.com/Automattic/jetpack/pull/21888/files#diff-aae7d66951585fc55053a4d53b68552a41864d2c69aee900574ef4404b7ad5f7L42
110     */
111    public function init_hooks() {
112        if ( ! self::$initialized ) {
113            self::$initialized = true;
114            // Any priority works: render() reads the loaded build after admin_menu, and the
115            // wpcom subclass overrides this to 100000.
116            add_action( 'admin_menu', array( $this, 'maybe_load_wp_build' ), $this->search_menu_priority );
117            add_action( 'admin_menu', array( $this, 'add_wp_admin_submenu' ), $this->search_menu_priority );
118            // Check if the site plan changed and deactivate module accordingly.
119            add_action( 'current_screen', array( $this, 'check_plan_deactivate_search_module' ) );
120        }
121    }
122
123    /**
124     * Load the wp-build dashboard bundle for this request.
125     *
126     * A no-op unless this is the Search page and `build/build.php` exists.
127     */
128    public function maybe_load_wp_build() {
129        if ( ! $this->is_search_admin_request() ) {
130            return;
131        }
132
133        $build_index = $this->wp_build_index();
134        if ( ! file_exists( $build_index ) ) {
135            return;
136        }
137
138        self::require_wp_build_with_screen_alias( $build_index );
139
140        // wp-build hooks module registration to wp_default_scripts, which has already
141        // fired by admin_menu â€” call it directly or the init module never registers.
142        if ( function_exists( 'jetpack_search_register_script_modules' ) ) {
143            jetpack_search_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- guarded by function_exists(); defined in the generated build/modules.php, which Phan excludes.
144        }
145
146        WP_Build_Polyfills::register(
147            'jetpack-search',
148            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
149        );
150    }
151
152    /**
153     * Require the generated build file with the screen ID aliased across its enqueue check.
154     *
155     * @see WP_Build_Screen_Id::load_with_alias()
156     * @param string $build_index Path to the generated `build.php`.
157     * @return void
158     */
159    private static function require_wp_build_with_screen_alias( $build_index ) {
160        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
161        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
162            WP_Build_Screen_Id::load_with_alias(
163                array( __CLASS__, 'alias_screen_id_for_wp_build' ),
164                array( __CLASS__, 'restore_screen_id_after_wp_build' ),
165                function () use ( $build_index ) {
166                    require_once $build_index;
167                }
168            );
169            return;
170        }
171
172        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
173        require_once $build_index;
174        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id_after_wp_build' ) );
175    }
176
177    /**
178     * Path to the generated build entry point. A seam, like the render function below:
179     * tests point it at a stub so this runs without the package being built.
180     *
181     * @return string
182     */
183    protected function wp_build_index() {
184        return dirname( __DIR__, 2 ) . '/build/build.php';
185    }
186
187    /**
188     * Name of the generated render function. A seam: tests override it to render
189     * the dashboard without depending on whether the package happens to be built.
190     *
191     * @return string
192     */
193    protected function wp_build_render_function() {
194        return self::WP_BUILD_RENDER_FN;
195    }
196
197    /**
198     * Whether the current request targets the Search admin page.
199     *
200     * @return bool
201     */
202    protected function is_search_admin_request() {
203        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
204        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
205            return false;
206        }
207
208        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
209        return 'jetpack-search' === sanitize_text_field( wp_unslash( $_GET['page'] ) );
210    }
211
212    /**
213     * Alias the current screen id to wp-build's expected slug so its
214     * auto-generated enqueue callback fires for our user-facing page.
215     */
216    public static function alias_screen_id_for_wp_build() {
217        $screen = get_current_screen();
218        if ( ! $screen ) {
219            return;
220        }
221
222        self::$wp_build_original_screen_id = $screen->id;
223        $screen->id                        = self::WP_BUILD_PAGE_ID;
224    }
225
226    /**
227     * Undo alias_screen_id_for_wp_build(), since JITM builds its message path from the screen ID.
228     */
229    public static function restore_screen_id_after_wp_build() {
230        $screen = get_current_screen();
231        if ( ! $screen || null === self::$wp_build_original_screen_id ) {
232            return;
233        }
234
235        $screen->id                        = self::$wp_build_original_screen_id;
236        self::$wp_build_original_screen_id = null;
237    }
238
239    /**
240     * The page to be added to submenu
241     */
242    public function add_wp_admin_submenu() {
243        // Jetpack of version <= 10.5 would register `jetpack-search` submenu with its built-in search module.
244        $this->remove_search_submenu_if_exists();
245
246        if ( $this->should_add_search_submenu() ) {
247            $page_suffix = Admin_Menu::add_menu(
248                /** "Search" is a product name, do not translate. */
249                'Jetpack Search',
250                'Search',
251                'manage_options',
252                'jetpack-search',
253                array( $this, 'render' ),
254                null,
255                array(
256                    'product' => 'search',
257                    'key'     => 'jetpack-search',
258                )
259            );
260        } else {
261            // always add the page, but hide it from the menu.
262            $page_suffix = add_submenu_page(
263                '',
264                /** "Search" is a product name, do not translate. */
265                'Jetpack Search',
266                'Search',
267                'manage_options',
268                'jetpack-search',
269                array( $this, 'render' )
270            );
271        }
272
273        if ( $page_suffix ) {
274            add_action( 'load-' . $page_suffix, array( $this, 'admin_init' ) );
275        }
276    }
277
278    /**
279     * Render the dashboard page.
280     *
281     * The generated render function is missing where the package was never built.
282     */
283    public function render() {
284        $render_function = $this->wp_build_render_function();
285        if ( function_exists( $render_function ) ) {
286            call_user_func( $render_function );
287        }
288    }
289
290    /**
291     * Test whether we should show Search menu.
292     *
293     * @return boolean Show search sub menu or not.
294     */
295    protected function should_add_search_submenu() {
296        /**
297         * The filter allows to ommit adding a submenu item for Jetpack Search.
298         *
299         * @since 0.11.2
300         *
301         * @param boolean $should_add_search_submenu Default value is true.
302         */
303        return apply_filters( 'jetpack_search_should_add_search_submenu', current_user_can( 'manage_options' ) );
304    }
305
306    /**
307     * Remove `jetpack-search` submenu page
308     */
309    protected function remove_search_submenu_if_exists() {
310        remove_submenu_page( 'jetpack', 'jetpack-search' );
311    }
312
313    /**
314     * Initialize the admin resources.
315     */
316    public function admin_init() {
317        add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
318    }
319
320    /**
321     * Enqueue admin scripts.
322     */
323    public function load_admin_scripts() {
324        if ( $this->should_enqueue_tracking_script() ) {
325            // Required for Analytics.
326            Tracking::register_tracks_functions_scripts( true );
327        }
328
329        // wp-build enqueues the app itself; this empty handle exists only to
330        // print the initial state before boot runs on DOMContentLoaded.
331        wp_register_script( self::DATA_SCRIPT_HANDLE, false, array(), Package::VERSION, true );
332        wp_enqueue_script( self::DATA_SCRIPT_HANDLE );
333
334        // The i18n loader is registered on every admin page but only enqueued
335        // when depended on; the esbuild bundle doesn't pull it in.
336        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
337            wp_enqueue_script( 'wp-jp-i18n-loader' );
338        }
339
340        // Add objects to be passed to the initial state of the app.
341        // Use wp_add_inline_script instead of wp_localize_script, see https://core.trac.wordpress.org/ticket/25280.
342        wp_add_inline_script(
343            self::DATA_SCRIPT_HANDLE,
344            ( new Initial_State() )->render(),
345            'before'
346        );
347
348        // Connection initial state.
349        Connection_Initial_State::render_script( self::DATA_SCRIPT_HANDLE );
350    }
351
352    /**
353     * Check if we should enqueue the tracking script.
354     */
355    protected function should_enqueue_tracking_script() {
356        return ! ( new Status() )->is_offline_mode() && $this->connection_manager->is_connected();
357    }
358
359    /**
360     * Deactivate search module if plan doesn't support search.
361     *
362     * @param \WP_Screen $current_screen Creent screen object.
363     */
364    public function check_plan_deactivate_search_module( $current_screen ) {
365        // Only run on Jetpack admin pages.
366        // The first two checks for current screen are cheap to run on every page.
367        if (
368            property_exists( $current_screen, 'base' ) &&
369            strpos( $current_screen->base, 'jetpack_page_' ) !== false &&
370            ( ! $this->plan->supports_search() || $this->plan->must_upgrade() )
371        ) {
372            $this->module_control->deactivate();
373        }
374    }
375}