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