From a02d1186afa3518ba0f2e503d9b590b4fb432437 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Tue, 22 Sep 2026 18:22:26 +0600 Subject: [PATCH 1/2] fix(export): register post meta in PHP export Exported field groups now include register_post_meta() calls for eligible fields, so exported fields work with block bindings in block themes without manual registration. Fixes #384 --- includes/acf-field-group-functions.php | 142 ++++++++++++++++++ .../tools/class-acf-admin-tool-export.php | 5 + .../test-acf-field-group-functions.php | 141 +++++++++++++++++ 3 files changed, 288 insertions(+) diff --git a/includes/acf-field-group-functions.php b/includes/acf-field-group-functions.php index c34d3a9c..f92d4d20 100644 --- a/includes/acf-field-group-functions.php +++ b/includes/acf-field-group-functions.php @@ -15,6 +15,148 @@ function acf_get_field_group( $id = 0 ) { return acf_get_internal_post_type( $id, 'acf-field-group' ); } +/** + * Returns the post types targeted by a field group for PHP export. + * + * Only location groups made up of a single "post_type" rule using the "==" + * operator are returned. Anything else is treated as ambiguous and skipped. + * + * @since SCF 6.9.6 + * + * @param array $field_group The field group array. + * @return array + */ +function acf_get_field_group_post_types_for_export( array $field_group ) { + if ( empty( $field_group['location'] ) || ! is_array( $field_group['location'] ) ) { + return array(); + } + + $post_types = array(); + + foreach ( $field_group['location'] as $rule_group ) { + if ( ! is_array( $rule_group ) || 1 !== count( $rule_group ) ) { + return array(); + } + + $rule = reset( $rule_group ); + if ( ! is_array( $rule ) || 'post_type' !== ( $rule['param'] ?? '' ) || '==' !== ( $rule['operator'] ?? '' ) || empty( $rule['value'] ) || ! is_string( $rule['value'] ) ) { + return array(); + } + + $post_types[] = $rule['value']; + } + + return array_values( array_unique( array_filter( $post_types, 'is_string' ) ) ); +} + +/** + * Returns registered post meta arguments for a field group. + * + * Only fields that can be represented as single scalar post meta are returned. + * Fields targeting ambiguous locations or storing multiple values are skipped. + * + * @since SCF 6.9.6 + * + * @param array $field_group The field group array. + * @return array + */ +function acf_get_field_group_post_meta_for_export( array $field_group ) { + if ( empty( $field_group['show_in_rest'] ) || empty( $field_group['fields'] ) ) { + return array(); + } + + $post_types = acf_get_field_group_post_types_for_export( $field_group ); + if ( empty( $post_types ) ) { + return array(); + } + + $supported_types = array( 'string', 'boolean', 'integer', 'number' ); + $meta = array(); + + foreach ( $field_group['fields'] as $field ) { + if ( ! is_array( $field ) || empty( $field['name'] ) ) { + continue; + } + + if ( is_protected_meta( $field['name'], 'post' ) ) { + continue; + } + + if ( isset( $field['allow_in_bindings'] ) && ! $field['allow_in_bindings'] ) { + continue; + } + + if ( ! acf_field_type_supports( $field['type'] ?? '', 'bindings', true ) ) { + continue; + } + + $field_type = acf_get_field_type( $field['type'] ?? '' ); + if ( ! $field_type || empty( $field_type->show_in_rest ) ) { + continue; + } + + $schema = acf_get_field_rest_schema( $field ); + $types = array_values( array_diff( (array) ( $schema['type'] ?? array() ), array( 'null' ) ) ); + + // Skip fields that store more than one kind of value, such as checkbox or post object. + if ( 1 !== count( $types ) || ! in_array( $types[0], $supported_types, true ) ) { + continue; + } + + $args = array( + 'single' => true, + 'type' => $types[0], + 'show_in_rest' => true, + ); + + foreach ( $post_types as $post_type ) { + $meta[ $post_type ][ $field['name'] ] = $args; + } + } + + return $meta; +} + +/** + * Exports registered post meta for eligible field groups as PHP. + * + * @since SCF 6.9.6 + * + * @param array $field_groups The field groups being exported. + * @return string + */ +function acf_export_field_groups_post_meta_as_php( array $field_groups ) { + $meta = array(); + + foreach ( $field_groups as $field_group ) { + foreach ( acf_get_field_group_post_meta_for_export( $field_group ) as $post_type => $fields ) { + foreach ( $fields as $name => $args ) { + $meta[ $post_type ][ $name ] = $args; + } + } + } + + if ( empty( $meta ) ) { + return ''; + } + + $instance = acf_get_internal_post_type_instance( 'acf-field-group' ); + $export = "add_action( 'init', function() {\r\n"; + + foreach ( $meta as $post_type => $fields ) { + foreach ( $fields as $name => $args ) { + $code = var_export( $args, true ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions -- Used for PHP export. + $code = $instance ? $instance->format_code_for_export( $code ) : $code; + + $export .= "\tregister_post_meta( " . var_export( $post_type, true ) . ', ' . var_export( $name, true ) . ", {$code} );\r\n"; // phpcs:ignore WordPress.PHP.DevelopmentFunctions -- Used for PHP export. + } + } + + $export .= "} );\r\n\r\n"; + + return esc_textarea( $export ); +} + /** * acf_get_raw_field_group * diff --git a/includes/admin/tools/class-acf-admin-tool-export.php b/includes/admin/tools/class-acf-admin-tool-export.php index dbffedd0..1fbf7981 100644 --- a/includes/admin/tools/class-acf-admin-tool-export.php +++ b/includes/admin/tools/class-acf-admin-tool-export.php @@ -419,6 +419,11 @@ public function html_generate() { if ( 'acf-post-type' === $post_type ) { echo acf_export_enter_title_here( $posts ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_textarea() used earlier. } + + // Register exported fields as post meta so they work with block bindings. + if ( 'acf-field-group' === $post_type ) { + echo acf_export_field_groups_post_meta_as_php( $posts ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_textarea() used earlier. + } } echo ''; diff --git a/tests/php/includes/test-acf-field-group-functions.php b/tests/php/includes/test-acf-field-group-functions.php index 1ed74c17..c190fc2d 100644 --- a/tests/php/includes/test-acf-field-group-functions.php +++ b/tests/php/includes/test-acf-field-group-functions.php @@ -100,6 +100,147 @@ private function make_field_group( $key, $location = array(), $active = true ) { ); } + // ========================================================================= + // Post meta export tests + // ========================================================================= + + /** + * Builds a REST enabled field group targeting the given post types. + * + * @param array $post_types Post type slugs to target. + * @param array $fields Field definitions. + * @return array + */ + private function make_meta_field_group( $post_types, $fields ) { + $location = array(); + foreach ( $post_types as $post_type ) { + $location[] = array( + array( + 'param' => 'post_type', + 'operator' => '==', + 'value' => $post_type, + ), + ); + } + + $field_group = $this->make_field_group( 'group_' . uniqid(), $location ); + $field_group['show_in_rest'] = true; + $field_group['fields'] = $fields; + + return $field_group; + } + + /** + * Test exporting scalar fields as registered post meta. + */ + public function test_export_field_group_post_meta() { + // Field types are loaded by the plugin init hook, which the test bootstrap does not fire. + acf_include( 'includes/fields/class-acf-field-text.php' ); + + $field_group = $this->make_meta_field_group( + array( 'book' ), + array( + array( + 'key' => 'field_title', + 'name' => 'book_title', + 'type' => 'text', + 'allow_in_bindings' => true, + ), + ) + ); + + $meta = acf_get_field_group_post_meta_for_export( $field_group ); + $code = html_entity_decode( acf_export_field_groups_post_meta_as_php( array( $field_group ) ) ); + + $this->assertSame( true, $meta['book']['book_title']['single'] ); + $this->assertSame( 'string', $meta['book']['book_title']['type'] ); + $this->assertStringContainsString( "register_post_meta( 'book', 'book_title'", $code ); + $this->assertStringContainsString( "'show_in_rest' => true", $code ); + } + + /** + * Test registering a field for every post type the group targets. + */ + public function test_export_field_group_post_meta_multiple_post_types() { + acf_include( 'includes/fields/class-acf-field-text.php' ); + + $field_group = $this->make_meta_field_group( + array( 'book', 'movie' ), + array( + array( + 'name' => 'subtitle', + 'type' => 'text', + ), + ) + ); + + $meta = acf_get_field_group_post_meta_for_export( $field_group ); + + $this->assertArrayHasKey( 'book', $meta ); + $this->assertArrayHasKey( 'movie', $meta ); + } + + /** + * Test skipping fields that should not be exported as post meta. + */ + public function test_export_field_group_post_meta_skips_ineligible_fields() { + acf_include( 'includes/fields/class-acf-field-text.php' ); + acf_include( 'includes/fields/class-acf-field-select.php' ); + acf_include( 'includes/fields/class-acf-field-checkbox.php' ); + + $field_group = $this->make_meta_field_group( + array( 'book' ), + array( + array( + 'name' => 'hidden_title', + 'type' => 'text', + 'allow_in_bindings' => false, + ), + array( + 'name' => 'multiple_values', + 'type' => 'checkbox', + 'allow_in_bindings' => true, + 'choices' => array( + 'one' => 'One', + 'two' => 'Two', + ), + ), + array( + 'name' => '_protected_title', + 'type' => 'text', + ), + ) + ); + + $this->assertSame( array(), acf_get_field_group_post_meta_for_export( $field_group ) ); + } + + /** + * Test skipping field groups without a concrete post type location. + */ + public function test_export_field_group_post_meta_skips_ambiguous_location() { + acf_include( 'includes/fields/class-acf-field-text.php' ); + + $field_group = $this->make_meta_field_group( + array( 'book' ), + array( + array( + 'name' => 'book_title', + 'type' => 'text', + ), + ) + ); + + // Add a second rule to the same location group so it is no longer a single post type. + $field_group['location'][0][] = array( + 'param' => 'post_status', + 'operator' => '==', + 'value' => 'publish', + ); + + $this->assertSame( array(), acf_get_field_group_post_meta_for_export( $field_group ) ); + } + // ========================================================================= // SECTION 1: Field Group Key Validation // ========================================================================= From f9fb7ce5d2751718e6f5a1b1ec62eca2b211591b Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Tue, 22 Sep 2026 20:51:07 +0600 Subject: [PATCH 2/2] fix(tests): align with PHP 7.4 and WP trunk CI Pass explicit flags to html_entity_decode() so single quote entities decode the same on PHP 7.4 and 8.x. Match the editor preview menu item with a tolerant pattern because WP trunk renamed it to Preview (opens in a new tab). Refs #544 --- tests/e2e/fixtures.js | 25 +++++++++++-------- .../test-acf-field-group-functions.php | 2 +- 2 files changed, 15 insertions(+), 12 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; diff --git a/tests/php/includes/test-acf-field-group-functions.php b/tests/php/includes/test-acf-field-group-functions.php index c190fc2d..141d2269 100644 --- a/tests/php/includes/test-acf-field-group-functions.php +++ b/tests/php/includes/test-acf-field-group-functions.php @@ -150,7 +150,7 @@ public function test_export_field_group_post_meta() { ); $meta = acf_get_field_group_post_meta_for_export( $field_group ); - $code = html_entity_decode( acf_export_field_groups_post_meta_as_php( array( $field_group ) ) ); + $code = html_entity_decode( acf_export_field_groups_post_meta_as_php( array( $field_group ) ), ENT_QUOTES, 'UTF-8' ); $this->assertSame( true, $meta['book']['book_title']['single'] ); $this->assertSame( 'string', $meta['book']['book_title']['type'] );