Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.73% covered (danger)
32.73%
91 / 278
55.56% covered (warning)
55.56%
10 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard
32.97% covered (danger)
32.97%
91 / 276
55.56% covered (warning)
55.56%
10 / 18
1910.22
0.00% covered (danger)
0.00%
0 / 1
 load_wp_build
68.18% covered (warning)
68.18%
15 / 22
0.00% covered (danger)
0.00%
0 / 1
7.16
 fix_boot_import_map_ordering
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
42
 init
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 redirect_dashboard_url_cross_variant
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
306
 get_admin_query_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 load_admin_scripts
0.00% covered (danger)
0.00%
0 / 84
0.00% covered (danger)
0.00%
0 / 1
42
 is_wp_build_dashboard_page
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 add_admin_submenu
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
12
 render_dashboard
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 has_feedback
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 get_classic_forms_state
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 detect_classic_forms
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 mark_classic_form_detected
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_forms_admin_url
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
4
 get_forms_admin_path_wp_build
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
 get_forms_admin_suffix_legacy
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 is_jetpack_forms_admin_page
40.00% covered (danger)
40.00%
4 / 10
0.00% covered (danger)
0.00%
0 / 1
7.46
 is_notes_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_admin_url
n/a
0 / 0
n/a
0 / 0
3
1<?php
2/**
3 * Jetpack forms dashboard.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\Dashboard;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Assets;
12use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
13use Automattic\Jetpack\Forms\ContactForm\Contact_Form;
14use Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin;
15use Automattic\Jetpack\Tracking;
16use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
17
18if ( ! defined( 'ABSPATH' ) ) {
19    exit( 0 );
20}
21
22/**
23 * Handles the Jetpack Forms dashboard.
24 */
25class Dashboard {
26    /**
27     * Load wp-build generated files if available.
28     * This is for the new DataViews-based responses list.
29     */
30    public static function load_wp_build() {
31        // Always load for the standalone Forms page.
32        $should_load = self::get_admin_query_page() === self::FORMS_WPBUILD_ADMIN_SLUG;
33
34        /**
35         * Filter whether to load the wp-build asset registrations.
36         * Host applications (e.g., CIAB) can return true to opt in.
37         *
38         * @param bool $should_load Whether build.php should be loaded.
39         */
40        $should_load = apply_filters( 'jetpack_forms_load_wp_build', $should_load );
41
42        if ( ! $should_load ) {
43            return;
44        }
45
46        $wp_build_index = dirname( __DIR__, 2 ) . '/build/build.php';
47
48        if ( file_exists( $wp_build_index ) ) {
49            require_once $wp_build_index;
50        }
51
52        // The remaining setup only applies to the standalone Forms page.
53        if ( self::get_admin_query_page() !== self::FORMS_WPBUILD_ADMIN_SLUG ) {
54            return;
55        }
56
57        // When no route path is specified, redirect to the default view
58        // so the client-side router doesn't need a catch-all root route.
59        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
60        if ( ! isset( $_GET['p'] ) ) {
61            $default_tab = Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' )
62                ? 'forms'
63                : 'inbox';
64
65            wp_safe_redirect( self::get_forms_admin_url( $default_tab ) );
66
67            exit;
68        }
69
70        // Register polyfills for WP < 7.0 (must run before enqueue).
71        WP_Build_Polyfills::register(
72            'jetpack-forms',
73            array_merge(
74                WP_Build_Polyfills::SCRIPT_HANDLES,
75                WP_Build_Polyfills::MODULE_IDS
76            )
77        );
78    }
79
80    /**
81     * Fix import map ordering for the wp-build boot script.
82     *
83     * In wp-admin, _wp_footer_scripts (classic scripts) and print_import_map
84     * both hook into admin_print_footer_scripts at priority 10, but
85     * _wp_footer_scripts is registered first. This causes the inline
86     * import("@wordpress/boot") to execute before the import map exists.
87     *
88     * This fix moves the import() call from the classic inline script to a
89     * <script type="module"> printed at priority 20 (after the import map).
90     *
91     * @todo Remove once @wordpress/build ships with the loader.js fix upstream
92     *       (WordPress/gutenberg#76870) and Jetpack updates the dependency.
93     */
94    public static function fix_boot_import_map_ordering() {
95        $handle = self::FORMS_WPBUILD_ADMIN_SLUG . '-prerequisites';
96
97        add_action(
98            'admin_enqueue_scripts',
99            static function () use ( $handle ) {
100                if ( ! Dashboard::is_jetpack_forms_admin_page() ) {
101                    return;
102                }
103
104                $data = wp_scripts()->get_data( $handle, 'after' );
105                if ( empty( $data ) ) {
106                    return;
107                }
108
109                // Find and extract the import("@wordpress/boot") inline script.
110                $boot_script = null;
111                $remaining   = array();
112                foreach ( $data as $line ) {
113                    if ( strpos( $line, '@wordpress/boot' ) !== false ) {
114                        $boot_script = $line;
115                    } else {
116                        $remaining[] = $line;
117                    }
118                }
119
120                if ( $boot_script === null ) {
121                    return;
122                }
123
124                // Remove from the classic script handle.
125                wp_scripts()->add_data( $handle, 'after', $remaining );
126
127                // Re-emit as a module script after the import map.
128                add_action(
129                    'admin_print_footer_scripts',
130                    static function () use ( $boot_script ) {
131                        wp_print_inline_script_tag( $boot_script, array( 'type' => 'module' ) );
132                    },
133                    20
134                );
135            },
136            PHP_INT_MAX
137        );
138    }
139
140    /**
141     * Script handle for the JS file we enqueue in the Feedback admin page.
142     *
143     * @var string
144     */
145    const SCRIPT_HANDLE = 'jp-forms-dashboard';
146
147    const ADMIN_SLUG = 'jetpack-forms-admin';
148
149    /**
150     * Slug for the wp-admin integrated Responses UI (wp-build page).
151     *
152     * Note: This must be a valid submenu slug (sanitize_key compatible), not a full URL.
153     *
154     * @var string
155     */
156    const FORMS_WPBUILD_ADMIN_SLUG = 'jetpack-forms-responses-wp-admin';
157
158    /**
159     * Priority for the dashboard menu.
160     * Needs to be high enough for us to be able to unregister the default edit.php menu item.
161     *
162     * @var int
163     */
164    const MENU_PRIORITY = 999;
165
166    /**
167     * Initialize the dashboard.
168     */
169    public function init() {
170        add_action( 'admin_menu', array( $this, 'add_admin_submenu' ), self::MENU_PRIORITY );
171        add_action( 'admin_menu', array( __CLASS__, 'redirect_dashboard_url_cross_variant' ), 1 );
172
173        /**
174         * Filter to enable or disable the wp-build-based Forms dashboard.
175         *
176         * Enabled by default since Central Forms Management is now available for all sites.
177         * Can be disabled by returning false from this filter.
178         *
179         * @since 7.18.0
180         *
181         * @param bool $enabled Whether the wp-build dashboard is enabled. Default true.
182         */
183        $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
184
185        if ( $is_wp_build_enabled ) {
186            self::load_wp_build();
187            self::fix_boot_import_map_ordering();
188        }
189
190        add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
191
192        // Removed all admin notices on the Jetpack Forms admin page.
193        if ( self::get_admin_query_page() === self::ADMIN_SLUG ) {
194            remove_all_actions( 'admin_notices' );
195        }
196    }
197
198    /**
199     * Redirect dashboard URLs when the wp-build flag has changed since the link was generated.
200     *
201     * Email links may point to the legacy or wp-build dashboard. If the flag has toggled,
202     * the requested page may not exist. This redirects to the correct variant.
203     */
204    public static function redirect_dashboard_url_cross_variant() {
205        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
206        $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
207
208        if ( $page !== self::ADMIN_SLUG && $page !== self::FORMS_WPBUILD_ADMIN_SLUG ) {
209            return;
210        }
211
212        /** This filter is documented in class-dashboard.php::init */
213        $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
214
215        // Legacy URL requested but wp-build is now active â†’ redirect to wp-build.
216        if ( $page === self::ADMIN_SLUG && $is_wp_build_enabled ) {
217            // The hash is never sent to the server. "inbox" used as default tab so we end up specifically in the responses
218            // route, where the client-side router will handle the redirect to the correct status in its beforeLoad hook.
219            $redirect = self::get_forms_admin_url( 'inbox' );
220            wp_safe_redirect( $redirect );
221            exit;
222        }
223
224        // WP-Build URL requested but legacy is now active â†’ redirect to legacy.
225        if ( $page === self::FORMS_WPBUILD_ADMIN_SLUG && ! $is_wp_build_enabled ) {
226            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
227            $p                = isset( $_GET['p'] ) ? rawurldecode( sanitize_text_field( wp_unslash( $_GET['p'] ) ) ) : '';
228            $tab              = 'inbox';
229            $post_id          = null;
230            $has_mark_as_spam = false;
231
232            // Check if mark_as_spam is a separate query parameter (old email format).
233            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
234            if ( isset( $_GET['mark_as_spam'] ) ) {
235                $has_mark_as_spam = true;
236            }
237
238            if ( $p !== '' ) {
239                // Parse path like /responses/inbox?responseIds=["2879"] or /responses/inbox?responseIds=["2879"]&mark_as_spam or /forms.
240                if ( preg_match( '#^/responses/(inbox|spam|trash)(?:\?responseIds=\["(\d+)"\])?(.*)$#', $p, $m ) ) {
241                    $tab     = $m[1];
242                    $post_id = ! empty( $m[2] ) ? absint( $m[2] ) : null;
243
244                    // Check if mark_as_spam parameter is present inside the path.
245                    if ( ! empty( $m[3] ) && strpos( $m[3], 'mark_as_spam' ) !== false ) {
246                        $has_mark_as_spam = true;
247                    }
248                } elseif ( preg_match( '#^/forms#', $p ) ) {
249                    $tab = 'forms';
250                }
251            }
252
253            $redirect = self::get_forms_admin_url( $tab, $post_id );
254
255            // Add mark_as_spam parameter if it was present in the original URL (either format).
256            if ( $has_mark_as_spam ) {
257                $redirect .= '&mark_as_spam';
258            }
259
260            wp_safe_redirect( $redirect );
261            exit;
262        }
263    }
264
265    /**
266     * Get the current query 'page' parameter.
267     *
268     * @return string
269     */
270    private static function get_admin_query_page() {
271        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
272        return isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
273    }
274
275    /**
276     * Load JavaScript for the dashboard.
277     */
278    public function load_admin_scripts() {
279        if ( ! self::is_jetpack_forms_admin_page() ) {
280            return;
281        }
282
283        // The wp-build (script-module) dashboard renders its own UI from build/pages/…,
284        // so the legacy SPA bundle is dead weight there. Only enqueue it on the legacy
285        // dashboard. The shared inline data below (connection initial state + REST
286        // preload) is instead attached to the always-present wp-api-fetch handle so the
287        // wp-build app still receives it.
288        if ( self::is_wp_build_dashboard_page() ) {
289            $inline_handle    = 'wp-api-fetch';
290            $preload_position = 'after';
291        } else {
292            $inline_handle    = self::SCRIPT_HANDLE;
293            $preload_position = 'before';
294
295            Assets::register_script(
296                self::SCRIPT_HANDLE,
297                '../../dist/dashboard/jetpack-forms-dashboard.js',
298                __FILE__,
299                array(
300                    'in_footer'  => true,
301                    'textdomain' => 'jetpack-forms',
302                    'enqueue'    => true,
303                )
304            );
305        }
306
307        if ( Contact_Form_Plugin::can_use_analytics() ) {
308            Tracking::register_tracks_functions_scripts( true );
309        }
310
311        // Adds Connection package initial state.
312        Connection_Initial_State::render_script( $inline_handle );
313
314        // Preload Forms endpoints needed in dashboard context.
315        // Pre-fetch the first inbox page so the UI renders instantly on first load.
316        $preload_params = array(
317            'context'       => 'edit',
318            'fields_format' => 'collection',
319            'order'         => 'desc',
320            'orderby'       => 'date',
321            'page'          => 1,
322            'per_page'      => 20,
323            'status'        => 'draft,publish',
324        );
325        \ksort( $preload_params );
326        $initial_responses_path        = \add_query_arg( $preload_params, '/wp/v2/feedback' );
327        $initial_responses_locale_path = \add_query_arg(
328            \array_merge(
329                $preload_params,
330                array( '_locale' => 'user' )
331            ),
332            '/wp/v2/feedback'
333        );
334        $filters_path                  = '/wp/v2/feedback/filters';
335        $filters_locale_path           = \add_query_arg( array( '_locale' => 'user' ), $filters_path );
336        $preload_paths                 = array(
337            '/wp/v2/types?context=view',
338            '/wp/v2/feedback/config',
339            '/wp/v2/feedback/integrations-metadata',
340            '/wp/v2/feedback/counts',
341            $filters_path,
342            $filters_locale_path,
343            $initial_responses_path,
344            $initial_responses_locale_path,
345        );
346
347        // Only preload the Forms list endpoint when centralized form management is enabled.
348        if ( Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' ) ) {
349            $forms_preload_params = array(
350                'context'               => 'edit',
351                'page'                  => 1,
352                'jetpack_forms_context' => 'dashboard',
353                'order'                 => 'desc',
354                'orderby'               => 'modified',
355                'per_page'              => 20,
356                'status'                => 'publish,draft,pending,future,private',
357            );
358            ksort( $forms_preload_params );
359            $preload_paths[] = add_query_arg( $forms_preload_params, '/wp/v2/jetpack-forms' );
360            $preload_paths[] = add_query_arg(
361                array_merge(
362                    $forms_preload_params,
363                    array( '_locale' => 'user' )
364                ),
365                '/wp/v2/jetpack-forms'
366            );
367            $preload_paths[] = '/wp/v2/jetpack-forms/status-counts';
368            $preload_paths[] = add_query_arg( array( '_locale' => 'user' ), '/wp/v2/jetpack-forms/status-counts' );
369        }
370        $preload_data_raw = array_reduce( $preload_paths, 'rest_preload_api_request', array() );
371
372        // Normalize keys to match what apiFetch will request (without domain).
373        $preload_data = array();
374        foreach ( $preload_data_raw as $key => $value ) {
375            $normalized_key                  = preg_replace( '#^https?://[^/]+/wp-json#', '', $key );
376            $preload_data[ $normalized_key ] = $value;
377        }
378
379        wp_add_inline_script(
380            $inline_handle,
381            sprintf(
382                'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
383                wp_json_encode( $preload_data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
384            ),
385            $preload_position
386        );
387    }
388
389    /**
390     * Whether the current request targets the wp-build (script-module) Forms dashboard,
391     * as opposed to the legacy SPA dashboard.
392     *
393     * When true, the legacy dashboard bundle should not be enqueued: the wp-build page
394     * (build/pages/jetpack-forms-responses/…) provides its own UI and asset loading.
395     *
396     * @return bool
397     */
398    public static function is_wp_build_dashboard_page() {
399        /** This filter is documented in class-dashboard.php::init */
400        return apply_filters( 'jetpack_forms_alpha', true )
401            && self::get_admin_query_page() === self::FORMS_WPBUILD_ADMIN_SLUG;
402    }
403
404    /**
405     * Register the dashboard admin submenu Forms under Jetpack menu.
406     */
407    public function add_admin_submenu() {
408
409        /** This filter is documented in class-dashboard.php::init */
410        if ( apply_filters( 'jetpack_forms_alpha', true ) ) {
411
412            // `jetpack_forms_jetpack_forms_responses_wp_admin_render_page` is the callback generated by WP build script.
413            $callback = function_exists( 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page' )
414                ? 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page'
415                : array( $this, 'render_dashboard' );
416
417            Admin_Menu::add_menu(
418                /** "Jetpack Forms" and "Forms" are product names, do not translate. */
419                'Jetpack Forms',
420                'Forms',
421                'edit_pages',
422                self::FORMS_WPBUILD_ADMIN_SLUG,
423                $callback,
424                10
425            );
426
427            return;
428        }
429
430        Admin_Menu::add_menu(
431            /** "Jetpack Forms" and "Forms" are Product names, do not translate. */
432            'Jetpack Forms',
433            'Forms',
434            'edit_pages',
435            self::ADMIN_SLUG,
436            array( $this, 'render_dashboard' ),
437            10
438        );
439    }
440
441    /**
442     * Render the dashboard.
443     */
444    public function render_dashboard() {
445        ?>
446        <div id="jp-forms-dashboard"></div>
447        <?php
448    }
449
450    /**
451     * Returns true if there are any feedback posts on the site.
452     *
453     * @return boolean
454     */
455    public function has_feedback() {
456        $posts = new \WP_Query(
457            array(
458                'post_type'              => 'feedback',
459                'post_status'            => array( 'publish', 'draft', 'spam', 'trash' ),
460                'posts_per_page'         => 1,
461                'fields'                 => 'ids',
462                'no_found_rows'          => true,
463                'update_post_meta_cache' => false,
464                'update_post_term_cache' => false,
465                'suppress_filters'       => true,
466            )
467        );
468        return $posts->have_posts();
469    }
470
471    /**
472     * Option name for storing classic forms state.
473     */
474    const CLASSIC_FORMS_OPTION = 'jetpack_forms_classic_state';
475
476    /**
477     * Classic forms state: site has classic (non-synced) form submissions.
478     */
479    const CLASSIC_FORMS_STATE_CLASSIC = 'classic';
480
481    /**
482     * Classic forms state: no classic form submissions detected.
483     */
484    const CLASSIC_FORMS_STATE_HIDDEN = 'hidden';
485
486    /**
487     * Classic forms state: user dismissed the classic forms notice.
488     */
489    const CLASSIC_FORMS_STATE_DISMISSED = 'dismissed';
490
491    /**
492     * Returns the classic forms state for the current site.
493     *
494     * Returns 'classic' if the site has form submissions (feedback posts) that were not
495     * created by a synced/reusable jetpack_form, 'dismissed' if the user dismissed the
496     * classic forms notice, or 'hidden' otherwise.
497     *
498     * The result is persisted in a WP option so the detection query only runs once per site.
499     * After that, the cached value is returned on every subsequent call. The cache is also
500     * updated eagerly via mark_classic_form_detected() when new classic submissions arrive.
501     *
502     * @since 7.14.0
503     *
504     * @return string 'classic', 'hidden', or 'dismissed'.
505     */
506    public function get_classic_forms_state() {
507        $state = get_option( self::CLASSIC_FORMS_OPTION );
508
509        if ( $state ) {
510            return $state;
511        }
512
513        $state = $this->detect_classic_forms();
514        update_option( self::CLASSIC_FORMS_OPTION, $state, false );
515
516        return $state;
517    }
518
519    /**
520     * Detects whether any feedback posts exist that are not linked to a jetpack_form post,
521     * indicating the site has classic (inline, widget, or template) forms.
522     *
523     * A feedback post is considered "classic" if:
524     * - It has no parent (post_parent = 0), meaning it was created by a form embedded in a
525     *   widget, page template, or other non-post context.
526     * - Its parent exists but is not a jetpack_form post, meaning it was created by a form
527     *   block or shortcode placed directly in a post or page.
528     *
529     * The query uses a LEFT JOIN on the posts table to find feedback posts with no matching
530     * jetpack_form parent. This leverages the primary key index for the join and the
531     * type_status_date index for filtering by post_type, making it efficient even on large
532     * sites. The LIMIT 1 ensures early exit as soon as one classic form is found.
533     *
534     * Note: An alternative approach would be to search post_content for the form block markup
535     * (<!-- wp:jetpack/contact-form) or shortcode ([contact-form]). However, that requires a
536     * full-text scan of the posts table (LIKE '%...%' on a TEXT column) with no usable index,
537     * making it significantly more expensive. The feedback-based approach also better fits the
538     * use case: we only need to surface the "Not seeing all your forms?" prompt when there are
539     * actual submissions that won't appear under any synced form in the dashboard.
540     *
541     * @since 7.14.0
542     *
543     * @return string 'classic' or 'hidden'.
544     */
545    private function detect_classic_forms() {
546        global $wpdb;
547
548        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
549        $result = $wpdb->get_var(
550            $wpdb->prepare(
551                "SELECT 1 FROM {$wpdb->posts} AS f
552                LEFT JOIN {$wpdb->posts} AS p
553                    ON p.ID = f.post_parent AND p.post_type = %s
554                WHERE f.post_type = 'feedback'
555                AND p.ID IS NULL
556                LIMIT 1",
557                Contact_Form::POST_TYPE
558            )
559        );
560
561        return $result ? self::CLASSIC_FORMS_STATE_CLASSIC : self::CLASSIC_FORMS_STATE_HIDDEN;
562    }
563
564    /**
565     * Eagerly marks the site as having classic forms by setting the option to 'classic'.
566     *
567     * Called when a new form submission is saved that does not belong to a synced jetpack_form.
568     * This avoids re-running the detection query â€” once a classic submission is observed, the
569     * state is permanently set without needing to scan the database again.
570     *
571     * If the user has already dismissed the classic forms notice, the state is left as
572     * 'dismissed' so the notice does not reappear.
573     *
574     * @since 7.14.0
575     */
576    public static function mark_classic_form_detected() {
577        $current = get_option( self::CLASSIC_FORMS_OPTION );
578
579        if ( self::CLASSIC_FORMS_STATE_DISMISSED === $current ) {
580            return;
581        }
582
583        update_option( self::CLASSIC_FORMS_OPTION, self::CLASSIC_FORMS_STATE_CLASSIC, false );
584    }
585
586    /**
587     * Returns url of forms admin page.
588     *
589     * @param string|null $tab Tab to open in the forms admin page.
590     * @param int|null    $post_id Post ID of response to open in the forms responses page.
591     *
592     * @return string
593     */
594    public static function get_forms_admin_url( $tab = null, $post_id = null ) {
595        /** This filter is documented in class-dashboard.php::init */
596        $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
597        $url                 = admin_url( 'admin.php' );
598
599        $url .= $is_wp_build_enabled
600            ? '?page=' . self::FORMS_WPBUILD_ADMIN_SLUG
601            : '?page=' . self::ADMIN_SLUG;
602
603        if ( $is_wp_build_enabled ) {
604            $path = self::get_forms_admin_path_wp_build( $tab, $post_id );
605            $url .= '&p=' . rawurlencode( $path );
606        } else {
607            $suffix = self::get_forms_admin_suffix_legacy( $tab, $post_id );
608
609            if ( $suffix !== '' ) {
610                $url .= $suffix;
611            }
612        }
613
614        /**
615         * Filters the Forms admin page URL.
616         *
617         * @module contact-form
618         * @since 7.8.0
619         *
620         * @param string      $url The Forms admin page URL.
621         * @param string|null $tab Tab to open in the forms admin page.
622         * @param int|null $post_id Post ID of response to open in the forms responses page.
623         *
624         * @return string The filtered Forms admin page URL.
625         */
626        return apply_filters( 'jetpack_forms_admin_url', $url, $tab, $post_id );
627    }
628
629    /**
630     * WP-Build path for the forms admin URL.
631     *
632     * @param string|null $tab    Tab to open.
633     * @param int|null    $post_id Post ID of response.
634     * @return string URL path (e.g. '/', '/responses/inbox', '/forms').
635     */
636    private static function get_forms_admin_path_wp_build( $tab, $post_id ) {
637        $post_id      = ! empty( $post_id ) ? absint( $post_id ) : null;
638        $response_ids = ! empty( $post_id ) ? '?responseIds=["' . $post_id . '"]' : '';
639
640        $path_map = array(
641            'inbox'           => '/responses/inbox',
642            'spam'            => '/responses/spam',
643            'trash'           => '/responses/trash',
644            'forms'           => '/forms',
645            'responses/inbox' => '/responses/inbox',
646        );
647
648        if ( $tab !== null && $tab !== '' && isset( $path_map[ $tab ] ) ) {
649            return $path_map[ $tab ] . $response_ids;
650        }
651
652        if ( ! empty( $post_id ) ) {
653            return '/responses/inbox?responseIds=["' . $post_id . '"]';
654        }
655
656        return '/responses/inbox';
657    }
658
659    /**
660     * Legacy (hash-based) URL suffix for the forms admin page.
661     *
662     * @param string|null $tab    Tab to open.
663     * @param int|null    $post_id Post ID of response.
664     * @return string URL suffix (e.g. '#/responses?status=inbox&r=123', or '#/forms').
665     */
666    private static function get_forms_admin_suffix_legacy( $tab, $post_id ) {
667        $post_id    = ! empty( $post_id ) ? absint( $post_id ) : null;
668        $valid_tabs = array( 'spam', 'inbox', 'trash' );
669        $r_param    = ! empty( $post_id ) ? '&r=' . $post_id : '';
670
671        if ( in_array( $tab, $valid_tabs, true ) ) {
672            return '#/responses?status=' . $tab . $r_param;
673        }
674
675        if ( $tab === 'forms' ) {
676            return '#/forms';
677        }
678
679        if ( ! empty( $post_id ) ) {
680            return '#/responses?status=inbox' . $r_param;
681        }
682
683        return '';
684    }
685
686    /**
687     * Returns true if the current screen is the Jetpack Forms admin page.
688     *
689     * @return boolean
690     */
691    public static function is_jetpack_forms_admin_page() {
692        if ( ! function_exists( 'get_current_screen' ) ) {
693            return false;
694        }
695
696        $screen = get_current_screen();
697
698        if ( ! $screen || ! isset( $screen->id ) ) {
699            return false;
700        }
701
702        $forms_admin_screens = array(
703            'jetpack_page_' . self::ADMIN_SLUG,
704            'jetpack_page_' . self::FORMS_WPBUILD_ADMIN_SLUG,
705        );
706
707        return in_array( $screen->id, $forms_admin_screens, true );
708    }
709
710    /**
711     * Returns true if form notes feature is enabled.
712     *
713     * @return boolean
714     */
715    public static function is_notes_enabled() {
716        /**
717        * Enable form notes feature in Jetpack Forms .
718        *
719        * @module contact-form
720        * @since 7.3.0
721        *
722        * @param bool $enabled Should the form notes feature be enabled? Defaults to false.
723        */
724        return apply_filters( 'jetpack_forms_notes_enable', false );
725    }
726
727    /**
728     * Get admin URL for given screen ID.
729     *
730     * @deprecated 7.9.0 Use Dashboard::get_forms_admin_url() instead.
731     *
732     * @param string $screen_id Screen ID.
733     * @return string Admin URL.
734     */
735    public static function get_admin_url( $screen_id ) {
736        _deprecated_function( __METHOD__, 'jetpack-7.9.0', 'Dashboard::get_forms_admin_url' );
737
738        if ( 'edit-jetpack_form' === $screen_id ) {
739            return self::get_forms_admin_url( 'forms' );
740        }
741
742        if ( 'edit-feedback' === $screen_id ) {
743            return self::get_forms_admin_url( 'inbox' );
744        }
745
746        return self::get_forms_admin_url();
747    }
748}