diff --git a/includes/admin/beta-features.php b/includes/admin/beta-features.php index e0e656ea..848ff6fb 100644 --- a/includes/admin/beta-features.php +++ b/includes/admin/beta-features.php @@ -27,6 +27,13 @@ class SCF_Admin_Beta_Features { */ private $beta_features = array(); + /** + * Whether the beta feature classes have been included. + * + * @var bool + */ + private $included = false; + /** * This function will setup the class functionality * @@ -64,6 +71,9 @@ public function register_beta_feature( $beta_feature ) { * @return mixed (SCF_Admin_Beta_Feature|null) */ public function get_beta_feature( $name ) { + // Include beta features. + $this->include_beta_features(); + return isset( $this->beta_features[ $name ] ) ? $this->beta_features[ $name ] : null; } @@ -137,8 +147,13 @@ public function admin_body_class( $classes ) { * @return void */ private function include_beta_features() { + if ( $this->included ) { + return; + } + $this->included = true; + acf_include( 'includes/admin/beta-features/class-scf-beta-feature.php' ); - acf_include( 'includes/admin/beta-features/class-scf-beta-feature-connect-fields.php' ); + acf_include( 'includes/admin/beta-features/class-scf-beta-feature-editor-sidebar.php' ); add_action( 'scf/include_admin_beta_features', array( $this, 'register_beta_features' ) ); @@ -153,6 +168,7 @@ private function include_beta_features() { * @return void */ public function register_beta_features() { + $this->register_beta_feature( 'SCF_Admin_Beta_Feature_Editor_Sidebar' ); } /** diff --git a/includes/ajax/class-acf-ajax-check-screen.php b/includes/ajax/class-acf-ajax-check-screen.php index c3218f54..53ffad02 100644 --- a/includes/ajax/class-acf-ajax-check-screen.php +++ b/includes/ajax/class-acf-ajax-check-screen.php @@ -37,6 +37,12 @@ public function get_response( $request ) { return new WP_Error( 'acf_invalid_permissions', __( 'Sorry, you do not have permission to do that.', 'secure-custom-fields' ) ); } + $post_type = $this->get( 'post_type' ); + + if ( ! $post_type && 'post' === $args['screen'] ) { + $post_type = get_post_type( (int) $args['post_id'] ); + } + $response = array( 'results' => array(), 'style' => '', @@ -45,6 +51,9 @@ public function get_response( $request ) { // get field groups $field_groups = acf_get_field_groups( $args ); + // Meta box IDs moved into the editor sidebar by a beta feature. + $relocated = array(); + // loop through field groups if ( $field_groups ) { foreach ( $field_groups as $i => $field_group ) { @@ -54,7 +63,7 @@ public function get_response( $request ) { 'id' => esc_attr( 'acf-' . $field_group['key'] ), 'key' => esc_attr( $field_group['key'] ), 'title' => acf_esc_html( acf_get_field_group_title( $field_group ) ), - 'position' => esc_attr( $field_group['position'] ), + 'position' => esc_attr( scf_get_field_group_meta_box_position( $post_type, $field_group['position'] ) ), 'classes' => postbox_classes( 'acf-' . $field_group['key'], $args['screen'] ), 'style' => esc_attr( $field_group['style'] ), 'label' => esc_attr( $field_group['label_placement'] ), @@ -62,6 +71,10 @@ public function get_response( $request ) { 'html' => '', ); + if ( $item['position'] !== $field_group['position'] ) { + $relocated[] = $item['id']; + } + $hidden_metaboxes = get_hidden_meta_boxes( $args['screen'] ); if ( is_array( $hidden_metaboxes ) && in_array( $item['id'], $hidden_metaboxes ) ) { @@ -95,7 +108,13 @@ public function get_response( $request ) { // Custom metabox order. if ( $this->get( 'screen' ) == 'post' ) { - $response['sorted'] = get_user_option( 'meta-box-order_' . $this->get( 'post_type' ) ); + $sorted = get_user_option( 'meta-box-order_' . $post_type ); + + if ( $relocated && is_array( $sorted ) ) { + $sorted = scf_move_meta_box_ids_to_sidebar( $sorted, $relocated ); + } + + $response['sorted'] = $sorted; } /** diff --git a/includes/api/api-helpers.php b/includes/api/api-helpers.php index bd8d2edf..ed98a294 100644 --- a/includes/api/api-helpers.php +++ b/includes/api/api-helpers.php @@ -4146,6 +4146,107 @@ function acf_is_block_editor() { return false; } +/** + * Returns true when the "Move Elements to Editor Sidebar" beta feature is + * enabled and the given post type is edited with the block editor. + * + * The beta feature registry only loads on admin screens, so this is always + * false outside of them, and post types using the classic editor are never + * affected. + * + * @param string $post_type The post type being edited. + * @return boolean + */ +function scf_field_groups_use_editor_sidebar( $post_type ) { + if ( ! is_string( $post_type ) || '' === $post_type ) { + return false; + } + + if ( ! function_exists( 'use_block_editor_for_post_type' ) || ! use_block_editor_for_post_type( $post_type ) ) { + return false; + } + + $acf = acf(); + + if ( ! isset( $acf->admin_beta_features ) ) { + return false; + } + + $feature = $acf->admin_beta_features->get_beta_feature( 'editor_sidebar' ); + + return $feature && $feature->is_enabled(); +} + +/** + * Returns the meta box position a field group should render in. + * + * Field groups keep the position saved on them unless the editor sidebar beta + * feature moves them into the block editor sidebar. + * + * @param string $post_type The post type being edited. + * @param string $position The position saved on the field group. + * @return string + */ +function scf_get_field_group_meta_box_position( $post_type, $position ) { + if ( 'side' === $position || ! scf_field_groups_use_editor_sidebar( $post_type ) ) { + return $position; + } + + return 'side'; +} + +/** + * Moves meta boxes into the sidebar within a saved meta box order. + * + * WordPress rebuilds the meta box registry from the order a user saved for the + * screen, and that order still lists field groups under the position saved on + * them. Without this the saved order would pull a field group back out of the + * editor sidebar. + * + * @param array $meta_box_order The saved order, a comma separated list of IDs per location. + * @param array $ids The meta box IDs to move. + * @return array + */ +function scf_move_meta_box_ids_to_sidebar( $meta_box_order, $ids ) { + if ( ! is_array( $meta_box_order ) || empty( $ids ) ) { + return $meta_box_order; + } + + $sidebar = array(); + + foreach ( array( 'side', 'acf_after_title', 'normal', 'advanced' ) as $location ) { + if ( empty( $meta_box_order[ $location ] ) || ! is_string( $meta_box_order[ $location ] ) ) { + continue; + } + + $remaining = array(); + + foreach ( explode( ',', $meta_box_order[ $location ] ) as $id ) { + $id = trim( $id ); + + if ( '' === $id ) { + continue; + } + + if ( 'side' === $location ) { + $sidebar[] = $id; + } elseif ( in_array( $id, $ids, true ) ) { + $sidebar[] = $id; + } else { + $remaining[] = $id; + } + } + + if ( 'side' !== $location ) { + $meta_box_order[ $location ] = implode( ',', $remaining ); + } + } + + $meta_box_order['side'] = implode( ',', array_values( array_unique( $sidebar ) ) ); + + return $meta_box_order; +} + /** * Return an array of the WordPress reserved terms * diff --git a/includes/forms/form-post.php b/includes/forms/form-post.php index 0f92bffb..4a8e7753 100644 --- a/includes/forms/form-post.php +++ b/includes/forms/form-post.php @@ -23,6 +23,13 @@ class ACF_Form_Post { */ public $style = ''; + /** + * Meta box IDs moved into the editor sidebar by a beta feature. + * + * @var array + */ + private $sidebar_meta_box_ids = array(); + /** * __construct * @@ -129,6 +136,12 @@ public function add_meta_boxes( $post_type, $post ) { // Storage for localized postboxes. $postboxes = array(); + // Storage for postboxes moved to the editor sidebar by a beta feature. + $this->sidebar_meta_box_ids = array(); + + // Whether field groups are rendered in the editor sidebar rather than below the content. + $use_sidebar = scf_field_groups_use_editor_sidebar( $post_type ); + // Get field groups for this screen. $field_groups = acf_get_field_groups( array( @@ -146,7 +159,7 @@ public function add_meta_boxes( $post_type, $post ) { } $id = esc_attr( "acf-{$field_group['key']}" ); - $context = esc_attr( $field_group['position'] ); + $context = esc_attr( scf_get_field_group_meta_box_position( $post_type, $field_group['position'] ) ); $priority = 'high'; // Reduce priority for sidebar metaboxes for best position. @@ -174,6 +187,10 @@ public function add_meta_boxes( $post_type, $post ) { 'edit' => esc_url( acf_get_field_group_edit_link( $field_group['ID'] ) ), ); + if ( $use_sidebar ) { + $this->sidebar_meta_box_ids[] = $id; + } + // Add the meta box. add_meta_box( $id, @@ -186,6 +203,11 @@ public function add_meta_boxes( $post_type, $post ) { ); } + // Keep the order saved for this screen from moving the boxes back out of the sidebar. + if ( $this->sidebar_meta_box_ids ) { + add_filter( 'get_user_option_meta-box-order_' . $post_type, array( $this, 'modify_user_option_meta_box_order' ) ); + } + // Set style from first field group. $this->style = acf_get_field_group_style( $field_groups[0] ); @@ -218,6 +240,21 @@ public function add_meta_boxes( $post_type, $post ) { do_action( 'acf/add_meta_boxes', $post_type, $post, $field_groups ); } + /** + * Keeps field groups registered in the sidebar inside the sidebar. + * + * WordPress rebuilds the meta box registry from the order saved for the + * screen, and that order still lists field groups under the position saved + * on them. Without this the saved order would move them back below the + * content. + * + * @param array $locations The meta box order saved for this screen. + * @return array + */ + public function modify_user_option_meta_box_order( $locations ) { + return scf_move_meta_box_ids_to_sidebar( $locations, $this->sidebar_meta_box_ids ); + } + /** * Called after the title and before the content editor to render the after title metaboxes. * Also renders the CSS required to hide the "hide-on-screen" elements on the page based on the field group settings. diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 59d8b016..c8fee97e 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -144,22 +144,25 @@ const test = wpTest.extend( { 'role=button[name="View"i]' ); - if ( + const hasViewButton = ( await viewButton.count() ) > 0 && - ( await viewButton.first().isVisible() ) - ) { - return editor.openPreviewPage(); - } + ( await viewButton.first().isVisible() ); - await editorTopBar - .locator( 'role=button[name="Preview"i]' ) - .click(); + await ( hasViewButton + ? viewButton.first() + : editorTopBar.locator( 'role=button[name="Preview"i]' ) + ).click(); + // WordPress trunk renamed the menu item to + // "Preview (opens in a new tab)", so match on the shared + // "Preview ... new tab" text instead of the exact label. const [ previewPage ] = await Promise.all( [ context.waitForEvent( 'page' ), - page.click( - 'role=menuitem[name=/Preview in new tab/i]' - ), + page + .getByRole( 'menuitem', { + name: /Preview.*new tab/i, + } ) + .click(), ] ); return previewPage; diff --git a/tests/php/EditorSidebarBetaTest.php b/tests/php/EditorSidebarBetaTest.php new file mode 100644 index 00000000..aa2a0623 --- /dev/null +++ b/tests/php/EditorSidebarBetaTest.php @@ -0,0 +1,271 @@ +admin_beta_features->get_beta_features(); + + $this->assertArrayHasKey( 'editor_sidebar', $beta_features ); + $this->assertInstanceOf( 'SCF_Admin_Beta_Feature_Editor_Sidebar', $beta_features['editor_sidebar'] ); + } + + /** + * Reading the features twice does not register them twice. + * + * @return void + */ + public function test_editor_sidebar_is_registered_once() { + $beta_features = acf()->admin_beta_features; + + $beta_features->get_beta_features(); + $beta_features->get_beta_features(); + $beta_features->get_beta_feature( 'editor_sidebar' ); + + $this->assertCount( 1, $beta_features->get_beta_features() ); + } + + /** + * A disabled feature leaves the saved position alone. + * + * @return void + */ + public function test_position_is_unchanged_when_the_beta_is_disabled() { + $this->assertSame( 'normal', scf_get_field_group_meta_box_position( 'post', 'normal' ) ); + $this->assertSame( 'acf_after_title', scf_get_field_group_meta_box_position( 'post', 'acf_after_title' ) ); + $this->assertFalse( scf_field_groups_use_editor_sidebar( 'post' ) ); + } + + /** + * An enabled feature moves content positions into the sidebar. + * + * @return void + */ + public function test_position_moves_to_side_when_the_beta_is_enabled() { + $this->enable_beta(); + + $this->assertTrue( scf_field_groups_use_editor_sidebar( 'post' ) ); + $this->assertSame( 'side', scf_get_field_group_meta_box_position( 'post', 'normal' ) ); + $this->assertSame( 'side', scf_get_field_group_meta_box_position( 'post', 'acf_after_title' ) ); + $this->assertSame( 'side', scf_get_field_group_meta_box_position( 'post', 'side' ) ); + } + + /** + * Post types using the classic editor keep their position. + * + * @return void + */ + public function test_position_is_unchanged_for_classic_editor_post_types() { + $this->enable_beta(); + add_filter( 'use_block_editor_for_post_type', '__return_false' ); + + $this->assertFalse( scf_field_groups_use_editor_sidebar( 'post' ) ); + $this->assertSame( 'normal', scf_get_field_group_meta_box_position( 'post', 'normal' ) ); + + remove_filter( 'use_block_editor_for_post_type', '__return_false' ); + } + + /** + * An unknown post type never uses the sidebar. + * + * @return void + */ + public function test_position_is_unchanged_without_a_post_type() { + $this->enable_beta(); + + $this->assertFalse( scf_field_groups_use_editor_sidebar( '' ) ); + $this->assertFalse( scf_field_groups_use_editor_sidebar( null ) ); + $this->assertFalse( scf_field_groups_use_editor_sidebar( 'no_such_post_type' ) ); + $this->assertSame( 'normal', scf_get_field_group_meta_box_position( '', 'normal' ) ); + } + + /** + * Saved order entries for moved groups end up in the sidebar. + * + * @return void + */ + public function test_saved_order_moves_moved_groups_to_the_sidebar() { + $order = array( + 'acf_after_title' => 'acf-group_one', + 'normal' => 'acf-group_two,submitdiv', + 'side' => 'postcustom', + ); + + $result = scf_move_meta_box_ids_to_sidebar( $order, array( 'acf-group_one', 'acf-group_two' ) ); + + $this->assertSame( '', $result['acf_after_title'] ); + $this->assertSame( 'submitdiv', $result['normal'] ); + $this->assertSame( 'postcustom,acf-group_one,acf-group_two', $result['side'] ); + } + + /** + * Groups already listed in the sidebar are not listed twice. + * + * @return void + */ + public function test_saved_order_does_not_duplicate_sidebar_entries() { + $order = array( + 'normal' => 'acf-group_one', + 'side' => 'acf-group_one,postcustom', + ); + + $result = scf_move_meta_box_ids_to_sidebar( $order, array( 'acf-group_one' ) ); + + $this->assertSame( 'acf-group_one,postcustom', $result['side'] ); + $this->assertSame( '', $result['normal'] ); + } + + /** + * Unrelated meta boxes and unusable input are left alone. + * + * @return void + */ + public function test_saved_order_keeps_unrelated_meta_boxes() { + $order = array( + 'normal' => 'submitdiv,acf-group_one', + 'side' => '', + ); + + $result = scf_move_meta_box_ids_to_sidebar( $order, array( 'acf-group_other' ) ); + + $this->assertSame( 'submitdiv,acf-group_one', $result['normal'] ); + $this->assertSame( '', $result['side'] ); + + $this->assertFalse( scf_move_meta_box_ids_to_sidebar( false, array( 'acf-group_one' ) ) ); + $this->assertSame( $order, scf_move_meta_box_ids_to_sidebar( $order, array() ) ); + } + + /** + * Registers a field group and returns the location it was registered with. + * + * @param string $key The field group key. + * @param string $position The position saved on the field group. + * @return string + */ + private function get_registered_meta_box_location( $key, $position ) { + global $wp_meta_boxes; + + $original_meta_boxes = isset( $wp_meta_boxes ) ? $wp_meta_boxes : array(); + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Started from a clean registry and restored below. + $wp_meta_boxes = array(); + + acf_add_local_field_group( + array( + 'key' => $key, + 'title' => ucwords( str_replace( '_', ' ', $key ) ), + 'position' => $position, + 'location' => array( + array( + array( + 'param' => 'post_type', + 'operator' => '==', + 'value' => 'post', + ), + ), + ), + ) + ); + + $post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Sidebar test', + ) + ); + $form_post = acf_get_instance( 'ACF_Form_Post' ); + + $form_post->add_meta_boxes( 'post', get_post( $post_id ) ); + + $location = ''; + + foreach ( (array) $wp_meta_boxes as $locations ) { + foreach ( (array) $locations as $name => $priorities ) { + foreach ( (array) $priorities as $boxes ) { + if ( array_key_exists( 'acf-' . $key, (array) $boxes ) ) { + $location = $name; + } + } + } + } + + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Restoring the value captured above. + $wp_meta_boxes = $original_meta_boxes; + + return $location; + } + + /** + * Field groups are registered as sidebar meta boxes when the beta is on. + * + * @return void + */ + public function test_meta_boxes_are_registered_in_the_sidebar() { + $this->enable_beta(); + acf_reset_local(); + + $this->assertSame( 'side', $this->get_registered_meta_box_location( 'group_editor_sidebar_on', 'normal' ) ); + } + + /** + * Field groups keep their saved location when the beta is off. + * + * @return void + */ + public function test_meta_boxes_keep_their_location_when_the_beta_is_disabled() { + acf_reset_local(); + + $this->assertSame( 'normal', $this->get_registered_meta_box_location( 'group_editor_sidebar_off', 'normal' ) ); + } +} diff --git a/tests/php/includes/ajax/test-ajax-check-screen-response.php b/tests/php/includes/ajax/test-ajax-check-screen-response.php index 2d6f88d8..af44b213 100644 --- a/tests/php/includes/ajax/test-ajax-check-screen-response.php +++ b/tests/php/includes/ajax/test-ajax-check-screen-response.php @@ -100,6 +100,7 @@ public function set_up(): void { */ public function tear_down(): void { acf_remove_local_field_group( $this->group_key ); + delete_option( 'scf_beta_feature_editor_sidebar_enabled' ); wp_set_current_user( 0 ); $_REQUEST = array(); @@ -281,6 +282,28 @@ public function test_hidden_metabox_gets_hide_if_js_class() { $this->assertStringContainsString( 'hide-if-js', $item['classes'] ); } + /** + * Test that the editor sidebar beta feature reports the sidebar position. + */ + public function test_position_is_the_sidebar_when_the_editor_sidebar_beta_is_enabled() { + wp_set_current_user( $this->admin_user_id ); + + acf_include( 'includes/admin/beta-features.php' ); + update_option( 'scf_beta_feature_editor_sidebar_enabled', true ); + + update_user_option( $this->admin_user_id, 'meta-box-order_post', array( 'normal' => 'acf-' . $this->group_key . ',submitdiv' ), true ); + + $response = $this->run_check_screen( $this->get_request() ); + $item = $this->find_group_item( $response ); + + delete_option( 'scf_beta_feature_editor_sidebar_enabled' ); + + $this->assertNotNull( $item ); + $this->assertSame( 'side', $item['position'], 'The field group should report the sidebar position' ); + $this->assertSame( 'submitdiv', $response['sorted']['normal'], 'The saved order should no longer list the group under the content' ); + $this->assertSame( 'acf-' . $this->group_key, $response['sorted']['side'], 'The saved order should list the group in the sidebar' ); + } + /** * Test that the response can be extended via the check_screen response filter. */