Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.50% covered (danger)
32.50%
91 / 280
55.56% covered (warning)
55.56%
10 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard
32.73% covered (danger)
32.73%
91 / 278
55.56% covered (warning)
55.56%
10 / 18
1978.52
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 / 86
0.00% covered (danger)
0.00%
0 / 1
56
 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
292            // The i18n loader is registered on every admin page by jetpack-assets but
293            // only enqueued when depended on; the esbuild bundles don't pull it in.
294            // Enqueue it so the wp-build dashboard's init module can download its JS
295            // translation catalogs.
296            if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
297                wp_enqueue_script( 'wp-jp-i18n-loader' );
298            }
299        } else {
300            $inline_handle    = self::SCRIPT_HANDLE;
301            $preload_position = 'before';
302
303            Assets::register_script(
304                self::SCRIPT_HANDLE,
305                '../../dist/dashboard/jetpack-forms-dashboard.js',
306                __FILE__,
307                array(
308                    'in_footer'  => true,
309                    'textdomain' => 'jetpack-forms',
310                    'enqueue'    => true,
311                )
312            );
313        }
314
315        if ( Contact_Form_Plugin::can_use_analytics() ) {
316            Tracking::register_tracks_functions_scripts( true );
317        }
318
319        // Adds Connection package initial state.
320        Connection_Initial_State::render_script( $inline_handle );
321
322        // Preload Forms endpoints needed in dashboard context.
323        // Pre-fetch the first inbox page so the UI renders instantly on first load.
324        $preload_params = array(
325            'context'       => 'edit',
326            'fields_format' => 'collection',
327            'order'         => 'desc',
328            'orderby'       => 'date',
329            'page'          => 1,
330            'per_page'      => 20,
331            'status'        => 'draft,publish',
332        );
333        \ksort( $preload_params );
334        $initial_responses_path        = \add_query_arg( $preload_params, '/wp/v2/feedback' );
335        $initial_responses_locale_path = \add_query_arg(
336            \array_merge(
337                $preload_params,
338                array( '_locale' => 'user' )
339            ),
340            '/wp/v2/feedback'
341        );
342        $filters_path                  = '/wp/v2/feedback/filters';
343        $filters_locale_path           = \add_query_arg( array( '_locale' => 'user' ), $filters_path );
344        $preload_paths                 = array(
345            '/wp/v2/types?context=view',
346            '/wp/v2/feedback/config',
347            '/wp/v2/feedback/integrations-metadata',
348            '/wp/v2/feedback/counts',
349            $filters_path,
350            $filters_locale_path,
351            $initial_responses_path,
352            $initial_responses_locale_path,
353        );
354
355        // Only preload the Forms list endpoint when centralized form management is enabled.
356        if ( Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' ) ) {
357            $forms_preload_params = array(
358                'context'               => 'edit',
359                'page'                  => 1,
360                'jetpack_forms_context' => 'dashboard',
361                'order'                 => 'desc',
362                'orderby'               => 'modified',
363                'per_page'              => 20,
364                'status'                => 'publish,draft,pending,future,private',
365            );
366            ksort( $forms_preload_params );
367            $preload_paths[] = add_query_arg( $forms_preload_params, '/wp/v2/jetpack-forms' );
368            $preload_paths[] = add_query_arg(
369                array_merge(
370                    $forms_preload_params,
371                    array( '_locale' => 'user' )
372                ),
373                '/wp/v2/jetpack-forms'
374            );
375            $preload_paths[] = '/wp/v2/jetpack-forms/status-counts';
376            $preload_paths[] = add_query_arg( array( '_locale' => 'user' ), '/wp/v2/jetpack-forms/status-counts' );
377        }
378        $preload_data_raw = array_reduce( $preload_paths, 'rest_preload_api_request', array() );
379
380        // Normalize keys to match what apiFetch will request (without domain).
381        $preload_data = array();
382        foreach ( $preload_data_raw as $key => $value ) {
383            $normalized_key                  = preg_replace( '#^https?://[^/]+/wp-json#', '', $key );
384            $preload_data[ $normalized_key ] = $value;
385        }
386
387        wp_add_inline_script(
388            $inline_handle,
389            sprintf(
390                'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
391                wp_json_encode( $preload_data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
392            ),
393            $preload_position
394        );
395    }
396
397    /**
398     * Whether the current request targets the wp-build (script-module) Forms dashboard,
399     * as opposed to the legacy SPA dashboard.
400     *
401     * When true, the legacy dashboard bundle should not be enqueued: the wp-build page
402     * (build/pages/jetpack-forms-responses/…) provides its own UI and asset loading.
403     *
404     * @return bool
405     */
406    public static function is_wp_build_dashboard_page() {
407        /** This filter is documented in class-dashboard.php::init */
408        return apply_filters( 'jetpack_forms_alpha', true )
409            && self::get_admin_query_page() === self::FORMS_WPBUILD_ADMIN_SLUG;
410    }
411
412    /**
413     * Register the dashboard admin submenu Forms under Jetpack menu.
414     */
415    public function add_admin_submenu() {
416
417        /** This filter is documented in class-dashboard.php::init */
418        if ( apply_filters( 'jetpack_forms_alpha', true ) ) {
419
420            // `jetpack_forms_jetpack_forms_responses_wp_admin_render_page` is the callback generated by WP build script.
421            $callback = function_exists( 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page' )
422                ? 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page'
423                : array( $this, 'render_dashboard' );
424
425            Admin_Menu::add_menu(
426                /** "Jetpack Forms" and "Forms" are product names, do not translate. */
427                'Jetpack Forms',
428                'Forms',
429                'edit_pages',
430                self::FORMS_WPBUILD_ADMIN_SLUG,
431                $callback,
432                10
433            );
434
435            return;
436        }
437
438        Admin_Menu::add_menu(
439            /** "Jetpack Forms" and "Forms" are Product names, do not translate. */
440            'Jetpack Forms',
441            'Forms',
442            'edit_pages',
443            self::ADMIN_SLUG,
444            array( $this, 'render_dashboard' ),
445            10
446        );
447    }
448
449    /**
450     * Render the dashboard.
451     */
452    public function render_dashboard() {
453        ?>
454        <div id="jp-forms-dashboard"></div>
455        <?php
456    }
457
458    /**
459     * Returns true if there are any feedback posts on the site.
460     *
461     * @return boolean
462     */
463    public function has_feedback() {
464        $posts = new \WP_Query(
465            array(
466                'post_type'              => 'feedback',
467                'post_status'            => array( 'publish', 'draft', 'spam', 'trash' ),
468                'posts_per_page'         => 1,
469                'fields'                 => 'ids',
470                'no_found_rows'          => true,
471                'update_post_meta_cache' => false,
472                'update_post_term_cache' => false,
473                'suppress_filters'       => true,
474            )
475        );
476        return $posts->have_posts();
477    }
478
479    /**
480     * Option name for storing classic forms state.
481     */
482    const CLASSIC_FORMS_OPTION = 'jetpack_forms_classic_state';
483
484    /**
485     * Classic forms state: site has classic (non-synced) form submissions.
486     */
487    const CLASSIC_FORMS_STATE_CLASSIC = 'classic';
488
489    /**
490     * Classic forms state: no classic form submissions detected.
491     */
492    const CLASSIC_FORMS_STATE_HIDDEN = 'hidden';
493
494    /**
495     * Classic forms state: user dismissed the classic forms notice.
496     */
497    const CLASSIC_FORMS_STATE_DISMISSED = 'dismissed';
498
499    /**
500     * Returns the classic forms state for the current site.
501     *
502     * Returns 'classic' if the site has form submissions (feedback posts) that were not
503     * created by a synced/reusable jetpack_form, 'dismissed' if the user dismissed the
504     * classic forms notice, or 'hidden' otherwise.
505     *
506     * The result is persisted in a WP option so the detection query only runs once per site.
507     * After that, the cached value is returned on every subsequent call. The cache is also
508     * updated eagerly via mark_classic_form_detected() when new classic submissions arrive.
509     *
510     * @since 7.14.0
511     *
512     * @return string 'classic', 'hidden', or 'dismissed'.
513     */
514    public function get_classic_forms_state() {
515        $state = get_option( self::CLASSIC_FORMS_OPTION );
516
517        if ( $state ) {
518            return $state;
519        }
520
521        $state = $this->detect_classic_forms();
522        update_option( self::CLASSIC_FORMS_OPTION, $state, false );
523
524        return $state;
525    }
526
527    /**
528     * Detects whether any feedback posts exist that are not linked to a jetpack_form post,
529     * indicating the site has classic (inline, widget, or template) forms.
530     *
531     * A feedback post is considered "classic" if:
532     * - It has no parent (post_parent = 0), meaning it was created by a form embedded in a
533     *   widget, page template, or other non-post context.
534     * - Its parent exists but is not a jetpack_form post, meaning it was created by a form
535     *   block or shortcode placed directly in a post or page.
536     *
537     * The query uses a LEFT JOIN on the posts table to find feedback posts with no matching
538     * jetpack_form parent. This leverages the primary key index for the join and the
539     * type_status_date index for filtering by post_type, making it efficient even on large
540     * sites. The LIMIT 1 ensures early exit as soon as one classic form is found.
541     *
542     * Note: An alternative approach would be to search post_content for the form block markup
543     * (<!-- wp:jetpack/contact-form) or shortcode ([contact-form]). However, that requires a
544     * full-text scan of the posts table (LIKE '%...%' on a TEXT column) with no usable index,
545     * making it significantly more expensive. The feedback-based approach also better fits the
546     * use case: we only need to surface the "Not seeing all your forms?" prompt when there are
547     * actual submissions that won't appear under any synced form in the dashboard.
548     *
549     * @since 7.14.0
550     *
551     * @return string 'classic' or 'hidden'.
552     */
553    private function detect_classic_forms() {
554        global $wpdb;
555
556        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
557        $result = $wpdb->get_var(
558            $wpdb->prepare(
559                "SELECT 1 FROM {$wpdb->posts} AS f
560                LEFT JOIN {$wpdb->posts} AS p
561                    ON p.ID = f.post_parent AND p.post_type = %s
562                WHERE f.post_type = 'feedback'
563                AND p.ID IS NULL
564                LIMIT 1",
565                Contact_Form::POST_TYPE
566            )
567        );
568
569        return $result ? self::CLASSIC_FORMS_STATE_CLASSIC : self::CLASSIC_FORMS_STATE_HIDDEN;
570    }
571
572    /**
573     * Eagerly marks the site as having classic forms by setting the option to 'classic'.
574     *
575     * Called when a new form submission is saved that does not belong to a synced jetpack_form.
576     * This avoids re-running the detection query â€” once a classic submission is observed, the
577     * state is permanently set without needing to scan the database again.
578     *
579     * If the user has already dismissed the classic forms notice, the state is left as
580     * 'dismissed' so the notice does not reappear.
581     *
582     * @since 7.14.0
583     */
584    public static function mark_classic_form_detected() {
585        $current = get_option( self::CLASSIC_FORMS_OPTION );
586
587        if ( self::CLASSIC_FORMS_STATE_DISMISSED === $current ) {
588            return;
589        }
590
591        update_option( self::CLASSIC_FORMS_OPTION, self::CLASSIC_FORMS_STATE_CLASSIC, false );
592    }
593
594    /**
595     * Returns url of forms admin page.
596     *
597     * @param string|null $tab Tab to open in the forms admin page.
598     * @param int|null    $post_id Post ID of response to open in the forms responses page.
599     *
600     * @return string
601     */
602    public static function get_forms_admin_url( $tab = null, $post_id = null ) {
603        /** This filter is documented in class-dashboard.php::init */
604        $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
605        $url                 = admin_url( 'admin.php' );
606
607        $url .= $is_wp_build_enabled
608            ? '?page=' . self::FORMS_WPBUILD_ADMIN_SLUG
609            : '?page=' . self::ADMIN_SLUG;
610
611        if ( $is_wp_build_enabled ) {
612            $path = self::get_forms_admin_path_wp_build( $tab, $post_id );
613            $url .= '&p=' . rawurlencode( $path );
614        } else {
615            $suffix = self::get_forms_admin_suffix_legacy( $tab, $post_id );
616
617            if ( $suffix !== '' ) {
618                $url .= $suffix;
619            }
620        }
621
622        /**
623         * Filters the Forms admin page URL.
624         *
625         * @module contact-form
626         * @since 7.8.0
627         *
628         * @param string      $url The Forms admin page URL.
629         * @param string|null $tab Tab to open in the forms admin page.
630         * @param int|null $post_id Post ID of response to open in the forms responses page.
631         *
632         * @return string The filtered Forms admin page URL.
633         */
634        return apply_filters( 'jetpack_forms_admin_url', $url, $tab, $post_id );
635    }
636
637    /**
638     * WP-Build path for the forms admin URL.
639     *
640     * @param string|null $tab    Tab to open.
641     * @param int|null    $post_id Post ID of response.
642     * @return string URL path (e.g. '/', '/responses/inbox', '/forms').
643     */
644    private static function get_forms_admin_path_wp_build( $tab, $post_id ) {
645        $post_id      = ! empty( $post_id ) ? absint( $post_id ) : null;
646        $response_ids = ! empty( $post_id ) ? '?responseIds=["' . $post_id . '"]' : '';
647
648        $path_map = array(
649            'inbox'           => '/responses/inbox',
650            'spam'            => '/responses/spam',
651            'trash'           => '/responses/trash',
652            'forms'           => '/forms',
653            'responses/inbox' => '/responses/inbox',
654        );
655
656        if ( $tab !== null && $tab !== '' && isset( $path_map[ $tab ] ) ) {
657            return $path_map[ $tab ] . $response_ids;
658        }
659
660        if ( ! empty( $post_id ) ) {
661            return '/responses/inbox?responseIds=["' . $post_id . '"]';
662        }
663
664        return '/responses/inbox';
665    }
666
667    /**
668     * Legacy (hash-based) URL suffix for the forms admin page.
669     *
670     * @param string|null $tab    Tab to open.
671     * @param int|null    $post_id Post ID of response.
672     * @return string URL suffix (e.g. '#/responses?status=inbox&r=123', or '#/forms').
673     */
674    private static function get_forms_admin_suffix_legacy( $tab, $post_id ) {
675        $post_id    = ! empty( $post_id ) ? absint( $post_id ) : null;
676        $valid_tabs = array( 'spam', 'inbox', 'trash' );
677        $r_param    = ! empty( $post_id ) ? '&r=' . $post_id : '';
678
679        if ( in_array( $tab, $valid_tabs, true ) ) {
680            return '#/responses?status=' . $tab . $r_param;
681        }
682
683        if ( $tab === 'forms' ) {
684            return '#/forms';
685        }
686
687        if ( ! empty( $post_id ) ) {
688            return '#/responses?status=inbox' . $r_param;
689        }
690
691        return '';
692    }
693
694    /**
695     * Returns true if the current screen is the Jetpack Forms admin page.
696     *
697     * @return boolean
698     */
699    public static function is_jetpack_forms_admin_page() {
700        if ( ! function_exists( 'get_current_screen' ) ) {
701            return false;
702        }
703
704        $screen = get_current_screen();
705
706        if ( ! $screen || ! isset( $screen->id ) ) {
707            return false;
708        }
709
710        $forms_admin_screens = array(
711            'jetpack_page_' . self::ADMIN_SLUG,
712            'jetpack_page_' . self::FORMS_WPBUILD_ADMIN_SLUG,
713        );
714
715        return in_array( $screen->id, $forms_admin_screens, true );
716    }
717
718    /**
719     * Returns true if form notes feature is enabled.
720     *
721     * @return boolean
722     */
723    public static function is_notes_enabled() {
724        /**
725        * Enable form notes feature in Jetpack Forms .
726        *
727        * @module contact-form
728        * @since 7.3.0
729        *
730        * @param bool $enabled Should the form notes feature be enabled? Defaults to false.
731        */
732        return apply_filters( 'jetpack_forms_notes_enable', false );
733    }
734
735    /**
736     * Get admin URL for given screen ID.
737     *
738     * @deprecated 7.9.0 Use Dashboard::get_forms_admin_url() instead.
739     *
740     * @param string $screen_id Screen ID.
741     * @return string Admin URL.
742     */
743    public static function get_admin_url( $screen_id ) {
744        _deprecated_function( __METHOD__, 'jetpack-7.9.0', 'Dashboard::get_forms_admin_url' );
745
746        if ( 'edit-jetpack_form' === $screen_id ) {
747            return self::get_forms_admin_url( 'forms' );
748        }
749
750        if ( 'edit-feedback' === $screen_id ) {
751            return self::get_forms_admin_url( 'inbox' );
752        }
753
754        return self::get_forms_admin_url();
755    }
756}