Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.62% covered (warning)
65.62%
105 / 160
66.67% covered (warning)
66.67%
8 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
Form_Editor
65.62% covered (warning)
65.62%
105 / 160
66.67% covered (warning)
66.67%
8 / 12
73.59
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
 allowed_blocks_for_jetpack_form
100.00% covered (success)
100.00%
61 / 61
100.00% covered (success)
100.00%
1 / 1
3
 block_editor_settings_all
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 disable_block_directory
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
4.12
 enqueue_admin_scripts
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
5.02
 enqueue_editor_bundle
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
6
 enqueue_welcome_guide
8.11% covered (danger)
8.11%
3 / 37
0.00% covered (danger)
0.00%
0 / 1
33.93
 get_persisted_preferences
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 preference_is
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 is_core_welcome_guide_pending
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_welcome_guide_dismissed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_welcome_guide_eligible
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Jetpack forms editor.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\Editor;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Forms\ContactForm\Contact_Form;
12
13/**
14 * Class Form_Editor
15 *
16 * Handles the form editor functionality for jetpack-form post type.
17 */
18class Form_Editor {
19
20    /**
21     * Script handle for the form editor.
22     *
23     * @var string
24     */
25    const SCRIPT_HANDLE = 'jetpack-form-editor';
26
27    /**
28     * Script handle for the welcome guide.
29     *
30     * @var string
31     */
32    const WELCOME_GUIDE_SCRIPT_HANDLE = 'jetpack-form-welcome-guide';
33
34    /**
35     * Preference scope owned by Jetpack Forms. Mirrors PREFERENCE_SCOPE in welcome-guide/index.tsx.
36     *
37     * @var string
38     */
39    const PREFERENCE_SCOPE = 'jetpack/forms';
40
41    /**
42     * Preference name holding whether the guide is still pending. Mirrors PREFERENCE_NAME in welcome-guide/index.tsx.
43     *
44     * @var string
45     */
46    const PREFERENCE_NAME = 'welcomeGuide';
47
48    /**
49     * Core's own welcome modal scope, which its Options menu item toggles. Owned by @wordpress/edit-post; mirrored in welcome-guide/index.tsx too.
50     *
51     * @var string
52     */
53    const CORE_PREFERENCE_SCOPE = 'core/edit-post';
54
55    /**
56     * Initialize the form editor.
57     */
58    public static function init() {
59        add_filter( 'allowed_block_types_all', array( __CLASS__, 'allowed_blocks_for_jetpack_form' ), 10, 2 );
60        add_filter( 'block_editor_settings_all', array( __CLASS__, 'block_editor_settings_all' ), 10, 2 );
61        add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_scripts' ) );
62        add_action( 'current_screen', array( __CLASS__, 'disable_block_directory' ) );
63    }
64
65    /**
66     * Restrict allowed blocks when editing jetpack-form posts.
67     *
68     * Only allows field blocks and supporting blocks. The contact-form block is excluded
69     * because visual wrapping is handled via DOM manipulation in the editor script.
70     *
71     * @param bool|array $allowed_block_types Array of block type slugs, or boolean to enable/disable all.
72     * @param object     $editor_context       The current editor context.
73     * @return bool|array Array of allowed block types for jetpack-form posts.
74     */
75    public static function allowed_blocks_for_jetpack_form( $allowed_block_types, $editor_context ) {
76        // Only apply to jetpack-form post type.
77        if ( ! isset( $editor_context->post ) || Contact_Form::POST_TYPE !== $editor_context->post->post_type ) {
78            return $allowed_block_types;
79        }
80
81        // Allow only field blocks, button, and core blocks.
82        // Visual wrapping is handled by JavaScript DOM manipulation.
83        return array(
84            // Field blocks.
85            'jetpack/field-name',
86            'jetpack/field-email',
87            'jetpack/field-url',
88            'jetpack/field-telephone',
89            'jetpack/field-textarea',
90            'jetpack/field-checkbox',
91            'jetpack/field-checkbox-multiple',
92            'jetpack/field-radio',
93            'jetpack/field-select',
94            'jetpack/field-date',
95            'jetpack/field-consent',
96            'jetpack/field-rating',
97            'jetpack/field-text',
98            'jetpack/field-number',
99            'jetpack/field-hidden',
100            'jetpack/field-file',
101            'jetpack/field-time',
102            'jetpack/field-slider',
103            'jetpack/field-image-select',
104
105            // Supporting blocks.
106            'jetpack/button', // Used for the submit button previously.
107            'jetpack/label',
108            'jetpack/input',
109            'jetpack/options',
110            'jetpack/option',
111            'jetpack/phone-input',
112            'jetpack/dropzone',
113            'jetpack/input-range',
114            'jetpack/input-rating',
115            'jetpack/fieldset-image-options',
116            'jetpack/input-image-option',
117
118            // Multistep blocks.
119            'jetpack/form-step',
120            'jetpack/form-step-container',
121            'jetpack/form-step-divider',
122            'jetpack/form-step-navigation',
123            'jetpack/form-progress-indicator',
124
125            // Core blocks for rich content.
126            'core/accordion',
127            'core/audio',
128            'core/button', // Used for the submit button.
129            'core/code',
130            'core/column',
131            'core/columns',
132            'core/details',
133            'core/group',
134            'core/heading',
135            'core/html',
136            'core/icon',
137            'core/image',
138            'core/list-item',
139            'core/list',
140            'core/math',
141            'core/paragraph',
142            'core/row',
143            'core/separator',
144            'core/spacer',
145            'core/stack',
146            'core/subhead',
147            'core/video',
148        );
149    }
150
151    /**
152     * Modify block editor settings for jetpack-form posts.
153     *
154     * @param array  $settings       Block editor settings.
155     * @param object $editor_context The current editor context.
156     * @return array Modified block editor settings for jetpack-form posts.
157     */
158    public static function block_editor_settings_all( $settings, $editor_context ) {
159        // Only apply to jetpack-form post type.
160        if ( ! isset( $editor_context->post ) || Contact_Form::POST_TYPE !== $editor_context->post->post_type ) {
161            return $settings;
162        }
163
164        // Disable block locking capability.
165        $settings['canLockBlocks'] = false;
166
167        return $settings;
168    }
169
170    /**
171     * Disable the block directory in the form editor.
172     *
173     * Removes the block directory assets (install blocks from the inserter)
174     * since this feature is not needed in the form editor.
175     * Hooked to `current_screen` so it runs before scripts are enqueued.
176     *
177     * @param \WP_Screen $screen The current screen object.
178     */
179    public static function disable_block_directory( $screen ) {
180        if ( ! isset( $screen->post_type ) ) {
181            return;
182        }
183        if ( Contact_Form::POST_TYPE === $screen->post_type ) {
184            remove_action( 'enqueue_block_editor_assets', 'wp_enqueue_editor_block_directory_assets' );
185        }
186    }
187
188    /**
189     * Enqueue admin scripts for block editor.
190     *
191     * Loads in all post block editor contexts (excluding the site editor). This
192     * cannot be narrowed to the form post type: `navigateToForm()` switches to a
193     * form through Gutenberg's in-editor entity navigation, which never reloads
194     * the page, so `admin_enqueue_scripts` does not run again. A page editor that
195     * did not load this bundle up front would jump into a form with none of the
196     * form editor behavior available.
197     */
198    public static function enqueue_admin_scripts() {
199        $screen = get_current_screen();
200
201        // Only load in block editor contexts, not site editor
202        if ( ! $screen || $screen->id === 'site-editor' || ! $screen->is_block_editor ) {
203            return;
204        }
205        // Separate calls, so a missing editor asset does not take the guide down
206        // with it — the two ship as their own entries.
207        self::enqueue_editor_bundle();
208        self::enqueue_welcome_guide();
209    }
210
211    /**
212     * Enqueue the form editor bundle.
213     */
214    private static function enqueue_editor_bundle() {
215        $asset_file = __DIR__ . '/../../dist/form-editor/jetpack-form-editor.asset.php';
216        if ( ! file_exists( $asset_file ) ) {
217            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
218            error_log( 'Form Editor asset file not found: ' . $asset_file );
219            return;
220        }
221
222        $asset = require $asset_file;
223        Assets::register_script(
224            self::SCRIPT_HANDLE,
225            '../../dist/form-editor/jetpack-form-editor.js',
226            __FILE__,
227            array(
228                'in_footer'    => true,
229                'textdomain'   => 'jetpack-forms',
230                'enqueue'      => true,
231                'dependencies' => $asset['dependencies'],
232                'version'      => $asset['version'],
233            )
234        );
235    }
236
237    /**
238     * Enqueue the welcome guide.
239     *
240     * Unlike the editor bundle, this is scoped to the form post type. The guide
241     * is for people meeting the block editor for the first time, so someone who
242     * reaches a form through in-editor navigation from a post or page has
243     * already demonstrated they do not need it — and that path never re-runs
244     * this hook, so not loading here is what skips the guide for them.
245     *
246     * Loaded on every form editor page load, including once the guide has been
247     * dismissed. The guide does not add an Options menu item of its own; it
248     * takes over core's "Welcome Guide" item, which is present whether or not
249     * this bundle is. Skipping the bundle for dismissed users would leave that
250     * item unclaimed, and because it is a toggle rather than a button, choosing
251     * it would persist `welcomeGuide: true` — outliving the page load, beating
252     * the runtime default this package sets, and reopening core's generic modal
253     * in the form editor on each load until the user finished it there.
254     *
255     * The bundle carries the slide copy and styles as well as the shim that
256     * claims the menu item, so a dismissed user does pay for those — a few KB
257     * gzipped. Only the artwork is deferred, fetched when the guide opens.
258     */
259    private static function enqueue_welcome_guide() {
260        $screen = get_current_screen();
261        if ( ! $screen || ! isset( $screen->post_type ) || Contact_Form::POST_TYPE !== $screen->post_type ) {
262            return;
263        }
264
265        $preferences = self::get_persisted_preferences();
266
267        /*
268         * Eligibility decides whether the guide opens on its own, which it
269         * never does once dismissed — reopening from the Options menu and the
270         * query argument both bypass it. The lookup costs a query, so skip it
271         * when the answer cannot change anything.
272         */
273        $is_eligible = ! self::is_welcome_guide_dismissed( $preferences )
274            && self::is_welcome_guide_eligible( $preferences );
275
276        $asset_file = __DIR__ . '/../../dist/form-editor/jetpack-form-welcome-guide.asset.php';
277        if ( ! file_exists( $asset_file ) ) {
278            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
279            error_log( 'Welcome guide asset file not found: ' . $asset_file );
280            return;
281        }
282
283        $asset = require $asset_file;
284        Assets::register_script(
285            self::WELCOME_GUIDE_SCRIPT_HANDLE,
286            '../../dist/form-editor/jetpack-form-welcome-guide.js',
287            __FILE__,
288            array(
289                'in_footer'    => true,
290                'textdomain'   => 'jetpack-forms',
291                'enqueue'      => true,
292                'dependencies' => $asset['dependencies'],
293                'version'      => $asset['version'],
294            )
295        );
296
297        // Written as JSON rather than through wp_localize_script(), which casts
298        // booleans to '1' and ''.
299        wp_add_inline_script(
300            self::WELCOME_GUIDE_SCRIPT_HANDLE,
301            'window.jetpackFormsWelcomeGuide = ' . wp_json_encode(
302                array(
303                    'isEligible'         => $is_eligible,
304                    'isCoreGuidePending' => self::is_core_welcome_guide_pending( $preferences ),
305
306                    /*
307                     * Build-dir URL for the guide's artwork. The bundle sets
308                     * webpack's publicPath from this because `'auto'` misresolves
309                     * the images on WordPress.com Simple, where JS concatenation
310                     * rewrites the script URL auto-detection reads. Derived the
311                     * same way register_script() resolves the script URL above.
312                     */
313                    'assetsUrl'          => trailingslashit(
314                        Assets::normalize_path( plugins_url( '../../dist/form-editor', __FILE__ ) )
315                    ),
316                ),
317                JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
318            ) . ';',
319            'before'
320        );
321    }
322
323    /**
324     * Reads the current user's persisted editor preferences.
325     *
326     * The same blob backs both the core welcome modal's state and this guide's
327     * dismissal, so it is read once and passed around.
328     *
329     * Core keeps these per site, under a blog-prefixed meta key built by
330     * wp_register_persisted_preferences_meta(), so the key has to come from
331     * get_blog_prefix() rather than a literal `wp_`. On a multisite subsite or
332     * an install with a custom table prefix a hardcoded key reads nothing,
333     * which looks exactly like "never dismissed, and eligible" for everyone.
334     *
335     * @return array The stored preferences, or an empty array.
336     */
337    private static function get_persisted_preferences() {
338        global $wpdb;
339
340        $user_id = get_current_user_id();
341        if ( ! $user_id ) {
342            return array();
343        }
344
345        $preferences = get_user_meta( $user_id, $wpdb->get_blog_prefix() . 'persisted_preferences', true );
346
347        return is_array( $preferences ) ? $preferences : array();
348    }
349
350    /**
351     * Whether a welcome guide preference is stored with the given value.
352     *
353     * Both scopes keep the flag under the same name, and every rule here turns
354     * on a stored value rather than an absent one, so the three predicates below
355     * differ only in which scope and which value they ask about.
356     *
357     * @param array  $preferences The user's persisted editor preferences.
358     * @param string $scope       Preference scope to look in.
359     * @param bool   $value       Value to compare against.
360     * @return bool Whether the preference is stored with that value.
361     */
362    private static function preference_is( array $preferences, $scope, $value ) {
363        return isset( $preferences[ $scope ][ self::PREFERENCE_NAME ] )
364            && $value === $preferences[ $scope ][ self::PREFERENCE_NAME ];
365    }
366
367    /**
368     * Whether the user has asked for core's welcome modal and not yet seen it.
369     *
370     * Only a *stored* true counts. Core's own default is also true, but it is
371     * never written to the blob, so the two are indistinguishable in the
372     * browser — which is why this is decided here. The guide treats a stored
373     * true as a request for itself, since core's Options menu item is a toggle:
374     * choosing it on a screen where this bundle was absent (arriving at a form
375     * through in-editor navigation) persists true, and nothing would otherwise
376     * clear it. Left alone it beats the runtime default the editor bundle sets
377     * and reopens core's generic modal on every later form editor load.
378     *
379     * @param array $preferences The user's persisted editor preferences.
380     * @return bool Whether core's welcome modal is pending by the user's own choice.
381     */
382    private static function is_core_welcome_guide_pending( array $preferences ) {
383        return self::preference_is( $preferences, self::CORE_PREFERENCE_SCOPE, true );
384    }
385
386    /**
387     * Whether the user has already dismissed the welcome guide.
388     *
389     * @param array $preferences The user's persisted editor preferences.
390     * @return bool Whether the guide has been dismissed.
391     */
392    private static function is_welcome_guide_dismissed( array $preferences ) {
393        return self::preference_is( $preferences, self::PREFERENCE_SCOPE, false );
394    }
395
396    /**
397     * Whether the welcome guide should open on its own for the current user.
398     *
399     * Two audiences get it. Someone who has never dismissed the core welcome
400     * modal is new to the block editor, and the form guide stands in for the
401     * core one here. Everyone else gets it only until they have a form of their
402     * own, as first-run onboarding — regardless of how many posts or pages they
403     * have written.
404     *
405     * This only decides whether the guide opens by itself. The query argument
406     * overrides it.
407     *
408     * @param array $preferences The user's persisted editor preferences.
409     * @return bool Whether the guide should open on its own.
410     */
411    private static function is_welcome_guide_eligible( array $preferences ) {
412        $user_id = get_current_user_id();
413        if ( ! $user_id ) {
414            return false;
415        }
416
417        // Core only stores false once the modal has been dismissed, so anything
418        // else — including no stored value at all — means it is still pending.
419        if ( ! self::preference_is( $preferences, self::CORE_PREFERENCE_SCOPE, false ) ) {
420            return true;
421        }
422
423        // Every status except auto-draft: opening this screen creates one before
424        // the enqueue runs, so counting it would hide the guide from the very
425        // first-time author it is meant for.
426        $statuses = array_values( array_diff( array_keys( get_post_stati() ), array( 'auto-draft' ) ) );
427
428        $existing_forms = get_posts(
429            array(
430                'post_type'     => Contact_Form::POST_TYPE,
431                'post_status'   => $statuses,
432                'author'        => $user_id,
433                'numberposts'   => 1,
434                'fields'        => 'ids',
435                'no_found_rows' => true,
436                'cache_results' => false,
437                'orderby'       => 'none',
438            )
439        );
440
441        return empty( $existing_forms );
442    }
443}