Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.64% covered (success)
94.64%
53 / 56
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Stored_Layout_Move
94.64% covered (success)
94.64%
53 / 56
66.67% covered (warning)
66.67%
4 / 6
32.16
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 filter_user_meta
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 move
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
9
 modified_after
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 layout_has
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 default_instance
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
8.38
1<?php
2/**
3 * Moves the Top tags & categories tile a reader placed on a customized Insights to Traffic, once.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10/**
11 * Top tags & categories moved from the Insights default layout to Traffic, which never reaches a
12 * layout the reader customized: core keeps those in the `persisted_preferences` user meta. This
13 * rewrites that meta once per reader, on the read that precedes the dashboard: the tile leaves a
14 * customized Insights and, when the reader customized Traffic too, joins its end as the default
15 * places it. The move is recorded in the same preferences, so a reader who removes the tile from
16 * Traffic afterwards is left alone.
17 *
18 * Temporary, to be removed once the move has reached the readers who customized a tab before it
19 * (WOOA7S-2291). The next layout change is a separate discussion, not a second entry here.
20 *
21 * @since $$next-version$$
22 */
23class Stored_Layout_Move {
24
25    const LAYOUTS_KEY = 'dashboardSectionLayouts';
26
27    /** The applied moves, by id, so a tile removed after its move stays removed. */
28    const MOVES_KEY = 'dashboardLayoutMoves';
29
30    const MOVE_ID = 'tags-to-traffic';
31
32    const TYPE = 'jpa/tags';
33
34    const FROM = 'insights';
35
36    const TO = 'traffic';
37
38    /**
39     * Hook the preferences read. Priority 99 runs after core's own readers, as Gutenberg's default
40     * layout injection does.
41     *
42     * @return void
43     */
44    public static function register() {
45        add_filter( 'get_user_metadata', array( __CLASS__, 'filter_user_meta' ), 99, 3 );
46    }
47
48    /**
49     * Rewrite the stored section layouts on read, and persist the rewrite.
50     *
51     * @param mixed  $value    The pre-fetched value, or null to let the meta API resolve normally.
52     * @param int    $user_id  User ID.
53     * @param string $meta_key Meta key being read.
54     * @return mixed The original value, or a single-element array holding the moved preferences.
55     */
56    public static function filter_user_meta( $value, $user_id, $meta_key ) {
57        global $wpdb;
58
59        if ( $meta_key !== $wpdb->get_blog_prefix() . 'persisted_preferences' ) {
60            return $value;
61        }
62
63        // The meta API runs this filter on our own read, and on the read inside the update.
64        remove_filter( 'get_user_metadata', array( __CLASS__, 'filter_user_meta' ), 99 );
65        $preferences = get_user_meta( $user_id, $meta_key, true );
66        $moved       = is_array( $preferences ) ? self::move( $preferences ) : $preferences;
67        if ( $moved !== $preferences ) {
68            $moved['_modified'] = self::modified_after( $preferences['_modified'] ?? null );
69            // The meta API unslashes what it is handed; this came from the database, already unslashed.
70            update_user_meta( $user_id, $meta_key, wp_slash( $moved ) );
71        }
72        add_filter( 'get_user_metadata', array( __CLASS__, 'filter_user_meta' ), 99, 3 );
73
74        return $moved === $preferences ? $value : array( $moved );
75    }
76
77    /**
78     * Apply the move to a reader who has stored layouts and has not had it yet.
79     *
80     * @param array $preferences The reader's persisted preferences.
81     * @return array The preferences, the same array when nothing moved.
82     */
83    private static function move( array $preferences ) {
84        $scope   = $preferences[ Enablement_Setting::PREFERENCES_SCOPE ] ?? array();
85        $layouts = $scope[ self::LAYOUTS_KEY ] ?? null;
86        $applied = is_array( $scope[ self::MOVES_KEY ] ?? null ) ? $scope[ self::MOVES_KEY ] : array();
87
88        if ( ! is_array( $layouts ) || in_array( self::MOVE_ID, $applied, true ) ) {
89            return $preferences;
90        }
91
92        // Without the destination's default there is nowhere to move the tile to, so nothing
93        // is touched or recorded, and a later read tries again.
94        $instance = self::default_instance( self::TO, self::TYPE );
95        if ( ! $instance ) {
96            return $preferences;
97        }
98
99        if ( is_array( $layouts[ self::FROM ] ?? null ) ) {
100            $layouts[ self::FROM ] = array_values(
101                array_filter(
102                    $layouts[ self::FROM ],
103                    function ( $widget ) {
104                        return ! is_array( $widget ) || ( $widget['type'] ?? null ) !== self::TYPE;
105                    }
106                )
107            );
108        }
109
110        if ( is_array( $layouts[ self::TO ] ?? null ) && ! self::layout_has( $layouts[ self::TO ], self::TYPE ) ) {
111            $layouts[ self::TO ][] = $instance;
112        }
113
114        $applied[] = self::MOVE_ID;
115
116        $preferences[ Enablement_Setting::PREFERENCES_SCOPE ][ self::LAYOUTS_KEY ] = $layouts;
117        $preferences[ Enablement_Setting::PREFERENCES_SCOPE ][ self::MOVES_KEY ]   = $applied;
118
119        return $preferences;
120    }
121
122    /**
123     * A `_modified` stamp the client's persistence layer takes over its localStorage copy: it
124     * keeps whichever is newer, and a browser clock ahead of the server would otherwise win.
125     *
126     * @param mixed $previous The stamp the preferences carried, if any.
127     * @return string
128     */
129    private static function modified_after( $previous ) {
130        $now      = time();
131        $previous = is_string( $previous ) ? strtotime( $previous ) : false;
132        if ( $previous !== false && $previous >= $now ) {
133            $now = $previous + 1;
134        }
135
136        return gmdate( 'Y-m-d\TH:i:s\Z', $now );
137    }
138
139    /**
140     * Whether a stored layout lists a widget type.
141     *
142     * @param mixed  $layout The stored layout, if any.
143     * @param string $type   Widget type.
144     * @return bool
145     */
146    private static function layout_has( $layout, $type ) {
147        if ( ! is_array( $layout ) ) {
148            return false;
149        }
150        foreach ( $layout as $widget ) {
151            if ( is_array( $widget ) && ( $widget['type'] ?? null ) === $type ) {
152                return true;
153            }
154        }
155        return false;
156    }
157
158    /**
159     * The instance a section's default layout carries for a type, without the default's `order`:
160     * a stored layout is positional, so the order would rank the widget ahead of the end.
161     *
162     * @param string $slug Section slug.
163     * @param string $type Widget type.
164     * @return array|null
165     */
166    private static function default_instance( $slug, $type ) {
167        if ( ! defined( __NAMESPACE__ . '\\DASHBOARD_NAME' ) ) {
168            return null;
169        }
170
171        $section = Dashboard_Section_Registry::get_instance()->get_registered_by_slug( DASHBOARD_NAME, $slug );
172        if ( ! $section ) {
173            return null;
174        }
175
176        foreach ( $section->get_default_layout() as $widget ) {
177            if ( is_array( $widget ) && ( $widget['type'] ?? null ) === $type ) {
178                if ( isset( $widget['placement'] ) && is_array( $widget['placement'] ) ) {
179                    unset( $widget['placement']['order'] );
180                }
181                return $widget;
182            }
183        }
184
185        return null;
186    }
187}