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