PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
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 3 days ago
class-dashboard.php
793 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 = 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
132 /**
133 * Filter to enable or disable the wp-build-based Forms dashboard.
134 *
135 * Enabled by default since Central Forms Management is now available for all sites.
136 * Can be disabled by returning false from this filter.
137 *
138 * @since 7.18.0
139 *
140 * @param bool $enabled Whether the wp-build dashboard is enabled. Default true.
141 */
142 $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
143
144 if ( $is_wp_build_enabled ) {
145 self::load_wp_build();
146 }
147
148 add_action( 'admin_enqueue_scripts', array( $this, 'load_admin_scripts' ) );
149
150 // Removed all admin notices on the Jetpack Forms admin page.
151 if ( self::get_admin_query_page() === self::ADMIN_SLUG ) {
152 remove_all_actions( 'admin_notices' );
153 }
154 }
155
156 /**
157 * Redirect dashboard URLs when the wp-build flag has changed since the link was generated.
158 *
159 * Email links may point to the legacy or wp-build dashboard. If the flag has toggled,
160 * the requested page may not exist. This redirects to the correct variant.
161 */
162 public static function redirect_dashboard_url_cross_variant() {
163 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
164 $page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
165
166 if ( $page !== self::ADMIN_SLUG && $page !== self::FORMS_WPBUILD_ADMIN_SLUG ) {
167 return;
168 }
169
170 /** This filter is documented in class-dashboard.php::init */
171 $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
172
173 // Legacy URL requested but wp-build is now active → redirect to wp-build.
174 if ( $page === self::ADMIN_SLUG && $is_wp_build_enabled ) {
175 // The hash is never sent to the server. "inbox" used as default tab so we end up specifically in the responses
176 // route, where the client-side router will handle the redirect to the correct status in its beforeLoad hook.
177 $redirect = self::get_forms_admin_url( 'inbox' );
178 wp_safe_redirect( $redirect );
179 exit;
180 }
181
182 // WP-Build URL requested but legacy is now active → redirect to legacy.
183 if ( $page === self::FORMS_WPBUILD_ADMIN_SLUG && ! $is_wp_build_enabled ) {
184 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
185 $p = isset( $_GET['p'] ) ? rawurldecode( sanitize_text_field( wp_unslash( $_GET['p'] ) ) ) : '';
186 $tab = 'inbox';
187 $post_id = null;
188 $has_mark_as_spam = false;
189
190 // Check if mark_as_spam is a separate query parameter (old email format).
191 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
192 if ( isset( $_GET['mark_as_spam'] ) ) {
193 $has_mark_as_spam = true;
194 }
195
196 if ( $p !== '' ) {
197 // Parse path like /responses/inbox?responseIds=["2879"] or /responses/inbox?responseIds=["2879"]&mark_as_spam or /forms.
198 if ( preg_match( '#^/responses/(inbox|spam|trash)(?:\?responseIds=\["(\d+)"\])?(.*)$#', $p, $m ) ) {
199 $tab = $m[1];
200 $post_id = ! empty( $m[2] ) ? absint( $m[2] ) : null;
201
202 // Check if mark_as_spam parameter is present inside the path.
203 if ( ! empty( $m[3] ) && strpos( $m[3], 'mark_as_spam' ) !== false ) {
204 $has_mark_as_spam = true;
205 }
206 } elseif ( preg_match( '#^/response/(\d+)(?:\?(.*))?$#', $p, $m ) ) {
207 // Standalone single response page (wp-build only) — the legacy
208 // dashboard shows the response in the inbox list instead. The path
209 // is matched whole so trailing junk isn't read as a response ID,
210 // but it may legitimately carry the email's mark_as_spam trigger.
211 $post_id = absint( $m[1] );
212
213 if ( ! empty( $m[2] ) && strpos( $m[2], 'mark_as_spam' ) !== false ) {
214 $has_mark_as_spam = true;
215 }
216 } elseif ( preg_match( '#^/forms#', $p ) ) {
217 $tab = 'forms';
218 }
219 }
220
221 $redirect = self::get_forms_admin_url( $tab, $post_id );
222
223 // Add mark_as_spam parameter if it was present in the original URL (either format).
224 if ( $has_mark_as_spam ) {
225 $redirect .= '&mark_as_spam';
226 }
227
228 wp_safe_redirect( $redirect );
229 exit;
230 }
231 }
232
233 /**
234 * Get the current query 'page' parameter.
235 *
236 * @return string
237 */
238 private static function get_admin_query_page() {
239 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
240 return isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
241 }
242
243 /**
244 * Load JavaScript for the dashboard.
245 */
246 public function load_admin_scripts() {
247 if ( ! self::is_jetpack_forms_admin_page() ) {
248 return;
249 }
250
251 // The wp-build (script-module) dashboard renders its own UI from build/pages/…,
252 // so the legacy SPA bundle is dead weight there. Only enqueue it on the legacy
253 // dashboard. The shared inline data below (connection initial state + REST
254 // preload) is instead attached to the always-present wp-api-fetch handle so the
255 // wp-build app still receives it.
256 if ( self::is_wp_build_dashboard_page() ) {
257 $inline_handle = 'wp-api-fetch';
258 $preload_position = 'after';
259
260 // The i18n loader is registered on every admin page by jetpack-assets but
261 // only enqueued when depended on; the esbuild bundles don't pull it in.
262 // Enqueue it so the wp-build dashboard's init module can download its JS
263 // translation catalogs.
264 if ( wp_script_is( 'wp-jp-i18n-loader', 'registered' ) ) {
265 wp_enqueue_script( 'wp-jp-i18n-loader' );
266 }
267 } else {
268 $inline_handle = self::SCRIPT_HANDLE;
269 $preload_position = 'before';
270
271 Assets::register_script(
272 self::SCRIPT_HANDLE,
273 '../../dist/dashboard/jetpack-forms-dashboard.js',
274 __FILE__,
275 array(
276 'in_footer' => true,
277 'textdomain' => 'jetpack-forms',
278 'enqueue' => true,
279 )
280 );
281 }
282
283 if ( Contact_Form_Plugin::can_use_analytics() ) {
284 Tracking::register_tracks_functions_scripts( true );
285 }
286
287 // Adds Connection package initial state.
288 Connection_Initial_State::render_script( $inline_handle );
289
290 // Preload Forms endpoints needed in dashboard context.
291 // Pre-fetch the first inbox page so the UI renders instantly on first load.
292 $preload_params = array(
293 'context' => 'edit',
294 'fields_format' => 'collection',
295 'order' => 'desc',
296 'orderby' => 'date',
297 'page' => 1,
298 'per_page' => 20,
299 'status' => 'draft,publish',
300 );
301 \ksort( $preload_params );
302 $initial_responses_path = \add_query_arg( $preload_params, '/wp/v2/feedback' );
303 $initial_responses_locale_path = \add_query_arg(
304 \array_merge(
305 $preload_params,
306 array( '_locale' => 'user' )
307 ),
308 '/wp/v2/feedback'
309 );
310 $filters_path = '/wp/v2/feedback/filters';
311 $filters_locale_path = \add_query_arg( array( '_locale' => 'user' ), $filters_path );
312 $preload_paths = array(
313 '/wp/v2/types?context=view',
314 '/wp/v2/feedback/config',
315 '/wp/v2/feedback/integrations-metadata',
316 '/wp/v2/feedback/counts',
317 $filters_path,
318 $filters_locale_path,
319 $initial_responses_path,
320 $initial_responses_locale_path,
321 );
322
323 // Only preload the Forms list endpoint when centralized form management is enabled.
324 if ( Contact_Form_Plugin::has_editor_feature_flag( 'central-form-management' ) ) {
325 $forms_preload_params = array(
326 'context' => 'edit',
327 'page' => 1,
328 'jetpack_forms_context' => 'dashboard',
329 'order' => 'desc',
330 'orderby' => 'modified',
331 'per_page' => 20,
332 'status' => 'publish,draft,pending,future,private',
333 );
334 ksort( $forms_preload_params );
335 $preload_paths[] = add_query_arg( $forms_preload_params, '/wp/v2/jetpack-forms' );
336 $preload_paths[] = add_query_arg(
337 array_merge(
338 $forms_preload_params,
339 array( '_locale' => 'user' )
340 ),
341 '/wp/v2/jetpack-forms'
342 );
343 $preload_paths[] = '/wp/v2/jetpack-forms/status-counts';
344 $preload_paths[] = add_query_arg( array( '_locale' => 'user' ), '/wp/v2/jetpack-forms/status-counts' );
345 }
346 $preload_data_raw = array_reduce( $preload_paths, 'rest_preload_api_request', array() );
347
348 // Normalize keys to match what apiFetch will request (without domain).
349 $preload_data = array();
350 foreach ( $preload_data_raw as $key => $value ) {
351 $normalized_key = preg_replace( '#^https?://[^/]+/wp-json#', '', $key );
352 $preload_data[ $normalized_key ] = $value;
353 }
354
355 wp_add_inline_script(
356 $inline_handle,
357 sprintf(
358 'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
359 wp_json_encode( $preload_data, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP )
360 ),
361 $preload_position
362 );
363 }
364
365 /**
366 * Whether the current request targets the wp-build (script-module) Forms dashboard,
367 * as opposed to the legacy SPA dashboard.
368 *
369 * When true, the legacy dashboard bundle should not be enqueued: the wp-build page
370 * (build/pages/jetpack-forms-responses/…) provides its own UI and asset loading.
371 *
372 * @return bool
373 */
374 public static function is_wp_build_dashboard_page() {
375 /** This filter is documented in class-dashboard.php::init */
376 return apply_filters( 'jetpack_forms_alpha', true )
377 && self::get_admin_query_page() === self::FORMS_WPBUILD_ADMIN_SLUG;
378 }
379
380 /**
381 * Register the dashboard admin submenu Forms under Jetpack menu.
382 */
383 public function add_admin_submenu() {
384
385 /** This filter is documented in class-dashboard.php::init */
386 if ( apply_filters( 'jetpack_forms_alpha', true ) ) {
387
388 // Report a missing build here rather than only on the page itself, so a partial
389 // deploy shows up on the first admin request instead of waiting for someone to
390 // open Forms. Keyed on the file and not on the generated callback: load_wp_build()
391 // only requires build.php on the Forms page, so the callback is legitimately
392 // absent on every other admin screen, which runs this method too.
393 if ( ! file_exists( self::wp_build_index_path() ) ) {
394 _doing_it_wrong(
395 __METHOD__,
396 '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.',
397 ''
398 );
399 }
400
401 // `jetpack_forms_jetpack_forms_responses_wp_admin_render_page` is the callback generated
402 // by the WP build script, named after the page slug. It only exists once `build/build.php`
403 // is loaded. Without it the page has nothing to render, so show an explanation rather
404 // than the legacy mount point, whose bundle load_admin_scripts() does not enqueue here.
405 $callback = function_exists( 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page' )
406 ? 'jetpack_forms_jetpack_forms_responses_wp_admin_render_page'
407 : array( $this, 'render_wp_build_unavailable' );
408
409 Admin_Menu::add_menu(
410 /** "Jetpack Forms" and "Forms" are product names, do not translate. */
411 'Jetpack Forms',
412 'Forms',
413 'edit_pages',
414 self::FORMS_WPBUILD_ADMIN_SLUG,
415 $callback,
416 10
417 );
418
419 return;
420 }
421
422 Admin_Menu::add_menu(
423 /** "Jetpack Forms" and "Forms" are Product names, do not translate. */
424 'Jetpack Forms',
425 'Forms',
426 'edit_pages',
427 self::ADMIN_SLUG,
428 array( $this, 'render_dashboard' ),
429 10
430 );
431 }
432
433 /**
434 * Render the dashboard.
435 */
436 public function render_dashboard() {
437 ?>
438 <div id="jp-forms-dashboard"></div>
439 <?php
440 }
441
442 /**
443 * Render an error notice when the wp-build dashboard cannot render.
444 *
445 * The wp-build dashboard renders through a callback generated into `build/build.php`.
446 * That file is missing when the package ships without a complete build, and it is
447 * never loaded when a host application filters `jetpack_forms_load_wp_build` to false.
448 * The legacy bundle is no fallback here: load_admin_scripts() skips it on this screen.
449 * So report the problem instead of rendering a blank page.
450 *
451 * @since 7.25.0
452 */
453 public function render_wp_build_unavailable() {
454 ?>
455 <div class="wrap">
456 <?php /* "Jetpack Forms" is a product name, do not translate. */ ?>
457 <h1>Jetpack Forms</h1>
458 <div class="notice notice-error">
459 <p><?php esc_html_e( 'The Forms dashboard is missing the files it needs to load.', 'jetpack-forms' ); ?></p>
460 <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>
461 </div>
462 </div>
463 <?php
464 }
465
466 /**
467 * Returns true if there are any feedback posts on the site.
468 *
469 * @return boolean
470 */
471 public function has_feedback() {
472 $posts = new \WP_Query(
473 array(
474 'post_type' => 'feedback',
475 'post_status' => array( 'publish', 'draft', 'spam', 'trash' ),
476 'posts_per_page' => 1,
477 'fields' => 'ids',
478 'no_found_rows' => true,
479 'update_post_meta_cache' => false,
480 'update_post_term_cache' => false,
481 'suppress_filters' => true,
482 )
483 );
484 return $posts->have_posts();
485 }
486
487 /**
488 * Option name for storing classic forms state.
489 */
490 const CLASSIC_FORMS_OPTION = 'jetpack_forms_classic_state';
491
492 /**
493 * Classic forms state: site has classic (non-synced) form submissions.
494 */
495 const CLASSIC_FORMS_STATE_CLASSIC = 'classic';
496
497 /**
498 * Classic forms state: no classic form submissions detected.
499 */
500 const CLASSIC_FORMS_STATE_HIDDEN = 'hidden';
501
502 /**
503 * Classic forms state: user dismissed the classic forms notice.
504 */
505 const CLASSIC_FORMS_STATE_DISMISSED = 'dismissed';
506
507 /**
508 * Returns the classic forms state for the current site.
509 *
510 * Returns 'classic' if the site has form submissions (feedback posts) that were not
511 * created by a synced/reusable jetpack_form, 'dismissed' if the user dismissed the
512 * classic forms notice, or 'hidden' otherwise.
513 *
514 * The result is persisted in a WP option so the detection query only runs once per site.
515 * After that, the cached value is returned on every subsequent call. The cache is also
516 * updated eagerly via mark_classic_form_detected() when new classic submissions arrive.
517 *
518 * @since 7.14.0
519 *
520 * @return string 'classic', 'hidden', or 'dismissed'.
521 */
522 public function get_classic_forms_state() {
523 $state = get_option( self::CLASSIC_FORMS_OPTION );
524
525 if ( $state ) {
526 return $state;
527 }
528
529 $state = $this->detect_classic_forms();
530 update_option( self::CLASSIC_FORMS_OPTION, $state, false );
531
532 return $state;
533 }
534
535 /**
536 * Detects whether any feedback posts exist that are not linked to a jetpack_form post,
537 * indicating the site has classic (inline, widget, or template) forms.
538 *
539 * A feedback post is considered "classic" if:
540 * - It has no parent (post_parent = 0), meaning it was created by a form embedded in a
541 * widget, page template, or other non-post context.
542 * - Its parent exists but is not a jetpack_form post, meaning it was created by a form
543 * block or shortcode placed directly in a post or page.
544 *
545 * The query uses a LEFT JOIN on the posts table to find feedback posts with no matching
546 * jetpack_form parent. This leverages the primary key index for the join and the
547 * type_status_date index for filtering by post_type, making it efficient even on large
548 * sites. The LIMIT 1 ensures early exit as soon as one classic form is found.
549 *
550 * Note: An alternative approach would be to search post_content for the form block markup
551 * (<!-- wp:jetpack/contact-form) or shortcode ([contact-form]). However, that requires a
552 * full-text scan of the posts table (LIKE '%...%' on a TEXT column) with no usable index,
553 * making it significantly more expensive. The feedback-based approach also better fits the
554 * use case: we only need to surface the "Not seeing all your forms?" prompt when there are
555 * actual submissions that won't appear under any synced form in the dashboard.
556 *
557 * @since 7.14.0
558 *
559 * @return string 'classic' or 'hidden'.
560 */
561 private function detect_classic_forms() {
562 global $wpdb;
563
564 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
565 $result = $wpdb->get_var(
566 $wpdb->prepare(
567 "SELECT 1 FROM {$wpdb->posts} AS f
568 LEFT JOIN {$wpdb->posts} AS p
569 ON p.ID = f.post_parent AND p.post_type = %s
570 WHERE f.post_type = 'feedback'
571 AND p.ID IS NULL
572 LIMIT 1",
573 Contact_Form::POST_TYPE
574 )
575 );
576
577 return $result ? self::CLASSIC_FORMS_STATE_CLASSIC : self::CLASSIC_FORMS_STATE_HIDDEN;
578 }
579
580 /**
581 * Eagerly marks the site as having classic forms by setting the option to 'classic'.
582 *
583 * Called when a new form submission is saved that does not belong to a synced jetpack_form.
584 * This avoids re-running the detection query — once a classic submission is observed, the
585 * state is permanently set without needing to scan the database again.
586 *
587 * If the user has already dismissed the classic forms notice, the state is left as
588 * 'dismissed' so the notice does not reappear.
589 *
590 * @since 7.14.0
591 */
592 public static function mark_classic_form_detected() {
593 $current = get_option( self::CLASSIC_FORMS_OPTION );
594
595 if ( self::CLASSIC_FORMS_STATE_DISMISSED === $current ) {
596 return;
597 }
598
599 update_option( self::CLASSIC_FORMS_OPTION, self::CLASSIC_FORMS_STATE_CLASSIC, false );
600 }
601
602 /**
603 * Returns url of forms admin page.
604 *
605 * @param string|null $tab Tab to open in the forms admin page.
606 * @param int|null $post_id Post ID of response to open in the forms responses page.
607 *
608 * @return string
609 */
610 public static function get_forms_admin_url( $tab = null, $post_id = null ) {
611 /** This filter is documented in class-dashboard.php::init */
612 $is_wp_build_enabled = apply_filters( 'jetpack_forms_alpha', true );
613 $url = admin_url( 'admin.php' );
614
615 $url .= $is_wp_build_enabled
616 ? '?page=' . self::FORMS_WPBUILD_ADMIN_SLUG
617 : '?page=' . self::ADMIN_SLUG;
618
619 if ( $is_wp_build_enabled ) {
620 $path = self::get_forms_admin_path_wp_build( $tab, $post_id );
621 $url .= '&p=' . rawurlencode( $path );
622 } else {
623 $suffix = self::get_forms_admin_suffix_legacy( $tab, $post_id );
624
625 if ( $suffix !== '' ) {
626 $url .= $suffix;
627 }
628 }
629
630 /**
631 * Filters the Forms admin page URL.
632 *
633 * @module contact-form
634 * @since 7.8.0
635 *
636 * @param string $url The Forms admin page URL.
637 * @param string|null $tab Tab to open in the forms admin page.
638 * @param int|null $post_id Post ID of response to open in the forms responses page.
639 *
640 * @return string The filtered Forms admin page URL.
641 */
642 return apply_filters( 'jetpack_forms_admin_url', $url, $tab, $post_id );
643 }
644
645 /**
646 * Returns the URL of the standalone single response page for a given response.
647 *
648 * The standalone page is a wp-build route (`/response/<id>`). The legacy
649 * dashboard has no equivalent, so it falls back to the responses list with the
650 * response selected — as does a missing/empty post ID.
651 *
652 * @since 7.25.0
653 *
654 * @param int|null $post_id Post ID of the response to open.
655 *
656 * @return string
657 */
658 public static function get_single_response_admin_url( $post_id = null ) {
659 $post_id = ! empty( $post_id ) ? absint( $post_id ) : null;
660
661 // `get_forms_admin_url()` owns the URL scheme for both dashboards. The
662 // 'response' tab resolves to the standalone page on wp-build, and falls
663 // through to the responses list on legacy, which has no such route.
664 return self::get_forms_admin_url( $post_id ? 'response' : 'inbox', $post_id );
665 }
666
667 /**
668 * WP-Build path for the forms admin URL.
669 *
670 * @param string|null $tab Tab to open.
671 * @param int|null $post_id Post ID of response.
672 * @return string URL path (e.g. '/', '/responses/inbox', '/forms').
673 */
674 private static function get_forms_admin_path_wp_build( $tab, $post_id ) {
675 $post_id = ! empty( $post_id ) ? absint( $post_id ) : null;
676 $response_ids = ! empty( $post_id ) ? '?responseIds=["' . $post_id . '"]' : '';
677
678 // The standalone single response page, which addresses the response by path
679 // rather than selecting it in a list.
680 if ( $tab === 'response' && ! empty( $post_id ) ) {
681 return '/response/' . $post_id;
682 }
683
684 $path_map = array(
685 'inbox' => '/responses/inbox',
686 'spam' => '/responses/spam',
687 'trash' => '/responses/trash',
688 'forms' => '/forms',
689 'responses/inbox' => '/responses/inbox',
690 );
691
692 if ( $tab !== null && $tab !== '' && isset( $path_map[ $tab ] ) ) {
693 return $path_map[ $tab ] . $response_ids;
694 }
695
696 if ( ! empty( $post_id ) ) {
697 return '/responses/inbox?responseIds=["' . $post_id . '"]';
698 }
699
700 return '/responses/inbox';
701 }
702
703 /**
704 * Legacy (hash-based) URL suffix for the forms admin page.
705 *
706 * @param string|null $tab Tab to open.
707 * @param int|null $post_id Post ID of response.
708 * @return string URL suffix (e.g. '#/responses?status=inbox&r=123', or '#/forms').
709 */
710 private static function get_forms_admin_suffix_legacy( $tab, $post_id ) {
711 $post_id = ! empty( $post_id ) ? absint( $post_id ) : null;
712 $valid_tabs = array( 'spam', 'inbox', 'trash' );
713 $r_param = ! empty( $post_id ) ? '&r=' . $post_id : '';
714
715 if ( in_array( $tab, $valid_tabs, true ) ) {
716 return '#/responses?status=' . $tab . $r_param;
717 }
718
719 if ( $tab === 'forms' ) {
720 return '#/forms';
721 }
722
723 if ( ! empty( $post_id ) ) {
724 return '#/responses?status=inbox' . $r_param;
725 }
726
727 return '';
728 }
729
730 /**
731 * Returns true if the current screen is the Jetpack Forms admin page.
732 *
733 * @return boolean
734 */
735 public static function is_jetpack_forms_admin_page() {
736 if ( ! function_exists( 'get_current_screen' ) ) {
737 return false;
738 }
739
740 $screen = get_current_screen();
741
742 if ( ! $screen || ! isset( $screen->id ) ) {
743 return false;
744 }
745
746 $forms_admin_screens = array(
747 'jetpack_page_' . self::ADMIN_SLUG,
748 'jetpack_page_' . self::FORMS_WPBUILD_ADMIN_SLUG,
749 );
750
751 return in_array( $screen->id, $forms_admin_screens, true );
752 }
753
754 /**
755 * Returns true if form notes feature is enabled.
756 *
757 * @return boolean
758 */
759 public static function is_notes_enabled() {
760 /**
761 * Enable form notes feature in Jetpack Forms .
762 *
763 * @module contact-form
764 * @since 7.3.0
765 *
766 * @param bool $enabled Should the form notes feature be enabled? Defaults to false.
767 */
768 return apply_filters( 'jetpack_forms_notes_enable', false );
769 }
770
771 /**
772 * Get admin URL for given screen ID.
773 *
774 * @deprecated 7.9.0 Use Dashboard::get_forms_admin_url() instead.
775 *
776 * @param string $screen_id Screen ID.
777 * @return string Admin URL.
778 */
779 public static function get_admin_url( $screen_id ) {
780 _deprecated_function( __METHOD__, 'jetpack-7.9.0', 'Dashboard::get_forms_admin_url' );
781
782 if ( 'edit-jetpack_form' === $screen_id ) {
783 return self::get_forms_admin_url( 'forms' );
784 }
785
786 if ( 'edit-feedback' === $screen_id ) {
787 return self::get_forms_admin_url( 'inbox' );
788 }
789
790 return self::get_forms_admin_url();
791 }
792 }
793