From 9cd6b6e27f9eab71e40f8174e1b8833df9b86673 Mon Sep 17 00:00:00 2001 From: ntsekouras Date: Thu, 2 Jul 2026 13:14:36 +0300 Subject: [PATCH 1/3] View Config: Add versioning handling --- src/wp-includes/default-filters.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 8edc26a221249..87cc7ad69c915 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -826,9 +826,7 @@ foreach ( array( 'page', 'wp_block', 'wp_template_part', 'wp_template' ) as $post_type ) { add_filter( "get_entity_view_config_postType_{$post_type}", - "_wp_get_entity_view_config_post_type_{$post_type}", - 10, - 1 + "_wp_get_entity_view_config_post_type_{$post_type}" ); } From e5758828362e4196c4b4a402f1cc2815e177ee9f Mon Sep 17 00:00:00 2001 From: ntsekouras Date: Tue, 7 Jul 2026 09:17:47 +0300 Subject: [PATCH 2/3] port the changes from the merged GB PR --- src/wp-includes/class-wp-view-config-data.php | 625 ++++++++++ src/wp-includes/default-filters.php | 6 +- .../class-wp-rest-view-config-controller.php | 6 + src/wp-includes/view-config.php | 258 ++-- src/wp-settings.php | 1 + .../rest-api/rest-view-config-controller.php | 24 +- tests/phpunit/tests/view-config-data.php | 1088 +++++++++++++++++ tests/phpunit/tests/view-config.php | 106 +- 8 files changed, 1966 insertions(+), 148 deletions(-) create mode 100644 src/wp-includes/class-wp-view-config-data.php create mode 100644 tests/phpunit/tests/view-config-data.php diff --git a/src/wp-includes/class-wp-view-config-data.php b/src/wp-includes/class-wp-view-config-data.php new file mode 100644 index 0000000000000..ea075891c4845 --- /dev/null +++ b/src/wp-includes/class-wp-view-config-data.php @@ -0,0 +1,625 @@ +config = $config; + } + + /** + * Returns the current configuration array. + * + * @since 7.1.0 + * + * @return array The configuration. + */ + public function get_config() { + return $this->config; + } + + /** + * Replaces a whole top-level key with a new value. + * + * It shouldn't be the default choice — a callback using it stops + * inheriting core's future changes to that key — but it's useful for + * cases like a post type that doesn't want the default form at all. + * + * A value that declares an unsupported schema version is rejected and + * does not replace anything. + * + * @since 7.1.0 + * + * @param string $key The configuration key to replace. + * @param mixed $value The new value. + * @param int $version The schema version the value was authored against. + * @return WP_View_Config_Data The instance, for chaining. + */ + public function set( $key, $value, int $version ) { + if ( ! $this->check_version( $version, __METHOD__ ) ) { + return $this; + } + + if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: the configuration key. */ + esc_html__( '"%s" is not a documented view configuration key.' ), + esc_html( $key ) + ), + '7.1.0' + ); + return $this; + } + + $this->config[ $key ] = $value; + return $this; + } + + /** + * Merges a partial configuration into `default_view`, `default_layouts`, + * and the `form` settings other than its `fields`. + * + * An associative array merges key by key, a numerically indexed array + * replaces the current value wholesale, and `null` deletes the key it + * names; deleting a whole top-level key (any documented key, including + * `view_list`) resets it to its default. + * + * The keyed collections have dedicated methods and are rejected here: a + * non-null `view_list` value must go through `update_view_list_items()`, + * and a `fields` key inside a `form` value must go through + * `update_form_fields()`. + * + * A patch that declares an unsupported schema version is rejected and + * does not merge. + * + * @since 7.1.0 + * + * @param array $patch The partial configuration to merge. + * @param int $version The schema version the patch was authored against. + * @return WP_View_Config_Data The instance, for chaining. + */ + public function update_properties( array $patch, int $version ) { + if ( ! $this->check_version( $version, __METHOD__ ) ) { + return $this; + } + + foreach ( $patch as $key => $value ) { + if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) { + _doing_it_wrong( + __METHOD__, + sprintf( + /* translators: %s: the configuration key. */ + esc_html__( '"%s" is not a documented view configuration key.' ), + esc_html( $key ) + ), + '7.1.0' + ); + continue; + } + // A null patch value drops the whole key from the container rather + // than assigning null. + if ( null === $value ) { + unset( $this->config[ $key ] ); + continue; + } + if ( 'view_list' === $key ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'The "view_list" entries are patched by identity. Use update_view_list_items() instead.' ), + '7.1.0' + ); + continue; + } + if ( 'form' === $key ) { + $value = $this->extract_form_properties( $value ); + // Nothing left to merge: the value was off-shape, or held only + // the rejected `fields` key. + if ( null === $value || array() === $value ) { + continue; + } + } + $this->config[ $key ] = $this->deep_merge( $this->config[ $key ] ?? array(), $value ); + } + + return $this; + } + + /** + * Adds, updates, or removes `view_list` entries, keyed by view `slug`. + * + * Each patch key names the `slug` of the view it targets: a matching view + * merges in place and keeps its position (following the shared rules — + * e.g. the view's `filters`, being numerically indexed, replace + * wholesale), an unknown slug appends a new view to the end, and `null` + * removes the view. The patch key is the identity: a `slug` property + * inside the value is ignored. A `null` for a slug that is not found is a + * silent no-op — the view may have been removed by another callback or + * simply not apply to this entity. + * + * A patch that declares an unsupported schema version is rejected and + * does not merge. + * + * @since 7.1.0 + * + * @param array $items The view patches, keyed by slug. + * @param int $version The schema version the patch was authored against. + * @return WP_View_Config_Data The instance, for chaining. + */ + public function update_view_list_items( array $items, int $version ) { + if ( ! $this->check_version( $version, __METHOD__ ) ) { + return $this; + } + + if ( empty( $items ) ) { + return $this; + } + if ( array_is_list( $items ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'A view list patch must be keyed by view "slug".' ), + '7.1.0' + ); + return $this; + } + + $view_list = isset( $this->config['view_list'] ) && is_array( $this->config['view_list'] ) ? $this->config['view_list'] : array(); + + foreach ( $items as $slug => $value ) { + // PHP casts numeric-string array keys to integers; identities are strings. + $slug = (string) $slug; + + if ( null === $value ) { + $view_list = array_values( + array_filter( + $view_list, + static fn( $item ) => ! is_array( $item ) || ! isset( $item['slug'] ) || $item['slug'] !== $slug + ) + ); + continue; + } + + if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'Each view patch must be an associative array of view properties, or null to remove the view.' ), + '7.1.0' + ); + continue; + } + + // The patch key is the identity. + unset( $value['slug'] ); + + $index = null; + foreach ( $view_list as $i => $item ) { + if ( is_array( $item ) && isset( $item['slug'] ) && $item['slug'] === $slug ) { + $index = $i; + break; + } + } + + if ( null === $index ) { + $view_list[] = array_merge( array( 'slug' => $slug ), $value ); + continue; + } + // An empty patch value has nothing to merge (and deep_merge would + // treat an empty array as a list, replacing the whole view). + if ( array() !== $value ) { + $view_list[ $index ] = $this->deep_merge( $view_list[ $index ], $value ); + } + } + + $this->config['view_list'] = array_values( $view_list ); + + return $this; + } + + /** + * Adds, updates, or removes `form` fields, keyed by field `id`. + * + * Each patch key names the `id` of the field it targets, and the field is + * found wherever it lives — at the top level or nested inside a group's + * `children`. Fields are visited in document order and a group is checked + * before its own children, so when an id appears at both levels the group + * wins. A matching field merges in place, an unknown id appends a new field + * to the end of the top-level fields, and `null` removes the field. The + * patch key is the identity: an `id` property inside the value is ignored. + * A `null` for an id that is not found is a silent no-op — the field may + * have been removed by another callback or simply not apply to this + * entity. + * + * Inside a field patch, `children` follows the shared rules: an associative + * array merges into the group's children by id (appending unknown ones), a + * numerically indexed array replaces the children wholesale, and `null` + * deletes the key. + * + * A patch that declares an unsupported schema version is rejected and + * does not merge. + * + * @since 7.1.0 + * + * @param array $fields The field patches, keyed by field id. + * @param int $version The schema version the patch was authored against. + * @return WP_View_Config_Data The instance, for chaining. + */ + public function update_form_fields( array $fields, int $version ) { + if ( ! $this->check_version( $version, __METHOD__ ) ) { + return $this; + } + + if ( empty( $fields ) ) { + return $this; + } + if ( array_is_list( $fields ) ) { + _doing_it_wrong( + __METHOD__, + esc_html__( 'A fields patch must be keyed by field "id".' ), + '7.1.0' + ); + return $this; + } + + if ( ! isset( $this->config['form'] ) || ! is_array( $this->config['form'] ) ) { + $this->config['form'] = array(); + } + $current = isset( $this->config['form']['fields'] ) && is_array( $this->config['form']['fields'] ) ? $this->config['form']['fields'] : array(); + + $this->config['form']['fields'] = $this->merge_fields_by_identity( $current, $fields ); + + return $this; + } + + /** + * Validates a declared patch version, reporting misuse against the given + * public method. + * + * @since 7.1.0 + * + * @param int $version The declared version. + * @param string $method The public method the patch was passed to. + * @return bool Whether the declared version is a supported schema version. + */ + private function check_version( int $version, $method ) { + if ( $version >= 1 && $version <= self::LATEST_VERSION ) { + return true; + } + + _doing_it_wrong( + esc_html( $method ), + esc_html__( 'A view configuration contribution must declare a supported schema version.' ), + '7.1.0' + ); + + return false; + } + + /** + * Validates a `form` patch value for update_properties() and strips the + * `fields` key, which is managed by update_form_fields(). + * + * @since 7.1.0 + * + * @param mixed $value The incoming `form` patch value. + * @return array|null The form properties to merge, or null when the value + * is off-shape. + */ + private function extract_form_properties( $value ) { + if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) { + _doing_it_wrong( + 'WP_View_Config_Data::update_properties', + esc_html__( 'A "form" patch must be an associative array of form properties.' ), + '7.1.0' + ); + return null; + } + if ( array_key_exists( 'fields', $value ) ) { + _doing_it_wrong( + 'WP_View_Config_Data::update_properties', + esc_html__( 'The form "fields" are patched by identity. Use update_form_fields() instead.' ), + '7.1.0' + ); + unset( $value['fields'] ); + } + + return $value; + } + + /** + * Recursively merges two values. + * + * Associative arrays (maps) merge key by key and a null patch value deletes + * the key; lists and scalars are replaced wholesale by the incoming value, + * since lists without a defined identity cannot be merged member by member. + * + * @since 7.1.0 + * + * @param mixed $current The current value. + * @param mixed $incoming The incoming value. + * @return mixed The merged value. + */ + private function deep_merge( $current, $incoming ) { + // An empty array counts as a list, so patching with array() empties + // the key (e.g. 'filters' => array() clears the filters) rather than + // being a no-op map merge. + if ( ! is_array( $incoming ) || array_is_list( $incoming ) ) { + return $incoming; + } + + // Merge onto the current map, or onto an empty base when the current + // value is absent, empty, or not a map, so null delete-markers in the + // patch are consumed rather than stored as literal values (e.g. + // array( 'layout' => null ) merged into an empty layouts entry yields + // array(), not array( 'layout' => null )). + $result = is_array( $current ) && ! array_is_list( $current ) ? $current : array(); + foreach ( $incoming as $key => $value ) { + if ( null === $value ) { + // A null patch value deletes the key. + unset( $result[ $key ] ); + continue; + } + $result[ $key ] = $this->deep_merge( + array_key_exists( $key, $result ) ? $result[ $key ] : array(), + $value + ); + } + return $result; + } + + /** + * Merges a map of field patches into a field list by identity. + * + * Shared by the top-level `form` fields and a group's `children`: a `null` + * value removes the matching field (recursing into children), a map value + * merges into the matching field wherever it lives, and an unknown id + * appends a new field to the end of this list. A `null` for an id that is + * not found is a silent no-op. + * + * @since 7.1.0 + * + * @param array $current The current list of fields. + * @param array $patches The field patches, keyed by field id. + * @return array The merged list of fields. + */ + private function merge_fields_by_identity( array $current, array $patches ) { + foreach ( $patches as $id => $value ) { + // PHP casts numeric-string array keys to integers; identities are strings. + $id = (string) $id; + + if ( null === $value ) { + $current = $this->reject_fields( $current, array( $id ) ); + continue; + } + if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) { + _doing_it_wrong( + 'WP_View_Config_Data::update_form_fields', + esc_html__( 'Each field patch must be an associative array of field properties, or null to remove the field.' ), + '7.1.0' + ); + continue; + } + + // The patch key is the identity. + unset( $value['id'] ); + + $merged = $this->merge_field_in_tree( $current, $id, $value ); + if ( null !== $merged ) { + $current = $merged; + continue; + } + // An unknown id appends: as a bare string reference when the patch + // carries no overrides, as an array otherwise. + $current[] = array() === $value ? $id : $this->merge_field_item( $id, $id, $value ); + } + + return $current; + } + + /** + * Merges a field patch into the field carrying the given identity, wherever + * it lives in the tree. + * + * Fields are visited in document order and a group is checked before its + * own children, so when an id appears at both levels the group wins. + * + * @since 7.1.0 + * + * @param array $fields The list of fields to search. + * @param string $id The identity of the field to patch. + * @param array $value The field patch. + * @return array|null The updated list, or null when the id was not found. + */ + private function merge_field_in_tree( array $fields, $id, array $value ) { + foreach ( $fields as $index => $field ) { + if ( $this->field_identity( $field ) === $id ) { + $fields[ $index ] = $this->merge_field_item( $field, $id, $value ); + return $fields; + } + if ( is_array( $field ) && isset( $field['children'] ) && is_array( $field['children'] ) ) { + $children = $this->merge_field_in_tree( $field['children'], $id, $value ); + if ( null !== $children ) { + $fields[ $index ]['children'] = $children; + return $fields; + } + } + } + + return null; + } + + /** + * Merges a field patch into an existing field. + * + * A bare string reference is promoted to an array so the overrides apply. + * The `children` key follows the same rules — a map merges into the + * group's children by id, a list replaces them wholesale, and `null` + * deletes the key — and every other key merges via deep_merge(). + * + * @since 7.1.0 + * + * @param array|string $existing The existing field. + * @param string $id The field identity. + * @param array $value The field patch. + * @return array|string The merged field. + */ + private function merge_field_item( $existing, $id, array $value ) { + if ( ! is_array( $existing ) ) { + // Nothing to apply: keep the bare string reference. + if ( array() === $value ) { + return $existing; + } + // Promote the reference so the incoming overrides apply. + $existing = array( 'id' => $id ); + } + + foreach ( $value as $key => $item ) { + if ( 'children' === $key ) { + if ( null === $item ) { + unset( $existing['children'] ); + continue; + } + if ( ! is_array( $item ) ) { + _doing_it_wrong( + 'WP_View_Config_Data::update_form_fields', + esc_html__( 'A "children" patch must be an associative array keyed by field id to merge, a numerically indexed array to replace the children wholesale, or null to delete the key.' ), + '7.1.0' + ); + continue; + } + // A list replaces the children wholesale (an empty array counts + // as a list, clearing them)... + if ( array_is_list( $item ) ) { + $existing['children'] = $item; + continue; + } + // ...and a map merges into them by identity. + $children = isset( $existing['children'] ) && is_array( $existing['children'] ) ? $existing['children'] : array(); + $existing['children'] = $this->merge_fields_by_identity( $children, $item ); + continue; + } + if ( null === $item ) { + // A null patch value deletes the key. + unset( $existing[ $key ] ); + continue; + } + $existing[ $key ] = $this->deep_merge( + array_key_exists( $key, $existing ) ? $existing[ $key ] : array(), + $item + ); + } + + return $existing; + } + + /** + * Returns a field list with the fields matching the given identities removed, + * recursing into group children. + * + * @since 7.1.0 + * + * @param array $fields The list of fields. + * @param string[] $ids The identities of the fields to remove. + * @return array The list with the matching fields removed. + */ + private function reject_fields( array $fields, array $ids ) { + $result = array(); + foreach ( $fields as $field ) { + if ( in_array( $this->field_identity( $field ), $ids, true ) ) { + continue; + } + if ( is_array( $field ) && isset( $field['children'] ) && is_array( $field['children'] ) ) { + $field['children'] = $this->reject_fields( $field['children'], $ids ); + } + $result[] = $field; + } + return $result; + } + + /** + * Resolves the identity of a form field. + * + * A bare string is its own identity; an object is identified by its `id`. + * + * @since 7.1.0 + * + * @param mixed $field The field. + * @return string|null The identity, or null if it cannot be resolved. + */ + private function field_identity( $field ) { + if ( is_string( $field ) ) { + return $field; + } + if ( is_array( $field ) && isset( $field['id'] ) && is_string( $field['id'] ) ) { + return $field['id']; + } + return null; + } +} diff --git a/src/wp-includes/default-filters.php b/src/wp-includes/default-filters.php index 87cc7ad69c915..783dd6fe42fc5 100644 --- a/src/wp-includes/default-filters.php +++ b/src/wp-includes/default-filters.php @@ -824,9 +824,13 @@ // View Config API. foreach ( array( 'page', 'wp_block', 'wp_template_part', 'wp_template' ) as $post_type ) { + // Base definitions run before the default priority, so third-party + // callbacks registered at the default compose on top of them + // regardless of registration order. add_filter( "get_entity_view_config_postType_{$post_type}", - "_wp_get_entity_view_config_post_type_{$post_type}" + "_wp_get_entity_view_config_post_type_{$post_type}", + 5 ); } diff --git a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php index e8aac4ccfafff..34cd1572526c6 100644 --- a/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php +++ b/src/wp-includes/rest-api/endpoints/class-wp-rest-view-config-controller.php @@ -154,6 +154,7 @@ public function get_items( $request ) { $response = array( 'kind' => $kind, 'name' => $name, + 'version' => WP_View_Config_Data::LATEST_VERSION, 'default_view' => $this->cast_empty_objects( $config['default_view'], $schema['properties']['default_view'] ), 'default_layouts' => $this->cast_empty_objects( $config['default_layouts'], $schema['properties']['default_layouts'] ), 'view_list' => $this->cast_empty_objects( $config['view_list'], $schema['properties']['view_list'] ), @@ -267,6 +268,11 @@ public function get_item_schema() { 'type' => 'string', 'readonly' => true, ), + 'version' => array( + 'description' => __( 'The schema version of the configuration.' ), + 'type' => 'integer', + 'readonly' => true, + ), 'default_view' => array( 'description' => __( 'Default view configuration.' ), 'type' => 'object', diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php index 5676fcaec1e2b..0e743b4ee9bef 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -151,46 +151,64 @@ function wp_get_entity_view_config( $kind, $name ) { 'form' => 'postType' === $kind ? _wp_get_default_post_type_form() : array(), ); + $data = new WP_View_Config_Data( $config ); + /** * Filters the view configuration for a given entity. * * The dynamic portions of the hook name, `$kind` and `$name`, refer to the * entity kind (e.g. `postType`) and the entity name (e.g. `page`). * - * @since 7.1.0 + * Callbacks receive a WP_View_Config_Data object and change the + * configuration through its methods: the `update_*()` methods merge + * partial changes into the current configuration, while `set()` replaces + * a whole top-level key. Callbacks must return the object they were + * given. * - * @param array $config { - * The view configuration for the entity. + * @since 7.1.0 * - * @type array $default_view Default view configuration. - * @type array $default_layouts Default layouts configuration. - * @type array $view_list List of available views. - * @type array $form Form configuration. - * } - * @param array $entity { + * @param WP_View_Config_Data $data The view configuration container + * for the entity, exposing the + * `default_view`, `default_layouts`, + * `view_list`, and `form` keys. + * @param array $entity { * The entity the configuration is built for. * * @type string $kind The entity kind. * @type string $name The entity name. * } */ - $filtered_config = apply_filters( + $filtered = apply_filters( "get_entity_view_config_{$kind}_{$name}", - $config, + $data, array( 'kind' => $kind, 'name' => $name, ) ); - if ( ! is_array( $filtered_config ) ) { + // A well-behaved callback returns the object it was given. Fall back to the + // unfiltered config if a callback replaced it with something else. + if ( ! $filtered instanceof WP_View_Config_Data ) { + _doing_it_wrong( + __FUNCTION__, + sprintf( + /* translators: %s: the filter hook name. */ + esc_html__( 'A "%s" filter callback must return the WP_View_Config_Data object it was given.' ), + esc_html( "get_entity_view_config_{$kind}_{$name}" ) + ), + '7.1.0' + ); return $config; } - // Backfill any dropped keys with their defaults, then discard any keys the - // filter introduced that are not part of the documented configuration shape. - $filtered_config = array_merge( $config, $filtered_config ); - return array_intersect_key( $filtered_config, $config ); + // The functions keep the container close to the documented shape (set() and + // update_properties() reject unknown keys), but a documented key may + // still be missing — dropped by a null reset patch, or omitted by a callback + // returning its own off-shape container. Normalize either way: drop + // undocumented keys and backfill any dropped documented keys from the + // defaults. + return array_intersect_key( array_merge( $config, $filtered->get_config() ), $config ); } /** @@ -198,13 +216,11 @@ function wp_get_entity_view_config( $kind, $name ) { * * @since 7.1.0 * - * @param array $config { - * The view configuration for the entity. - * } - * @return array The filtered view configuration. + * @param WP_View_Config_Data $data The view configuration container for the entity. + * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_page( $config ) { - $config['default_layouts'] = array( +function _wp_get_entity_view_config_post_type_page( $data ) { + $default_layouts = array( 'table' => array( 'layout' => array( 'styles' => array( @@ -218,7 +234,7 @@ function _wp_get_entity_view_config_post_type_page( $config ) { 'list' => array(), ); - $config['default_view'] = array( + $default_view = array( 'type' => 'list', 'filters' => array(), 'perPage' => 20, @@ -232,10 +248,7 @@ function _wp_get_entity_view_config_post_type_page( $config ) { 'fields' => array( 'author', 'status' ), ); - $config['view_list'] = array( - // Reuse the base "all items" view, whose title is derived from the post - // type's `all_items` label in wp_get_entity_view_config(). - $config['view_list'][0], + $view_list = array( array( 'title' => __( 'Published' ), 'slug' => 'published', @@ -311,7 +324,7 @@ function _wp_get_entity_view_config_post_type_page( $config ) { 'slug' => 'trash', 'view' => array( 'type' => 'table', - 'layout' => $config['default_layouts']['table']['layout'], + 'layout' => $default_layouts['table']['layout'], 'filters' => array( array( 'field' => 'status', @@ -324,7 +337,15 @@ function _wp_get_entity_view_config_post_type_page( $config ) { ), ); - return $config; + $data->set( 'default_layouts', $default_layouts, 1 ); + $data->set( 'default_view', $default_view, 1 ); + // Append the status views onto the inherited base "all items" view rather + // than replacing it, so its post-type-specific title is kept. The version + // is pinned to the literal this patch was authored against as an extra guard, + // so a future LATEST_VERSION bump migrates it instead of silently skipping it. + $data->update_view_list_items( array_column( $view_list, null, 'slug' ), 1 ); + + return $data; } /** @@ -332,13 +353,11 @@ function _wp_get_entity_view_config_post_type_page( $config ) { * * @since 7.1.0 * - * @param array $config { - * The view configuration for the entity. - * } - * @return array The filtered view configuration. + * @param WP_View_Config_Data $data The view configuration container for the entity. + * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_wp_block( $config ) { - $config['default_layouts'] = array( +function _wp_get_entity_view_config_post_type_wp_block( $data ) { + $default_layouts = array( 'table' => array( 'layout' => array( 'styles' => array( @@ -355,16 +374,19 @@ function _wp_get_entity_view_config_post_type_wp_block( $config ) { ), ); - $config['default_view'] = array( + $default_view = array( 'type' => 'grid', 'perPage' => 20, 'titleField' => 'title', 'mediaField' => 'preview', 'fields' => array( 'sync-status' ), 'filters' => array(), - 'layout' => $config['default_layouts']['grid']['layout'], + 'layout' => $default_layouts['grid']['layout'], ); + $data->set( 'default_layouts', $default_layouts, 1 ); + $data->set( 'default_view', $default_view, 1 ); + $view_list = array( array( 'title' => __( 'All patterns' ), @@ -412,31 +434,35 @@ function _wp_get_entity_view_config_post_type_wp_block( $config ) { ); } - $config['view_list'] = $view_list; + $data->set( 'view_list', $view_list, 1 ); - $config['form'] = array( - 'layout' => array( 'type' => 'panel' ), - 'fields' => array( - array( - 'id' => 'excerpt', - 'layout' => array( - 'type' => 'panel', - 'labelPosition' => 'top', + $data->set( + 'form', + array( + 'layout' => array( 'type' => 'panel' ), + 'fields' => array( + array( + 'id' => 'excerpt', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'top', + ), ), - ), - array( - 'id' => 'post-content-info', - 'layout' => array( - 'type' => 'regular', - 'labelPosition' => 'none', + array( + 'id' => 'post-content-info', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), ), + 'sync-status', + 'revisions', ), - 'sync-status', - 'revisions', ), + 1 ); - return $config; + return $data; } /** @@ -444,13 +470,11 @@ function _wp_get_entity_view_config_post_type_wp_block( $config ) { * * @since 7.1.0 * - * @param array $config { - * The view configuration for the entity. - * } - * @return array The filtered view configuration. + * @param WP_View_Config_Data $data The view configuration container for the entity. + * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_wp_template_part( $config ) { - $config['default_layouts'] = array( +function _wp_get_entity_view_config_post_type_wp_template_part( $data ) { + $default_layouts = array( 'table' => array( 'layout' => array( 'styles' => array( @@ -465,16 +489,19 @@ function _wp_get_entity_view_config_post_type_wp_template_part( $config ) { ), ); - $config['default_view'] = array( + $default_view = array( 'type' => 'grid', 'perPage' => 20, 'titleField' => 'title', 'mediaField' => 'preview', 'fields' => array( 'author' ), 'filters' => array(), - 'layout' => $config['default_layouts']['grid']['layout'], + 'layout' => $default_layouts['grid']['layout'], ); + $data->set( 'default_layouts', $default_layouts, 1 ); + $data->set( 'default_view', $default_view, 1 ); + $view_list = array( array( 'title' => __( 'All template parts' ), @@ -516,23 +543,27 @@ function _wp_get_entity_view_config_post_type_wp_template_part( $config ) { ); } - $config['view_list'] = $view_list; + $data->set( 'view_list', $view_list, 1 ); - $config['form'] = array( - 'layout' => array( 'type' => 'panel' ), - 'fields' => array( - array( - 'id' => 'last_edited_date', - 'layout' => array( - 'type' => 'panel', - 'labelPosition' => 'none', + $data->set( + 'form', + array( + 'layout' => array( 'type' => 'panel' ), + 'fields' => array( + array( + 'id' => 'last_edited_date', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'none', + ), ), + 'revisions', ), - 'revisions', ), + 1 ); - return $config; + return $data; } /** @@ -540,13 +571,11 @@ function _wp_get_entity_view_config_post_type_wp_template_part( $config ) { * * @since 7.1.0 * - * @param array $config { - * The view configuration for the entity. - * } - * @return array The filtered view configuration. + * @param WP_View_Config_Data $data The view configuration container for the entity. + * @return WP_View_Config_Data The updated view configuration container. */ -function _wp_get_entity_view_config_post_type_wp_template( $config ) { - $config['default_view'] = array( +function _wp_get_entity_view_config_post_type_wp_template( $data ) { + $default_view = array( 'type' => 'grid', 'perPage' => 20, 'sort' => array( @@ -561,12 +590,15 @@ function _wp_get_entity_view_config_post_type_wp_template( $config ) { 'showMedia' => true, ); - $config['default_layouts'] = array( + $default_layouts = array( 'table' => array( 'showMedia' => false ), 'grid' => array( 'showMedia' => true ), 'list' => array( 'showMedia' => false ), ); + $data->set( 'default_view', $default_view, 1 ); + $data->set( 'default_layouts', $default_layouts, 1 ); + $view_list = array( array( 'title' => __( 'All templates' ), @@ -701,42 +733,46 @@ function _wp_get_entity_view_config_post_type_wp_template( $config ) { } } - $config['view_list'] = array_merge( $view_list, $registered_authors, $user_authors ); + $data->set( 'view_list', array_merge( $view_list, $registered_authors, $user_authors ), 1 ); - $config['form'] = array( - 'layout' => array( 'type' => 'panel' ), - 'fields' => array( - array( - 'id' => 'description', - 'layout' => array( - 'type' => 'panel', - 'labelPosition' => 'top', + $data->set( + 'form', + array( + 'layout' => array( 'type' => 'panel' ), + 'fields' => array( + array( + 'id' => 'description', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'top', + ), ), - ), - array( - 'id' => 'description_readonly', - 'layout' => array( - 'type' => 'regular', - 'labelPosition' => 'none', + array( + 'id' => 'description_readonly', + 'layout' => array( + 'type' => 'regular', + 'labelPosition' => 'none', + ), ), - ), - array( - 'id' => 'last_edited_date', - 'layout' => array( - 'type' => 'panel', - 'labelPosition' => 'none', + array( + 'id' => 'last_edited_date', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'none', + ), ), + 'revisions', + // The following fields are only meaningful in the `home`/`index` + // template summary. They edit other entities (`root/site` and the + // posts page); the editor merges those records into the form data + // under a namespace and controls when the fields are shown. + 'posts_page_title', + 'posts_per_page', + 'default_comment_status', ), - 'revisions', - // The following fields are only meaningful in the `home`/`index` - // template summary. They edit other entities (`root/site` and the - // posts page); the editor merges those records into the form data - // under a namespace and controls when the fields are shown. - 'posts_page_title', - 'posts_per_page', - 'default_comment_status', ), + 1 ); - return $config; + return $data; } diff --git a/src/wp-settings.php b/src/wp-settings.php index 7e951681e4d53..d15310c665eae 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -205,6 +205,7 @@ require ABSPATH . WPINC . '/theme-templates.php'; require ABSPATH . WPINC . '/theme-previews.php'; require ABSPATH . WPINC . '/template.php'; +require ABSPATH . WPINC . '/class-wp-view-config-data.php'; require ABSPATH . WPINC . '/view-config.php'; require ABSPATH . WPINC . '/https-detection.php'; require ABSPATH . WPINC . '/https-migration.php'; diff --git a/tests/phpunit/tests/rest-api/rest-view-config-controller.php b/tests/phpunit/tests/rest-api/rest-view-config-controller.php index b487055ff14d8..81cdae93dc4ab 100644 --- a/tests/phpunit/tests/rest-api/rest-view-config-controller.php +++ b/tests/phpunit/tests/rest-api/rest-view-config-controller.php @@ -275,6 +275,7 @@ public function test_get_items_returns_entity_and_config_shape() { $this->assertSame( 200, $response->get_status() ); $this->assertSame( 'postType', $data['kind'] ); $this->assertSame( 'page', $data['name'] ); + $this->assertSame( WP_View_Config_Data::LATEST_VERSION, $data['version'] ); $this->assertArrayHasKey( 'default_view', $data ); $this->assertArrayHasKey( 'default_layouts', $data ); $this->assertArrayHasKey( 'view_list', $data ); @@ -339,18 +340,21 @@ public function test_empty_object_fields_serialize_as_json_objects() { public function test_empty_objects_inside_view_list_view_serialize_as_json_objects() { wp_set_current_user( self::$editor_id ); - $filter = static function ( $config ) { - $config['view_list'][] = array( - 'title' => 'Custom', - 'slug' => 'custom', - 'view' => array( - 'type' => 'table', - 'layout' => array( - 'styles' => array(), + $filter = static function ( $data ) { + return $data->update_view_list_items( + array( + 'custom' => array( + 'title' => 'Custom', + 'view' => array( + 'type' => 'table', + 'layout' => array( + 'styles' => array(), + ), + ), ), ), + 1 ); - return $config; }; add_filter( 'get_entity_view_config_custom_kind_custom_name', $filter ); @@ -374,7 +378,7 @@ public function test_get_item_schema() { $this->assertSame( 'view-config', $schema['title'] ); $this->assertSameSets( - array( 'kind', 'name', 'default_view', 'default_layouts', 'view_list', 'form' ), + array( 'kind', 'name', 'version', 'default_view', 'default_layouts', 'view_list', 'form' ), array_keys( $schema['properties'] ) ); } diff --git a/tests/phpunit/tests/view-config-data.php b/tests/phpunit/tests/view-config-data.php new file mode 100644 index 0000000000000..f0bb5a873bc64 --- /dev/null +++ b/tests/phpunit/tests/view-config-data.php @@ -0,0 +1,1088 @@ + array( + 'type' => 'table', + 'perPage' => 20, + ), + ) + ); + $data->set( 'default_view', array( 'type' => 'grid' ), 1 ); + + $this->assertSame( array( 'type' => 'grid' ), $data->get_config()['default_view'] ); + } + + /** + * set() rejects an undocumented key. + * + * @covers ::set + */ + public function test_set_unknown_key_triggers_doing_it_wrong() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::set' ); + + $data = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) ); + $before = $data->get_config(); + $data->set( 'not_a_real_key', 'nope', 1 ); + + $this->assertSame( $before, $data->get_config() ); + } + + /** + * update_properties() merges object-shaped keys recursively. + * + * @covers ::update_properties + */ + public function test_update_properties_merges_default_view_recursively() { + $data = new WP_View_Config_Data( + array( + 'default_view' => array( + 'type' => 'table', + 'perPage' => 20, + 'sort' => array( + 'field' => 'title', + 'direction' => 'asc', + ), + ), + ) + ); + $data->update_properties( + array( + 'default_view' => array( + 'perPage' => 50, + 'sort' => array( 'direction' => 'desc' ), + ), + ), + 1 + ); + + $this->assertSame( + array( + 'type' => 'table', + 'perPage' => 50, + 'sort' => array( + 'field' => 'title', + 'direction' => 'desc', + ), + ), + $data->get_config()['default_view'] + ); + } + + /** + * update_properties() merges default_layouts by map key and adds unknown ones. + * + * @covers ::update_properties + */ + public function test_update_properties_merges_default_layouts_by_key() { + $data = new WP_View_Config_Data( + array( + 'default_layouts' => array( + 'table' => array(), + 'grid' => array(), + ), + ) + ); + $data->update_properties( + array( + 'default_layouts' => array( + 'table' => array( 'density' => 'compact' ), + 'activity' => array(), + ), + ), + 1 + ); + + $this->assertSame( + array( + 'table' => array( 'density' => 'compact' ), + 'grid' => array(), + 'activity' => array(), + ), + $data->get_config()['default_layouts'] + ); + } + + /** + * update_properties() merges the form's plain properties and leaves its + * fields untouched. + * + * @covers ::update_properties + */ + public function test_update_properties_merges_form_layout() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'layout' => array( 'type' => 'panel' ), + 'fields' => array( 'date', 'slug' ), + ), + ) + ); + $data->update_properties( + array( 'form' => array( 'layout' => array( 'type' => 'card' ) ) ), + 1 + ); + + $this->assertSame( + array( + 'layout' => array( 'type' => 'card' ), + 'fields' => array( 'date', 'slug' ), + ), + $data->get_config()['form'] + ); + } + + /** + * update_properties() merges a documented key that is absent from the config. + * + * @covers ::update_properties + */ + public function test_update_properties_merges_a_documented_key_absent_from_config() { + $data = new WP_View_Config_Data( array( 'default_view' => array() ) ); + $data->update_properties( + array( 'default_layouts' => array( 'table' => array( 'density' => 'compact' ) ) ), + 1 + ); + + $this->assertSame( + array( 'table' => array( 'density' => 'compact' ) ), + $data->get_config()['default_layouts'] + ); + } + + /** + * update_properties() unsets a property when the patch value is null. + * + * @covers ::update_properties + */ + public function test_update_properties_null_unsets_property() { + $data = new WP_View_Config_Data( + array( + 'default_view' => array( + 'type' => 'table', + 'perPage' => 20, + ), + ) + ); + $data->update_properties( array( 'default_view' => array( 'perPage' => null ) ), 1 ); + + $this->assertSame( array( 'type' => 'table' ), $data->get_config()['default_view'] ); + } + + /** + * update_properties() unsets a deeply nested layout property when the value is null. + * + * @covers ::update_properties + */ + public function test_update_properties_null_unsets_nested_layout_prop() { + $data = new WP_View_Config_Data( + array( + 'default_layouts' => array( + 'table' => array( + 'layout' => array( + 'styles' => array( 'title' => array( 'width' => '20%' ) ), + 'density' => 'compact', + ), + ), + ), + ) + ); + $data->update_properties( + array( 'default_layouts' => array( 'table' => array( 'layout' => array( 'styles' => null ) ) ) ), + 1 + ); + + $this->assertSame( + array( 'layout' => array( 'density' => 'compact' ) ), + $data->get_config()['default_layouts']['table'] + ); + } + + /** + * update_properties() drops a whole top-level key when the patch value is + * null — any documented key, including the identity-keyed view_list — + * rather than storing a literal null. wp_get_entity_view_config() + * backfills a dropped documented key from the defaults, so that reads as + * a reset. + * + * @covers ::update_properties + */ + public function test_update_properties_null_drops_whole_top_level_key() { + $data = new WP_View_Config_Data( + array( + 'default_view' => array( 'type' => 'table' ), + 'view_list' => array( + array( + 'title' => 'All', + 'slug' => 'all', + ), + ), + 'form' => array( 'layout' => array( 'type' => 'panel' ) ), + ) + ); + $data->update_properties( + array( + 'default_view' => null, + 'view_list' => null, + ), + 1 + ); + + $this->assertSame( + array( 'form' => array( 'layout' => array( 'type' => 'panel' ) ) ), + $data->get_config() + ); + } + + /** + * update_properties() consumes a null delete-marker merged into an empty + * base instead of storing it as a literal value. + * + * @covers ::update_properties + */ + public function test_update_properties_null_into_empty_base_is_consumed() { + $data = new WP_View_Config_Data( array( 'default_layouts' => array( 'table' => array() ) ) ); + $data->update_properties( + array( 'default_layouts' => array( 'table' => array( 'layout' => null ) ) ), + 1 + ); + + $this->assertSame( array(), $data->get_config()['default_layouts']['table'] ); + } + + /** + * update_properties() strips nulls from a subtree assigned to a key absent + * from the base instead of storing them as literal values. + * + * @covers ::update_properties + */ + public function test_update_properties_null_stripped_from_absent_key_subtree() { + $data = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) ); + // The base default_view has no `layout` key. + $data->update_properties( + array( + 'default_view' => array( + 'layout' => array( + 'type' => 'flex', + 'badgeFields' => null, + ), + ), + ), + 1 + ); + + $this->assertSame( array( 'type' => 'flex' ), $data->get_config()['default_view']['layout'] ); + } + + /** + * update_properties() rejects the identity-keyed branches: a non-null + * view_list value and form fields belong to their dedicated functions. A + * valid sibling form property still merges. + * + * @covers ::update_properties + */ + public function test_update_properties_rejects_identity_keyed_branches() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_properties' ); + + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'layout' => array( 'type' => 'panel' ), + 'fields' => array( 'date' ), + ), + ) + ); + + $data->update_properties( + array( + 'view_list' => array( + array( + 'slug' => 'mine', + 'title' => 'Mine', + ), + ), + ), + 1 + ); + $this->assertArrayNotHasKey( 'view_list', $data->get_config() ); + + $data->update_properties( + array( + 'form' => array( + 'layout' => array( 'type' => 'card' ), + 'fields' => array( 'my_field' ), + ), + ), + 1 + ); + $this->assertSame( + array( + 'layout' => array( 'type' => 'card' ), + 'fields' => array( 'date' ), + ), + $data->get_config()['form'] + ); + } + + /** + * update_properties() rejects an undocumented top-level key. Nested + * properties are not validated: their vocabulary is owned by the + * client-side consumers. + * + * @covers ::update_properties + */ + public function test_update_properties_warns_on_unknown_top_level_key() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_properties' ); + + $data = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) ); + $data->update_properties( array( 'not_a_real_key' => 'nope' ), 1 ); + + $this->assertSame( array( 'default_view' => array( 'type' => 'table' ) ), $data->get_config() ); + } + + /** + * update_properties() rejects a list where the form map is expected. + * + * @covers ::update_properties + */ + public function test_update_properties_rejects_list_shaped_form_patch() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_properties' ); + + $data = new WP_View_Config_Data( array( 'form' => array( 'layout' => array( 'type' => 'panel' ) ) ) ); + $before = $data->get_config(); + $data->update_properties( array( 'form' => array( array( 'id' => 'my_field' ) ) ), 1 ); + + $this->assertSame( $before, $data->get_config() ); + } + + /** + * Every update function and set() reject a patch whose version cannot be + * migrated — newer than the latest supported version. + * + * @covers ::update_properties + * @covers ::update_view_list_items + * @covers ::update_form_fields + * @covers ::set + */ + public function test_update_functions_reject_unmigratable_version() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_properties' ); + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_view_list_items' ); + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_form_fields' ); + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::set' ); + + $data = new WP_View_Config_Data( array( 'default_view' => array( 'type' => 'table' ) ) ); + $before = $data->get_config(); + + $version = WP_View_Config_Data::LATEST_VERSION + 1; + $data->update_properties( array( 'default_view' => array( 'type' => 'grid' ) ), $version ); + $data->update_view_list_items( array( 'mine' => array( 'title' => 'Mine' ) ), $version ); + $data->update_form_fields( array( 'excerpt' => array( 'layout' => array( 'labelPosition' => 'side' ) ) ), $version ); + $data->set( 'default_view', array( 'type' => 'grid' ), $version ); + + $this->assertSame( $before, $data->get_config() ); + } + + /** + * update_view_list_items() merges a matching slug in place and appends an + * unknown one, injecting the slug from the patch key. + * + * @covers ::update_view_list_items + */ + public function test_update_view_list_items_merges_by_slug_and_appends_unknown() { + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'title' => 'All', + 'slug' => 'all', + ), + array( + 'title' => 'Published', + 'slug' => 'published', + ), + ), + ) + ); + $data->update_view_list_items( + array( + 'published' => array( 'title' => 'Live' ), + 'mine' => array( 'title' => 'Mine' ), + ), + 1 + ); + + $this->assertSame( + array( + array( + 'title' => 'All', + 'slug' => 'all', + ), + array( + 'title' => 'Live', + 'slug' => 'published', + ), + array( + 'slug' => 'mine', + 'title' => 'Mine', + ), + ), + $data->get_config()['view_list'] + ); + } + + /** + * update_view_list_items() removes a view when the patch value is null. + * + * @covers ::update_view_list_items + */ + public function test_update_view_list_items_null_removes_view() { + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'title' => 'All', + 'slug' => 'all', + ), + array( + 'title' => 'Published', + 'slug' => 'published', + ), + ), + ) + ); + $data->update_view_list_items( array( 'published' => null ), 1 ); + + $this->assertSame( + array( + array( + 'title' => 'All', + 'slug' => 'all', + ), + ), + $data->get_config()['view_list'] + ); + } + + /** + * The patch key is the identity: a conflicting `slug` property inside the + * value is ignored. + * + * @covers ::update_view_list_items + */ + public function test_update_view_list_items_patch_key_wins_over_slug_property() { + $data = new WP_View_Config_Data( array( 'view_list' => array() ) ); + $data->update_view_list_items( + array( + 'mine' => array( + 'slug' => 'other', + 'title' => 'Mine', + ), + ), + 1 + ); + + $this->assertSame( + array( + array( + 'slug' => 'mine', + 'title' => 'Mine', + ), + ), + $data->get_config()['view_list'] + ); + } + + /** + * update_view_list_items() rejects patches that are not keyed by slug and + * members that are not view objects. + * + * @covers ::update_view_list_items + */ + public function test_update_view_list_items_rejects_off_shape_patches() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_view_list_items' ); + + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'title' => 'All', + 'slug' => 'all', + ), + ), + ) + ); + $before = $data->get_config(); + + // A positional list where a map keyed by slug is expected. + $data->update_view_list_items( + array( + array( + 'slug' => 'mine', + 'title' => 'Mine', + ), + ), + 1 + ); + // A scalar where a view object (or null) is expected. + $data->update_view_list_items( array( 'all' => 'nope' ), 1 ); + + $this->assertSame( $before, $data->get_config() ); + } + + /** + * update_form_fields() merges a top-level field by its id: in place, with + * siblings untouched and nothing appended. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_merges_top_level_field_by_id() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + array( + 'id' => 'excerpt', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'top', + ), + ), + 'date', + ), + ), + ) + ); + $data->update_form_fields( array( 'excerpt' => array( 'layout' => array( 'labelPosition' => 'side' ) ) ), 1 ); + + $this->assertSame( + array( + array( + 'id' => 'excerpt', + 'layout' => array( + 'type' => 'panel', + 'labelPosition' => 'side', + ), + ), + 'date', + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * update_form_fields() finds a nested field by its bare id: the caller does + * not need to know (or address) the group the field lives in. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_merges_nested_field_without_addressing_group() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status', 'ping_status' ), + ), + ), + ), + ) + ); + $data->update_form_fields( array( 'ping_status' => array( 'layout' => array( 'labelPosition' => 'side' ) ) ), 1 ); + + // comment_status stays a bare string; the matched ping_status child is + // promoted from a bare string and merged with the incoming overrides. + $this->assertSame( + array( + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( + 'comment_status', + array( + 'id' => 'ping_status', + 'layout' => array( 'labelPosition' => 'side' ), + ), + ), + ), + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * Fields are visited in document order and a group matches before its own + * children, so a group and a child sharing an id (as in core's default + * `status` group) resolve to the group; the child is reached through a + * `children` patch on the group. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_matches_group_before_its_children() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + array( + 'id' => 'status', + 'label' => 'Status', + 'children' => array( 'status', 'password' ), + ), + ), + ), + ) + ); + $data->update_form_fields( + array( + 'status' => array( + 'label' => 'Visibility', + 'children' => array( 'status' => array( 'layout' => array( 'labelPosition' => 'none' ) ) ), + ), + ), + 1 + ); + + $this->assertSame( + array( + array( + 'id' => 'status', + 'label' => 'Visibility', + 'children' => array( + array( + 'id' => 'status', + 'layout' => array( 'labelPosition' => 'none' ), + ), + 'password', + ), + ), + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * update_form_fields() appends an unknown id to the end of the top-level + * list: as an object when the patch carries overrides, as a bare string + * reference otherwise. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_appends_unknown_field() { + $data = new WP_View_Config_Data( array( 'form' => array( 'fields' => array( 'date' ) ) ) ); + $data->update_form_fields( + array( + 'my_field' => array( 'layout' => array( 'labelPosition' => 'side' ) ), + 'other_field' => array(), + ), + 1 + ); + + $this->assertSame( + array( + 'date', + array( + 'id' => 'my_field', + 'layout' => array( 'labelPosition' => 'side' ), + ), + 'other_field', + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * A null patch value removes the field wherever it lives, including nested + * inside a group's children. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_null_removes_field_wherever_it_lives() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + array( + 'id' => 'excerpt', + 'layout' => array( 'type' => 'panel' ), + ), + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status', 'ping_status' ), + ), + 'date', + ), + ), + ) + ); + $data->update_form_fields( + array( + 'excerpt' => null, + 'ping_status' => null, + ), + 1 + ); + + $this->assertSame( + array( + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status' ), + ), + 'date', + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * A `children` map merges into the group's children by id, appending + * unknown ones. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_children_map_merges_by_id() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status', 'ping_status' ), + ), + ), + ), + ) + ); + $data->update_form_fields( + array( + 'discussion' => array( + 'children' => array( + 'comment_status' => array( 'layout' => array( 'labelPosition' => 'none' ) ), + 'my_field' => array(), + ), + ), + ), + 1 + ); + + $this->assertSame( + array( + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( + array( + 'id' => 'comment_status', + 'layout' => array( 'labelPosition' => 'none' ), + ), + 'ping_status', + 'my_field', + ), + ), + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * A `children` list replaces the group's children wholesale while the group + * keeps its position among the other top-level fields. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_children_list_replaces_wholesale() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + 'excerpt', + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status', 'ping_status' ), + ), + 'date', + ), + ), + ) + ); + $data->update_form_fields( + array( 'discussion' => array( 'children' => array( 'ping_status', 'my_field' ) ) ), + 1 + ); + + $this->assertSame( + array( + 'excerpt', + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'ping_status', 'my_field' ), + ), + 'date', + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * A null `children` value deletes the key, turning the group into a plain + * field. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_children_null_drops_key() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status' ), + ), + ), + ), + ) + ); + $data->update_form_fields( array( 'discussion' => array( 'children' => null ) ), 1 ); + + $this->assertSame( + array( + array( + 'id' => 'discussion', + 'label' => 'Discussion', + ), + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * The patch key is the identity: a conflicting `id` property inside the + * value is ignored. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_patch_key_wins_over_id_property() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + array( + 'id' => 'excerpt', + 'layout' => array( 'labelPosition' => 'top' ), + ), + ), + ), + ) + ); + $data->update_form_fields( + array( + 'excerpt' => array( + 'id' => 'other', + 'layout' => array( 'labelPosition' => 'side' ), + ), + ), + 1 + ); + + $this->assertSame( + array( + array( + 'id' => 'excerpt', + 'layout' => array( 'labelPosition' => 'side' ), + ), + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * update_form_fields() rejects patches that are not keyed by id and members + * that are not field objects. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_rejects_off_shape_patches() { + $this->setExpectedIncorrectUsage( 'WP_View_Config_Data::update_form_fields' ); + + $data = new WP_View_Config_Data( array( 'form' => array( 'fields' => array( 'date' ) ) ) ); + $before = $data->get_config(); + + // A positional list where a map keyed by id is expected. + $data->update_form_fields( array( array( 'id' => 'my_field' ) ), 1 ); + // A scalar where a field object (or null) is expected. + $data->update_form_fields( array( 'date' => 'nope' ), 1 ); + + $this->assertSame( $before, $data->get_config() ); + } + + /** + * Several null field patches drop several members in one patch: both nested + * children are removed while the group itself remains. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_null_removes_multiple_nested_fields() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + 'excerpt', + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status', 'ping_status' ), + ), + ), + ), + ) + ); + $data->update_form_fields( + array( + 'ping_status' => null, + 'comment_status' => null, + ), + 1 + ); + + $this->assertSame( + array( + 'excerpt', + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array(), + ), + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * Removing a group removes its children with it: they are not hoisted to + * the top level. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_null_removes_group_with_its_children() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status', 'ping_status' ), + ), + 'date', + ), + ), + ) + ); + $data->update_form_fields( array( 'discussion' => null ), 1 ); + + $this->assertSame( array( 'date' ), $data->get_config()['form']['fields'] ); + } + + /** + * Patch entries apply in order and a null removes every occurrence of the + * id, so a field moves into a group by removing it first and appending it + * to the group's children later in the same patch. + * + * @covers ::update_form_fields + */ + public function test_update_form_fields_moves_field_into_group_in_one_patch() { + $data = new WP_View_Config_Data( + array( + 'form' => array( + 'fields' => array( + 'author', + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status' ), + ), + ), + ), + ) + ); + $data->update_form_fields( + array( + 'author' => null, + 'discussion' => array( 'children' => array( 'author' => array() ) ), + ), + 1 + ); + + $this->assertSame( + array( + array( + 'id' => 'discussion', + 'label' => 'Discussion', + 'children' => array( 'comment_status', 'author' ), + ), + ), + $data->get_config()['form']['fields'] + ); + } + + /** + * A null patch for an identity that is not found is a silent no-op. + * + * A member that is not present may have been removed by another filter or + * simply not apply to this entity, so it is not treated as misuse. + * + * @covers ::update_form_fields + * @covers ::update_view_list_items + */ + public function test_null_patch_for_unknown_identity_is_silent_no_op() { + $data = new WP_View_Config_Data( array( 'form' => array( 'fields' => array( 'date' ) ) ) ); + $data->update_form_fields( array( 'does_not_exist' => null ), 1 ); + + $this->assertSame( array( 'date' ), $data->get_config()['form']['fields'] ); + + $data = new WP_View_Config_Data( + array( + 'view_list' => array( + array( + 'title' => 'All', + 'slug' => 'all', + ), + ), + ) + ); + $data->update_view_list_items( array( 'does_not_exist' => null ), 1 ); + + $this->assertSame( + array( + array( + 'title' => 'All', + 'slug' => 'all', + ), + ), + $data->get_config()['view_list'] + ); + } +} diff --git a/tests/phpunit/tests/view-config.php b/tests/phpunit/tests/view-config.php index 8aa4ace4eb592..d9c79e00e5944 100644 --- a/tests/phpunit/tests/view-config.php +++ b/tests/phpunit/tests/view-config.php @@ -120,15 +120,17 @@ public function test_view_list_uses_post_type_all_items_label() { } /** - * The dynamic filter receives the default config and the entity descriptor. + * The dynamic filter receives the data container and the entity descriptor. */ - public function test_filter_receives_config_and_entity() { + public function test_filter_receives_data_and_entity() { $received_entity = null; + $received_data = null; add_filter( 'get_entity_view_config_custom_kind_custom_name', - function ( $config, $entity ) use ( &$received_entity ) { + function ( $data, $entity ) use ( &$received_entity, &$received_data ) { $received_entity = $entity; - return $config; + $received_data = $data; + return $data; }, 10, 2 @@ -136,6 +138,7 @@ function ( $config, $entity ) use ( &$received_entity ) { wp_get_entity_view_config( 'custom_kind', 'custom_name' ); + $this->assertInstanceOf( 'WP_View_Config_Data', $received_data ); $this->assertSame( array( 'kind' => 'custom_kind', @@ -146,14 +149,16 @@ function ( $config, $entity ) use ( &$received_entity ) { } /** - * A filter can override the configuration values. + * A filter can override configuration values through update_properties(). */ - public function test_filter_can_override_config() { + public function test_filter_update_properties_overrides_config() { add_filter( 'get_entity_view_config_custom_kind_custom_name', - function ( $config ) { - $config['default_view']['type'] = 'grid'; - return $config; + function ( $data ) { + return $data->update_properties( + array( 'default_view' => array( 'type' => 'grid' ) ), + 1 + ); } ); @@ -163,53 +168,102 @@ function ( $config ) { } /** - * Dropped keys are backfilled with their defaults. + * Successive filters share the same container, so their patches compose and + * a later null field patch reaches a member contributed by an earlier filter. */ - public function test_filter_dropped_keys_are_backfilled() { + public function test_filters_compose_across_the_chain() { + add_filter( + 'get_entity_view_config_custom_kind_custom_name', + function ( $data ) { + return $data->set( + 'form', + array( + 'fields' => array( + array( + 'id' => 'discussion', + 'children' => array( 'comment_status', 'ping_status' ), + ), + ), + ), + 1 + ); + }, + 9 + ); + add_filter( + 'get_entity_view_config_custom_kind_custom_name', + function ( $data ) { + return $data->update_form_fields( array( 'ping_status' => null ), 1 ); + }, + 11 + ); + + $config = wp_get_entity_view_config( 'custom_kind', 'custom_name' ); + + $this->assertSame( + array( 'comment_status' ), + $config['form']['fields'][0]['children'] + ); + } + + /** + * A filter that returns its own off-shape container has the result normalized: + * undocumented keys are dropped and dropped documented keys are backfilled + * from the defaults. + */ + public function test_off_shape_container_return_is_normalized() { add_filter( 'get_entity_view_config_custom_kind_custom_name', function () { - // Return a config that is missing most of the documented keys. - return array( 'form' => array( 'custom' => true ) ); + return new WP_View_Config_Data( + array( + 'default_view' => array( 'type' => 'grid' ), + 'not_a_real_key' => 'nope', + ) + ); } ); $config = wp_get_entity_view_config( 'custom_kind', 'custom_name' ); + // Undocumented key dropped; shape is exactly the documented keys. $this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) ); - $this->assertSame( array( 'custom' => true ), $config['form'] ); - // Backfilled from defaults. - $this->assertSame( self::DEFAULT_VIEW, $config['default_view'] ); + // The container's own value is respected. + $this->assertSame( array( 'type' => 'grid' ), $config['default_view'] ); + // Documented keys the container omitted are backfilled from the defaults. $this->assertSame( self::DEFAULT_LAYOUTS, $config['default_layouts'] ); $this->assertSame( self::DEFAULT_VIEW_LIST, $config['view_list'] ); + $this->assertSame( self::DEFAULT_FORM, $config['form'] ); } /** - * Keys introduced by a filter that are not part of the documented shape are discarded. + * A documented key dropped through a null patch value is backfilled from + * the defaults, so a null never reaches the response. */ - public function test_filter_unknown_keys_are_discarded() { + public function test_filter_null_reset_is_backfilled_from_defaults() { add_filter( 'get_entity_view_config_custom_kind_custom_name', - function ( $config ) { - $config['not_a_real_key'] = 'nope'; - return $config; + function ( $data ) { + return $data->update_properties( array( 'default_view' => null ), 1 ); } ); $config = wp_get_entity_view_config( 'custom_kind', 'custom_name' ); - $this->assertArrayNotHasKey( 'not_a_real_key', $config ); - $this->assertSameSets( self::CONFIG_KEYS, array_keys( $config ) ); + $this->assertSame( self::DEFAULT_VIEW, $config['default_view'] ); } /** - * A non-array filter return falls back to the default config. + * A filter that returns something other than the container falls back to the + * default config. */ - public function test_non_array_filter_return_falls_back_to_default() { + public function test_non_object_filter_return_falls_back_to_default() { + $this->setExpectedIncorrectUsage( 'wp_get_entity_view_config' ); + add_filter( 'get_entity_view_config_custom_kind_custom_name', function () { - return 'not an array'; + return 'not the container'; } ); From 9b18f0e62cc771b1ad22917c2ad4a7e4b6ba650a Mon Sep 17 00:00:00 2001 From: ntsekouras Date: Wed, 8 Jul 2026 14:59:45 +0300 Subject: [PATCH 3/3] feedback --- src/wp-includes/view-config.php | 14 ++++---------- tests/phpunit/tests/view-config.php | 5 +++-- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/wp-includes/view-config.php b/src/wp-includes/view-config.php index 0e743b4ee9bef..3a997248b0b4a 100644 --- a/src/wp-includes/view-config.php +++ b/src/wp-includes/view-config.php @@ -202,12 +202,8 @@ function wp_get_entity_view_config( $kind, $name ) { return $config; } - // The functions keep the container close to the documented shape (set() and - // update_properties() reject unknown keys), but a documented key may - // still be missing — dropped by a null reset patch, or omitted by a callback - // returning its own off-shape container. Normalize either way: drop - // undocumented keys and backfill any dropped documented keys from the - // defaults. + // Backfill any dropped keys with their defaults, then discard any keys the + // filter introduced that are not part of the documented configuration shape. return array_intersect_key( array_merge( $config, $filtered->get_config() ), $config ); } @@ -339,10 +335,8 @@ function _wp_get_entity_view_config_post_type_page( $data ) { $data->set( 'default_layouts', $default_layouts, 1 ); $data->set( 'default_view', $default_view, 1 ); - // Append the status views onto the inherited base "all items" view rather - // than replacing it, so its post-type-specific title is kept. The version - // is pinned to the literal this patch was authored against as an extra guard, - // so a future LATEST_VERSION bump migrates it instead of silently skipping it. + // Append the status views, thereby preserving the base "all items" view, + // so its post-type-specific title is kept. $data->update_view_list_items( array_column( $view_list, null, 'slug' ), 1 ); return $data; diff --git a/tests/phpunit/tests/view-config.php b/tests/phpunit/tests/view-config.php index d9c79e00e5944..ec36e1c80fbd2 100644 --- a/tests/phpunit/tests/view-config.php +++ b/tests/phpunit/tests/view-config.php @@ -168,8 +168,9 @@ function ( $data ) { } /** - * Successive filters share the same container, so their patches compose and - * a later null field patch reaches a member contributed by an earlier filter. + * Successive filters share the same WP_View_Config_Data instance, so their + * effects compose: a later filter can remove a form field that an earlier + * one added. */ public function test_filters_compose_across_the_chain() { add_filter(