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