Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.62% covered (success)
90.62%
618 / 682
52.38% covered (warning)
52.38%
11 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Contact_Form_Block
90.62% covered (success)
90.62%
618 / 682
52.38% covered (warning)
52.38%
11 / 21
68.49
0.00% covered (danger)
0.00%
0 / 1
 register_block
97.83% covered (success)
97.83%
45 / 46
0.00% covered (danger)
0.00%
0 / 1
2
 disable_field_visibility_support
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
7
 drop_field_hidden_everywhere
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 register_feature
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 register_central_form_management_default
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 find_nested_html_block
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
5
 render_wrapped_html_block
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 register_child_blocks
99.79% covered (success)
99.79%
484 / 485
0.00% covered (danger)
0.00%
0 / 1
2
 set_file_field_extension_available
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 pre_render_contact_form
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 count_form_steps_in_block
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 get_form_step_count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render_fallback
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 render_email
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 gutenblock_render_form
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
10.64
 load_editor_styles
n/a
0 / 0
n/a
0 / 0
1
 load_editor_scripts
0.00% covered (danger)
0.00%
0 / 31
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_load_ai_integration
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
30
 preload_endpoints
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 load_view_scripts
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
2.00
 maybe_register_blocks_editor_script
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
4.00
 can_manage_block
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
4.13
1<?php
2/**
3 * Contact Form Block.
4 *
5 * @package automattic/jetpack
6 */
7
8namespace Automattic\Jetpack\Extensions\Contact_Form;
9
10use Automattic\Jetpack\Assets;
11use Automattic\Jetpack\Blocks;
12use Automattic\Jetpack\Current_Plan;
13use Automattic\Jetpack\Forms\ContactForm\Contact_Form;
14use Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin;
15use Automattic\Jetpack\Forms\ContactForm\Form_Preview;
16use Automattic\Jetpack\Forms\Dashboard\Dashboard as Forms_Dashboard;
17use Automattic\Jetpack\Forms\Jetpack_Forms;
18use Automattic\Jetpack\Modules;
19use Automattic\Jetpack\Status\Request;
20use Jetpack;
21
22/**
23 * Contact Form block render callback.
24 */
25class Contact_Form_Block {
26    /**
27     * Register the Contact Form block.
28     * We are core block dependent only on whether the jetpack contact form plugin
29     * is active or not. This is allowing us to make it more discoverable
30     * and enable the plugin in one click
31     */
32    public static function register_block() {
33        /*
34         * The block is available even when the module is not active,
35         * so we can display a nudge to activate the module instead of the block.
36         * However, since non-admins cannot activate modules, we do not display the empty block for them.
37         */
38        if ( ! self::can_manage_block() ) {
39            return;
40        }
41
42        Blocks::jetpack_register_block(
43            'jetpack/contact-form',
44            array(
45                'render_email_callback' => array( __CLASS__, 'render_email' ),
46                'render_callback'       => array( __CLASS__, 'gutenblock_render_form' ),
47                'supports'              => array(
48                    'layout'               => array(
49                        'default'                => array(
50                            'type'              => 'flex',
51                            'flexWrap'          => 'wrap',
52                            'orientation'       => 'horizontal',
53                            'justifyContent'    => 'left',
54                            'verticalAlignment' => 'top',
55                        ),
56                        'allowSwitching'         => false,
57                        'allowEditing'           => true,
58                        'allowOrientation'       => true,
59                        'allowVerticalAlignment' => true,
60                        'allowJustification'     => true,
61                        'allowWrap'              => false,
62                    ),
63                    '__experimentalBorder' => array(
64                        'color'                         => true,
65                        'radius'                        => true,
66                        'style'                         => true,
67                        'width'                         => true,
68                        '__experimentalDefaultControls' => array(
69                            'color'  => true,
70                            'radius' => true,
71                            'style'  => true,
72                            'width'  => true,
73                        ),
74                    ),
75                ),
76                'style_handles'         => array( 'jetpack-forms-layout' ),
77            )
78        );
79
80        add_filter( 'render_block_data', array( __CLASS__, 'find_nested_html_block' ), 10, 3 );
81        add_filter( 'render_block_core/html', array( __CLASS__, 'render_wrapped_html_block' ), 10, 2 );
82        add_filter( 'jetpack_block_editor_feature_flags', array( __CLASS__, 'register_feature' ) );
83        add_filter( 'pre_render_block', array( __CLASS__, 'pre_render_contact_form' ), 10, 3 );
84
85        add_filter( 'block_editor_rest_api_preload_paths', array( __CLASS__, 'preload_endpoints' ) );
86
87        // Load scripts for the editing interface
88        add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'load_editor_scripts' ), 9 );
89
90        // Load AI integration after Jetpack_Gutenberg registers extensions (priority 10)
91        add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'maybe_load_ai_integration' ), 11 );
92    }
93
94    /**
95     * Disable the block "visibility" support on form field and input blocks.
96     *
97     * FORMS-694 (interim). The per-viewport "Hide on…" option is not honored in
98     * the forms render pipeline â€” fields flatten to a shortcode and bypass core's
99     * render_block class injection â€” and on a required field it cannot be made
100     * safe (server- and client-side validation are both viewport-blind). "Hide
101     * everywhere" does work for fields, but the control bundles both modes under
102     * one boolean, so we disable it wholesale on fields, inputs, and choice/option
103     * blocks as an interim. This mirrors the JS registration. The label is the
104     * only block that keeps visibility support â€” it honors hiding (full-hide via
105     * labelhiddenbyblockvisibility, per-viewport via wp-block-hidden-* classes)
106     * and never affects validation or submission. Full field visibility is a
107     * separate decision.
108     *
109     * @param array  $args       Block type registration args.
110     * @param string $block_name Block name being registered.
111     * @return array
112     */
113    public static function disable_field_visibility_support( $args, $block_name ) {
114        // TODO: refactor into an array_merge'd shared supports array mirroring the JS defaultSettings, instead of this filter.
115        // Fields, incl. the deprecated field-option-* choice blocks.
116        $is_field = strpos( $block_name, 'jetpack/field-' ) === 0;
117        // All input variants: jetpack/input, input-range, input-rating,
118        // input-image-option, plus the differently-named phone-input and dropzone.
119        $is_input = strpos( $block_name, 'jetpack/input' ) === 0 || in_array( $block_name, array( 'jetpack/phone-input', 'jetpack/dropzone' ), true );
120        // Choice/option containers.
121        $is_option = in_array( $block_name, array( 'jetpack/option', 'jetpack/options', 'jetpack/fieldset-image-options' ), true );
122
123        if ( $is_input || $is_field || $is_option ) {
124            if ( ! isset( $args['supports'] ) || ! is_array( $args['supports'] ) ) {
125                $args['supports'] = array();
126            }
127            $args['supports']['visibility'] = false;
128        }
129
130        return $args;
131    }
132
133    /**
134     * Drop a field that has been hidden everywhere via block visibility.
135     *
136     * FORMS-694. "Hide everywhere" (metadata.blockVisibility === false) must keep
137     * working on fields even though we disable the visibility *support* on them
138     * (see disable_field_visibility_support). We can't rely on core's render_block
139     * visibility filter for this: on released WP it gates the full-hide branch
140     * behind the support check, so once support is false the field would reappear
141     * (the fix that reorders those â€” WordPress/gutenberg#78780 â€” is unreleased).
142     *
143     * Dropping the field's rendered output ('') before Contact_Form::parse() sees
144     * it removes the [contact-field] shortcode entirely, so the field is never
145     * parsed, rendered, or validated â€” which is what makes this required-safe (a
146     * required hidden-everywhere field simply isn't in the form). This is a full
147     * removal, not a CSS hide; the per-viewport "Hide on…" mode (which leaves the
148     * field in place and only display:none's it) is intentionally not honored on
149     * fields for exactly that reason.
150     *
151     * @param string $block_content Rendered block content.
152     * @param array  $block         Parsed block.
153     * @return string
154     */
155    public static function drop_field_hidden_everywhere( $block_content, $block ) {
156        $block_name       = $block['blockName'] ?? '';
157        $block_visibility = $block['attrs']['metadata']['blockVisibility'] ?? null;
158
159        if ( false === $block_visibility && strpos( $block_name, 'jetpack/field-' ) === 0 ) {
160            return '';
161        }
162
163        return $block_content;
164    }
165
166    /**
167     * Register the contact form block feature flag.
168     *
169     * @param array $features - the features array.
170     *
171     * @return array
172     */
173    public static function register_feature( $features ) {
174        // Features that are only available to users with a paid plan.
175        $features['multistep-form'] = Current_Plan::supports( 'multistep-form' );
176        $features['form-webhooks']  = Current_Plan::supports( 'form-webhooks' );
177
178        return self::register_central_form_management_default( $features );
179    }
180
181    /**
182     * Lightweight bootstrap default for the `central-form-management` feature flag.
183     *
184     * Registered from Util::init() so that early callers of
185     * Contact_Form_Plugin::has_editor_feature_flag() â€” such as the Forms dashboard
186     * default-tab redirect, which runs before the WP `init` hook fires â€” see the
187     * correct flag value. Kept separate from register_feature() so the early-boot
188     * code path avoids the Current_Plan::supports() calls used by the paid-plan flags.
189     *
190     * @param array $features - the features array.
191     *
192     * @return array
193     */
194    public static function register_central_form_management_default( $features ) {
195        if ( ! isset( $features['central-form-management'] ) ) {
196            $features['central-form-management'] = true;
197        }
198
199        return $features;
200    }
201
202    /**
203     *  Find nested html block that reside in the contact form block.
204     *  We are using this to wrap the html block with div if it is nested inside contact form block. So that the elements render as expected.
205     *
206     *  @param array  $parsed_block - the parsed block.
207     *  @param array  $source_block - the source block.
208     *  @param object $parent_block - the parent WP_Block.
209     *
210     *  @return array
211     */
212    public static function find_nested_html_block( $parsed_block, $source_block, $parent_block ) {
213        if ( ! empty( $parsed_block['blockName'] ) && $parsed_block['blockName'] === 'core/html' && isset( $parent_block->parsed_block ) && $parent_block->parsed_block['blockName'] === 'jetpack/contact-form' ) {
214            $parsed_block['hasJPFormParent'] = true;
215        }
216        return $parsed_block;
217    }
218
219    /**
220     * Render wrapped html block that is inside the form block with a wrapped div so that the elements render as expected.
221     * The extra div is needed because the form block has a `flex: 0 0 100%;` applied to all the children of the form block.
222     * This cases all the elementes inside the block to render in a single line and make it not possible to add have inline elements.
223     *
224     * @param string $content - the content of the block.
225     * @param array  $parsed_block - the parsed block.
226     *
227     * @return string
228     */
229    public static function render_wrapped_html_block( $content, $parsed_block ) {
230        if ( ! empty( $parsed_block['hasJPFormParent'] ) ) {
231            return '<div>' . $content . '</div>';
232        }
233
234        return $content;
235    }
236
237    /**
238     * Register the Child blocks of Contact Form
239     * We are registering child blocks only when Contact Form plugin is Active
240     */
241    public static function register_child_blocks() {
242        // Bail early if the user cannot manage the block.
243        if ( ! self::can_manage_block() ) {
244            return;
245        }
246
247        // Keep the PHP-registered "visibility" support in sync with the JS
248        // registration (src/blocks/shared/settings/index.js and
249        // src/blocks/input/index.js), which disables it on fields and inputs.
250        add_filter( 'register_block_type_args', array( __CLASS__, 'disable_field_visibility_support' ), 10, 2 );
251
252        // Honor "hide everywhere" on fields ourselves, independent of core's
253        // version-dependent visibility filter. See drop_field_hidden_everywhere().
254        add_filter( 'render_block', array( __CLASS__, 'drop_field_hidden_everywhere' ), 10, 2 );
255
256        // Field inner block types.
257        Blocks::jetpack_register_block(
258            'jetpack/input',
259            array(
260                'supports'     => array(
261                    '__experimentalBorder' => array(
262                        'color'  => true,
263                        'radius' => true,
264                        'style'  => true,
265                        'width'  => true,
266                    ),
267                    'color'                => array(
268                        'text'       => true,
269                        'background' => true,
270                        'gradients'  => false,
271                    ),
272                    'typography'           => array(
273                        'fontSize'                     => true,
274                        'lineHeight'                   => true,
275                        '__experimentalFontFamily'     => true,
276                        '__experimentalFontWeight'     => true,
277                        '__experimentalFontStyle'      => true,
278                        '__experimentalTextTransform'  => true,
279                        '__experimentalTextDecoration' => true,
280                        '__experimentalLetterSpacing'  => true,
281                    ),
282                ),
283                'selectors'    => array(
284                    'border' => '.wp-block-jetpack-input, .is-style-outlined .notched-label:has(+ .wp-block-jetpack-input) > *,.is-style-outlined .wp-block-jetpack-input + .notched-label > *, .is-style-outlined .wp-block-jetpack-field-select .notched-label > *',
285                    'color'  => '.wp-block-jetpack-input, .is-style-outlined .notched-label:has(+ .wp-block-jetpack-input) > *,.is-style-outlined .wp-block-jetpack-input + .notched-label > *, .is-style-outlined .wp-block-jetpack-field-select .notched-label > *',
286                ),
287                'uses_context' => array( 'jetpack/field-default-value' ),
288            )
289        );
290        Blocks::jetpack_register_block(
291            'jetpack/label',
292            array(
293                'supports'     => array(
294                    'color'      => array(
295                        'text'       => true,
296                        'background' => false,
297                        'gradients'  => false,
298                    ),
299                    'typography' => array(
300                        'fontSize'                     => true,
301                        'lineHeight'                   => true,
302                        '__experimentalFontFamily'     => true,
303                        '__experimentalFontWeight'     => true,
304                        '__experimentalFontStyle'      => true,
305                        '__experimentalTextTransform'  => true,
306                        '__experimentalTextDecoration' => true,
307                        '__experimentalLetterSpacing'  => true,
308                    ),
309                    // The real support key is `visibility`, not `blockVisibility`
310                    // (that's the per-instance saved attribute). The label is the
311                    // only forms block that keeps the control â€” see FORMS-694.
312                    'visibility' => true,
313                ),
314                'uses_context' => array(
315                    'jetpack/field-required',
316                    'jetpack/field-date-format',
317                ),
318            )
319        );
320        Blocks::jetpack_register_block(
321            'jetpack/options',
322            array(
323                'supports'         => array(
324                    '__experimentalBorder' => array(
325                        'color'  => true,
326                        'radius' => true,
327                        'style'  => true,
328                        'width'  => true,
329                    ),
330                    'color'                => array(
331                        'text'       => false,
332                        'background' => true,
333                    ),
334                    'spacing'              => array(
335                        'blockGap' => false,
336                    ),
337                ),
338                'provides_context' => array(
339                    'jetpack/field-options-type' => 'type',
340                ),
341                'selectors'        => array(
342                    'border' => '.wp-block-jetpack-options, .is-style-outlined .notched-label:has(+ .wp-block-jetpack-options) > *',
343                    'color'  => '.wp-block-jetpack-options, .is-style-outlined .notched-label:has(+ .wp-block-jetpack-options) > *',
344                ),
345            )
346        );
347        Blocks::jetpack_register_block(
348            'jetpack/option',
349            array(
350                'supports'     => array(
351                    'color'      => array(
352                        'text'       => true,
353                        'background' => false,
354                        'gradients'  => false,
355                    ),
356                    'typography' => array(
357                        'fontSize'                     => true,
358                        'lineHeight'                   => true,
359                        '__experimentalFontFamily'     => true,
360                        '__experimentalFontWeight'     => true,
361                        '__experimentalFontStyle'      => true,
362                        '__experimentalTextTransform'  => true,
363                        '__experimentalTextDecoration' => true,
364                        '__experimentalLetterSpacing'  => true,
365                    ),
366                ),
367                'uses_context' => array(
368                    'jetpack/field-default-value',
369                    'jetpack/field-options-type',
370                    'jetpack/field-required',
371                ),
372            )
373        );
374
375        Blocks::jetpack_register_block(
376            'jetpack/phone-input',
377            array(
378                'supports'     => array(
379                    '__experimentalBorder' => array(
380                        'color'  => true,
381                        'radius' => true,
382                        'style'  => true,
383                        'width'  => true,
384                    ),
385                    'color'                => array(
386                        'text'       => true,
387                        'background' => true,
388                        'gradients'  => false,
389                    ),
390                    'typography'           => array(
391                        'fontSize'                     => true,
392                        'lineHeight'                   => true,
393                        '__experimentalFontFamily'     => true,
394                        '__experimentalFontWeight'     => true,
395                        '__experimentalFontStyle'      => true,
396                        '__experimentalTextTransform'  => true,
397                        '__experimentalTextDecoration' => true,
398                        '__experimentalLetterSpacing'  => true,
399                    ),
400                ),
401                'uses_context' => array(
402                    'jetpack/field-share-attributes',
403                    'jetpack/field-prefix-options',
404                    'jetpack/field-prefix-default',
405                    'jetpack/field-prefix-onChange',
406                    'jetpack/field-phone-country-toggle',
407                ),
408            )
409        );
410
411        Blocks::jetpack_register_block(
412            'jetpack/input-rating',
413            array(
414                'supports' => array(
415                    'color'      => array(
416                        'text'       => true,
417                        'background' => false,
418                    ),
419                    'typography' => array(
420                        'fontSize' => true,
421                    ),
422                ),
423            )
424        );
425
426        Blocks::jetpack_register_block(
427            'jetpack/input-range',
428            array(
429                'supports' => array(
430                    'color'      => array(
431                        'text'       => true,
432                        'background' => false,
433                    ),
434                    'typography' => array(
435                        'fontSize'                     => true,
436                        '__experimentalFontFamily'     => true,
437                        '__experimentalFontWeight'     => true,
438                        '__experimentalFontStyle'      => true,
439                        '__experimentalTextTransform'  => true,
440                        '__experimentalTextDecoration' => true,
441                        '__experimentalLetterSpacing'  => true,
442                    ),
443                ),
444            )
445        );
446
447        // Field render methods.
448        Blocks::jetpack_register_block(
449            'jetpack/field-text',
450            array(
451                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_text' ),
452                'provides_context' => array( 'jetpack/field-required' => 'required' ),
453            )
454        );
455        Blocks::jetpack_register_block(
456            'jetpack/field-name',
457            array(
458                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_name' ),
459                'provides_context' => array( 'jetpack/field-required' => 'required' ),
460            )
461        );
462        Blocks::jetpack_register_block(
463            'jetpack/field-email',
464            array(
465                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_email' ),
466                'provides_context' => array( 'jetpack/field-required' => 'required' ),
467            )
468        );
469        Blocks::jetpack_register_block(
470            'jetpack/field-url',
471            array(
472                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_url' ),
473                'provides_context' => array( 'jetpack/field-required' => 'required' ),
474            )
475        );
476        Blocks::jetpack_register_block(
477            'jetpack/field-date',
478            array(
479                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_date' ),
480                'provides_context' => array(
481                    'jetpack/field-required'    => 'required',
482                    'jetpack/field-date-format' => 'dateFormat',
483                ),
484            )
485        );
486        Blocks::jetpack_register_block(
487            'jetpack/field-telephone',
488            array(
489                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_telephone' ),
490                'attributes'       => array(
491                    'showCountrySelector' => array(
492                        'type' => 'boolean',
493                    ),
494                    'default'             => array(
495                        'type' => 'string',
496                        'role' => 'content',
497                    ),
498                    'searchPlaceholder'   => array(
499                        'type' => 'string',
500                        'role' => 'content',
501                    ),
502                ),
503                'supports'         => array(
504                    'interactivity' => true,
505                ),
506                'provides_context' => array(
507                    'jetpack/field-share-attributes'     => 'shareAttributes',
508                    'jetpack/field-required'             => 'required',
509                    'jetpack/field-prefix-default'       => 'default',
510                    'jetpack/field-phone-country-toggle' => 'showCountrySelector',
511                    'jetpack/field-phone-search-placeholder' => 'searchPlaceholder',
512                ),
513            )
514        );
515        Blocks::jetpack_register_block(
516            'jetpack/field-textarea',
517            array(
518                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_textarea' ),
519                'provides_context' => array( 'jetpack/field-required' => 'required' ),
520            )
521        );
522        Blocks::jetpack_register_block(
523            'jetpack/field-checkbox',
524            array(
525                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_checkbox' ),
526                'provides_context' => array(
527                    'jetpack/field-required'      => 'required',
528                    'jetpack/field-default-value' => 'defaultValue',
529                ),
530            )
531        );
532        Blocks::jetpack_register_block(
533            'jetpack/field-checkbox-multiple',
534            array(
535                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_field_checkbox_multiple' ),
536            )
537        );
538
539        Blocks::jetpack_register_block(
540            'jetpack/field-option-checkbox',
541            array(
542                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_field_option' ),
543            )
544        );
545
546        Blocks::jetpack_register_block(
547            'jetpack/field-radio',
548            array(
549                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_field_radio' ),
550            )
551        );
552
553        Blocks::jetpack_register_block(
554            'jetpack/field-option-radio',
555            array(
556                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_field_option' ),
557            )
558        );
559        Blocks::jetpack_register_block(
560            'jetpack/field-select',
561            array(
562                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_select' ),
563                'provides_context' => array( 'jetpack/field-required' => 'required' ),
564            )
565        );
566        Blocks::jetpack_register_block(
567            'jetpack/field-consent',
568            array(
569                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_field_consent' ),
570            )
571        );
572
573        Blocks::jetpack_register_block(
574            'jetpack/field-number',
575            array(
576                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_number' ),
577                'provides_context' => array( 'jetpack/field-required' => 'required' ),
578            )
579        );
580
581        Blocks::jetpack_register_block(
582            'jetpack/field-file',
583            array(
584                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_file' ),
585                'provides_context' => array( 'jetpack/field-required' => 'required' ),
586                'plan_check'       => apply_filters( 'jetpack_unauth_file_upload_plan_check', true ),
587            )
588        );
589
590        Blocks::jetpack_register_block(
591            'jetpack/dropzone',
592            array(
593                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_dropzone' ),
594            )
595        );
596
597        Blocks::jetpack_register_block(
598            'jetpack/field-hidden',
599            array(
600                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_field_hidden' ),
601            )
602        );
603
604        Blocks::jetpack_register_block(
605            'jetpack/field-rating',
606            array(
607                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_rating' ),
608                'provides_context' => array(
609                    'jetpack/field-required' => 'required',
610                ),
611            )
612        );
613
614        Blocks::jetpack_register_block(
615            'jetpack/field-slider',
616            array(
617                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_slider' ),
618                'provides_context' => array( 'jetpack/field-required' => 'required' ),
619            )
620        );
621
622        Blocks::jetpack_register_block(
623            'jetpack/field-time',
624            array(
625                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_time' ),
626                'provides_context' => array( 'jetpack/field-required' => 'required' ),
627            )
628        );
629
630        // Paid file field block
631        add_action(
632            'jetpack_register_gutenberg_extensions',
633            array( __CLASS__, 'set_file_field_extension_available' )
634        );
635
636        /**
637         * The blocks 'jetpack/field-checkbox-multiple' and 'jetpack/field-radio' are wrapper blocks.
638         * Styles must be registered so that they are available to be overridden by the theme or global styles.
639         * Form field blocks define the block style via the settings in their index.js files.
640         * A follow up issue is to update them to use block.json files, which can be reused
641         * in both JS and PHP block registration.
642         */
643        register_block_style(
644            array( 'jetpack/field-checkbox-multiple', 'jetpack/field-radio' ),
645            array(
646                'name'       => 'list',
647                'label'      => __( 'List', 'jetpack-forms' ),
648                'is_default' => true,
649            )
650        );
651
652        register_block_style(
653            array( 'jetpack/field-checkbox-multiple', 'jetpack/field-radio' ),
654            array(
655                'name'  => 'button',
656                'label' => __( 'Button', 'jetpack-forms' ),
657            )
658        );
659
660        Blocks::jetpack_register_block(
661            'jetpack/form-step',
662            array(
663                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_form_step' ),
664            )
665        );
666
667        Blocks::jetpack_register_block(
668            'jetpack/form-step-navigation',
669            array(
670                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_form_step_navigation' ),
671            )
672        );
673
674        Blocks::jetpack_register_block(
675            'jetpack/form-progress-indicator',
676            array(
677                'render_callback' => array( Contact_Form_Plugin::class, 'gutenblock_render_form_progress_indicator' ),
678            )
679        );
680
681        Blocks::jetpack_register_block(
682            'jetpack/form-step-container'
683        );
684
685        Blocks::jetpack_register_block(
686            'jetpack/field-image-select',
687            array(
688                'render_callback'  => array( Contact_Form_Plugin::class, 'gutenblock_render_field_image_select' ),
689                'provides_context' => array(
690                    'jetpack/field-required' => 'required',
691                    'jetpack/field-image-select-show-labels' => 'showLabels',
692                    'jetpack/field-image-select-is-supersized' => 'isSupersized',
693                    'jetpack/field-image-select-is-multiple' => 'isMultiple',
694                    'jetpack/field-image-select-randomize-options' => 'randomizeOptions',
695                    'jetpack/field-image-select-show-other-option' => 'showOtherOption',
696                ),
697            )
698        );
699
700        Blocks::jetpack_register_block(
701            'jetpack/fieldset-image-options',
702            array(
703                'uses_context'     => array(
704                    'jetpack/field-image-select-is-supersized',
705                    'jetpack/field-image-select-is-multiple',
706                    'jetpack/field-share-attributes',
707                ),
708                'provides_context' => array(
709                    'jetpack/field-image-options-type' => 'type',
710                ),
711            )
712        );
713
714        Blocks::jetpack_register_block(
715            'jetpack/input-image-option',
716            array(
717                'supports'         => array(
718                    'color'                => array(
719                        'background'                    => true,
720                        'text'                          => true,
721                        'gradients'                     => false,
722                        '__experimentalDefaultControls' => array(
723                            'background' => true,
724                            'text'       => true,
725                        ),
726                    ),
727                    'typography'           => array(
728                        'fontSize'                      => true,
729                        'lineHeight'                    => true,
730                        '__experimentalFontFamily'      => true,
731                        '__experimentalFontWeight'      => true,
732                        '__experimentalFontStyle'       => true,
733                        '__experimentalTextTransform'   => true,
734                        '__experimentalTextDecoration'  => true,
735                        '__experimentalLetterSpacing'   => true,
736                        '__experimentalDefaultControls' => array(
737                            'fontSize' => true,
738                        ),
739                    ),
740                    '__experimentalBorder' => array(
741                        'color'                         => true,
742                        'radius'                        => true,
743                        'style'                         => true,
744                        'width'                         => true,
745                        '__experimentalDefaultControls' => array(
746                            'color'  => true,
747                            'radius' => true,
748                            'style'  => true,
749                            'width'  => true,
750                        ),
751                    ),
752                    'spacing'              => array(
753                        'margin'                        => true,
754                        'padding'                       => true,
755                        '__experimentalDefaultControls' => array(
756                            'margin'  => true,
757                            'padding' => true,
758                        ),
759                    ),
760                ),
761                'uses_context'     => array(
762                    'jetpack/field-image-select-is-supersized',
763                    'jetpack/field-image-select-show-labels',
764                    'jetpack/field-image-options-type',
765                    'jetpack/field-share-attributes',
766                ),
767                'provides_context' => array(
768                    'allowResize' => 'allowResize',
769                    'imageCrop'   => 'imageCrop',
770                    'fixedHeight' => 'fixedHeight',
771                ),
772            )
773        );
774    }
775
776    /**
777     * Set field-file extension available hook handler
778     */
779    public static function set_file_field_extension_available() {
780        if ( ! apply_filters( 'jetpack_unauth_file_upload_plan_check', true ) ) {
781            \Jetpack_Gutenberg::set_extension_available( 'field-file' );
782        }
783    }
784
785    /**
786     * Render the gutenblock form.
787     *
788     * @param array  $atts - the block attributes.
789     * @param string $content - html content.
790     *
791     * @return string
792     */
793    /**
794     * Static storage for form step count.
795     *
796     * @var int
797     */
798    private static $form_step_count = 1;
799
800    /**
801     * Hook into pre_render_block to count form steps before inner blocks render.
802     *
803     * @param string|null $pre_render   The pre-rendered content. Default null.
804     * @param array       $parsed_block The block being rendered.
805     * @return string|null
806     */
807    public static function pre_render_contact_form( $pre_render, $parsed_block ) {
808        // Only process contact form blocks
809        if ( ! isset( $parsed_block['blockName'] ) || $parsed_block['blockName'] !== 'jetpack/contact-form' ) {
810            return $pre_render;
811        }
812
813        // Count and store form steps
814        self::$form_step_count = self::count_form_steps_in_block( $parsed_block );
815
816        // For ref (synced) forms, render here via pre_render_block to short-circuit
817        // render_block(). This prevents wp_render_layout_support_flag from adding
818        // layout classes to the outer ref block's container div. The synced form's
819        // inner jetpack/contact-form block renders through the normal pipeline and
820        // gets layout classes on the correct element (wp-block-jetpack-contact-form).
821        if ( isset( $parsed_block['attrs']['ref'] ) ) {
822            return self::gutenblock_render_form( $parsed_block['attrs'], '' );
823        }
824
825        return $pre_render; // Don't actually pre-render, let normal rendering continue
826    }
827
828    /**
829     * Count form step blocks in a contact form block.
830     *
831     * @param array $block The contact form block.
832     * @return int Number of form steps found.
833     */
834    private static function count_form_steps_in_block( $block ) {
835        $step_count = 0;
836
837        if ( isset( $block['innerBlocks'] ) ) {
838            foreach ( $block['innerBlocks'] as $inner_block ) {
839                if ( $inner_block['blockName'] === 'jetpack/form-step' ) {
840                    ++$step_count;
841                }
842                // Also check nested blocks (like step containers)
843                $step_count += self::count_form_steps_in_block( $inner_block );
844            }
845        }
846
847        return $step_count;
848    }
849
850    /**
851     * Get the step count for forms (used by progress indicator).
852     *
853     * @return int The step count.
854     */
855    public static function get_form_step_count() {
856        return self::$form_step_count;
857    }
858
859    /**
860     * Render fallback for non-interactive contexts (email, feed, API, etc.).
861     *
862     * @param array $atts - the block attributes.
863     *
864     * @return string
865     */
866    private static function render_fallback( $atts ) {
867        return sprintf(
868            '<div class="%1$s"><a href="%2$s" target="_blank" rel="noopener noreferrer">%3$s</a></div>',
869            esc_attr( Blocks::classes( 'contact-form', $atts ) ),
870            esc_url( get_the_permalink() ),
871            esc_html__( 'Submit a form.', 'jetpack-forms' )
872        );
873    }
874
875    /**
876     * Render the contact form block for email contexts.
877     *
878     * This method is called by WordPress/WooCommerce email rendering system when a form block
879     * appears in email content. Forms are not interactive in email contexts, so we always return
880     * a fallback link that directs recipients to the form on the website.
881     *
882     * Note: The $block_content and $rendering_context parameters are required by the
883     * render_email_callback signature but are intentionally unused here. We only need the
884     * block attributes from $parsed_block to generate the fallback HTML, and we don't need
885     * to process the block content or use email-specific rendering context since we're
886     * always returning a simple fallback.
887     *
888     * @param string $block_content     The original block HTML content. Unused - we always return a fallback.
889     * @param array  $parsed_block      The parsed block data including attributes.
890     * @param object $rendering_context Email rendering context. Unused - not needed for fallback rendering.
891     *
892     * @return string HTML fallback with link to submit the form on the website.
893     */
894    public static function render_email( $block_content, array $parsed_block, $rendering_context ) { // phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
895        $atts = $parsed_block['attrs'] ?? array();
896
897        return self::render_fallback( $atts );
898    }
899
900    /**
901     * Render the gutenblock form.
902     *
903     * @param array  $atts - the block attributes.
904     * @param string $content - html content.
905     *
906     * @return string
907     */
908    public static function gutenblock_render_form( $atts, $content ) {
909        // We should not render block if the module is disabled on a site using the Jetpack plugin.
910        // Exception: allow rendering in preview mode so form previews work.
911        if ( class_exists( 'Jetpack' ) && ! ( new Modules() )->is_active( 'contact-form' ) && ! Form_Preview::is_preview_mode() ) {
912            return '';
913        }
914        // Render fallback in other contexts than frontend (i.e. feed, emails, API, etc.), unless the form is being submitted.
915        // Exception: allow rendering in preview mode.
916        if ( ! Request::is_frontend() && ! isset( $_POST['contact-form-id'] ) && ! Form_Preview::is_preview_mode() ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing
917            return self::render_fallback( $atts );
918        }
919
920        self::load_view_scripts();
921
922        // Handle ref attribute - load form from jetpack_form post
923        if ( isset( $atts['ref'] ) ) {
924            $ref_id = absint( $atts['ref'] );
925            if ( $ref_id > 0 ) {
926                return Contact_Form::render_synced_form( $ref_id );
927            } else {
928                return ''; // Invalid ref ID.
929            }
930        }
931
932        return Contact_Form::parse( $atts, do_blocks( $content ) );
933    }
934
935    /**
936     * Load editor styles for the block.
937     *
938     * @deprecated 7.5.0 This function is deprecated and will be removed in a future version.
939     */
940    public static function load_editor_styles() {
941        _deprecated_function( __FUNCTION__, 'jetpack-7.5.0' );
942    }
943
944    /**
945     * Loads scripts
946     */
947    public static function load_editor_scripts() {
948        global $post;
949        // Bail early if the user cannot manage the block.
950        if ( ! self::can_manage_block() ) {
951            return;
952        }
953
954        $handle = 'jp-forms-blocks';
955
956        // Ensure the jetpack-blocks-editor dependency exists. When the Blocks module
957        // is inactive, nothing registers it, but the Forms editor JS needs the
958        // Jetpack_Editor_Initial_State global (with availability data) it provides.
959        self::maybe_register_blocks_editor_script();
960
961        Assets::register_script(
962            $handle,
963            '../../../dist/blocks/editor.js',
964            __FILE__,
965            array(
966                'dependencies' => array( 'jetpack-blocks-editor' ),
967                'in_footer'    => true,
968                'textdomain'   => 'jetpack-forms',
969                'enqueue'      => true,
970                'css_path'     => '../../../dist/blocks/editor.css',
971            )
972        );
973
974        // Create a Contact_Form instance to get the default values
975        $form_responses_url      = Forms_Dashboard::get_forms_admin_url();
976        $akismet_active_with_key = Jetpack::is_akismet_active();
977        $akismet_key_url         = admin_url( 'admin.php?page=akismet-key-config' );
978
979        $data = array(
980            'defaults' => array(
981                'to'                   => Contact_Form::get_default_to_for_editor( $post ),
982                'subject'              => Contact_Form::get_default_subject( array() ),
983                'formsResponsesUrl'    => $form_responses_url,
984                'akismetActiveWithKey' => $akismet_active_with_key,
985                'akismetUrl'           => $akismet_key_url,
986                'assetsUrl'            => Jetpack_Forms::assets_url(),
987                'isMailPoetEnabled'    => Jetpack_Forms::is_mailpoet_enabled(),
988            ),
989        );
990
991        wp_add_inline_script( $handle, 'window.jpFormsBlocks = ' . wp_json_encode( $data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';', 'before' );
992    }
993
994    /**
995     * Conditionally loads the AI form generation integration script.
996     *
997     * This script is only loaded when:
998     * 1. The AI Assistant extension is available (ai-assistant-form-support)
999     * 2. The central-form-management feature flag is enabled
1000     *
1001     * By checking these conditions in PHP, we ensure no JavaScript is loaded
1002     * when either the AI extension is disabled or central form management is off.
1003     *
1004     * This is hooked at priority 11 on enqueue_block_editor_assets to ensure
1005     * it runs after Jetpack_Gutenberg registers extensions at priority 10.
1006     */
1007    public static function maybe_load_ai_integration() {
1008        // Bail if the user cannot manage the block â€” jp-forms-blocks won't be registered.
1009        if ( ! self::can_manage_block() ) {
1010            return;
1011        }
1012
1013        // Check if central form management is enabled.
1014        if ( ! Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' ) ) {
1015            return;
1016        }
1017
1018        // Check if AI Assistant form support is available.
1019        // This extension is set as available when the AI Assistant block is registered.
1020        if ( ! class_exists( 'Jetpack_Gutenberg' ) ) {
1021            return;
1022        }
1023
1024        // Ensure extensions are registered by calling get_cached_availability().
1025        \Jetpack_Gutenberg::get_cached_availability();
1026        if ( ! \Jetpack_Gutenberg::is_available( 'ai-assistant-form-support' ) ) {
1027            return;
1028        }
1029
1030        Assets::register_script(
1031            'jp-forms-ai-plugin',
1032            '../../../dist/blocks/ai-form-plugin.js',
1033            __FILE__,
1034            array(
1035                'dependencies' => array( 'jp-forms-blocks' ),
1036                'in_footer'    => true,
1037                'textdomain'   => 'jetpack-forms',
1038                'enqueue'      => true,
1039            )
1040        );
1041    }
1042
1043    /**
1044     * Add REST API endpoints to the block editor preload list.
1045     *
1046     * @param array $paths Existing paths to preload.
1047     * @return array Updated paths to preload.
1048     */
1049    public static function preload_endpoints( $paths ) {
1050        $paths[] = array( '/wp/v2/feedback/config', 'GET' );
1051        $paths[] = array( '/wp/v2/feedback/config?_locale=user', 'GET' );
1052        return $paths;
1053    }
1054
1055    /**
1056     * Loads scripts
1057     */
1058    public static function load_view_scripts() {
1059        if ( is_admin() ) {
1060            // A block's view assets will not be required in wp-admin.
1061            return;
1062        }
1063
1064        Assets::register_script(
1065            'jp-forms-blocks',
1066            '../../../dist/blocks/view.js',
1067            __FILE__,
1068            array(
1069                'in_footer'  => true,
1070                'strategy'   => 'defer',
1071                'textdomain' => 'jetpack-forms',
1072                'enqueue'    => true,
1073            )
1074        );
1075    }
1076
1077    /**
1078     * Register a minimal jetpack-blocks-editor script when the Blocks module is inactive.
1079     *
1080     * The Forms editor JS depends on jetpack-blocks-editor for the
1081     * Jetpack_Editor_Initial_State global, which includes block availability data.
1082     * Normally the Blocks module registers this, but Forms needs to work independently.
1083     */
1084    private static function maybe_register_blocks_editor_script() {
1085        if ( wp_script_is( 'jetpack-blocks-editor', 'registered' ) ) {
1086            return;
1087        }
1088
1089        // When the Blocks module is active it will register the real script, so bail.
1090        if ( class_exists( 'Jetpack' ) && ( new Modules() )->is_active( 'blocks' ) ) {
1091            return;
1092        }
1093
1094        // Register a minimal script to satisfy the dependency.
1095        wp_register_script( 'jetpack-blocks-editor', '', array(), JETPACK__VERSION, true );
1096        wp_register_style( 'jetpack-blocks-editor', false, array(), JETPACK__VERSION );
1097
1098        // Provide the initial state the Forms editor JS expects:
1099        // - available_blocks: the JS block registration checks this before calling registerBlockType.
1100        // - modules: the useModuleStatus hook reads this to decide whether to show the block
1101        // or an "Activate Forms" placeholder.
1102        // - feature_flags: hasFeatureFlag() reads this for central-form-management,
1103        // form-webhooks, and multistep-form.
1104        wp_localize_script(
1105            'jetpack-blocks-editor',
1106            'Jetpack_Editor_Initial_State',
1107            array(
1108                'available_blocks' => array(
1109                    'contact-form' => array( 'available' => true ),
1110                ),
1111                'modules'          => array(
1112                    'contact-form' => array( 'activated' => true ),
1113                ),
1114                'feature_flags'    => apply_filters( 'jetpack_block_editor_feature_flags', array() ),
1115            )
1116        );
1117    }
1118
1119    /**
1120     * Check if the current user can view the block.
1121     * Every user can see it if the Contact Form module is active,
1122     * but if it is inactive, only admins can see it.
1123     *
1124     * This is only useful when the Contact Form package is used within the Jetpack plugin,
1125     * where the module logic exists.
1126     *
1127     * @since 0.49.0
1128     *
1129     * @return bool
1130     */
1131    public static function can_manage_block() {
1132        if (
1133            /**
1134             * Allow third-parties to override the form block's visibility.
1135             *
1136             * @since 0.49.0
1137             *
1138             * @module contact-form
1139             *
1140             * @param bool $can_manage_block Whether the current user can manage the block.
1141             */
1142            apply_filters( 'jetpack_contact_form_can_manage_block', false )
1143        ) {
1144            return true;
1145        }
1146
1147        if ( ! class_exists( 'Jetpack' ) ) {
1148            return true;
1149        }
1150
1151        return ( new Modules() )->is_active( 'contact-form' ) || current_user_can( 'jetpack_activate_modules' );
1152    }
1153}