From 8703b441ad39cf32fb9713ef8f731f226ea04162 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Thu, 24 Sep 2026 18:54:55 +0600 Subject: [PATCH 1/2] Move field groups into the editor sidebar behind a beta feature Wire the existing editor_sidebar beta feature into the registry and register eligible field group meta boxes with side context when the beta is enabled and the post type uses the block editor. Rewrite the saved meta box order so it cannot pull the boxes back out of the sidebar, and report the sidebar position from the check_screen AJAX response for dynamically added groups. Classic editor post types and the beta-off state are unchanged. See #154 --- includes/admin/beta-features.php | 18 +- includes/ajax/class-acf-ajax-check-screen.php | 23 +- includes/api/api-helpers.php | 101 +++++++ includes/forms/form-post.php | 39 ++- tests/php/EditorSidebarBetaTest.php | 271 ++++++++++++++++++ .../ajax/test-ajax-check-screen-response.php | 23 ++ 6 files changed, 471 insertions(+), 4 deletions(-) create mode 100644 tests/php/EditorSidebarBetaTest.php 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/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. */ From 6ce43bc7cb3c0323ca5907bf223ee92b69b5d9b6 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Thu, 24 Sep 2026 21:25:33 +0600 Subject: [PATCH 2/2] fix(tests): match the renamed preview menu item on WP trunk The block editor on WP trunk renamed the View menu item from "Preview in new tab" to "Preview (opens in a new tab)". The pinned e2e test helper still matches the old label exactly, so every test that opens a preview timed out. The fixture now clicks the button itself and matches the item with a regex that accepts both labels. Fixes the WP trunk E2E timeouts waiting for the preview page. Refs #548 --- tests/e2e/fixtures.js | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) 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;