Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
81 / 84
90.91% covered (success)
90.91%
10 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard_Section_Registry
97.59% covered (success)
97.59%
81 / 83
90.91% covered (success)
90.91%
10 / 11
26
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
6
 get_registered
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_registered_by_slug
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 find_registered_by_slug
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_all_registered
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_available_sections
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 is_registered
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensure_hydrated
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_instance
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 is_valid_dashboard_name
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 is_valid_section_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Dashboard Sections API: Dashboard_Section_Registry class.
4 *
5 * @package automattic/jetpack-premium-analytics
6 */
7
8namespace Automattic\Jetpack\PremiumAnalytics;
9
10require_once __DIR__ . '/dashboard-grammar.php';
11
12/**
13 * Stores Dashboard_Section instances keyed by dashboard and section ID.
14 *
15 * Hydrates on its first read: the registration action fires once, and every registrant, this
16 * package included, registers its sections from there. Reads happen after `init`, from wp-admin
17 * and from REST, so hooking the action is the one moment that covers both paths.
18 */
19final class Dashboard_Section_Registry {
20
21    /**
22     * Action through which sections are registered, fired once on the first read.
23     *
24     * @since 0.9.0
25     * @var string
26     */
27    const REGISTER_ACTION = 'jetpack_premium_analytics_register_dashboard_sections';
28
29    /**
30     * Registered sections, as `$dashboard_name => $id => $section` pairs.
31     *
32     * @var array<string, Dashboard_Section[]>
33     */
34    private $registered_sections = array();
35
36    /**
37     * Whether the registration action has fired.
38     *
39     * @var bool
40     */
41    private $hydrated = false;
42
43    /**
44     * Container for the main instance of the class.
45     *
46     * @var Dashboard_Section_Registry|null
47     */
48    private static $instance = null;
49
50    /**
51     * Registers a dashboard section.
52     *
53     * @param string $dashboard_name Dashboard identifier.
54     * @param string $id             Section identifier.
55     * @param array  $args           Optional. Section arguments.
56     * @return Dashboard_Section|false The registered section on success, or false on failure.
57     */
58    public function register( $dashboard_name, $id, $args = array() ) {
59        if ( ! $this->is_valid_dashboard_name( $dashboard_name ) ) {
60            _doing_it_wrong(
61                __METHOD__,
62                esc_html__( 'Dashboard names must be lowercase strings of letters, numbers, and hyphens, optionally separated by underscores.', 'jetpack-premium-analytics-pkg' ),
63                '0.1.0'
64            );
65            return false;
66        }
67
68        if ( ! $this->is_valid_section_id( $id ) ) {
69            _doing_it_wrong(
70                __METHOD__,
71                esc_html__( 'Dashboard section IDs must contain a namespace prefix. Example: my-plugin/my-custom-section', 'jetpack-premium-analytics-pkg' ),
72                '0.1.0'
73            );
74            return false;
75        }
76
77        if ( $this->is_registered( $dashboard_name, $id ) ) {
78            _doing_it_wrong(
79                __METHOD__,
80                sprintf(
81                    /* translators: 1: Dashboard name. 2: Dashboard section ID. */
82                    esc_html__( 'Dashboard section "%2$s" is already registered for dashboard "%1$s".', 'jetpack-premium-analytics-pkg' ),
83                    esc_html( $dashboard_name ),
84                    esc_html( $id )
85                ),
86                '0.1.0'
87            );
88            return false;
89        }
90
91        $section = new Dashboard_Section( $dashboard_name, $id, $args );
92
93        // The client keys tabs, URLs and stored layouts by slug, so two ids may not share one.
94        $holder = $this->find_registered_by_slug( $dashboard_name, $section->slug );
95        if ( $holder ) {
96            $message = sprintf(
97                /* translators: 1: Dashboard name. 2: Section slug. 3: ID of the section already using the slug. */
98                __( 'Dashboard section slug "%2$s" is already used on dashboard "%1$s" by "%3$s".', 'jetpack-premium-analytics-pkg' ),
99                $dashboard_name,
100                $section->slug,
101                $holder->id
102            );
103            // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
104            _doing_it_wrong( __METHOD__, esc_html( $message ), 'jetpack-premium-analytics-0.9.0' );
105            return false;
106        }
107
108        if ( ! isset( $this->registered_sections[ $dashboard_name ] ) ) {
109            $this->registered_sections[ $dashboard_name ] = array();
110        }
111
112        $this->registered_sections[ $dashboard_name ][ $id ] = $section;
113
114        return $section;
115    }
116
117    /**
118     * Retrieves a registered section.
119     *
120     * @param string $dashboard_name Dashboard identifier.
121     * @param string $id             Section identifier.
122     * @return Dashboard_Section|null The registered section, or null when absent.
123     */
124    public function get_registered( $dashboard_name, $id ) {
125        $this->ensure_hydrated();
126
127        if ( ! $this->is_registered( $dashboard_name, $id ) ) {
128            return null;
129        }
130
131        return $this->registered_sections[ $dashboard_name ][ $id ];
132    }
133
134    /**
135     * Retrieves a registered section by its URL-facing slug.
136     *
137     * @since 0.9.0
138     *
139     * @param string $dashboard_name Dashboard identifier.
140     * @param string $slug           Section slug, e.g. `ads`.
141     * @return Dashboard_Section|null The registered section, or null when no section uses the slug.
142     */
143    public function get_registered_by_slug( $dashboard_name, $slug ) {
144        $this->ensure_hydrated();
145
146        return $this->find_registered_by_slug( $dashboard_name, $slug );
147    }
148
149    /**
150     * Finds a registered section by slug without hydrating: register() relies on it.
151     *
152     * @param string $dashboard_name Dashboard identifier.
153     * @param string $slug           Section slug.
154     * @return Dashboard_Section|null
155     */
156    private function find_registered_by_slug( $dashboard_name, $slug ) {
157        foreach ( $this->registered_sections[ $dashboard_name ] ?? array() as $section ) {
158            if ( $section->slug === $slug ) {
159                return $section;
160            }
161        }
162
163        return null;
164    }
165
166    /**
167     * Retrieves all registered sections for a dashboard.
168     *
169     * @param string $dashboard_name Dashboard identifier.
170     * @return Dashboard_Section[] Map of `$id => $section` pairs.
171     */
172    public function get_all_registered( $dashboard_name ) {
173        $this->ensure_hydrated();
174
175        if ( ! isset( $this->registered_sections[ $dashboard_name ] ) ) {
176            // Unknown dashboards may be valid REST targets but have no sections registered.
177            return array();
178        }
179
180        return $this->registered_sections[ $dashboard_name ];
181    }
182
183    /**
184     * Retrieves available sections sorted by order.
185     *
186     * @param string $dashboard_name Dashboard identifier.
187     * @return Dashboard_Section[] Ordered list of available sections.
188     */
189    public function get_available_sections( $dashboard_name ) {
190        $sections = array_filter(
191            $this->get_all_registered( $dashboard_name ),
192            static function ( Dashboard_Section $section ) {
193                return $section->is_available();
194            }
195        );
196
197        uasort(
198            $sections,
199            static function ( Dashboard_Section $a, Dashboard_Section $b ) {
200                if ( $a->order === $b->order ) {
201                    return strcmp( $a->id, $b->id );
202                }
203
204                return $a->order <=> $b->order;
205            }
206        );
207
208        return array_values( $sections );
209    }
210
211    /**
212     * Checks if a section is registered. Does not hydrate: register() relies on it, and a
213     * registrant may run before the action fires.
214     *
215     * @param string $dashboard_name Dashboard identifier.
216     * @param string $id             Section identifier.
217     * @return bool True if registered.
218     */
219    public function is_registered( $dashboard_name, $id ) {
220        return isset( $this->registered_sections[ $dashboard_name ][ $id ] );
221    }
222
223    /**
224     * Fires the registration action once, on the first read after `init`.
225     *
226     * @return void
227     */
228    private function ensure_hydrated() {
229        if ( $this->hydrated ) {
230            return;
231        }
232
233        // Latching this early would drop every registrant hooked later, so the read skips the action.
234        if ( ! did_action( 'init' ) ) {
235            $message = __( 'Dashboard sections are read after init. A read before it does not hydrate the registry and answers only what was registered directly.', 'jetpack-premium-analytics-pkg' );
236            // One line: tools/replace-next-version-tag.sh only rewrites the token in a single-line call.
237            _doing_it_wrong( __METHOD__, esc_html( $message ), 'jetpack-premium-analytics-0.9.0' );
238            return;
239        }
240
241        // Latched before the action so a registrant that reads the registry cannot re-enter.
242        $this->hydrated = true;
243
244        /**
245         * Fires when the dashboard section registry hydrates, on its first read after `init`.
246         *
247         * Register sections here rather than on `init`: the registry is read from wp-admin and
248         * from REST, and each path loads it at a different moment. A registrant that may run
249         * twice guards with `is_registered()`.
250         *
251         * @since 0.9.0
252         *
253         * @param Dashboard_Section_Registry $registry The registry being hydrated.
254         */
255        do_action( self::REGISTER_ACTION, $this );
256    }
257
258    /**
259     * Utility method to retrieve the main instance of the class.
260     *
261     * @return Dashboard_Section_Registry The main instance.
262     */
263    public static function get_instance() {
264        if ( null === self::$instance ) {
265            self::$instance = new self();
266        }
267
268        return self::$instance;
269    }
270
271    /**
272     * Checks whether a dashboard name can be registered.
273     *
274     * @param mixed $dashboard_name Candidate dashboard name.
275     * @return bool
276     */
277    private function is_valid_dashboard_name( $dashboard_name ) {
278        return is_string( $dashboard_name ) && 1 === preg_match( '/^' . get_dashboard_name_pattern() . '$/', $dashboard_name );
279    }
280
281    /**
282     * Checks whether a section ID can be registered.
283     *
284     * @param mixed $id Candidate section ID.
285     * @return bool
286     */
287    private function is_valid_section_id( $id ) {
288        return is_string( $id ) && 1 === preg_match( '/^' . get_dashboard_section_id_pattern() . '$/', $id );
289    }
290}