Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.25% covered (warning)
68.25%
43 / 63
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
Wpcom_Dashboard
68.25% covered (warning)
68.25%
43 / 63
57.14% covered (warning)
57.14%
4 / 7
55.91
0.00% covered (danger)
0.00%
0 / 1
 init
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 is_treatment
57.69% covered (warning)
57.69%
15 / 26
0.00% covered (danger)
0.00%
0 / 1
20.16
 enqueue_dashboard_styles
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 limit_dashboard_columns
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 cap_dashboard_column_preference
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 redistribute_meta_box_order
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 merge_widget_lists
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
3.03
1<?php
2/**
3 * Wpcom Dashboard feature.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Status\Host;
13
14/**
15 * Manages the WordPress.com Dashboard replacement holdout experiment.
16 */
17class Wpcom_Dashboard {
18
19    const EXPERIMENT_NAME                = 'wpcom_custom_dashboard_holdout'; // Temporary name!
20    const EXPERIMENT_TREATMENT_VARIATION = 'treatment';
21
22    /**
23     * Initialize the feature.
24     */
25    public static function init() {
26        add_filter( 'screen_layout_columns', array( __CLASS__, 'limit_dashboard_columns' ) );
27        add_filter( 'get_user_option_screen_layout_dashboard', array( __CLASS__, 'cap_dashboard_column_preference' ) );
28        add_filter( 'get_user_option_meta-box-order_dashboard', array( __CLASS__, 'redistribute_meta_box_order' ) );
29        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_dashboard_styles' ) );
30    }
31
32    /**
33     * Whether the current user is in the holdout treatment group.
34     *
35     * Checks the ExPlat experiment assignment. On Simple sites, uses
36     * \ExPlat\assign_current_user() directly. On Atomic sites with a
37     * connected user, uses the REST API.
38     * Caches the result in a transient for 1 hour.
39     *
40     * The result can be overridden via the
41     * {@see 'wpcom_dashboard_override_is_treatment'} filter.
42     *
43     * @return bool
44     */
45    public static function is_treatment() {
46        /**
47         * Overrides the holdout experiment assignment.
48         * Return true to force-enable the treatment for testing.
49         *
50         * @param bool|null $override Return a bool to override, or null to use the experiment value.
51         */
52        $override = apply_filters( 'wpcom_dashboard_override_is_treatment', null );
53
54        if ( null !== $override ) {
55            return (bool) $override;
56        }
57
58        $user_id = get_current_user_id();
59        if ( ! $user_id ) {
60            return false;
61        }
62
63        $cache_key     = 'wpcom-dashboard-holdout-' . $user_id . '-' . self::EXPERIMENT_NAME;
64        $cached_result = get_transient( $cache_key );
65
66        if ( false !== $cached_result ) {
67            return (bool) $cached_result;
68        }
69
70        $result = false;
71
72        if ( ( new Host() )->is_wpcom_simple() ) {
73            if ( function_exists( '\ExPlat\assign_current_user' ) ) {
74                $result = self::EXPERIMENT_TREATMENT_VARIATION === \ExPlat\assign_current_user( self::EXPERIMENT_NAME );
75            }
76        } elseif ( ( new Connection_Manager() )->is_user_connected() ) {
77            $request_path = '/experiments/0.1.0/assignments/wpcom';
78            $response     = Client::wpcom_json_api_request_as_user(
79                add_query_arg( array( 'experiment_name' => self::EXPERIMENT_NAME ), $request_path ),
80                'v2'
81            );
82
83            if ( ! is_wp_error( $response ) && 200 === wp_remote_retrieve_response_code( $response ) ) {
84                $data = json_decode( wp_remote_retrieve_body( $response ), true );
85                if ( isset( $data['variations'][ self::EXPERIMENT_NAME ] ) ) {
86                    $result = self::EXPERIMENT_TREATMENT_VARIATION === $data['variations'][ self::EXPERIMENT_NAME ];
87                }
88            }
89        }
90
91        set_transient( $cache_key, $result ? 1 : 0, HOUR_IN_SECONDS );
92
93        return $result;
94    }
95
96    /**
97     * Enqueue responsive dashboard column styles on the Dashboard screen.
98     *
99     * @param string $hook_suffix The current admin page hook suffix.
100     */
101    public static function enqueue_dashboard_styles( $hook_suffix ) {
102        if ( 'index.php' !== $hook_suffix || ! self::is_treatment() ) {
103            return;
104        }
105
106        wp_enqueue_style(
107            'wpcom-dashboard-styles',
108            plugins_url( 'wpcom-dashboard.css', __FILE__ ),
109            array(),
110            \Automattic\Jetpack\Jetpack_Mu_Wpcom::PACKAGE_VERSION
111        );
112    }
113
114    /**
115     * Limit the dashboard screen to a maximum of 2 columns.
116     *
117     * @param array $columns Screen layout columns keyed by screen ID.
118     * @return array
119     */
120    public static function limit_dashboard_columns( $columns ) {
121        if ( ! self::is_treatment() ) {
122            return $columns;
123        }
124
125        $columns['dashboard'] = 2;
126
127        return $columns;
128    }
129
130    /**
131     * Cap the user's saved dashboard column preference to 2.
132     *
133     * @param int|false $value The user's saved column count, or false if not set.
134     * @return int|false
135     */
136    public static function cap_dashboard_column_preference( $value ) {
137        if ( ! self::is_treatment() ) {
138            return $value;
139        }
140
141        if ( false !== $value && (int) $value > 2 ) {
142            return 2;
143        }
144
145        return $value;
146    }
147
148    /**
149     * Move widgets from columns 3 and 4 into columns 1 and 2.
150     *
151     * WordPress stores the dashboard meta box order as an array with keys:
152     * 'normal' (column 1), 'side' (column 2), 'column3', 'column4'.
153     *
154     * @param array|false $order The saved meta box order, or false if not set.
155     * @return array|false
156     */
157    public static function redistribute_meta_box_order( $order ) {
158        if ( ! self::is_treatment() || ! is_array( $order ) ) {
159            return $order;
160        }
161
162        // Append column3 widgets to normal (column 1).
163        if ( ! empty( $order['column3'] ) ) {
164            $order['normal']  = self::merge_widget_lists( $order['normal'] ?? '', $order['column3'] );
165            $order['column3'] = '';
166        }
167
168        // Append column4 widgets to side (column 2).
169        if ( ! empty( $order['column4'] ) ) {
170            $order['side']    = self::merge_widget_lists( $order['side'] ?? '', $order['column4'] );
171            $order['column4'] = '';
172        }
173
174        return $order;
175    }
176
177    /**
178     * Merge two comma-separated widget ID lists.
179     *
180     * @param string $target Existing comma-separated list.
181     * @param string $source List to append.
182     * @return string
183     */
184    private static function merge_widget_lists( $target, $source ) {
185        $target = trim( $target, ',' );
186        $source = trim( $source, ',' );
187
188        if ( '' === $target ) {
189            return $source;
190        }
191
192        if ( '' === $source ) {
193            return $target;
194        }
195
196        return $target . ',' . $source;
197    }
198}