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