PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.1-a.5
16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-forms / src / dashboard / class-dashboard.php
jetpack / jetpack_vendor / automattic / jetpack-forms / src / dashboard Last commit date
class-dashboard-view-switch.php 7 months ago class-dashboard.php 1 month ago
class-dashboard.php
749 lines
1 <?php
2 /**
3 * Jetpack forms dashboard.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8 namespace Automattic\Jetpack\Forms\Dashboard;
9
10 use Automattic\Jetpack\Admin_UI\Admin_Menu;
11 use Automattic\Jetpack\Assets;
12 use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
13 use Automattic\Jetpack\Forms\ContactForm\Contact_Form;
14 use Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin;
15 use Automattic\Jetpack\Tracking;
16 use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
17
18 if ( ! defined( 'ABSPATH' ) ) {
19 exit( 0 );
20 }
21
22 /**
23 * Handles the Jetpack Forms dashboard.
24 */
25 class 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 = dirname( __DIR__, 2 ) . '/build/build.php';
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 * Fix import map ordering for the wp-build boot script.
82 *
83 * In wp-admin, _wp_footer_scripts (classic scripts) and print_import_map
84 * both hook into admin_print_footer_scripts at priority 10, but
85 * _wp_footer_scripts is registered first. This causes the inline
86 * import("@wordpress/boot") to execute before the import map exists.
87 *
88 * This fix moves the import() call from the classic inline script to a
89 * <script type="module"> printed at priority 20 (after the import map).
90 *
91 * @todo Remove once @wordpress/build ships with the loader.js fix upstream
92 * (WordPress/gutenberg#76870) and Jetpack updates the dependency.
93 */
94 public static function fix_boot_import_map_ordering() {
95 $handle = self::FORMS_WPBUILD_ADMIN_SLUG . '-prerequisites';
96
97 add_action(
98 'admin_enqueue_scripts',
99 static function () use ( $handle ) {
100 if ( ! Dashboard::is_jetpack_forms_admin_page() ) {
101 return;
102 }
103
104 $data = wp_scripts()->get_data( $handle, 'after' );
105 if ( empty( $data ) ) {
106 return;
107 }
108
109 // Find and extract the import("@wordpress/boot") inline script.
110 $boot_script = null;
111 $remaining = array();
112 foreach ( $data as $line ) {
113 if ( strpos( $line, '@wordpress/boot' ) !== false ) {
114 $boot_script = $line;
115 } else {
116 $remaining[] = $line;
117 }
118 }
119
120 if ( $boot_script === null ) {
121 return;
122 }
123
124 // Remove from the classic script handle.
125 wp_scripts()->add_data( $handle, 'after', $remaining );
126
127 // Re-emit as a module script after the import map.
128 add_action(
129 'admin_print_footer_scripts',
130 static function () use ( $boot_script ) {
131 wp_print_inline_script_tag( $boot_script, array( 'type' => 'module' ) );
132 },
133 20
134 );
135 },
136 PHP_INT_MAX
137 );
138 }
139
140 /**
141 * Script handle for the JS file we enqueue in the Feedback admin page.
142 *
143 * @var string
144 */
145 const SCRIPT_HANDLE = 'jp-forms-dashboard';
146
147 const ADMIN_SLUG = 'jetpack-forms-admin';
148
149 /**
150 * Slug for the wp-admin integrated Responses UI (wp-build page).
151 *
152 * Note: This must be a valid submenu slug (sanitize_key compatible), not a full URL.
153 *
154 * @var string
155 */
156 const FORMS_WPBUILD_ADMIN_SLUG = 'jetpack-forms-responses-wp-admin';
157
158 /**
159 * Priority for the dashboard menu.
160 * Needs to be high enough for us to be able to unregister the default edit.php menu item.
161 *
162 * @var int
163 */
164 const MENU_PRIORITY = 999;
165
166 /**
167 * Initialize the dashboard.
168 */
169 public function init() {
170 add_action( 'admin_menu', array( $this, 'add_admin_submenu' ), self::MENU_PRIORITY );
171 add_action( 'admin_menu', array( __CLASS__, 'redirect_dashboard_url_cross_variant' ), 1 );
172
173 /**
174 * Filter to enable or disable the wp-build-based Forms dashboard.
175 *
176 * Enabled by default since Central Forms Management is now available for all sites.
177 * Can be disabled by returning false from this filter.
178 *
179 * @since 7.18.0
180 *
181 * @param bool $enabled Whether the wp-build dashboard is enabled. Default true.
182 */
183 $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
184
185 if ( $is_wp_build_enabled ) {
186 self::load_wp_build();
187 self::fix_boot_import_map_ordering();
188 }
189
190 add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
191
192 // Removed all admin notices on the Jetpack Forms admin page.
193 if ( self::get_admin_query_page() === self::ADMIN_SLUG ) {
194 remove_all_actions( 'admin_notices' );
195 }
196 }
197
198 /**
199 * Redirect dashboard URLs when the wp-build flag has changed since the link was generated.
200 *
201 * Email links may point to the legacy or wp-build dashboard. If the flag has toggled,
202 * the requested page may not exist. This redirects to the correct variant.
203 */
204 public static function redirect_dashboard_url_cross_variant() {
205 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
206 $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
207
208 if ( $page !== self::ADMIN_SLUG && $page !== self::FORMS_WPBUILD_ADMIN_SLUG ) {
209 return;
210 }
211
212 /** This filter is documented in class-dashboard.php::init */
213 $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
214
215 // Legacy URL requested but wp-build is now active → redirect to wp-build.
216 if ( $page === self::ADMIN_SLUG && $is_wp_build_enabled ) {
217 // The hash is never sent to the server. "inbox" used as default tab so we end up specifically in the responses
218 // route, where the client-side router will handle the redirect to the correct status in its beforeLoad hook.
219 $redirect = self::get_forms_admin_url( 'inbox' );
220 wp_safe_redirect( $redirect );
221 exit;
222 }
223
224 // WP-Build URL requested but legacy is now active → redirect to legacy.
225 if ( $page === self::FORMS_WPBUILD_ADMIN_SLUG && ! $is_wp_build_enabled ) {
226 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
227 $p = isset( $_GET['p'] ) ? rawurldecode( sanitize_text_field( wp_unslash( $_GET['p'] ) ) ) : '';
228 $tab = 'inbox';
229 $post_id = null;
230 $has_mark_as_spam = false;
231
232 // Check if mark_as_spam is a separate query parameter (old email format).
233 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
234 if ( isset( $_GET['mark_as_spam'] ) ) {
235 $has_mark_as_spam = true;
236 }
237
238 if ( $p !== '' ) {
239 // Parse path like /responses/inbox?responseIds=["2879"] or /responses/inbox?responseIds=["2879"]&mark_as_spam or /forms.
240 if ( preg_match( '#^/responses/(inbox|spam|trash)(?:\?responseIds=\["(\d+)"\])?(.*)$#', $p, $m ) ) {
241 $tab = $m[1];
242 $post_id = ! empty( $m[2] ) ? absint( $m[2] ) : null;
243
244 // Check if mark_as_spam parameter is present inside the path.
245 if ( ! empty( $m[3] ) && strpos( $m[3], 'mark_as_spam' ) !== false ) {
246 $has_mark_as_spam = true;
247 }
248 } elseif ( preg_match( '#^/forms#', $p ) ) {
249 $tab = 'forms';
250 }
251 }
252
253 $redirect = self::get_forms_admin_url( $tab, $post_id );
254
255 // Add mark_as_spam parameter if it was present in the original URL (either format).
256 if ( $has_mark_as_spam ) {
257 $redirect .= '&mark_as_spam';
258 }
259
260 wp_safe_redirect( $redirect );
261 exit;
262 }
263 }
264
265 /**
266 * Get the current query 'page' parameter.
267 *
268 * @return string
269 */
270 private static function get_admin_query_page() {
271 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
272 return isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
273 }
274
275 /**
276 * Load JavaScript for the dashboard.
277 */
278 public function load_admin_scripts() {
279 if ( ! self::is_jetpack_forms_admin_page() ) {
280 return;
281 }
282
283 // The wp-build (script-module) dashboard renders its own UI from build/pages/…,
284 // so the legacy SPA bundle is dead weight there. Only enqueue it on the legacy
285 // dashboard. The shared inline data below (connection initial state + REST
286 // preload) is instead attached to the always-present wp-api-fetch handle so the
287 // wp-build app still receives it.
288 if ( self::is_wp_build_dashboard_page() ) {
289 $inline_handle = 'wp-api-fetch';
290 $preload_position = 'after';
291 } else {
292 $inline_handle = self::SCRIPT_HANDLE;
293 $preload_position = 'before';
294
295 Assets::register_script(
296 self::SCRIPT_HANDLE,
297 '../../dist/dashboard/jetpack-forms-dashboard.js',
298 __FILE__,
299 array(
300 'in_footer' => true,
301 'textdomain' => 'jetpack-forms',
302 'enqueue' => true,
303 )
304 );
305 }
306
307 if ( Contact_Form_Plugin::can_use_analytics() ) {
308 Tracking::register_tracks_functions_scripts( true );
309 }
310
311 // Adds Connection package initial state.
312 Connection_Initial_State::render_script( $inline_handle );
313
314 // Preload Forms endpoints needed in dashboard context.
315 // Pre-fetch the first inbox page so the UI renders instantly on first load.
316 $preload_params = array(
317 'context' => 'edit',
318 'fields_format' => 'collection',
319 'order' => 'desc',
320 'orderby' => 'date',
321 'page' => 1,
322 'per_page' => 20,
323 'status' => 'draft,publish',
324 );
325 \ksort( $preload_params );
326 $initial_responses_path = \add_query_arg( $preload_params, '/wp/v2/feedback' );
327 $initial_responses_locale_path = \add_query_arg(
328 \array_merge(
329 $preload_params,
330 array( '_locale' => 'user' )
331 ),
332 '/wp/v2/feedback'
333 );
334 $filters_path = '/wp/v2/feedback/filters';
335 $filters_locale_path = \add_query_arg( array( '_locale' => 'user' ), $filters_path );
336 $preload_paths = array(
337 '/wp/v2/types?context=view',
338 '/wp/v2/feedback/config',
339 '/wp/v2/feedback/integrations-metadata',
340 '/wp/v2/feedback/counts',
341 $filters_path,
342 $filters_locale_path,
343 $initial_responses_path,
344 $initial_responses_locale_path,
345 );
346
347 // Only preload the Forms list endpoint when centralized form management is enabled.
348 if ( Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' ) ) {
349 $forms_preload_params = array(
350 'context' => 'edit',
351 'page' => 1,
352 'jetpack_forms_context' => 'dashboard',
353 'order' => 'desc',
354 'orderby' => 'modified',
355 'per_page' => 20,
356 'status' => 'publish,draft,pending,future,private',
357 );
358 ksort( $forms_preload_params );
359 $preload_paths[] = add_query_arg( $forms_preload_params, '/wp/v2/jetpack-forms' );
360 $preload_paths[] = add_query_arg(
361 array_merge(
362 $forms_preload_params,
363 array( '_locale' => 'user' )
364 ),
365 '/wp/v2/jetpack-forms'
366 );
367 $preload_paths[] = '/wp/v2/jetpack-forms/status-counts';
368 $preload_paths[] = add_query_arg( array( '_locale' => 'user' ), '/wp/v2/jetpack-forms/status-counts' );
369 }
370 $preload_data_raw = array_reduce( $preload_paths, 'rest_preload_api_request', array() );
371
372 // Normalize keys to match what apiFetch will request (without domain).
373 $preload_data = array();
374 foreach ( $preload_data_raw as $key => $value ) {
375 $normalized_key = preg_replace( '#^https?://[^/]+/wp-json#', '', $key );
376 $preload_data[ $normalized_key ] = $value;
377 }
378
379 wp_add_inline_script(
380 $inline_handle,
381 sprintf(
382 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
383 wp_json_encode( $preload_data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
384 ),
385 $preload_position
386 );
387 }
388
389 /**
390 * Whether the current request targets the wp-build (script-module) Forms dashboard,
391 * as opposed to the legacy SPA dashboard.
392 *
393 * When true, the legacy dashboard bundle should not be enqueued: the wp-build page
394 * (build/pages/jetpack-forms-responses/…) provides its own UI and asset loading.
395 *
396 * @return bool
397 */
398 public static function is_wp_build_dashboard_page() {
399 /** This filter is documented in class-dashboard.php::init */
400 return apply_filters( 'jetpack_forms_alpha', true )
401 && self::get_admin_query_page() === self::FORMS_WPBUILD_ADMIN_SLUG;
402 }
403
404 /**
405 * Register the dashboard admin submenu Forms under Jetpack menu.
406 */
407 public function add_admin_submenu() {
408
409 /** This filter is documented in class-dashboard.php::init */
410 if ( apply_filters( 'jetpack_forms_alpha', true ) ) {
411
412 // `jetpack_forms_jetpack_forms_responses_wp_admin_render_page` is the callback generated by WP build script.
413 $callback = function_exists( 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page' )
414 ? 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page'
415 : array( $this, 'render_dashboard' );
416
417 Admin_Menu::add_menu(
418 /** "Jetpack Forms" and "Forms" are product names, do not translate. */
419 'Jetpack Forms',
420 'Forms',
421 'edit_pages',
422 self::FORMS_WPBUILD_ADMIN_SLUG,
423 $callback,
424 10
425 );
426
427 return;
428 }
429
430 Admin_Menu::add_menu(
431 /** "Jetpack Forms" and "Forms" are Product names, do not translate. */
432 'Jetpack Forms',
433 'Forms',
434 'edit_pages',
435 self::ADMIN_SLUG,
436 array( $this, 'render_dashboard' ),
437 10
438 );
439 }
440
441 /**
442 * Render the dashboard.
443 */
444 public function render_dashboard() {
445 ?>
446 <div id="jp-forms-dashboard"></div>
447 <?php
448 }
449
450 /**
451 * Returns true if there are any feedback posts on the site.
452 *
453 * @return boolean
454 */
455 public function has_feedback() {
456 $posts = new \WP_Query(
457 array(
458 'post_type' => 'feedback',
459 'post_status' => array( 'publish', 'draft', 'spam', 'trash' ),
460 'posts_per_page' => 1,
461 'fields' => 'ids',
462 'no_found_rows' => true,
463 'update_post_meta_cache' => false,
464 'update_post_term_cache' => false,
465 'suppress_filters' => true,
466 )
467 );
468 return $posts->have_posts();
469 }
470
471 /**
472 * Option name for storing classic forms state.
473 */
474 const CLASSIC_FORMS_OPTION = 'jetpack_forms_classic_state';
475
476 /**
477 * Classic forms state: site has classic (non-synced) form submissions.
478 */
479 const CLASSIC_FORMS_STATE_CLASSIC = 'classic';
480
481 /**
482 * Classic forms state: no classic form submissions detected.
483 */
484 const CLASSIC_FORMS_STATE_HIDDEN = 'hidden';
485
486 /**
487 * Classic forms state: user dismissed the classic forms notice.
488 */
489 const CLASSIC_FORMS_STATE_DISMISSED = 'dismissed';
490
491 /**
492 * Returns the classic forms state for the current site.
493 *
494 * Returns 'classic' if the site has form submissions (feedback posts) that were not
495 * created by a synced/reusable jetpack_form, 'dismissed' if the user dismissed the
496 * classic forms notice, or 'hidden' otherwise.
497 *
498 * The result is persisted in a WP option so the detection query only runs once per site.
499 * After that, the cached value is returned on every subsequent call. The cache is also
500 * updated eagerly via mark_classic_form_detected() when new classic submissions arrive.
501 *
502 * @since 7.14.0
503 *
504 * @return string 'classic', 'hidden', or 'dismissed'.
505 */
506 public function get_classic_forms_state() {
507 $state = get_option( self::CLASSIC_FORMS_OPTION );
508
509 if ( $state ) {
510 return $state;
511 }
512
513 $state = $this->detect_classic_forms();
514 update_option( self::CLASSIC_FORMS_OPTION, $state, false );
515
516 return $state;
517 }
518
519 /**
520 * Detects whether any feedback posts exist that are not linked to a jetpack_form post,
521 * indicating the site has classic (inline, widget, or template) forms.
522 *
523 * A feedback post is considered "classic" if:
524 * - It has no parent (post_parent = 0), meaning it was created by a form embedded in a
525 * widget, page template, or other non-post context.
526 * - Its parent exists but is not a jetpack_form post, meaning it was created by a form
527 * block or shortcode placed directly in a post or page.
528 *
529 * The query uses a LEFT JOIN on the posts table to find feedback posts with no matching
530 * jetpack_form parent. This leverages the primary key index for the join and the
531 * type_status_date index for filtering by post_type, making it efficient even on large
532 * sites. The LIMIT 1 ensures early exit as soon as one classic form is found.
533 *
534 * Note: An alternative approach would be to search post_content for the form block markup
535 * (<!-- wp:jetpack/contact-form) or shortcode ([contact-form]). However, that requires a
536 * full-text scan of the posts table (LIKE '%...%' on a TEXT column) with no usable index,
537 * making it significantly more expensive. The feedback-based approach also better fits the
538 * use case: we only need to surface the "Not seeing all your forms?" prompt when there are
539 * actual submissions that won't appear under any synced form in the dashboard.
540 *
541 * @since 7.14.0
542 *
543 * @return string 'classic' or 'hidden'.
544 */
545 private function detect_classic_forms() {
546 global $wpdb;
547
548 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
549 $result = $wpdb->get_var(
550 $wpdb->prepare(
551 "SELECT 1 FROM {$wpdb->posts} AS f
552 LEFT JOIN {$wpdb->posts} AS p
553 ON p.ID = f.post_parent AND p.post_type = %s
554 WHERE f.post_type = 'feedback'
555 AND p.ID IS NULL
556 LIMIT 1",
557 Contact_Form::POST_TYPE
558 )
559 );
560
561 return $result ? self::CLASSIC_FORMS_STATE_CLASSIC : self::CLASSIC_FORMS_STATE_HIDDEN;
562 }
563
564 /**
565 * Eagerly marks the site as having classic forms by setting the option to 'classic'.
566 *
567 * Called when a new form submission is saved that does not belong to a synced jetpack_form.
568 * This avoids re-running the detection query — once a classic submission is observed, the
569 * state is permanently set without needing to scan the database again.
570 *
571 * If the user has already dismissed the classic forms notice, the state is left as
572 * 'dismissed' so the notice does not reappear.
573 *
574 * @since 7.14.0
575 */
576 public static function mark_classic_form_detected() {
577 $current = get_option( self::CLASSIC_FORMS_OPTION );
578
579 if ( self::CLASSIC_FORMS_STATE_DISMISSED === $current ) {
580 return;
581 }
582
583 update_option( self::CLASSIC_FORMS_OPTION, self::CLASSIC_FORMS_STATE_CLASSIC, false );
584 }
585
586 /**
587 * Returns url of forms admin page.
588 *
589 * @param string|null $tab Tab to open in the forms admin page.
590 * @param int|null $post_id Post ID of response to open in the forms responses page.
591 *
592 * @return string
593 */
594 public static function get_forms_admin_url( $tab = null, $post_id = null ) {
595 /** This filter is documented in class-dashboard.php::init */
596 $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
597 $url = admin_url( 'admin.php' );
598
599 $url .= $is_wp_build_enabled
600 ? '?page=' . self::FORMS_WPBUILD_ADMIN_SLUG
601 : '?page=' . self::ADMIN_SLUG;
602
603 if ( $is_wp_build_enabled ) {
604 $path = self::get_forms_admin_path_wp_build( $tab, $post_id );
605 $url .= '&p=' . rawurlencode( $path );
606 } else {
607 $suffix = self::get_forms_admin_suffix_legacy( $tab, $post_id );
608
609 if ( $suffix !== '' ) {
610 $url .= $suffix;
611 }
612 }
613
614 /**
615 * Filters the Forms admin page URL.
616 *
617 * @module contact-form
618 * @since 7.8.0
619 *
620 * @param string $url The Forms admin page URL.
621 * @param string|null $tab Tab to open in the forms admin page.
622 * @param int|null $post_id Post ID of response to open in the forms responses page.
623 *
624 * @return string The filtered Forms admin page URL.
625 */
626 return apply_filters( 'jetpack_forms_admin_url', $url, $tab, $post_id );
627 }
628
629 /**
630 * WP-Build path for the forms admin URL.
631 *
632 * @param string|null $tab Tab to open.
633 * @param int|null $post_id Post ID of response.
634 * @return string URL path (e.g. '/', '/responses/inbox', '/forms').
635 */
636 private static function get_forms_admin_path_wp_build( $tab, $post_id ) {
637 $post_id = ! empty( $post_id ) ? absint( $post_id ) : null;
638 $response_ids = ! empty( $post_id ) ? '?responseIds=["' . $post_id . '"]' : '';
639
640 $path_map = array(
641 'inbox' => '/responses/inbox',
642 'spam' => '/responses/spam',
643 'trash' => '/responses/trash',
644 'forms' => '/forms',
645 'responses/inbox' => '/responses/inbox',
646 );
647
648 if ( $tab !== null && $tab !== '' && isset( $path_map[ $tab ] ) ) {
649 return $path_map[ $tab ] . $response_ids;
650 }
651
652 if ( ! empty( $post_id ) ) {
653 return '/responses/inbox?responseIds=["' . $post_id . '"]';
654 }
655
656 return '/responses/inbox';
657 }
658
659 /**
660 * Legacy (hash-based) URL suffix for the forms admin page.
661 *
662 * @param string|null $tab Tab to open.
663 * @param int|null $post_id Post ID of response.
664 * @return string URL suffix (e.g. '#/responses?status=inbox&r=123', or '#/forms').
665 */
666 private static function get_forms_admin_suffix_legacy( $tab, $post_id ) {
667 $post_id = ! empty( $post_id ) ? absint( $post_id ) : null;
668 $valid_tabs = array( 'spam', 'inbox', 'trash' );
669 $r_param = ! empty( $post_id ) ? '&r=' . $post_id : '';
670
671 if ( in_array( $tab, $valid_tabs, true ) ) {
672 return '#/responses?status=' . $tab . $r_param;
673 }
674
675 if ( $tab === 'forms' ) {
676 return '#/forms';
677 }
678
679 if ( ! empty( $post_id ) ) {
680 return '#/responses?status=inbox' . $r_param;
681 }
682
683 return '';
684 }
685
686 /**
687 * Returns true if the current screen is the Jetpack Forms admin page.
688 *
689 * @return boolean
690 */
691 public static function is_jetpack_forms_admin_page() {
692 if ( ! function_exists( 'get_current_screen' ) ) {
693 return false;
694 }
695
696 $screen = get_current_screen();
697
698 if ( ! $screen || ! isset( $screen->id ) ) {
699 return false;
700 }
701
702 $forms_admin_screens = array(
703 'jetpack_page_' . self::ADMIN_SLUG,
704 'jetpack_page_' . self::FORMS_WPBUILD_ADMIN_SLUG,
705 );
706
707 return in_array( $screen->id, $forms_admin_screens, true );
708 }
709
710 /**
711 * Returns true if form notes feature is enabled.
712 *
713 * @return boolean
714 */
715 public static function is_notes_enabled() {
716 /**
717 * Enable form notes feature in Jetpack Forms .
718 *
719 * @module contact-form
720 * @since 7.3.0
721 *
722 * @param bool $enabled Should the form notes feature be enabled? Defaults to false.
723 */
724 return apply_filters( 'jetpack_forms_notes_enable', false );
725 }
726
727 /**
728 * Get admin URL for given screen ID.
729 *
730 * @deprecated 7.9.0 Use Dashboard::get_forms_admin_url() instead.
731 *
732 * @param string $screen_id Screen ID.
733 * @return string Admin URL.
734 */
735 public static function get_admin_url( $screen_id ) {
736 _deprecated_function( __METHOD__, 'jetpack-7.9.0', 'Dashboard::get_forms_admin_url' );
737
738 if ( 'edit-jetpack_form' === $screen_id ) {
739 return self::get_forms_admin_url( 'forms' );
740 }
741
742 if ( 'edit-feedback' === $screen_id ) {
743 return self::get_forms_admin_url( 'inbox' );
744 }
745
746 return self::get_forms_admin_url();
747 }
748 }
749