Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
78.70% covered (warning)
78.70%
85 / 108
57.89% covered (warning)
57.89%
11 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard
80.19% covered (warning)
80.19%
85 / 106
57.89% covered (warning)
57.89%
11 / 19
50.83
0.00% covered (danger)
0.00%
0 / 1
 register_feature_flag
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 is_enabled
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 init
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 register_sections
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 register_section
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 register_rest_routes
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 can_manage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_initial_state
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 add_script_data
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 maybe_load_wp_build
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 is_dashboard_request
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_tab_url
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 load_wp_build
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
5.12
 alias_screen_id
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 restore_screen_id
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 enqueue_i18n_loader
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 add_menu
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 add_wp_admin_submenu
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 render
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
2.04
1<?php
2/**
3 * Monetize dashboard: a wp-build page in the Jetpack sidebar.
4 *
5 * @package automattic/jetpack-monetize
6 */
7
8namespace Automattic\Jetpack\Monetize;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Feature_Flags\Feature_Flags;
12use Automattic\Jetpack\Status\Host;
13use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
14use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Screen_Id;
15
16if ( ! defined( 'ABSPATH' ) ) {
17    exit( 0 );
18}
19
20/**
21 * Registers the Monetize sidebar item and renders its wp-build route.
22 */
23class Dashboard {
24
25    /**
26     * Package version, bumped at release through composer.json `version-constants`.
27     *
28     * @var string
29     */
30    const PACKAGE_VERSION = '0.1.0';
31
32    /**
33     * Feature flag that makes the dashboard available while it is built.
34     *
35     * @var string
36     */
37    const FEATURE_FLAG = 'jetpack-monetize-dashboard';
38
39    /**
40     * Sidebar menu slug.
41     *
42     * @var string
43     */
44    const MENU_SLUG = 'jetpack-monetize';
45
46    /**
47     * The wp-build route's page id. It must not be the menu slug: the generated
48     * standalone page.php intercepts `admin_init` for its own id and exits.
49     *
50     * @var string
51     */
52    const WP_BUILD_PAGE_ID = 'jetpack-monetize-dashboard';
53
54    /**
55     * Name of the wp-build render function generated for WP_BUILD_PAGE_ID.
56     *
57     * @var string
58     */
59    const RENDER_FUNCTION = 'jetpack_monetize_jetpack_monetize_dashboard_wp_admin_render_page';
60
61    /**
62     * The dashboard's sections.
63     *
64     * @var string[]
65     */
66    const SECTIONS = array(
67        Sections\Payments::class,
68        Sections\Ads::class,
69    );
70
71    /**
72     * Extra Admin_Menu item options, such as the module that owns the item.
73     *
74     * @var array
75     */
76    private static $menu_options = array();
77
78    /**
79     * The screen ID alias_screen_id() replaced, until it is restored.
80     *
81     * @var string|null
82     */
83    private static $original_screen_id = null;
84
85    /**
86     * Registered sections, keyed by section key.
87     *
88     * @var Dashboard_Section[]
89     */
90    private static $sections = array();
91
92    /**
93     * Register the flag. Hosts call this before they decide whether to load the dashboard.
94     *
95     * @return void
96     */
97    public static function register_feature_flag() {
98        Feature_Flags::register(
99            self::FEATURE_FLAG,
100            array(
101                'default'     => false,
102                'description' => 'Make the Monetize dashboard available while it is built.',
103                'owner'       => 'jetpack',
104            )
105        );
106    }
107
108    /**
109     * Whether the dashboard's feature flag is on.
110     *
111     * @return bool
112     */
113    public static function is_enabled() {
114        return Feature_Flags::is_enabled( self::FEATURE_FLAG );
115    }
116
117    /**
118     * Wire the hooks.
119     *
120     * @param array $menu_options Admin_Menu item options merged over the defaults, e.g. `module`.
121     * @return void
122     */
123    public static function init( array $menu_options = array() ) {
124        self::$menu_options = $menu_options;
125
126        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
127
128        // A wpcom host adds the item itself: see README.
129        if ( ! ( new Host() )->is_wpcom_platform() ) {
130            // Before Admin_Menu registers its items at 1000.
131            add_action( 'admin_menu', array( __CLASS__, 'add_menu' ), 999 );
132        }
133
134        // Simple reaches WordPress.com directly, so it needs none of the forwarding routes.
135        if ( ! ( new Host() )->is_wpcom_simple() ) {
136            add_action( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
137        }
138
139        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'add_script_data' ) );
140
141        self::register_sections();
142    }
143
144    /**
145     * Register each of SECTIONS, skipping keys already registered so init() can run twice.
146     *
147     * @return void
148     */
149    private static function register_sections() {
150        foreach ( self::SECTIONS as $class ) {
151            $section = new $class();
152            if ( ! isset( self::$sections[ $section->get_key() ] ) ) {
153                self::register_section( $section );
154            }
155        }
156    }
157
158    /**
159     * Add a section to the dashboard.
160     *
161     * @param Dashboard_Section $section The section.
162     * @return void
163     */
164    public static function register_section( Dashboard_Section $section ) {
165        $key = $section->get_key();
166        if ( isset( self::$sections[ $key ] ) ) {
167            /* translators: %s is a dashboard section key. */
168            $message = sprintf( __( 'A Monetize dashboard section with the key "%s" is already registered.', 'jetpack-monetize' ), $key );
169            _doing_it_wrong( __METHOD__, esc_html( $message ), '$$next-version$$' );
170            return;
171        }
172        self::$sections[ $key ] = $section;
173    }
174
175    /**
176     * Register every section's REST routes.
177     *
178     * @return void
179     */
180    public static function register_rest_routes() {
181        foreach ( self::$sections as $section ) {
182            $section->register_routes();
183        }
184    }
185
186    /**
187     * Whether the current user may see the dashboard and use its REST routes.
188     *
189     * @return bool
190     */
191    public static function can_manage() {
192        return current_user_can( 'manage_options' );
193    }
194
195    /**
196     * Each section's state, keyed by section key.
197     *
198     * @return object[]
199     */
200    public static function get_initial_state() {
201        $state = array();
202        foreach ( self::$sections as $key => $section ) {
203            // An empty array would print as `[]`, and the page reads every section's state as an object.
204            $state[ $key ] = (object) $section->get_state();
205        }
206        return $state;
207    }
208
209    /**
210     * Add each section's state to `JetpackScriptData.monetize.sections` on the dashboard's page.
211     *
212     * @param array $data Script data.
213     * @return array
214     */
215    public static function add_script_data( $data ) {
216        if ( self::is_dashboard_request() ) {
217            $data['monetize']['sections'] = (object) self::get_initial_state();
218        }
219        return $data;
220    }
221
222    /**
223     * Load wp-build output, only on this page's request.
224     *
225     * Scoped to the page so WP_Build_Polyfills does not replace core scripts everywhere else.
226     *
227     * @return void
228     */
229    public static function maybe_load_wp_build() {
230        if ( self::is_dashboard_request() ) {
231            self::load_wp_build( dirname( __DIR__ ) . '/build/build.php' );
232        }
233    }
234
235    /**
236     * Whether the request is for the dashboard's page.
237     *
238     * @return bool
239     */
240    public static function is_dashboard_request() {
241        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only.
242        return isset( $_GET['page'] ) && self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
243    }
244
245    /**
246     * The admin URL that opens one of the dashboard's tabs.
247     *
248     * @since $$next-version$$
249     *
250     * @param string $tab The tab's `?tab=` value; 'overview' opens the bare page.
251     * @return string
252     */
253    public static function get_tab_url( $tab ) {
254        $path = 'admin.php?page=' . self::MENU_SLUG;
255        if ( 'overview' !== $tab ) {
256            // wp-build's router reads a route's search params from `p`.
257            $path .= '&p=' . rawurlencode( '/?tab=' . $tab );
258        }
259        return admin_url( $path );
260    }
261
262    /**
263     * Load a wp-build index and, if it can render the page, its scripts and polyfills.
264     *
265     * @param string $build_index Path to the generated build.php.
266     * @return bool Whether the build can render the page.
267     */
268    public static function load_wp_build( $build_index ) {
269        if ( ! file_exists( $build_index ) ) {
270            return false;
271        }
272
273        $load_wp_build = static function () use ( $build_index ) {
274            require_once $build_index;
275        };
276
277        // Fallback: an older wp-build-polyfills under the jetpack-autoloader may predate load_with_alias().
278        if ( method_exists( WP_Build_Screen_Id::class, 'load_with_alias' ) ) {
279            WP_Build_Screen_Id::load_with_alias(
280                array( __CLASS__, 'alias_screen_id' ),
281                array( __CLASS__, 'restore_screen_id' ),
282                $load_wp_build
283            );
284        } else {
285            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'alias_screen_id' ) );
286            $load_wp_build();
287            add_action( 'admin_enqueue_scripts', array( __CLASS__, 'restore_screen_id' ) );
288        }
289
290        // A stale or partial build can't render the page, so don't swap core's scripts for it.
291        if ( ! function_exists( self::RENDER_FUNCTION ) ) {
292            return false;
293        }
294
295        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_i18n_loader' ) );
296
297        // wp-build hooks module registration to wp_default_scripts, which has already fired by
298        // admin_menu — call it directly or the init module never reaches the import map.
299        if ( function_exists( 'jetpack_monetize_register_script_modules' ) ) {
300            jetpack_monetize_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Checked with function_exists(); defined in the generated build/modules.php, which Phan excludes.
301        }
302
303        WP_Build_Polyfills::register(
304            'jetpack-monetize',
305            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
306        );
307
308        return true;
309    }
310
311    /**
312     * Point the screen ID at the wp-build page while its generated enqueue check runs.
313     *
314     * @return void
315     */
316    public static function alias_screen_id() {
317        $screen = get_current_screen();
318        if ( ! $screen ) {
319            return;
320        }
321
322        self::$original_screen_id = $screen->id;
323        $screen->id               = self::WP_BUILD_PAGE_ID;
324    }
325
326    /**
327     * Undo alias_screen_id(), since JITM builds its message path from the screen ID.
328     *
329     * @return void
330     */
331    public static function restore_screen_id() {
332        $screen = get_current_screen();
333        if ( ! $screen || null === self::$original_screen_id ) {
334            return;
335        }
336
337        $screen->id               = self::$original_screen_id;
338        self::$original_screen_id = null;
339    }
340
341    /**
342     * Enqueue the JS translation loader, which the esbuild route bundles don't pull in.
343     *
344     * @return void
345     */
346    public static function enqueue_i18n_loader() {
347        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
348            wp_enqueue_script( 'wp-jp-i18n-loader' );
349        }
350    }
351
352    /**
353     * Add the "Monetize" item to the Jetpack sidebar.
354     *
355     * @return void
356     */
357    public static function add_menu() {
358        Admin_Menu::add_menu(
359            __( 'Monetize', 'jetpack-monetize' ),
360            __( 'Monetize', 'jetpack-monetize' ),
361            'manage_options',
362            self::MENU_SLUG,
363            array( __CLASS__, 'render' ),
364            null,
365            array_merge( array( 'key' => self::MENU_SLUG ), self::$menu_options )
366        );
367    }
368
369    /**
370     * Add the "Monetize" item under the Jetpack menu on Simple, once wpcom-admin-menu.php has built that menu.
371     *
372     * @return void
373     */
374    public static function add_wp_admin_submenu() {
375        add_submenu_page(
376            'jetpack',
377            __( 'Monetize', 'jetpack-monetize' ),
378            __( 'Monetize', 'jetpack-monetize' ),
379            'manage_options',
380            self::MENU_SLUG,
381            array( __CLASS__, 'render' )
382        );
383    }
384
385    /**
386     * Render the wp-build page, or say why it could not be rendered.
387     *
388     * @return void
389     */
390    public static function render() {
391        $render_fn = self::RENDER_FUNCTION;
392        if ( function_exists( $render_fn ) ) {
393            $render_fn();
394            return;
395        }
396
397        printf(
398            '<div class="wrap"><h1>%s</h1><div class="notice notice-error"><p>%s</p></div></div>',
399            esc_html__( 'Monetize', 'jetpack-monetize' ),
400            esc_html__( 'The Monetize dashboard could not be loaded because its assets are missing.', 'jetpack-monetize' )
401        );
402    }
403}