PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
← All changes | jetpack_vendor/automattic/jetpack-forms/src/dashboard/class-dashboard.php +610 -103 13.7.2 → 16.3-beta View file →
@@ -6,29 +6,128 @@
6 6 */
7 7
8 8 namespace Automattic\Jetpack\Forms\Dashboard;
9 9
10 -use Automattic\Jetpack\Assets;
10 +use Automattic\Jetpack\Admin_UI\Admin_Menu;
11 11 use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
12 -use Automattic\Jetpack\Connection\Manager as Connection_Manager;
13 -use Automattic\Jetpack\Forms\Jetpack_Forms;
14 -use Automattic\Jetpack\Forms\Service\Google_Drive;
15 -use Automattic\Jetpack\Redirect;
16 -use Automattic\Jetpack\Status;
17 -use Automattic\Jetpack\Status\Host;
12 +use Automattic\Jetpack\Forms\ContactForm\Contact_Form;
13 +use Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin;
14 +use Automattic\Jetpack\Tracking;
15 +use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
18 16
17 +if ( ! defined( 'ABSPATH' ) ) {
18 + exit( 0 );
19 +}
20 +
19 21 /**
20 22 * Handles the Jetpack Forms dashboard.
21 23 */
22 24 class Dashboard {
23 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 + /**
24 95 * Script handle for the JS file we enqueue in the Feedback admin page.
25 96 *
26 97 * @var string
98 + * @deprecated 8.2.0 The legacy dashboard bundle was removed.
27 99 */
28 100 const SCRIPT_HANDLE = 'jp-forms-dashboard';
29 101
102 + const ADMIN_SLUG = 'jetpack-forms-admin';
103 +
30 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 + /**
31 130 * Priority for the dashboard menu.
32 131 * Needs to be high enough for us to be able to unregister the default edit.php menu item.
33 132 *
34 133 * @var int
@@ -35,143 +134,271 @@
35 134 */
36 135 const MENU_PRIORITY = 999;
37 136
38 137 /**
39 - * Dashboard_View_Switch instance
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.
40 152 *
41 - * @var Dashboard_View_Switch
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
42 171 */
43 - private $switch;
172 + public static function announce_retired_filter() {
173 + if ( ! has_filter( 'jetpack_forms_alpha' ) ) {
174 + return;
175 + }
44 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 +
45 182 /**
46 - * Creates a new Dashboard instance.
183 + * Send legacy dashboard URLs to the wp-build dashboard.
47 184 *
48 - * @param Dashboard_View_Switch $switch Dashboard_View_Switch instance to use.
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.
49 187 */
50 - public function __construct( Dashboard_View_Switch $switch ) {
51 - $this->switch = $switch;
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;
52 200 }
53 201
54 202 /**
55 - * Initialize the dashboard.
203 + * Get the current query 'page' parameter.
204 + *
205 + * @return string
56 206 */
57 - public function init() {
58 - add_action( 'admin_menu', array( $this, 'add_admin_submenu' ), self::MENU_PRIORITY );
59 - add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
60 -
61 - $this->switch->init();
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'] ) ) : '';
62 210 }
63 211
64 212 /**
65 213 * Load JavaScript for the dashboard.
66 - *
67 - * @param string $hook The current admin page.
68 214 */
69 - public function load_admin_scripts( $hook ) {
70 - if ( 'toplevel_page_jetpack-forms' !== $hook ) {
215 + public function load_admin_scripts() {
216 + if ( ! self::is_jetpack_forms_admin_page() ) {
71 217 return;
72 218 }
73 219
74 - Assets::register_script(
75 - self::SCRIPT_HANDLE,
76 - '../../dist/dashboard/jetpack-forms-dashboard.js',
77 - __FILE__,
78 - array(
79 - 'in_footer' => true,
80 - 'textdomain' => 'jetpack-forms',
81 - 'enqueue' => true,
82 - 'dependencies' => array( 'wp-api-fetch' ),
83 - )
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',
84 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 + );
85 272
86 - // Adds Connection package initial state.
87 - Connection_Initial_State::render_script( self::SCRIPT_HANDLE );
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() );
88 297
89 - $api_root = defined( 'IS_WPCOM' ) && IS_WPCOM
90 - ? sprintf( '/wpcom/v2/sites/%s/', esc_url_raw( rest_url() ) )
91 - : '/wp-json/wpcom/v2/';
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 + }
92 304
93 305 wp_add_inline_script(
94 - self::SCRIPT_HANDLE,
95 - 'window.jetpackFormsData = ' . wp_json_encode( array( 'apiRoot' => $api_root ) ) . ';',
96 - 'before'
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
97 312 );
313 + }
98 314
99 - if ( ( new Host() )->is_wpcom_platform() ) {
100 - Assets::register_script(
101 - 'jp-forms-dashboard-wpcom',
102 - '../../dist/dashboard/jetpack-forms-dashboard.wpcom.js',
103 - __FILE__,
104 - array(
105 - 'in_footer' => true,
106 - 'textdomain' => 'jetpack-forms',
107 - 'enqueue' => true,
108 - 'dependencies' => array( self::SCRIPT_HANDLE ),
109 - )
110 - );
111 - }
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;
112 322 }
113 323
114 324 /**
115 - * Register the dashboard admin submenu.
325 + * Register the dashboard admin submenu Forms under Jetpack menu.
116 326 */
117 327 public function add_admin_submenu() {
118 - if ( $this->switch->get_preferred_view() === Dashboard_View_Switch::CLASSIC_VIEW ) {
119 - // We still need to register the jetpack forms page so it can be accessed manually.
120 - add_submenu_page(
121 - '',
122 - __( 'Form Responses', 'jetpack-forms' ),
123 - _x( 'Feedback', 'post type name shown in menu', 'jetpack-forms' ),
124 - 'edit_pages',
125 - 'jetpack-forms',
126 - array( $this, 'render_dashboard' )
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 + ''
127 338 );
128 -
129 - return;
130 339 }
131 340
132 - remove_menu_page( 'feedback' );
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' );
133 347
134 - add_menu_page(
135 - __( 'Form Responses', 'jetpack-forms' ),
136 - _x( 'Feedback', 'post type name shown in menu', 'jetpack-forms' ),
348 + Admin_Menu::add_menu(
349 + /** "Jetpack Forms" and "Forms" are product names, do not translate. */
350 + 'Jetpack Forms',
351 + 'Forms',
137 352 'edit_pages',
138 - 'jetpack-forms',
139 - array( $this, 'render_dashboard' ),
140 - 'dashicons-feedback',
141 - 25 // Places 'Feedback' under 'Comments' in the menu
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 + )
142 362 );
143 363 }
144 364
145 365 /**
146 - * Render the dashboard.
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.
147 373 */
148 374 public function render_dashboard() {
149 - if ( ! class_exists( 'Jetpack_AI_Helper' ) ) {
150 - require_once JETPACK__PLUGIN_DIR . '_inc/lib/class-jetpack-ai-helper.php';
151 - }
375 + _deprecated_function( __METHOD__, 'jetpack-forms-8.1.0' );
376 + ?>
377 + <div id="jp-forms-dashboard"></div>
378 + <?php
379 + }
152 380
153 - $ai_feature = \Jetpack_AI_Helper::get_ai_assistance_feature();
154 - $has_ai = ! is_wp_error( $ai_feature ) ? $ai_feature['has-feature'] : false;
155 -
156 - $jetpack_connected = ( defined( 'IS_WPCOM' ) && IS_WPCOM ) || ( new Connection_Manager( 'jetpack-forms' ) )->is_user_connected( get_current_user_id() );
157 - $user_id = (int) get_current_user_id();
158 -
159 - $config = array(
160 - 'blogId' => get_current_blog_id(),
161 - 'exportNonce' => wp_create_nonce( 'feedback_export' ),
162 - 'newFormNonce' => wp_create_nonce( 'create_new_form' ),
163 - 'gdriveConnection' => $jetpack_connected && Google_Drive::has_valid_connection( $user_id ),
164 - 'gdriveConnectURL' => esc_url( Redirect::get_url( 'jetpack-forms-responses-connect' ) ),
165 - 'gdriveConnectSupportURL' => esc_url( Redirect::get_url( 'jetpack-support-contact-form-export' ) ),
166 - 'checkForSpamNonce' => wp_create_nonce( 'grunion_recheck_queue' ),
167 - 'pluginAssetsURL' => Jetpack_Forms::assets_url(),
168 - 'siteURL' => ( new Status() )->get_site_suffix(),
169 - 'hasFeedback' => $this->has_feedback(),
170 - 'hasAI' => $has_ai,
171 - );
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() {
172 392 ?>
173 - <div id="jp-forms-dashboard" style="min-height: calc(100vh - 100px);" data-config="<?php echo esc_attr( wp_json_encode( $config, JSON_FORCE_OBJECT ) ); ?>"></div>
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>
174 401 <?php
175 402 }
176 403
177 404 /**
@@ -178,15 +405,295 @@
178 405 * Returns true if there are any feedback posts on the site.
179 406 *
180 407 * @return boolean
181 408 */
182 - private function has_feedback() {
409 + public function has_feedback() {
183 410 $posts = new \WP_Query(
184 411 array(
185 - 'post_type' => 'feedback',
186 - 'post_status' => array( 'publish', 'draft', 'spam', 'trash' ),
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,
187 420 )
188 421 );
422 + return $posts->have_posts();
423 + }
189 424
190 - return $posts->found_posts > 0;
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();
191 698 }
192 699 }