Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.83% covered (success)
95.83%
46 / 48
87.50% covered (warning)
87.50%
7 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard
95.83% covered (success)
95.83%
46 / 48
87.50% covered (warning)
87.50%
7 / 8
25
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 init_hooks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 add_wp_admin_menu
84.62% covered (warning)
84.62%
11 / 13
0.00% covered (danger)
0.00%
0 / 1
7.18
 get_capability
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 render
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
3
 get_bootstrap_script
n/a
0 / 0
n/a
0 / 0
1
 admin_init
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 maybe_refresh_plan
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 load_admin_scripts
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * A class that adds a stats dashboard to wp-admin.
4 *
5 * @package automattic/jetpack-stats-admin
6 */
7
8namespace Automattic\Jetpack\Stats_Admin;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
12use Automattic\Jetpack\Current_Plan as Jetpack_Plan;
13use Automattic\Jetpack\Stats\Options as Stats_Options;
14
15/**
16 * Responsible for adding a stats dashboard to wp-admin.
17 *
18 * @package jetpack-stats-admin
19 */
20class Dashboard {
21    /**
22     * Whether the class has been initialized
23     *
24     * @var boolean
25     */
26    private static $initialized = false;
27
28    /**
29     * Transient that throttles the plan refresh below.
30     *
31     * @var string
32     */
33    private const PLAN_REFRESH_TRANSIENT = 'jetpack_stats_admin_plan_refresh';
34
35    /**
36     * Priority for the dashboard menu
37     * For Jetpack sites: Jetpack uses 998 and 'Admin_Menu' uses 1000, so we need to use 999.
38     *
39     * Admin_Menu registers what it has queued at priority 1000, so this has to stay below it.
40     *
41     * @var int
42     */
43    protected $menu_priority = 999;
44
45    /**
46     * Init Stats dashboard.
47     */
48    public static function init() {
49        if ( ! self::$initialized ) {
50            self::$initialized = true;
51            ( new self() )->init_hooks();
52        }
53    }
54
55    /**
56     * Initialize the hooks.
57     */
58    public function init_hooks() {
59        self::$initialized = true;
60        // Jetpack uses 998 and 'Admin_Menu' uses 1000.
61        add_action( 'admin_menu', array( $this, 'add_wp_admin_menu' ), $this->menu_priority );
62    }
63
64    /**
65     * Add a "Stats" top-level admin menu.
66     *
67     * Declares no `product` gate: that resolves false without the Jetpack plugin, which is
68     * exactly when the standalone Stats plugin registers this page.
69     *
70     * @return void
71     */
72    public function add_wp_admin_menu() {
73        /**
74         * Disable this menu for dashboard.wordpress.com because older versions of Jetpack need to fetch the old Stats UI.
75         *
76         * If this menu is registered, it will conflict with the back-end and break non-odyssey Stats.
77         */
78        if ( defined( 'IS_WPCOM' ) && IS_WPCOM && 120742 === get_current_blog_id() ) {
79            return;
80        }
81
82        $page_title = __( 'Stats', 'jetpack-stats-admin' );
83        $menu_title = _x( 'Stats', 'product name shown in menu', 'jetpack-stats-admin' );
84        $capability = $this->get_capability();
85        $callback   = array( $this, 'render' );
86
87        // An older admin-ui, loaded first by another plugin, may predate add_top_level_menu().
88        if ( method_exists( Admin_Menu::class, 'add_top_level_menu' ) ) {
89            // The key the legacy Stats screen in the Jetpack plugin also declares, so hosts name Stats once.
90            $page_suffix = Admin_Menu::add_top_level_menu( $page_title, $menu_title, $capability, 'stats', $callback, 'dashicons-chart-bar', 2, array( 'key' => 'jetpack-stats' ) );
91        } else {
92            $page_suffix = add_menu_page( $page_title, $menu_title, $capability, 'stats', $callback, 'dashicons-chart-bar', 2 );
93        }
94
95        if ( $page_suffix ) {
96            add_action( 'load-' . $page_suffix, array( $this, 'admin_init' ) );
97            // The dashboard renders full bleed, so core notices stacked above it look broken.
98            if ( method_exists( Admin_Menu::class, 'hide_core_admin_notices' ) ) {
99                add_action( 'load-' . $page_suffix, array( Admin_Menu::class, 'hide_core_admin_notices' ) );
100            }
101        }
102    }
103
104    /**
105     * Capability a user needs to reach the dashboard.
106     *
107     * Until the site is connected the page exists to pick a plan and connect, which only a user
108     * who can manage the connection can act on. Once connected it is a reporting page, open to
109     * everyone allowed to view stats.
110     *
111     * Pre-connection that is `jetpack_connect`: it maps to `manage_options` on a normal site,
112     * but is `do_not_allow` in offline mode and honours the same multisite/filter rules as the
113     * register endpoint, so the menu is not offered where the connection cannot be completed.
114     *
115     * @return string
116     */
117    protected function get_capability() {
118        return Main::is_site_connected() ? 'view_stats' : 'jetpack_connect';
119    }
120
121    /**
122     * Override render funtion
123     */
124    public function render() {
125        // Record the number of views of the stats dashboard on the initial several loads for the
126        // purpose of showing feedback notice. Views before the site is connected show the plan
127        // choice rather than the dashboard, and there is nothing to give feedback on yet.
128        $views = intval( Stats_Options::get_option( 'views' ) ) + 1;
129        if ( $views <= Notices::VIEWS_TO_SHOW_FEEDBACK && Main::is_site_connected() ) {
130            Stats_Options::set_option( 'views', $views );
131        }
132
133        ?>
134        <div id="wpcom" class="jp-stats-dashboard" style="min-height: calc(100vh - 100px);">
135            <div class="hide-if-js"><?php esc_html_e( 'Your Jetpack Stats dashboard requires JavaScript to function properly.', 'jetpack-stats-admin' ); ?></div>
136            <div class="hide-if-no-js" style="height: 100%">
137                <img
138                    class="jp-stats-dashboard-loading-spinner"
139                    width="32"
140                    height="32"
141                    style="position: absolute; left: 50%; top: 50%;"
142                    alt=<?php echo esc_attr( __( 'Loading', 'jetpack-stats-admin' ) ); ?>
143                    src="//en.wordpress.com/i/loading/loading-64.gif"
144                />
145            </div>
146        </div>
147        <?php
148    }
149
150    /**
151     * The dashboard bootstrap: load the icon sprite, and keep in-app links inside the dashboard.
152     *
153     * @return string
154     */
155    private function get_bootstrap_script() {
156        return <<<'JS'
157jQuery(document).ready(function($) {
158    // Load SVG sprite.
159    $.get("https://widgets.wp.com/odyssey-stats/common/gridicons-506499ddac13811fee8e.svg", function(data) {
160        var div = document.createElement("div");
161        div.innerHTML = new XMLSerializer().serializeToString(data.documentElement);
162        div.style = 'display: none';
163        document.body.insertBefore(div, document.body.childNodes[0]);
164    });
165    // we intercept on all anchor tags and change it to hashbang style.
166    $("#wpcom").on('click', 'a', function (e) {
167        const link = e && e.currentTarget && e.currentTarget.attributes && e.currentTarget.attributes.href && e.currentTarget.attributes.href.value;
168        if( link && link.startsWith( '/stats' ) ) {
169            location.hash = `#!${link}`;
170            return false;
171        }
172    });
173});
174JS;
175    }
176
177    /**
178     * Initialize the admin resources.
179     */
180    public function admin_init() {
181        $this->maybe_refresh_plan();
182        add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
183    }
184
185    /**
186     * Fill an empty plan cache before the config data that reads it is printed.
187     *
188     * The app cannot refresh the plan it paywalls on, so a site that never stored one renders as
189     * free. Throttled and time-boxed, because WordPress.com can keep answering without a plan.
190     */
191    private function maybe_refresh_plan() {
192        if ( ! Main::is_site_connected() ) {
193            return;
194        }
195
196        // method_exists guard: an older plans package may win the autoloader on another plugin.
197        if ( method_exists( Jetpack_Plan::class, 'get_wpcom_site_specific_features' )
198            && null !== Jetpack_Plan::get_wpcom_site_specific_features() ) {
199            return;
200        }
201
202        $plan = Jetpack_Plan::get();
203        if ( ! empty( $plan['features']['active'] ) || get_transient( self::PLAN_REFRESH_TRANSIENT ) ) {
204            return;
205        }
206
207        set_transient( self::PLAN_REFRESH_TRANSIENT, 1, 15 * MINUTE_IN_SECONDS );
208
209        Jetpack_Plan::refresh_from_wpcom( array( 'timeout' => 5 ) );
210    }
211
212    /**
213     * Load the admin scripts.
214     */
215    public function load_admin_scripts() {
216        ( new Odyssey_Assets() )->load_admin_scripts( 'jp-stats-dashboard', 'build.min', array( 'config_variable_name' => 'jetpackStatsOdysseyAppConfigData' ) );
217
218        // The bootstrap runs on jQuery, which the Odyssey bundle does not depend on. It gets its own
219        // handle rather than jQuery being added to that bundle, which the dashboard widget shares
220        // and which has no use for it.
221        wp_register_script( 'jp-stats-dashboard-bootstrap', false, array( 'jquery' ), Main::VERSION, true );
222        wp_enqueue_script( 'jp-stats-dashboard-bootstrap' );
223        wp_add_inline_script( 'jp-stats-dashboard-bootstrap', $this->get_bootstrap_script() );
224
225        // The app is served from our CDN and so cannot bundle the connection package. Print the
226        // state Search and Protect print on their own pages, so it can read the connection status
227        // and register the site through the connection REST API itself. Connected sites fetch
228        // `jetpack/v4/connection` over REST instead, and must not receive registrationNonce and
229        // the connected-plugin list on a page that `view_stats` users can open.
230        if ( ! Main::is_site_connected() ) {
231            Connection_Initial_State::render_script( 'jp-stats-dashboard' );
232        }
233    }
234}