Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
65.62% |
105 / 160 |
|
66.67% |
8 / 12 |
CRAP | |
0.00% |
0 / 1 |
| Form_Editor | |
65.62% |
105 / 160 |
|
66.67% |
8 / 12 |
73.59 | |
0.00% |
0 / 1 |
| init | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| allowed_blocks_for_jetpack_form | |
100.00% |
61 / 61 |
|
100.00% |
1 / 1 |
3 | |||
| block_editor_settings_all | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| disable_block_directory | |
50.00% |
2 / 4 |
|
0.00% |
0 / 1 |
4.12 | |||
| enqueue_admin_scripts | |
60.00% |
3 / 5 |
|
0.00% |
0 / 1 |
5.02 | |||
| enqueue_editor_bundle | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
6 | |||
| enqueue_welcome_guide | |
8.11% |
3 / 37 |
|
0.00% |
0 / 1 |
33.93 | |||
| get_persisted_preferences | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| preference_is | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| is_core_welcome_guide_pending | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_welcome_guide_dismissed | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| is_welcome_guide_eligible | |
100.00% |
19 / 19 |
|
100.00% |
1 / 1 |
3 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Jetpack forms editor. |
| 4 | * |
| 5 | * @package automattic/jetpack-forms |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\Forms\Editor; |
| 9 | |
| 10 | use Automattic\Jetpack\Assets; |
| 11 | use Automattic\Jetpack\Forms\ContactForm\Contact_Form; |
| 12 | |
| 13 | /** |
| 14 | * Class Form_Editor |
| 15 | * |
| 16 | * Handles the form editor functionality for jetpack-form post type. |
| 17 | */ |
| 18 | class Form_Editor { |
| 19 | |
| 20 | /** |
| 21 | * Script handle for the form editor. |
| 22 | * |
| 23 | * @var string |
| 24 | */ |
| 25 | const SCRIPT_HANDLE = 'jetpack-form-editor'; |
| 26 | |
| 27 | /** |
| 28 | * Script handle for the welcome guide. |
| 29 | * |
| 30 | * @var string |
| 31 | */ |
| 32 | const WELCOME_GUIDE_SCRIPT_HANDLE = 'jetpack-form-welcome-guide'; |
| 33 | |
| 34 | /** |
| 35 | * Preference scope owned by Jetpack Forms. Mirrors PREFERENCE_SCOPE in welcome-guide/index.tsx. |
| 36 | * |
| 37 | * @var string |
| 38 | */ |
| 39 | const PREFERENCE_SCOPE = 'jetpack/forms'; |
| 40 | |
| 41 | /** |
| 42 | * Preference name holding whether the guide is still pending. Mirrors PREFERENCE_NAME in welcome-guide/index.tsx. |
| 43 | * |
| 44 | * @var string |
| 45 | */ |
| 46 | const PREFERENCE_NAME = 'welcomeGuide'; |
| 47 | |
| 48 | /** |
| 49 | * Core's own welcome modal scope, which its Options menu item toggles. Owned by @wordpress/edit-post; mirrored in welcome-guide/index.tsx too. |
| 50 | * |
| 51 | * @var string |
| 52 | */ |
| 53 | const CORE_PREFERENCE_SCOPE = 'core/edit-post'; |
| 54 | |
| 55 | /** |
| 56 | * Initialize the form editor. |
| 57 | */ |
| 58 | public static function init() { |
| 59 | add_filter( 'allowed_block_types_all', array( __CLASS__, 'allowed_blocks_for_jetpack_form' ), 10, 2 ); |
| 60 | add_filter( 'block_editor_settings_all', array( __CLASS__, 'block_editor_settings_all' ), 10, 2 ); |
| 61 | add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_admin_scripts' ) ); |
| 62 | add_action( 'current_screen', array( __CLASS__, 'disable_block_directory' ) ); |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Restrict allowed blocks when editing jetpack-form posts. |
| 67 | * |
| 68 | * Only allows field blocks and supporting blocks. The contact-form block is excluded |
| 69 | * because visual wrapping is handled via DOM manipulation in the editor script. |
| 70 | * |
| 71 | * @param bool|array $allowed_block_types Array of block type slugs, or boolean to enable/disable all. |
| 72 | * @param object $editor_context The current editor context. |
| 73 | * @return bool|array Array of allowed block types for jetpack-form posts. |
| 74 | */ |
| 75 | public static function allowed_blocks_for_jetpack_form( $allowed_block_types, $editor_context ) { |
| 76 | // Only apply to jetpack-form post type. |
| 77 | if ( ! isset( $editor_context->post ) || Contact_Form::POST_TYPE !== $editor_context->post->post_type ) { |
| 78 | return $allowed_block_types; |
| 79 | } |
| 80 | |
| 81 | // Allow only field blocks, button, and core blocks. |
| 82 | // Visual wrapping is handled by JavaScript DOM manipulation. |
| 83 | return array( |
| 84 | // Field blocks. |
| 85 | 'jetpack/field-name', |
| 86 | 'jetpack/field-email', |
| 87 | 'jetpack/field-url', |
| 88 | 'jetpack/field-telephone', |
| 89 | 'jetpack/field-textarea', |
| 90 | 'jetpack/field-checkbox', |
| 91 | 'jetpack/field-checkbox-multiple', |
| 92 | 'jetpack/field-radio', |
| 93 | 'jetpack/field-select', |
| 94 | 'jetpack/field-date', |
| 95 | 'jetpack/field-consent', |
| 96 | 'jetpack/field-rating', |
| 97 | 'jetpack/field-text', |
| 98 | 'jetpack/field-number', |
| 99 | 'jetpack/field-hidden', |
| 100 | 'jetpack/field-file', |
| 101 | 'jetpack/field-time', |
| 102 | 'jetpack/field-slider', |
| 103 | 'jetpack/field-image-select', |
| 104 | |
| 105 | // Supporting blocks. |
| 106 | 'jetpack/button', // Used for the submit button previously. |
| 107 | 'jetpack/label', |
| 108 | 'jetpack/input', |
| 109 | 'jetpack/options', |
| 110 | 'jetpack/option', |
| 111 | 'jetpack/phone-input', |
| 112 | 'jetpack/dropzone', |
| 113 | 'jetpack/input-range', |
| 114 | 'jetpack/input-rating', |
| 115 | 'jetpack/fieldset-image-options', |
| 116 | 'jetpack/input-image-option', |
| 117 | |
| 118 | // Multistep blocks. |
| 119 | 'jetpack/form-step', |
| 120 | 'jetpack/form-step-container', |
| 121 | 'jetpack/form-step-divider', |
| 122 | 'jetpack/form-step-navigation', |
| 123 | 'jetpack/form-progress-indicator', |
| 124 | |
| 125 | // Core blocks for rich content. |
| 126 | 'core/accordion', |
| 127 | 'core/audio', |
| 128 | 'core/button', // Used for the submit button. |
| 129 | 'core/code', |
| 130 | 'core/column', |
| 131 | 'core/columns', |
| 132 | 'core/details', |
| 133 | 'core/group', |
| 134 | 'core/heading', |
| 135 | 'core/html', |
| 136 | 'core/icon', |
| 137 | 'core/image', |
| 138 | 'core/list-item', |
| 139 | 'core/list', |
| 140 | 'core/math', |
| 141 | 'core/paragraph', |
| 142 | 'core/row', |
| 143 | 'core/separator', |
| 144 | 'core/spacer', |
| 145 | 'core/stack', |
| 146 | 'core/subhead', |
| 147 | 'core/video', |
| 148 | ); |
| 149 | } |
| 150 | |
| 151 | /** |
| 152 | * Modify block editor settings for jetpack-form posts. |
| 153 | * |
| 154 | * @param array $settings Block editor settings. |
| 155 | * @param object $editor_context The current editor context. |
| 156 | * @return array Modified block editor settings for jetpack-form posts. |
| 157 | */ |
| 158 | public static function block_editor_settings_all( $settings, $editor_context ) { |
| 159 | // Only apply to jetpack-form post type. |
| 160 | if ( ! isset( $editor_context->post ) || Contact_Form::POST_TYPE !== $editor_context->post->post_type ) { |
| 161 | return $settings; |
| 162 | } |
| 163 | |
| 164 | // Disable block locking capability. |
| 165 | $settings['canLockBlocks'] = false; |
| 166 | |
| 167 | return $settings; |
| 168 | } |
| 169 | |
| 170 | /** |
| 171 | * Disable the block directory in the form editor. |
| 172 | * |
| 173 | * Removes the block directory assets (install blocks from the inserter) |
| 174 | * since this feature is not needed in the form editor. |
| 175 | * Hooked to `current_screen` so it runs before scripts are enqueued. |
| 176 | * |
| 177 | * @param \WP_Screen $screen The current screen object. |
| 178 | */ |
| 179 | public static function disable_block_directory( $screen ) { |
| 180 | if ( ! isset( $screen->post_type ) ) { |
| 181 | return; |
| 182 | } |
| 183 | if ( Contact_Form::POST_TYPE === $screen->post_type ) { |
| 184 | remove_action( 'enqueue_block_editor_assets', 'wp_enqueue_editor_block_directory_assets' ); |
| 185 | } |
| 186 | } |
| 187 | |
| 188 | /** |
| 189 | * Enqueue admin scripts for block editor. |
| 190 | * |
| 191 | * Loads in all post block editor contexts (excluding the site editor). This |
| 192 | * cannot be narrowed to the form post type: `navigateToForm()` switches to a |
| 193 | * form through Gutenberg's in-editor entity navigation, which never reloads |
| 194 | * the page, so `admin_enqueue_scripts` does not run again. A page editor that |
| 195 | * did not load this bundle up front would jump into a form with none of the |
| 196 | * form editor behavior available. |
| 197 | */ |
| 198 | public static function enqueue_admin_scripts() { |
| 199 | $screen = get_current_screen(); |
| 200 | |
| 201 | // Only load in block editor contexts, not site editor |
| 202 | if ( ! $screen || $screen->id === 'site-editor' || ! $screen->is_block_editor ) { |
| 203 | return; |
| 204 | } |
| 205 | // Separate calls, so a missing editor asset does not take the guide down |
| 206 | // with it — the two ship as their own entries. |
| 207 | self::enqueue_editor_bundle(); |
| 208 | self::enqueue_welcome_guide(); |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Enqueue the form editor bundle. |
| 213 | */ |
| 214 | private static function enqueue_editor_bundle() { |
| 215 | $asset_file = __DIR__ . '/../../dist/form-editor/jetpack-form-editor.asset.php'; |
| 216 | if ( ! file_exists( $asset_file ) ) { |
| 217 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log |
| 218 | error_log( 'Form Editor asset file not found: ' . $asset_file ); |
| 219 | return; |
| 220 | } |
| 221 | |
| 222 | $asset = require $asset_file; |
| 223 | Assets::register_script( |
| 224 | self::SCRIPT_HANDLE, |
| 225 | '../../dist/form-editor/jetpack-form-editor.js', |
| 226 | __FILE__, |
| 227 | array( |
| 228 | 'in_footer' => true, |
| 229 | 'textdomain' => 'jetpack-forms', |
| 230 | 'enqueue' => true, |
| 231 | 'dependencies' => $asset['dependencies'], |
| 232 | 'version' => $asset['version'], |
| 233 | ) |
| 234 | ); |
| 235 | } |
| 236 | |
| 237 | /** |
| 238 | * Enqueue the welcome guide. |
| 239 | * |
| 240 | * Unlike the editor bundle, this is scoped to the form post type. The guide |
| 241 | * is for people meeting the block editor for the first time, so someone who |
| 242 | * reaches a form through in-editor navigation from a post or page has |
| 243 | * already demonstrated they do not need it — and that path never re-runs |
| 244 | * this hook, so not loading here is what skips the guide for them. |
| 245 | * |
| 246 | * Loaded on every form editor page load, including once the guide has been |
| 247 | * dismissed. The guide does not add an Options menu item of its own; it |
| 248 | * takes over core's "Welcome Guide" item, which is present whether or not |
| 249 | * this bundle is. Skipping the bundle for dismissed users would leave that |
| 250 | * item unclaimed, and because it is a toggle rather than a button, choosing |
| 251 | * it would persist `welcomeGuide: true` — outliving the page load, beating |
| 252 | * the runtime default this package sets, and reopening core's generic modal |
| 253 | * in the form editor on each load until the user finished it there. |
| 254 | * |
| 255 | * The bundle carries the slide copy and styles as well as the shim that |
| 256 | * claims the menu item, so a dismissed user does pay for those — a few KB |
| 257 | * gzipped. Only the artwork is deferred, fetched when the guide opens. |
| 258 | */ |
| 259 | private static function enqueue_welcome_guide() { |
| 260 | $screen = get_current_screen(); |
| 261 | if ( ! $screen || ! isset( $screen->post_type ) || Contact_Form::POST_TYPE !== $screen->post_type ) { |
| 262 | return; |
| 263 | } |
| 264 | |
| 265 | $preferences = self::get_persisted_preferences(); |
| 266 | |
| 267 | /* |
| 268 | * Eligibility decides whether the guide opens on its own, which it |
| 269 | * never does once dismissed — reopening from the Options menu and the |
| 270 | * query argument both bypass it. The lookup costs a query, so skip it |
| 271 | * when the answer cannot change anything. |
| 272 | */ |
| 273 | $is_eligible = ! self::is_welcome_guide_dismissed( $preferences ) |
| 274 | && self::is_welcome_guide_eligible( $preferences ); |
| 275 | |
| 276 | $asset_file = __DIR__ . '/../../dist/form-editor/jetpack-form-welcome-guide.asset.php'; |
| 277 | if ( ! file_exists( $asset_file ) ) { |
| 278 | // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log |
| 279 | error_log( 'Welcome guide asset file not found: ' . $asset_file ); |
| 280 | return; |
| 281 | } |
| 282 | |
| 283 | $asset = require $asset_file; |
| 284 | Assets::register_script( |
| 285 | self::WELCOME_GUIDE_SCRIPT_HANDLE, |
| 286 | '../../dist/form-editor/jetpack-form-welcome-guide.js', |
| 287 | __FILE__, |
| 288 | array( |
| 289 | 'in_footer' => true, |
| 290 | 'textdomain' => 'jetpack-forms', |
| 291 | 'enqueue' => true, |
| 292 | 'dependencies' => $asset['dependencies'], |
| 293 | 'version' => $asset['version'], |
| 294 | ) |
| 295 | ); |
| 296 | |
| 297 | // Written as JSON rather than through wp_localize_script(), which casts |
| 298 | // booleans to '1' and ''. |
| 299 | wp_add_inline_script( |
| 300 | self::WELCOME_GUIDE_SCRIPT_HANDLE, |
| 301 | 'window.jetpackFormsWelcomeGuide = ' . wp_json_encode( |
| 302 | array( |
| 303 | 'isEligible' => $is_eligible, |
| 304 | 'isCoreGuidePending' => self::is_core_welcome_guide_pending( $preferences ), |
| 305 | |
| 306 | /* |
| 307 | * Build-dir URL for the guide's artwork. The bundle sets |
| 308 | * webpack's publicPath from this because `'auto'` misresolves |
| 309 | * the images on WordPress.com Simple, where JS concatenation |
| 310 | * rewrites the script URL auto-detection reads. Derived the |
| 311 | * same way register_script() resolves the script URL above. |
| 312 | */ |
| 313 | 'assetsUrl' => trailingslashit( |
| 314 | Assets::normalize_path( plugins_url( '../../dist/form-editor', __FILE__ ) ) |
| 315 | ), |
| 316 | ), |
| 317 | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT |
| 318 | ) . ';', |
| 319 | 'before' |
| 320 | ); |
| 321 | } |
| 322 | |
| 323 | /** |
| 324 | * Reads the current user's persisted editor preferences. |
| 325 | * |
| 326 | * The same blob backs both the core welcome modal's state and this guide's |
| 327 | * dismissal, so it is read once and passed around. |
| 328 | * |
| 329 | * Core keeps these per site, under a blog-prefixed meta key built by |
| 330 | * wp_register_persisted_preferences_meta(), so the key has to come from |
| 331 | * get_blog_prefix() rather than a literal `wp_`. On a multisite subsite or |
| 332 | * an install with a custom table prefix a hardcoded key reads nothing, |
| 333 | * which looks exactly like "never dismissed, and eligible" for everyone. |
| 334 | * |
| 335 | * @return array The stored preferences, or an empty array. |
| 336 | */ |
| 337 | private static function get_persisted_preferences() { |
| 338 | global $wpdb; |
| 339 | |
| 340 | $user_id = get_current_user_id(); |
| 341 | if ( ! $user_id ) { |
| 342 | return array(); |
| 343 | } |
| 344 | |
| 345 | $preferences = get_user_meta( $user_id, $wpdb->get_blog_prefix() . 'persisted_preferences', true ); |
| 346 | |
| 347 | return is_array( $preferences ) ? $preferences : array(); |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * Whether a welcome guide preference is stored with the given value. |
| 352 | * |
| 353 | * Both scopes keep the flag under the same name, and every rule here turns |
| 354 | * on a stored value rather than an absent one, so the three predicates below |
| 355 | * differ only in which scope and which value they ask about. |
| 356 | * |
| 357 | * @param array $preferences The user's persisted editor preferences. |
| 358 | * @param string $scope Preference scope to look in. |
| 359 | * @param bool $value Value to compare against. |
| 360 | * @return bool Whether the preference is stored with that value. |
| 361 | */ |
| 362 | private static function preference_is( array $preferences, $scope, $value ) { |
| 363 | return isset( $preferences[ $scope ][ self::PREFERENCE_NAME ] ) |
| 364 | && $value === $preferences[ $scope ][ self::PREFERENCE_NAME ]; |
| 365 | } |
| 366 | |
| 367 | /** |
| 368 | * Whether the user has asked for core's welcome modal and not yet seen it. |
| 369 | * |
| 370 | * Only a *stored* true counts. Core's own default is also true, but it is |
| 371 | * never written to the blob, so the two are indistinguishable in the |
| 372 | * browser — which is why this is decided here. The guide treats a stored |
| 373 | * true as a request for itself, since core's Options menu item is a toggle: |
| 374 | * choosing it on a screen where this bundle was absent (arriving at a form |
| 375 | * through in-editor navigation) persists true, and nothing would otherwise |
| 376 | * clear it. Left alone it beats the runtime default the editor bundle sets |
| 377 | * and reopens core's generic modal on every later form editor load. |
| 378 | * |
| 379 | * @param array $preferences The user's persisted editor preferences. |
| 380 | * @return bool Whether core's welcome modal is pending by the user's own choice. |
| 381 | */ |
| 382 | private static function is_core_welcome_guide_pending( array $preferences ) { |
| 383 | return self::preference_is( $preferences, self::CORE_PREFERENCE_SCOPE, true ); |
| 384 | } |
| 385 | |
| 386 | /** |
| 387 | * Whether the user has already dismissed the welcome guide. |
| 388 | * |
| 389 | * @param array $preferences The user's persisted editor preferences. |
| 390 | * @return bool Whether the guide has been dismissed. |
| 391 | */ |
| 392 | private static function is_welcome_guide_dismissed( array $preferences ) { |
| 393 | return self::preference_is( $preferences, self::PREFERENCE_SCOPE, false ); |
| 394 | } |
| 395 | |
| 396 | /** |
| 397 | * Whether the welcome guide should open on its own for the current user. |
| 398 | * |
| 399 | * Two audiences get it. Someone who has never dismissed the core welcome |
| 400 | * modal is new to the block editor, and the form guide stands in for the |
| 401 | * core one here. Everyone else gets it only until they have a form of their |
| 402 | * own, as first-run onboarding — regardless of how many posts or pages they |
| 403 | * have written. |
| 404 | * |
| 405 | * This only decides whether the guide opens by itself. The query argument |
| 406 | * overrides it. |
| 407 | * |
| 408 | * @param array $preferences The user's persisted editor preferences. |
| 409 | * @return bool Whether the guide should open on its own. |
| 410 | */ |
| 411 | private static function is_welcome_guide_eligible( array $preferences ) { |
| 412 | $user_id = get_current_user_id(); |
| 413 | if ( ! $user_id ) { |
| 414 | return false; |
| 415 | } |
| 416 | |
| 417 | // Core only stores false once the modal has been dismissed, so anything |
| 418 | // else — including no stored value at all — means it is still pending. |
| 419 | if ( ! self::preference_is( $preferences, self::CORE_PREFERENCE_SCOPE, false ) ) { |
| 420 | return true; |
| 421 | } |
| 422 | |
| 423 | // Every status except auto-draft: opening this screen creates one before |
| 424 | // the enqueue runs, so counting it would hide the guide from the very |
| 425 | // first-time author it is meant for. |
| 426 | $statuses = array_values( array_diff( array_keys( get_post_stati() ), array( 'auto-draft' ) ) ); |
| 427 | |
| 428 | $existing_forms = get_posts( |
| 429 | array( |
| 430 | 'post_type' => Contact_Form::POST_TYPE, |
| 431 | 'post_status' => $statuses, |
| 432 | 'author' => $user_id, |
| 433 | 'numberposts' => 1, |
| 434 | 'fields' => 'ids', |
| 435 | 'no_found_rows' => true, |
| 436 | 'cache_results' => false, |
| 437 | 'orderby' => 'none', |
| 438 | ) |
| 439 | ); |
| 440 | |
| 441 | return empty( $existing_forms ); |
| 442 | } |
| 443 | } |