Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
9 / 9
CRAP
100.00% covered (success)
100.00%
1 / 1
Integration_Registry
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
9 / 9
22
100.00% covered (success)
100.00%
1 / 1
 register
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 normalize
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 unregister
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 all
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 available
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 normalize_settings_attribute
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
9
 get_settings_attribute
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 reset
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Registry of Jetpack Forms integrations.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\Integrations;
9
10/**
11 * Holds every integration known to Jetpack Forms, whether it ships with the package or
12 * comes from a third-party plugin.
13 *
14 * Registration is deliberately order-insensitive: `register()` only records an entry, and
15 * carries no requirement about when it is called or what has loaded first. Nothing about the
16 * order in which integrations arrive is meaningful — presentation order is decided by the
17 * layer that displays them, not here.
18 */
19class Integration_Registry {
20
21    /**
22     * Shape of a valid integration slug.
23     *
24     * Either a bare name, or a `vendor/name` pair. The namespaced form is what new
25     * integrations should use; the bare form is kept valid because the bundled integrations
26     * already publish theirs through the REST API and store them in saved block attributes,
27     * where renaming them would orphan existing settings.
28     */
29    public const SLUG_PATTERN = '#^[\w-]+(?:/[\w-]+)?$#';
30
31    /**
32     * Registered integrations, keyed by slug.
33     *
34     * @var array<string, array>
35     */
36    private static $integrations = array();
37
38    /**
39     * Default shape of a registered integration.
40     *
41     * Every key a consumer may read is present after normalization, so callers never have to
42     * guard with isset() against an integration that declared only the keys it cared about.
43     *
44     * @var array
45     */
46    private const DEFAULTS = array(
47        // Presentation metadata, mirrored into the REST responses.
48        'type'                    => 'service',
49        'file'                    => null,
50        'settings_url'            => null,
51        'marketing_redirect_slug' => null,
52        'title'                   => '',
53        'subtitle'                => '',
54        'active_tooltip'          => '',
55        'enabled_by_default'      => false,
56        'icon_url'                => null,
57
58        // Behavior.
59        'is_available'            => null,
60        'status_callback'         => null,
61        'on_submission'           => null,
62
63        // Storage. Null means per-form settings live in the shared `integrations` block
64        // attribute under this integration's slug. Integrations that predate that container
65        // name the top-level block attribute they already use, so their saved content keeps
66        // working untouched. See self::normalize_settings_attribute() for the accepted forms.
67        'settings_attribute'      => null,
68
69        // Script handles registered with wp_register_script(), enqueued on the screens that
70        // render the integrations modal.
71        'editor_script'           => null,
72        'dashboard_script'        => null,
73    );
74
75    /**
76     * Register an integration.
77     *
78     * Re-registering a slug replaces the previous entry, which lets a plugin override a
79     * bundled integration.
80     *
81     * @param string $slug Unique slug, used as the REST identifier and the key in the shared
82     *                     `integrations` block attribute. New integrations should namespace
83     *                     it as `vendor/name` so two plugins cannot collide on a common name.
84     *                     A bare name stays valid — the bundled integrations predate the
85     *                     convention and keep theirs.
86     * @param array  $args Integration definition. See self::DEFAULTS for supported keys.
87     * @return bool Whether the integration was registered.
88     */
89    public static function register( $slug, array $args = array() ) {
90        if ( ! is_string( $slug ) || ! preg_match( self::SLUG_PATTERN, $slug ) ) {
91            $message = esc_html( 'Integration slugs must look like "name" or "vendor/name", using word characters and hyphens.' );
92            _doing_it_wrong( __METHOD__, $message, 'jetpack-forms-$$next-version$$' ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped above.
93            return false;
94        }
95
96        self::$integrations[ $slug ] = self::normalize( $args );
97
98        return true;
99    }
100
101    /**
102     * Fill in every key a consumer may read.
103     *
104     * Also applied to integrations that arrive through the
105     * `jetpack_forms_supported_integrations` filter, which predates this registry and supplies
106     * only the presentation keys.
107     *
108     * @param array $args A partial integration definition.
109     * @return array The definition with every supported key present.
110     */
111    public static function normalize( array $args ) {
112        $args = array_merge( self::DEFAULTS, $args );
113
114        $args['settings_attribute'] = self::normalize_settings_attribute( $args['settings_attribute'] );
115
116        return $args;
117    }
118
119    /**
120     * Remove a registered integration.
121     *
122     * @param string $slug Integration slug.
123     * @return void
124     */
125    public static function unregister( $slug ) {
126        unset( self::$integrations[ $slug ] );
127    }
128
129    /**
130     * Get a single registered integration.
131     *
132     * @param string $slug Integration slug.
133     * @return array|null The normalized definition, or null when nothing is registered.
134     */
135    public static function get( $slug ) {
136        return self::$integrations[ $slug ] ?? null;
137    }
138
139    /**
140     * Get every registered integration, including ones that are not currently available.
141     *
142     * @return array<string, array> Definitions keyed by slug, in registration order.
143     */
144    public static function all() {
145        return self::$integrations;
146    }
147
148    /**
149     * Get the integrations that are currently available.
150     *
151     * An integration with no `is_available` callback is always available. This is the hook a
152     * feature flag uses, so a flagged-off integration is absent from the REST responses and
153     * the UI entirely rather than shown in a disabled state.
154     *
155     * @return array<string, array> Available definitions keyed by slug.
156     */
157    public static function available() {
158        return array_filter(
159            self::$integrations,
160            function ( $args ) {
161                if ( null === $args['is_available'] ) {
162                    return true;
163                }
164
165                return is_callable( $args['is_available'] ) && (bool) call_user_func( $args['is_available'] );
166            }
167        );
168    }
169
170    /**
171     * Put a declared `settings_attribute` into a single internal shape.
172     *
173     * Two forms are accepted, because the block attributes that predate the shared container
174     * are not uniformly shaped:
175     *
176     * - `'salesforceData'` — the attribute holds the whole settings object.
177     * - `array( 'name' => 'jetpackCRM', 'maps_to' => 'enabled' )` — the attribute holds a
178     *   single scalar, which stands for one named setting. `jetpackCRM` is a bare boolean, so
179     *   without this form it could not be expressed at all.
180     *
181     * @param mixed $declared The value passed at registration.
182     * @return array|null Normalized as array{name: string, maps_to: string|null}, or null.
183     */
184    private static function normalize_settings_attribute( $declared ) {
185        if ( is_string( $declared ) && '' !== $declared ) {
186            return array(
187                'name'    => $declared,
188                'maps_to' => null,
189            );
190        }
191
192        if ( is_array( $declared ) && ! empty( $declared['name'] ) && is_string( $declared['name'] ) ) {
193            $maps_to = isset( $declared['maps_to'] ) && is_string( $declared['maps_to'] ) && '' !== $declared['maps_to']
194                ? $declared['maps_to']
195                : null;
196
197            return array(
198                'name'    => $declared['name'],
199                'maps_to' => $maps_to,
200            );
201        }
202
203        return null;
204    }
205
206    /**
207     * How a form stores settings for an integration.
208     *
209     * @param string $slug Integration slug.
210     * @return array|null array{name: string, maps_to: string|null} for an integration using a
211     *                    legacy top-level block attribute, or null when it uses the shared
212     *                    `integrations` container.
213     */
214    public static function get_settings_attribute( $slug ) {
215        $args = self::get( $slug );
216
217        return $args ? $args['settings_attribute'] : null;
218    }
219
220    /**
221     * Reset the registry.
222     *
223     * Only intended for tests, which need a clean registry between cases.
224     *
225     * @return void
226     */
227    public static function reset() {
228        self::$integrations = array();
229    }
230}