Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
55.14% covered (warning)
55.14%
118 / 214
71.43% covered (warning)
71.43%
15 / 21
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dashboard
55.66% covered (warning)
55.66%
118 / 212
71.43% covered (warning)
71.43%
15 / 21
385.37
0.00% covered (danger)
0.00%
0 / 1
 load_wp_build
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
4.03
 wp_build_index_path
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 add_admin_shell_member
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 load_as_admin_shell_guest
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 announce_retired_filter
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 redirect_dashboard_url_cross_variant
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 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 / 73
0.00% covered (danger)
0.00%
0 / 1
42
 is_wp_build_dashboard_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_admin_submenu
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 render_dashboard
n/a
0 / 0
n/a
0 / 0
1
 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%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_single_response_admin_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 get_default_tab
n/a
0 / 0
n/a
0 / 0
3
 get_forms_admin_path_wp_build
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
9
 is_jetpack_forms_admin_page
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
4.59
 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\Admin_UI\Admin_Shell;
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        // Register polyfills for WP < 7.0 (must run before enqueue).
58        WP_Build_Polyfills::register(
59            'jetpack-forms',
60            array_merge(
61                WP_Build_Polyfills::SCRIPT_HANDLES,
62                WP_Build_Polyfills::MODULE_IDS
63            )
64        );
65    }
66
67    /**
68     * Overrides the generated entry point path. Test seam, null in production.
69     *
70     * Private with no setter: tests reach it by reflection, so nothing outside the
71     * package can point the dashboard at another file.
72     *
73     * @var string|null
74     */
75    private static $wp_build_index = null;
76
77    /**
78     * Absolute path to the entry point generated by the WP build script.
79     *
80     * @return string
81     */
82    private static function wp_build_index_path() {
83        return self::$wp_build_index ?? dirname( __DIR__, 2 ) . '/build/build.php';
84    }
85
86    /**
87     * Script handle for the JS file we enqueue in the Feedback admin page.
88     *
89     * @var string
90     * @deprecated 8.2.0 The legacy dashboard bundle was removed.
91     */
92    const SCRIPT_HANDLE = 'jp-forms-dashboard';
93
94    const ADMIN_SLUG = 'jetpack-forms-admin';
95
96    /**
97     * Slug for the wp-admin integrated Responses UI (wp-build page).
98     *
99     * Note: This must be a valid submenu slug (sanitize_key compatible), not a full URL.
100     *
101     * @var string
102     */
103    const FORMS_WPBUILD_ADMIN_SLUG = 'jetpack-forms-responses-wp-admin';
104
105    /**
106     * Cookie holding the top tab the user last chose, written by src/dashboard/last-tab-cookie.ts.
107     */
108    const LAST_TAB_COOKIE = 'jetpack_forms_last_tab';
109
110    /**
111     * Priority for the dashboard menu.
112     * Needs to be high enough for us to be able to unregister the default edit.php menu item.
113     *
114     * @var int
115     */
116    const MENU_PRIORITY = 999;
117
118    /**
119     * Initialize the dashboard.
120     */
121    public function init() {
122        add_action( 'admin_menu', array( $this, 'add_admin_submenu' ), self::MENU_PRIORITY );
123        add_action( 'admin_menu', array( __CLASS__, 'redirect_dashboard_url_cross_variant' ), 1 );
124        add_action( 'admin_notices', array( __CLASS__, 'announce_retired_filter' ) );
125
126        self::load_wp_build();
127
128        add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
129
130        if ( class_exists( Admin_Shell::class ) ) {
131            add_filter( 'jetpack_admin_shell_members', array( __CLASS__, 'add_admin_shell_member' ) );
132        }
133    }
134
135    /**
136     * Register Forms with the admin shell, which switches between Jetpack dashboards without a page load.
137     *
138     * @since $$next-version$$
139     *
140     * @param array $members Shell members keyed by admin page slug.
141     * @return array
142     */
143    public static function add_admin_shell_member( $members ) {
144        if ( ! file_exists( self::wp_build_index_path() ) ) {
145            return $members;
146        }
147
148        $members[ self::FORMS_WPBUILD_ADMIN_SLUG ] = array(
149            'build_prefix' => 'jetpack_forms',
150            'build_page'   => 'jetpack-forms-responses',
151            'init_modules' => array( '@jetpack-forms/init' ),
152            /** "Jetpack Forms" is a product name, do not translate. */
153            'title'        => 'Jetpack Forms',
154            'capability'   => 'edit_pages',
155            // The connection initial state and REST preload are inline scripts on wp-api-fetch.
156            'handles'      => array( 'jetpack-forms-layout', 'jetpack-script-data', 'wp-api-fetch', 'wp-jp-i18n-loader', 'jp-tracks-functions' ),
157            'load'         => array( __CLASS__, 'load_as_admin_shell_guest' ),
158        );
159        return $members;
160    }
161
162    /**
163     * Ready the Forms routes while another dashboard's page is open.
164     *
165     * @since $$next-version$$
166     *
167     * @return void
168     */
169    public static function load_as_admin_shell_guest() {
170        $wp_build_index = self::wp_build_index_path();
171        require_once $wp_build_index;
172
173        // The generated registration runs on wp_default_scripts, which has already fired.
174        if ( function_exists( 'jetpack_forms_register_script_modules' ) ) {
175            jetpack_forms_register_script_modules(); // @phan-suppress-current-line PhanUndeclaredFunction -- Defined in the generated build/modules.php.
176        }
177    }
178
179    /**
180     * Tell anyone still filtering `jetpack_forms_alpha` that it no longer does anything.
181     *
182     * The filter gated the wp-build dashboard while it was in development. That dashboard
183     * is now the only one, so a `false` return has nothing left to select and is ignored.
184     *
185     * Announced rather than applied: _deprecated_hook() reports the hook without honoring
186     * it, where apply_filters_deprecated() would return a `false` this code can no longer
187     * act on. Guarded by has_filter() so sites that never used it stay silent.
188     *
189     * Hooked to `admin_notices` rather than called from init(). This class loads at
190     * `after_setup_theme` priority -2, so with WP_DEBUG display on the notice would print
191     * â€” and send headers â€” before load_wp_build() and redirect_dashboard_url_cross_variant()
192     * get to redirect, leaving both on "headers already sent" and a blank page.
193     *
194     * That hook also fires only while an admin screen renders, so the notice stays out of
195     * admin-ajax and admin-post responses, which `is_admin()` would have let through. And
196     * it runs late enough that has_filter() sees callbacks registered on `init`, not just
197     * those added at file scope.
198     *
199     * @since 8.1.0
200     */
201    public static function announce_retired_filter() {
202        if ( ! has_filter( 'jetpack_forms_alpha' ) ) {
203            return;
204        }
205
206        // Kept on one line: replace-next-version-tag.sh only recognizes the token in a
207        // single-line deprecation call, and errors the build out otherwise.
208        _deprecated_hook( 'jetpack_forms_alpha', 'jetpack-forms-8.1.0', '', 'The legacy Forms dashboard has been removed, so this filter no longer selects anything.' );
209    }
210
211    /**
212     * Send legacy dashboard URLs to the wp-build dashboard.
213     *
214     * Load-bearing, not a courtesy for stale links: Creative Mail's JITMs and post-install
215     * redirect, and My Jetpack's fallback URL, still emit the legacy slug today. Keep it.
216     */
217    public static function redirect_dashboard_url_cross_variant() {
218        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
219        $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
220
221        if ( $page !== self::ADMIN_SLUG ) {
222            return;
223        }
224
225        // The hash is never sent to the server. "inbox" used as default tab so we end up specifically in the responses
226        // route, where the client-side router will handle the redirect to the correct status in its beforeLoad hook.
227        wp_safe_redirect( self::get_forms_admin_url( 'inbox' ) );
228        exit;
229    }
230
231    /**
232     * Get the current query 'page' parameter.
233     *
234     * @return string
235     */
236    private static function get_admin_query_page() {
237        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
238        return isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
239    }
240
241    /**
242     * Load JavaScript for the dashboard.
243     */
244    public function load_admin_scripts() {
245        if ( ! self::is_jetpack_forms_admin_page() ) {
246            return;
247        }
248
249        // Attach the shared inline data (connection initial state + REST preload) to
250        // wp-api-fetch, which is always on the page.
251        $inline_handle    = 'wp-api-fetch';
252        $preload_position = 'after';
253
254        // The i18n loader is registered on every admin page by jetpack-assets but
255        // only enqueued when depended on; the esbuild bundles don't pull it in.
256        // Enqueue it so the wp-build dashboard's init module can download its JS
257        // translation catalogs.
258        if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
259            wp_enqueue_script( 'wp-jp-i18n-loader' );
260        }
261
262        if ( Contact_Form_Plugin::can_use_analytics() ) {
263            Tracking::register_tracks_functions_scripts( true );
264        }
265
266        // Adds Connection package initial state.
267        Connection_Initial_State::render_script( $inline_handle );
268
269        // Preload Forms endpoints needed in dashboard context.
270        // Pre-fetch the first inbox page so the UI renders instantly on first load.
271        $preload_params = array(
272            'context'       => 'edit',
273            'fields_format' => 'collection',
274            'order'         => 'desc',
275            'orderby'       => 'date',
276            'page'          => 1,
277            'per_page'      => 20,
278            'status'        => 'draft,publish',
279        );
280        \ksort( $preload_params );
281        $initial_responses_path        = \add_query_arg( $preload_params, '/wp/v2/feedback' );
282        $initial_responses_locale_path = \add_query_arg(
283            \array_merge(
284                $preload_params,
285                array( '_locale' => 'user' )
286            ),
287            '/wp/v2/feedback'
288        );
289        $filters_path                  = '/wp/v2/feedback/filters';
290        $filters_locale_path           = \add_query_arg( array( '_locale' => 'user' ), $filters_path );
291        $preload_paths                 = array(
292            '/wp/v2/types?context=view',
293            '/wp/v2/feedback/config',
294            '/wp/v2/feedback/integrations-metadata',
295            '/wp/v2/feedback/counts',
296            $filters_path,
297            $filters_locale_path,
298            $initial_responses_path,
299            $initial_responses_locale_path,
300        );
301
302        // Only preload the Forms list endpoint when centralized form management is enabled.
303        if ( Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' ) ) {
304            $forms_preload_params = array(
305                'context'               => 'edit',
306                'page'                  => 1,
307                'jetpack_forms_context' => 'dashboard',
308                'order'                 => 'desc',
309                'orderby'               => 'modified',
310                'per_page'              => 20,
311                'status'                => 'publish,draft,pending,future,private',
312            );
313            ksort( $forms_preload_params );
314            $preload_paths[] = add_query_arg( $forms_preload_params, '/wp/v2/jetpack-forms' );
315            $preload_paths[] = add_query_arg(
316                array_merge(
317                    $forms_preload_params,
318                    array( '_locale' => 'user' )
319                ),
320                '/wp/v2/jetpack-forms'
321            );
322            $preload_paths[] = '/wp/v2/jetpack-forms/status-counts';
323            $preload_paths[] = add_query_arg( array( '_locale' => 'user' ), '/wp/v2/jetpack-forms/status-counts' );
324        }
325        $preload_data_raw = array_reduce( $preload_paths, 'rest_preload_api_request', array() );
326
327        // Normalize keys to match what apiFetch will request (without domain).
328        $preload_data = array();
329        foreach ( $preload_data_raw as $key => $value ) {
330            $normalized_key                  = preg_replace( '#^https?://[^/]+/wp-json#', '', $key );
331            $preload_data[ $normalized_key ] = $value;
332        }
333
334        wp_add_inline_script(
335            $inline_handle,
336            sprintf(
337                'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
338                wp_json_encode( $preload_data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
339            ),
340            $preload_position
341        );
342    }
343
344    /**
345     * Whether the current request targets the Forms dashboard page.
346     *
347     * @return bool
348     */
349    public static function is_wp_build_dashboard_page() {
350        return self::get_admin_query_page() === self::FORMS_WPBUILD_ADMIN_SLUG;
351    }
352
353    /**
354     * Register the dashboard admin submenu Forms under Jetpack menu.
355     */
356    public function add_admin_submenu() {
357        // Report a missing build here rather than only on the page itself, so a partial
358        // deploy shows up on the first admin request instead of waiting for someone to
359        // open Forms. Keyed on the file and not on the generated callback: load_wp_build()
360        // only requires build.php on the Forms page, so the callback is legitimately
361        // absent on every other admin screen, which runs this method too.
362        if ( ! file_exists( self::wp_build_index_path() ) ) {
363            _doing_it_wrong(
364                __METHOD__,
365                '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.',
366                ''
367            );
368        }
369
370        // `jetpack_forms_jetpack_forms_responses_wp_admin_render_page` is the callback generated
371        // by the WP build script, named after the page slug. It only exists once `build/build.php`
372        // is loaded. Without it the page has nothing to render, so show an explanation.
373        $callback = function_exists( 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page' )
374            ? 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page'
375            : array( $this, 'render_wp_build_unavailable' );
376
377        Admin_Menu::add_menu(
378            /** "Jetpack Forms" and "Forms" are product names, do not translate. */
379            'Jetpack Forms',
380            'Forms',
381            'edit_pages',
382            self::FORMS_WPBUILD_ADMIN_SLUG,
383            $callback,
384            null,
385            // The key is not the slug: the page's URL still reads
386            // jetpack-forms-responses-wp-admin, which FORMS-795 tracks separately.
387            array(
388                'product' => 'jetpack-forms',
389                'key'     => 'jetpack-forms',
390            )
391        );
392    }
393
394    /**
395     * Render the legacy dashboard mount point.
396     *
397     * Nothing registers this any more â€” the legacy dashboard was retired and its bundle
398     * is no longer enqueued, so the container it prints stays empty. Kept, and left
399     * printing the same markup, so any caller outside this package behaves as before.
400     *
401     * @deprecated 8.1.0 The legacy dashboard was retired.
402     */
403    public function render_dashboard() {
404        _deprecated_function( __METHOD__, 'jetpack-forms-8.1.0' );
405        ?>
406        <div id="jp-forms-dashboard"></div>
407        <?php
408    }
409
410    /**
411     * Render an error notice when the wp-build dashboard cannot render.
412     *
413     * The wp-build dashboard renders through a callback generated into `build/build.php`.
414     * That file is missing when the package ships without a complete build, and it is
415     * never loaded when a host application filters `jetpack_forms_load_wp_build` to false.
416     * So report the problem instead of rendering a blank page.
417     *
418     * @since 7.25.0
419     */
420    public function render_wp_build_unavailable() {
421        ?>
422        <div class="wrap">
423            <?php /* "Jetpack Forms" is a product name, do not translate. */ ?>
424            <h1>Jetpack Forms</h1>
425            <div class="notice notice-error">
426                <p><?php esc_html_e( 'The Forms dashboard is missing the files it needs to load.', 'jetpack-forms' ); ?></p>
427                <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>
428            </div>
429        </div>
430        <?php
431    }
432
433    /**
434     * Returns true if there are any feedback posts on the site.
435     *
436     * @return boolean
437     */
438    public function has_feedback() {
439        $posts = new \WP_Query(
440            array(
441                'post_type'              => 'feedback',
442                'post_status'            => array( 'publish', 'draft', 'spam', 'trash' ),
443                'posts_per_page'         => 1,
444                'fields'                 => 'ids',
445                'no_found_rows'          => true,
446                'update_post_meta_cache' => false,
447                'update_post_term_cache' => false,
448                'suppress_filters'       => true,
449            )
450        );
451        return $posts->have_posts();
452    }
453
454    /**
455     * Option name for storing classic forms state.
456     */
457    const CLASSIC_FORMS_OPTION = 'jetpack_forms_classic_state';
458
459    /**
460     * Classic forms state: site has classic (non-synced) form submissions.
461     */
462    const CLASSIC_FORMS_STATE_CLASSIC = 'classic';
463
464    /**
465     * Classic forms state: no classic form submissions detected.
466     */
467    const CLASSIC_FORMS_STATE_HIDDEN = 'hidden';
468
469    /**
470     * Classic forms state: user dismissed the classic forms notice.
471     */
472    const CLASSIC_FORMS_STATE_DISMISSED = 'dismissed';
473
474    /**
475     * Returns the classic forms state for the current site.
476     *
477     * Returns 'classic' if the site has form submissions (feedback posts) that were not
478     * created by a synced/reusable jetpack_form, 'dismissed' if the user dismissed the
479     * classic forms notice, or 'hidden' otherwise.
480     *
481     * The result is persisted in a WP option so the detection query only runs once per site.
482     * After that, the cached value is returned on every subsequent call. The cache is also
483     * updated eagerly via mark_classic_form_detected() when new classic submissions arrive.
484     *
485     * @since 7.14.0
486     *
487     * @return string 'classic', 'hidden', or 'dismissed'.
488     */
489    public function get_classic_forms_state() {
490        $state = get_option( self::CLASSIC_FORMS_OPTION );
491
492        if ( $state ) {
493            return $state;
494        }
495
496        $state = $this->detect_classic_forms();
497        update_option( self::CLASSIC_FORMS_OPTION, $state, false );
498
499        return $state;
500    }
501
502    /**
503     * Detects whether any feedback posts exist that are not linked to a jetpack_form post,
504     * indicating the site has classic (inline, widget, or template) forms.
505     *
506     * A feedback post is considered "classic" if:
507     * - It has no parent (post_parent = 0), meaning it was created by a form embedded in a
508     *   widget, page template, or other non-post context.
509     * - Its parent exists but is not a jetpack_form post, meaning it was created by a form
510     *   block or shortcode placed directly in a post or page.
511     *
512     * The query uses a LEFT JOIN on the posts table to find feedback posts with no matching
513     * jetpack_form parent. This leverages the primary key index for the join and the
514     * type_status_date index for filtering by post_type, making it efficient even on large
515     * sites. The LIMIT 1 ensures early exit as soon as one classic form is found.
516     *
517     * Note: An alternative approach would be to search post_content for the form block markup
518     * (<!-- wp:jetpack/contact-form) or shortcode ([contact-form]). However, that requires a
519     * full-text scan of the posts table (LIKE '%...%' on a TEXT column) with no usable index,
520     * making it significantly more expensive. The feedback-based approach also better fits the
521     * use case: we only need to surface the "Not seeing all your forms?" prompt when there are
522     * actual submissions that won't appear under any synced form in the dashboard.
523     *
524     * @since 7.14.0
525     *
526     * @return string 'classic' or 'hidden'.
527     */
528    private function detect_classic_forms() {
529        global $wpdb;
530
531        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
532        $result = $wpdb->get_var(
533            $wpdb->prepare(
534                "SELECT 1 FROM {$wpdb->posts} AS f
535                LEFT JOIN {$wpdb->posts} AS p
536                    ON p.ID = f.post_parent AND p.post_type = %s
537                WHERE f.post_type = 'feedback'
538                AND p.ID IS NULL
539                LIMIT 1",
540                Contact_Form::POST_TYPE
541            )
542        );
543
544        return $result ? self::CLASSIC_FORMS_STATE_CLASSIC : self::CLASSIC_FORMS_STATE_HIDDEN;
545    }
546
547    /**
548     * Eagerly marks the site as having classic forms by setting the option to 'classic'.
549     *
550     * Called when a new form submission is saved that does not belong to a synced jetpack_form.
551     * This avoids re-running the detection query â€” once a classic submission is observed, the
552     * state is permanently set without needing to scan the database again.
553     *
554     * If the user has already dismissed the classic forms notice, the state is left as
555     * 'dismissed' so the notice does not reappear.
556     *
557     * @since 7.14.0
558     */
559    public static function mark_classic_form_detected() {
560        $current = get_option( self::CLASSIC_FORMS_OPTION );
561
562        if ( self::CLASSIC_FORMS_STATE_DISMISSED === $current ) {
563            return;
564        }
565
566        update_option( self::CLASSIC_FORMS_OPTION, self::CLASSIC_FORMS_STATE_CLASSIC, false );
567    }
568
569    /**
570     * Returns url of forms admin page.
571     *
572     * @param string|null $tab Tab to open in the forms admin page.
573     * @param int|null    $post_id Post ID of response to open in the forms responses page.
574     *
575     * @return string
576     */
577    public static function get_forms_admin_url( $tab = null, $post_id = null ) {
578        $url  = admin_url( 'admin.php' );
579        $url .= '?page=' . self::FORMS_WPBUILD_ADMIN_SLUG;
580        $url .= '&p=' . rawurlencode( self::get_forms_admin_path_wp_build( $tab, $post_id ) );
581
582        /**
583         * Filters the Forms admin page URL.
584         *
585         * @module contact-form
586         * @since 7.8.0
587         *
588         * @param string      $url The Forms admin page URL.
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 The filtered Forms admin page URL.
593         */
594        return apply_filters( 'jetpack_forms_admin_url', $url, $tab, $post_id );
595    }
596
597    /**
598     * Returns the URL of the standalone single response page for a given response.
599     *
600     * The standalone page is a wp-build route (`/response/<id>`). A missing or empty
601     * post ID falls back to the responses list.
602     *
603     * @since 7.25.0
604     *
605     * @param int|null $post_id Post ID of the response to open.
606     *
607     * @return string
608     */
609    public static function get_single_response_admin_url( $post_id = null ) {
610        $post_id = ! empty( $post_id ) ? absint( $post_id ) : null;
611
612        return self::get_forms_admin_url( $post_id ? 'response' : 'inbox', $post_id );
613    }
614
615    /**
616     * The tab the standalone Forms page opens on when the URL names no route.
617     *
618     * @deprecated 8.3.5 The dashboard's root route picks the tab client-side now.
619     *
620     * @return string Tab slug understood by get_forms_admin_url().
621     */
622    public static function get_default_tab() {
623        _deprecated_function( __METHOD__, 'package-8.3.5' );
624
625        if ( ! Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' ) ) {
626            // The Forms tab is not rendered at all here, so honouring a stored 'forms'
627            // would strand the user on a tab they cannot leave.
628            return 'inbox';
629        }
630
631        $remembered = sanitize_key( wp_unslash( $_COOKIE[ self::LAST_TAB_COOKIE ] ?? '' ) );
632
633        return in_array( $remembered, array( 'forms', 'responses' ), true ) ? $remembered : 'forms';
634    }
635
636    /**
637     * WP-Build path for the forms admin URL.
638     *
639     * @param string|null $tab    Tab to open.
640     * @param int|null    $post_id Post ID of response.
641     * @return string URL path (e.g. '/', '/responses/inbox', '/forms').
642     */
643    private static function get_forms_admin_path_wp_build( $tab, $post_id ) {
644        $post_id      = ! empty( $post_id ) ? absint( $post_id ) : null;
645        $response_ids = ! empty( $post_id ) ? '?responseIds=["' . $post_id . '"]' : '';
646
647        // The standalone single response page, which addresses the response by path
648        // rather than selecting it in a list.
649        if ( $tab === 'response' && ! empty( $post_id ) ) {
650            return '/response/' . $post_id;
651        }
652
653        $path_map = array(
654            'inbox'           => '/responses/inbox',
655            'spam'            => '/responses/spam',
656            'trash'           => '/responses/trash',
657            'forms'           => '/forms',
658            'responses'       => '/responses/inbox',
659            'responses/inbox' => '/responses/inbox',
660        );
661
662        if ( $tab !== null && $tab !== '' && isset( $path_map[ $tab ] ) ) {
663            return $path_map[ $tab ] . $response_ids;
664        }
665
666        if ( ! empty( $post_id ) ) {
667            return '/responses/inbox?responseIds=["' . $post_id . '"]';
668        }
669
670        return '/responses/inbox';
671    }
672
673    /**
674     * Returns true if the current screen is the Jetpack Forms admin page.
675     *
676     * @return boolean
677     */
678    public static function is_jetpack_forms_admin_page() {
679        if ( ! function_exists( 'get_current_screen' ) ) {
680            return false;
681        }
682
683        $screen = get_current_screen();
684
685        if ( ! $screen || ! isset( $screen->id ) ) {
686            return false;
687        }
688
689        return $screen->id === 'jetpack_page_' . self::FORMS_WPBUILD_ADMIN_SLUG;
690    }
691
692    /**
693     * Returns true if form notes feature is enabled.
694     *
695     * @return boolean
696     */
697    public static function is_notes_enabled() {
698        /**
699        * Enable form notes feature in Jetpack Forms .
700        *
701        * @module contact-form
702        * @since 7.3.0
703        *
704        * @param bool $enabled Should the form notes feature be enabled? Defaults to false.
705        */
706        return apply_filters( 'jetpack_forms_notes_enable', false );
707    }
708
709    /**
710     * Get admin URL for given screen ID.
711     *
712     * @deprecated 7.9.0 Use Dashboard::get_forms_admin_url() instead.
713     *
714     * @param string $screen_id Screen ID.
715     * @return string Admin URL.
716     */
717    public static function get_admin_url( $screen_id ) {
718        _deprecated_function( __METHOD__, 'jetpack-7.9.0', 'Dashboard::get_forms_admin_url' );
719
720        if ( 'edit-jetpack_form' === $screen_id ) {
721            return self::get_forms_admin_url( 'forms' );
722        }
723
724        if ( 'edit-feedback' === $screen_id ) {
725            return self::get_forms_admin_url( 'inbox' );
726        }
727
728        return self::get_forms_admin_url();
729    }
730}