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