← All changes
|
lib/compat/wordpress-7.1/class-gutenberg-view-config-data.php
+518
-398
23.6.2
→
trunk
View file →
| @@ -11,30 +11,48 @@ | ||
| 11 | 11 | * An instance of this class is what `get_entity_view_config_{$kind}_{$name}` |
| 12 | 12 | * filter callbacks receive: a callback changes the configuration by calling |
| 13 | 13 | * methods on the instance and returning it. The configuration has four |
| 14 | 14 | * top-level keys — `default_view`, `default_layouts`, `view_list`, and |
| 15 | - * `form` — and there are two ways to contribute: | |
| 15 | + * `form` — and there are three ways to contribute. They form a gradient of how | |
| 16 | + * deep the replacement reaches: | |
| 16 | 17 | * |
| 17 | - * - The `update_*()` methods merge partial changes (patches) into what is | |
| 18 | - * already there, each covering one part of the configuration: | |
| 19 | - * `update_properties()` for `default_view`, `default_layouts`, and the | |
| 20 | - * `form` settings other than its `fields`; `update_view_list_items()` for | |
| 21 | - * the `view_list` entries, keyed by view `slug`; and `update_form_fields()` | |
| 22 | - * for the `form` fields, keyed by field `id`. This is what plugins should | |
| 23 | - * use: patches compose with core's configuration and with other plugins'. | |
| 24 | - * - `set()` replaces a whole top-level key. It shouldn't be the default | |
| 25 | - * choice — a callback using it stops inheriting core's future changes to | |
| 26 | - * that key — but it's useful for cases like a post type that doesn't | |
| 27 | - * want the default form at all. | |
| 18 | + * - The `merge()` method merges partial changes (patches) into what is already | |
| 19 | + * there: `default_view`, `default_layouts`, and the `form` settings by key, | |
| 20 | + * and the `view_list` entries by view `slug` identity. This is what plugins | |
| 21 | + * should use: patches compose with core's configuration and with other | |
| 22 | + * plugins'. | |
| 23 | + * - `replace()` applies a patch the same way `merge()` does, with one | |
| 24 | + * difference: a list in the patch replaces the current list wholesale | |
| 25 | + * instead of merging into it by member identity. It shouldn't be the | |
| 26 | + * default choice — a callback that replaces a list stops inheriting core's | |
| 27 | + * future additions to it — but it's useful when a contributor needs to pin | |
| 28 | + * a list to an exact set of members. | |
| 29 | + * - `set()` goes one step further: it replaces each top-level key the patch | |
| 30 | + * names wholesale, dropping whatever that key held instead of merging into | |
| 31 | + * it. It's for a callback that owns a key outright and wants to pin it to an | |
| 32 | + * exact shape without the inherited default leaking through a key-by-key | |
| 33 | + * merge. | |
| 28 | 34 | * |
| 29 | - * Patches follow three shared rules: an associative array merges key by | |
| 30 | - * key, a numerically indexed array replaces the current value wholesale, | |
| 31 | - * and `null` deletes what it names — deleting a whole top-level key resets | |
| 32 | - * it to its default. Each patch and each `set()` value also declares the | |
| 33 | - * configuration schema version it was written against (currently 1), so a | |
| 34 | - * future WordPress release that changes the configuration shape can migrate | |
| 35 | - * existing patches forward instead of breaking them. | |
| 35 | + * All three touch only the top-level keys the patch names — an omitted key | |
| 36 | + * keeps whatever it had, and a top-level `null` value drops the key it names, | |
| 37 | + * which resets it to its default. They differ only in how deep the replacement | |
| 38 | + * reaches once a key is named: `merge()` and `replace()` merge the value in | |
| 39 | + * key by key (an associative array merges member by member, a nested `null` | |
| 40 | + * deletes just that leaf, a scalar replaces just that value), while `set()` | |
| 41 | + * swaps the whole value. A nested `null` deletes just the leaf it names in | |
| 42 | + * every case. A patch value whose shape does not match the current value — | |
| 43 | + * an associative array where a list lives, or the reverse — is rejected with | |
| 44 | + * a notice rather than merged, and an empty array under `merge()` is a | |
| 45 | + * no-op. Each patch also declares the configuration schema | |
| 46 | + * version it was written against (currently 1), so a future WordPress release | |
| 47 | + * that changes the configuration shape can migrate existing patches forward | |
| 48 | + * instead of breaking them. | |
| 36 | 49 | * |
| 50 | + * Where those three write values, `remove()` deletes them: it takes a spec of | |
| 51 | + * names — a list to delete entries at a level, or a nested map to reach deeper — | |
| 52 | + * and prunes just what it names, mirroring the configuration's shape all the way | |
| 53 | + * down to individual list members. | |
| 54 | + * | |
| 37 | 55 | * @since 7.1.0 |
| 38 | 56 | */ |
| 39 | 57 | class Gutenberg_View_Config_Data { |
| 40 | 58 | |
| @@ -62,8 +80,16 @@ | ||
| 62 | 80 | */ |
| 63 | 81 | private $config; |
| 64 | 82 | |
| 65 | 83 | /** |
| 84 | + * The default configuration. | |
| 85 | + * | |
| 86 | + * @since 7.1.0 | |
| 87 | + * @var array | |
| 88 | + */ | |
| 89 | + private $defaults; | |
| 90 | + | |
| 91 | + /** | |
| 66 | 92 | * Constructor. |
| 67 | 93 | * |
| 68 | 94 | * @since 7.1.0 |
| 69 | 95 | * |
| @@ -69,90 +95,184 @@ | ||
| 69 | 95 | * |
| 70 | 96 | * @param array $config The base configuration to contribute to. |
| 71 | 97 | */ |
| 72 | 98 | public function __construct( array $config ) { |
| 73 | - $this->config = $config; | |
| 99 | + $this->config = $config; | |
| 100 | + $this->defaults = $config; | |
| 74 | 101 | } |
| 75 | 102 | |
| 76 | 103 | /** |
| 77 | 104 | * Returns the current configuration array. |
| 78 | 105 | * |
| 106 | + * Deliberately private: filter callbacks receive the container, not the | |
| 107 | + * materialized configuration, so they cannot read the built result and | |
| 108 | + * become coupled to a specific configuration shape or schema version. Only | |
| 109 | + * the class itself reconciles the container back into an array. | |
| 110 | + * | |
| 79 | 111 | * @since 7.1.0 |
| 80 | 112 | * |
| 81 | 113 | * @return array The configuration. |
| 82 | 114 | */ |
| 83 | - public function get_config() { | |
| 115 | + private function get_data() { | |
| 84 | 116 | return $this->config; |
| 85 | 117 | } |
| 86 | 118 | |
| 87 | 119 | /** |
| 88 | - * Replaces a whole top-level key with a new value. | |
| 120 | + * Applies the entity view configuration filter and returns the result. | |
| 89 | 121 | * |
| 90 | - * It shouldn't be the default choice — a callback using it stops | |
| 91 | - * inheriting core's future changes to that key — but it's useful for | |
| 92 | - * cases like a post type that doesn't want the default form at all. | |
| 122 | + * Exposes the container through the dynamic | |
| 123 | + * `get_entity_view_config_{$kind}_{$name}` filter (with the dynamic portions | |
| 124 | + * lowercased), so that core and third parties can provide the configuration for a specific entity, | |
| 125 | + * then reconciles the filtered container back into a plain configuration array, | |
| 126 | + * limited to the documented configuration keys. | |
| 93 | 127 | * |
| 94 | - * A value that declares an unsupported schema version is rejected and | |
| 95 | - * does not replace anything. | |
| 128 | + * @since 7.1.0 | |
| 96 | 129 | * |
| 130 | + * @param string $kind The entity kind (e.g. `postType`). | |
| 131 | + * @param string $name The entity name (e.g. `page`). | |
| 132 | + * @return array The filtered configuration, limited to the documented keys. | |
| 133 | + */ | |
| 134 | + public function apply_filters( $kind, $name ) { | |
| 135 | + /** | |
| 136 | + * Filters the view configuration for a given entity. | |
| 137 | + * | |
| 138 | + * The dynamic portions of the hook name, `$kind` and `$name`, refer to the | |
| 139 | + * entity kind (e.g. `postType`) and the entity name (e.g. `page`), | |
| 140 | + * lowercased — so the `postType`/`page` entity maps to the | |
| 141 | + * `get_entity_view_config_posttype_page` hook. | |
| 142 | + * | |
| 143 | + * Callbacks receive a Gutenberg_View_Config_Data object and change the | |
| 144 | + * configuration through its methods. Each write method takes the schema | |
| 145 | + * version the change was authored against as its second argument, | |
| 146 | + * and returns the object for chaining: | |
| 147 | + * | |
| 148 | + * - `merge( $patch, $version )` merges a partial change into the current | |
| 149 | + * configuration. It touches only the top-level keys the patch names, and | |
| 150 | + * merges each named value into the current one by shape: a scalar | |
| 151 | + * replaces, an associative array merges key by key, and a list merges by | |
| 152 | + * member identity (`id`, `slug`, or `field`). A `null` value drops the | |
| 153 | + * key it names, resetting it to its default. | |
| 154 | + * - `replace( $patch, $version )` applies a patch exactly like `merge()`, | |
| 155 | + * but swaps any list it names wholesale instead of merging that list by | |
| 156 | + * member identity. | |
| 157 | + * - `set( $patch, $version )` also touches only the keys the patch names, | |
| 158 | + * but swaps each named value in wholesale, dropping whatever the key held | |
| 159 | + * before — for a callback that owns those keys outright. | |
| 160 | + * - `remove( $spec, $version )` deletes named properties. The spec mirrors | |
| 161 | + * the configuration shape: a list of names deletes entries at that level, | |
| 162 | + * and a nested map recurses to prune from within a named value, down to | |
| 163 | + * individual list members. | |
| 164 | + * | |
| 165 | + * A change that declares an unsupported schema version is rejected and does | |
| 166 | + * not alter anything. As with any filter, each callback's return value is | |
| 167 | + * passed to the next callback as `$data`, so callbacks must return the | |
| 168 | + * container they received: a callback that returns nothing, or any other | |
| 169 | + * value, hands that result to every callback hooked at a later priority | |
| 170 | + * instead of the container. Since the write methods return the container, | |
| 171 | + * a callback can end with `return $data->merge( $patch, $version );`. | |
| 172 | + * | |
| 173 | + * @param Gutenberg_View_Config_Data $data The view configuration container | |
| 174 | + * for the entity, exposing the | |
| 175 | + * `default_view`, `default_layouts`, | |
| 176 | + * `view_list`, and `form` keys. | |
| 177 | + * @param array $entity { | |
| 178 | + * The entity the configuration is built for. | |
| 179 | + * | |
| 180 | + * @type string $kind The entity kind. | |
| 181 | + * @type string $name The entity name. | |
| 182 | + * } | |
| 183 | + */ | |
| 184 | + apply_filters( | |
| 185 | + gutenberg_get_entity_view_config_hook_name( $kind, $name ), | |
| 186 | + $this, | |
| 187 | + array( | |
| 188 | + 'kind' => $kind, | |
| 189 | + 'name' => $name, | |
| 190 | + ) | |
| 191 | + ); | |
| 192 | + | |
| 193 | + // Discard any keys the filter introduced that are not part of the | |
| 194 | + // documented configuration shape. | |
| 195 | + return array_intersect_key( $this->get_data(), array_flip( self::CONFIG_KEYS ) ); | |
| 196 | + } | |
| 197 | + | |
| 198 | + /** | |
| 199 | + * Replaces whole top-level keys, leaving the rest of the configuration alone. | |
| 200 | + * | |
| 201 | + * Like merge() and replace(), set() applies a patch of top-level keys and | |
| 202 | + * touches only the keys the patch names: a key the patch omits keeps whatever | |
| 203 | + * it had, and a `null` value drops the key it names (which resets it to its | |
| 204 | + * default). The difference is depth — where merge() and replace() merge a | |
| 205 | + * named key's value into the current one key by key, set() swaps the whole | |
| 206 | + * value in wholesale, dropping whatever the key held before. A `null` nested | |
| 207 | + * within that value still drops the property it names, so set() honours | |
| 208 | + * nulls at every depth just as merge() and replace() do. | |
| 209 | + * | |
| 210 | + * Use it when a callback owns a key outright and wants to pin it to an exact | |
| 211 | + * shape, without the inherited default leaking through a key-by-key merge. | |
| 212 | + * | |
| 213 | + * A patch that declares an unsupported schema version is rejected and does | |
| 214 | + * not change anything. | |
| 215 | + * | |
| 97 | 216 | * @since 7.1.0 |
| 98 | 217 | * |
| 99 | - * @param string $key The configuration key to replace. | |
| 100 | - * @param mixed $value The new value. | |
| 101 | - * @param int $version The schema version the value was authored against. | |
| 218 | + * @param array $patch The partial configuration whose named keys to replace. | |
| 219 | + * @param int $version The schema version the patch was authored against. | |
| 102 | 220 | * @return Gutenberg_View_Config_Data The instance, for chaining. |
| 103 | 221 | */ |
| 104 | - public function set( $key, $value, int $version ) { | |
| 105 | - if ( ! $this->check_version( $version, __METHOD__ ) ) { | |
| 106 | - return $this; | |
| 107 | - } | |
| 108 | - | |
| 109 | - if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) { | |
| 110 | - _doing_it_wrong( | |
| 111 | - __METHOD__, | |
| 112 | - sprintf( | |
| 113 | - /* translators: %s: the configuration key. */ | |
| 114 | - esc_html__( '"%s" is not a documented view configuration key.', 'gutenberg' ), | |
| 115 | - esc_html( $key ) | |
| 116 | - ), | |
| 117 | - '7.1.0' | |
| 118 | - ); | |
| 119 | - return $this; | |
| 120 | - } | |
| 121 | - | |
| 122 | - $this->config[ $key ] = $value; | |
| 123 | - return $this; | |
| 222 | + public function set( array $patch, int $version ) { | |
| 223 | + return $this->apply( $patch, $version, __METHOD__, 'set' ); | |
| 124 | 224 | } |
| 125 | 225 | |
| 126 | 226 | /** |
| 127 | - * Merges a partial configuration into `default_view`, `default_layouts`, | |
| 128 | - * and the `form` settings other than its `fields`. | |
| 227 | + * Removes named properties from the configuration, leaving the rest alone. | |
| 129 | 228 | * |
| 130 | - * An associative array merges key by key, a numerically indexed array | |
| 131 | - * replaces the current value wholesale, and `null` deletes the key it | |
| 132 | - * names; deleting a whole top-level key (any documented key, including | |
| 133 | - * `view_list`) resets it to its default. | |
| 229 | + * Where merge(), replace(), and set() take a patch of *values* to write, | |
| 230 | + * remove() takes a spec of *names* to delete, and its shape mirrors the | |
| 231 | + * configuration it prunes: | |
| 134 | 232 | * |
| 135 | - * The keyed collections have dedicated methods and are rejected here: a | |
| 136 | - * non-null `view_list` value must go through `update_view_list_items()`, | |
| 137 | - * and a `fields` key inside a `form` value must go through | |
| 138 | - * `update_form_fields()`. | |
| 233 | + * - A list of names deletes each named entry from the value at that level: a | |
| 234 | + * key from an associative array, or the member with a matching identity | |
| 235 | + * (`id`, `slug`, `field`, or a bare scalar) from a list. | |
| 236 | + * - An associative array maps a name to a nested spec, recursing into that | |
| 237 | + * entry's value to delete from within it. | |
| 139 | 238 | * |
| 140 | - * A patch that declares an unsupported schema version is rejected and | |
| 141 | - * does not merge. | |
| 239 | + * Naming a top-level configuration key is the one exception: like a `null` | |
| 240 | + * value in a patch, it resets that key to its default rather than dropping it | |
| 241 | + * outright, so top-level removal and top-level `null` compose the same way. | |
| 142 | 242 | * |
| 243 | + * So `array( 'default_view' )` resets the whole `default_view` key to its | |
| 244 | + * default, `array( 'default_view' => array( 'sort' ) )` drops just its `sort` | |
| 245 | + * property, and `array( 'default_view' => array( 'fields' => array( 'f2' ) ) )` | |
| 246 | + * drops the `f2` member from its `fields` list. A name that is not present is | |
| 247 | + * ignored, and a list is renumbered after a member is removed. | |
| 248 | + * | |
| 249 | + * A spec that declares an unsupported schema version is rejected and does not | |
| 250 | + * change anything. | |
| 251 | + * | |
| 143 | 252 | * @since 7.1.0 |
| 144 | 253 | * |
| 145 | - * @param array $patch The partial configuration to merge. | |
| 146 | - * @param int $version The schema version the patch was authored against. | |
| 254 | + * @param array $spec The names to remove, keyed to match the configuration shape. | |
| 255 | + * @param int $version The schema version the spec was authored against. | |
| 147 | 256 | * @return Gutenberg_View_Config_Data The instance, for chaining. |
| 148 | 257 | */ |
| 149 | - public function update_properties( array $patch, int $version ) { | |
| 150 | - if ( ! $this->check_version( $version, __METHOD__ ) ) { | |
| 258 | + public function remove( array $spec, int $version ) { | |
| 259 | + if ( $version <= 0 || $version > self::LATEST_VERSION ) { | |
| 260 | + _doing_it_wrong( | |
| 261 | + __METHOD__, | |
| 262 | + esc_html__( 'A view configuration patch must declare a supported schema version.', 'gutenberg' ), | |
| 263 | + '7.1.0' | |
| 264 | + ); | |
| 265 | + | |
| 151 | 266 | return $this; |
| 152 | 267 | } |
| 153 | 268 | |
| 154 | - foreach ( $patch as $key => $value ) { | |
| 269 | + // A flat list names top-level keys to reset; a map recurses into each | |
| 270 | + // named key to prune from within its value. | |
| 271 | + $spec_is_list = array_is_list( $spec ); | |
| 272 | + foreach ( $spec as $spec_key => $spec_value ) { | |
| 273 | + $key = $spec_is_list ? $spec_value : $spec_key; | |
| 274 | + | |
| 155 | 275 | if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) { |
| 156 | 276 | _doing_it_wrong( |
| 157 | 277 | __METHOD__, |
| 158 | 278 | sprintf( |
| @@ -163,31 +283,16 @@ | ||
| 163 | 283 | '7.1.0' |
| 164 | 284 | ); |
| 165 | 285 | continue; |
| 166 | 286 | } |
| 167 | - // A null patch value drops the whole key from the container rather | |
| 168 | - // than assigning null. | |
| 169 | - if ( null === $value ) { | |
| 170 | - unset( $this->config[ $key ] ); | |
| 171 | - continue; | |
| 287 | + | |
| 288 | + if ( $spec_is_list ) { | |
| 289 | + // Removing a top-level key resets it to its default, just as a | |
| 290 | + // null patch value does. | |
| 291 | + $this->config[ $key ] = $this->defaults[ $key ] ?? array(); | |
| 292 | + } elseif ( array_key_exists( $key, $this->config ) ) { | |
| 293 | + $this->config[ $key ] = $this->remove_properties( $this->config[ $key ], $spec_value ); | |
| 172 | 294 | } |
| 173 | - if ( 'view_list' === $key ) { | |
| 174 | - _doing_it_wrong( | |
| 175 | - __METHOD__, | |
| 176 | - esc_html__( 'The "view_list" entries are patched by identity. Use update_view_list_items() instead.', 'gutenberg' ), | |
| 177 | - '7.1.0' | |
| 178 | - ); | |
| 179 | - continue; | |
| 180 | - } | |
| 181 | - if ( 'form' === $key ) { | |
| 182 | - $value = $this->extract_form_properties( $value ); | |
| 183 | - // Nothing left to merge: the value was off-shape, or held only | |
| 184 | - // the rejected `fields` key. | |
| 185 | - if ( null === $value || array() === $value ) { | |
| 186 | - continue; | |
| 187 | - } | |
| 188 | - } | |
| 189 | - $this->config[ $key ] = $this->deep_merge( $this->config[ $key ] ?? array(), $value ); | |
| 190 | 295 | } |
| 191 | 296 | |
| 192 | 297 | return $this; |
| 193 | 298 | } |
| @@ -192,433 +297,448 @@ | ||
| 192 | 297 | return $this; |
| 193 | 298 | } |
| 194 | 299 | |
| 195 | 300 | /** |
| 196 | - * Adds, updates, or removes `view_list` entries, keyed by view `slug`. | |
| 301 | + * Replaces list values while merging the rest of a partial configuration. | |
| 197 | 302 | * |
| 198 | - * Each patch key names the `slug` of the view it targets: a matching view | |
| 199 | - * merges in place and keeps its position (following the shared rules — | |
| 200 | - * e.g. the view's `filters`, being numerically indexed, replace | |
| 201 | - * wholesale), an unknown slug appends a new view to the end, and `null` | |
| 202 | - * removes the view. The patch key is the identity: a `slug` property | |
| 203 | - * inside the value is ignored. A `null` for a slug that is not found is a | |
| 204 | - * silent no-op — the view may have been removed by another callback or | |
| 205 | - * simply not apply to this entity. | |
| 303 | + * Takes the same arguments as merge() and applies the patch the same way, | |
| 304 | + * with one difference: a list in the patch replaces the current list | |
| 305 | + * wholesale instead of merging into it by member identity. Associative | |
| 306 | + * arrays still merge key by key, `null` still drops what it names, and a | |
| 307 | + * scalar still replaces the current value. | |
| 206 | 308 | * |
| 207 | - * A patch that declares an unsupported schema version is rejected and | |
| 208 | - * does not merge. | |
| 309 | + * It shouldn't be the default choice — a callback that replaces a list | |
| 310 | + * stops inheriting core's future additions to it — but it's useful when a | |
| 311 | + * contributor needs to pin a list to an exact set of members. | |
| 209 | 312 | * |
| 313 | + * The shape rule applies here too: a patch value whose shape does not match | |
| 314 | + * the current value — an associative array where a list lives, or a | |
| 315 | + * non-empty list where an associative value lives — is rejected with a | |
| 316 | + * notice and leaves the current value unchanged. An empty array is exempt, | |
| 317 | + * so replacing a list with an empty list still clears it. | |
| 318 | + * | |
| 319 | + * A patch that declares an unsupported schema version is rejected and does | |
| 320 | + * not change anything. | |
| 321 | + * | |
| 210 | 322 | * @since 7.1.0 |
| 211 | 323 | * |
| 212 | - * @param array $items The view patches, keyed by slug. | |
| 324 | + * @param array $patch The partial configuration to apply. | |
| 213 | 325 | * @param int $version The schema version the patch was authored against. |
| 214 | 326 | * @return Gutenberg_View_Config_Data The instance, for chaining. |
| 215 | 327 | */ |
| 216 | - public function update_view_list_items( array $items, int $version ) { | |
| 217 | - if ( ! $this->check_version( $version, __METHOD__ ) ) { | |
| 218 | - return $this; | |
| 219 | - } | |
| 328 | + public function replace( array $patch, int $version ) { | |
| 329 | + return $this->apply( $patch, $version, __METHOD__, 'replace' ); | |
| 330 | + } | |
| 220 | 331 | |
| 221 | - if ( empty( $items ) ) { | |
| 222 | - return $this; | |
| 223 | - } | |
| 224 | - if ( array_is_list( $items ) ) { | |
| 332 | + /** | |
| 333 | + * Merges a partial configuration into the existing one. | |
| 334 | + * | |
| 335 | + * Applies a patch of top-level keys and touches only the keys the patch | |
| 336 | + * names: a key the patch omits keeps whatever it had, and a `null` value | |
| 337 | + * drops the key it names (which resets it to its default). Each named key's | |
| 338 | + * value is then merged into the current one by value shape: | |
| 339 | + * | |
| 340 | + * - a scalar replaces the current value; | |
| 341 | + * - an associative array merges key by key, with a nested `null` deleting | |
| 342 | + * just the leaf it names; | |
| 343 | + * - a list merges into the current list by member identity. | |
| 344 | + * | |
| 345 | + * Identity is the member's value cast to a string: a bare scalar is its own | |
| 346 | + * identity, and a map is identified by the value of the first of the | |
| 347 | + * well-known identity keys (`id`, `slug`, `field`) it carries. A member | |
| 348 | + * whose identity matches one already present merges into it in place, keeping | |
| 349 | + * its position; a member with no identity is appended to the end of the list. | |
| 350 | + * | |
| 351 | + * For example, given this patch: | |
| 352 | + * | |
| 353 | + * ```php | |
| 354 | + * array( | |
| 355 | + * 'default_view' => array( 'titleField' => 'newTitleField', 'fields' => array( 'newField' ) ), | |
| 356 | + * 'default_layouts' => array( 'grid' => array( 'layout' => array( 'badgeFields' => array( 'newField' ) ) ) ), | |
| 357 | + * 'view_list' => array( array( 'slug' => 'table', 'title' => 'New title' ) ), | |
| 358 | + * ) | |
| 359 | + * ``` | |
| 360 | + * | |
| 361 | + * - default_view will be updated so the titleField is 'newTitleField' and the newField is appended to the list of fields. | |
| 362 | + * - default_layouts will be updated so that newField is appended to the badgeFields. | |
| 363 | + * - view_list will be updated so that the view with slug 'table' has its title changed to 'New title'. | |
| 364 | + * | |
| 365 | + * A patch value only merges into a current value of the same shape: an | |
| 366 | + * associative array where a list lives, or a non-empty list where an | |
| 367 | + * associative value lives, is rejected with a notice and leaves the current | |
| 368 | + * value unchanged. An empty array merges nothing and is a no-op — clear a | |
| 369 | + * list with replace() and an empty list, or reset a key to its default with | |
| 370 | + * a top-level `null`. | |
| 371 | + * | |
| 372 | + * A patch that declares an unsupported schema version is rejected and does | |
| 373 | + * not change anything. | |
| 374 | + * | |
| 375 | + * @since 7.1.0 | |
| 376 | + * | |
| 377 | + * @param array $patch The partial configuration to merge. | |
| 378 | + * @param int $version The schema version the patch was authored against. | |
| 379 | + * @return Gutenberg_View_Config_Data The instance, for chaining. | |
| 380 | + */ | |
| 381 | + public function merge( array $patch, int $version ) { | |
| 382 | + return $this->apply( $patch, $version, __METHOD__, 'merge' ); | |
| 383 | + } | |
| 384 | + | |
| 385 | + /** | |
| 386 | + * Applies a patch to the configuration, top-level key by top-level key. | |
| 387 | + * | |
| 388 | + * Shared by merge(), replace(), and set(); the three differ only in how the | |
| 389 | + * value of a named key is applied, which is carried by $mode: | |
| 390 | + * | |
| 391 | + * - `merge` merges the value into the current one, lists by member identity; | |
| 392 | + * - `replace` merges the value in the same way but swaps lists wholesale; | |
| 393 | + * - `set` swaps the whole value in wholesale, without merging. | |
| 394 | + * | |
| 395 | + * In every mode a top-level `null` resets the key it names to its default, a | |
| 396 | + * nested `null` drops the property it names, and an omitted key is left | |
| 397 | + * untouched, so all three treat nulls the same way at every depth. | |
| 398 | + * | |
| 399 | + * @since 7.1.0 | |
| 400 | + * | |
| 401 | + * @param array $patch The partial configuration to apply. | |
| 402 | + * @param int $version The schema version the patch was authored against. | |
| 403 | + * @param string $method The public method the patch was passed to, for misuse reporting. | |
| 404 | + * @param string $mode How to apply each named key's value: `merge`, `replace`, or `set`. | |
| 405 | + * @return Gutenberg_View_Config_Data The instance, for chaining. | |
| 406 | + */ | |
| 407 | + private function apply( array $patch, int $version, $method, $mode ) { | |
| 408 | + if ( $version <= 0 || $version > self::LATEST_VERSION ) { | |
| 225 | 409 | _doing_it_wrong( |
| 226 | - __METHOD__, | |
| 227 | - esc_html__( 'A view list patch must be keyed by view "slug".', 'gutenberg' ), | |
| 410 | + esc_html( $method ), | |
| 411 | + esc_html__( 'A view configuration patch must declare a supported schema version.', 'gutenberg' ), | |
| 228 | 412 | '7.1.0' |
| 229 | 413 | ); |
| 414 | + | |
| 230 | 415 | return $this; |
| 231 | 416 | } |
| 232 | 417 | |
| 233 | - $view_list = isset( $this->config['view_list'] ) && is_array( $this->config['view_list'] ) ? $this->config['view_list'] : array(); | |
| 234 | - | |
| 235 | - foreach ( $items as $slug => $value ) { | |
| 236 | - // PHP casts numeric-string array keys to integers; identities are strings. | |
| 237 | - $slug = (string) $slug; | |
| 238 | - | |
| 239 | - if ( null === $value ) { | |
| 240 | - $view_list = array_values( | |
| 241 | - array_filter( | |
| 242 | - $view_list, | |
| 243 | - static fn( $item ) => ! is_array( $item ) || ! isset( $item['slug'] ) || $item['slug'] !== $slug | |
| 244 | - ) | |
| 245 | - ); | |
| 246 | - continue; | |
| 247 | - } | |
| 248 | - | |
| 249 | - if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) { | |
| 418 | + foreach ( $patch as $key => $value ) { | |
| 419 | + if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) { | |
| 250 | 420 | _doing_it_wrong( |
| 251 | - __METHOD__, | |
| 252 | - esc_html__( 'Each view patch must be an associative array of view properties, or null to remove the view.', 'gutenberg' ), | |
| 421 | + esc_html( $method ), | |
| 422 | + sprintf( | |
| 423 | + /* translators: %s: the configuration key. */ | |
| 424 | + esc_html__( '"%s" is not a documented view configuration key.', 'gutenberg' ), | |
| 425 | + esc_html( $key ) | |
| 426 | + ), | |
| 253 | 427 | '7.1.0' |
| 254 | 428 | ); |
| 255 | 429 | continue; |
| 256 | 430 | } |
| 257 | 431 | |
| 258 | - // The patch key is the identity. | |
| 259 | - unset( $value['slug'] ); | |
| 260 | - | |
| 261 | - $index = null; | |
| 262 | - foreach ( $view_list as $i => $item ) { | |
| 263 | - if ( is_array( $item ) && isset( $item['slug'] ) && $item['slug'] === $slug ) { | |
| 264 | - $index = $i; | |
| 265 | - break; | |
| 266 | - } | |
| 432 | + // A null patch value makes the top-level property reset to defaults. | |
| 433 | + if ( null === $value ) { | |
| 434 | + $this->config[ $key ] = $this->defaults[ $key ] ?? array(); | |
| 435 | + continue; | |
| 267 | 436 | } |
| 268 | 437 | |
| 269 | - if ( null === $index ) { | |
| 270 | - $view_list[] = array_merge( array( 'slug' => $slug ), $value ); | |
| 271 | - continue; | |
| 272 | - } | |
| 273 | - // An empty patch value has nothing to merge (and deep_merge would | |
| 274 | - // treat an empty array as a list, replacing the whole view). | |
| 275 | - if ( array() !== $value ) { | |
| 276 | - $view_list[ $index ] = $this->deep_merge( $view_list[ $index ], $value ); | |
| 277 | - } | |
| 438 | + // set() swaps the whole value in; merge()/replace() merge it into the | |
| 439 | + // current one, differing only in how they treat lists. In every mode a | |
| 440 | + // nested null still drops the property it names. | |
| 441 | + $this->config[ $key ] = 'set' === $mode | |
| 442 | + ? $this->strip_nulls( $value ) | |
| 443 | + : $this->merge_properties( $this->config[ $key ] ?? array(), $value, 'replace' === $mode ); | |
| 278 | 444 | } |
| 279 | 445 | |
| 280 | - $this->config['view_list'] = array_values( $view_list ); | |
| 281 | - | |
| 282 | 446 | return $this; |
| 283 | 447 | } |
| 284 | 448 | |
| 285 | 449 | /** |
| 286 | - * Adds, updates, or removes `form` fields, keyed by field `id`. | |
| 450 | + * Recursively drops every property whose value is `null` from a value. | |
| 287 | 451 | * |
| 288 | - * Each patch key names the `id` of the field it targets, and the field is | |
| 289 | - * found wherever it lives — at the top level or nested inside a group's | |
| 290 | - * `children`. Fields are visited in document order and a group is checked | |
| 291 | - * before its own children, so when an id appears at both levels the group | |
| 292 | - * wins. A matching field merges in place, an unknown id appends a new field | |
| 293 | - * to the end of the top-level fields, and `null` removes the field. The | |
| 294 | - * patch key is the identity: an `id` property inside the value is ignored. | |
| 295 | - * A `null` for an id that is not found is a silent no-op — the field may | |
| 296 | - * have been removed by another callback or simply not apply to this | |
| 297 | - * entity. | |
| 452 | + * set() swaps a named key's value in wholesale rather than merging it into | |
| 453 | + * the current one, so it has no existing leaf for a nested `null` to delete | |
| 454 | + * the way merge() and replace() do. Stripping nulls here gives a nested | |
| 455 | + * `null` the same "drop the property it names" meaning under set() that it | |
| 456 | + * carries everywhere else. The same applies to a list replace() swaps in | |
| 457 | + * wholesale. A list is renumbered after a member is removed so removed | |
| 458 | + * entries do not leave gaps. | |
| 298 | 459 | * |
| 299 | - * Inside a field patch, `children` follows the shared rules: an associative | |
| 300 | - * array merges into the group's children by id (appending unknown ones), a | |
| 301 | - * numerically indexed array replaces the children wholesale, and `null` | |
| 302 | - * deletes the key. | |
| 303 | - * | |
| 304 | - * A patch that declares an unsupported schema version is rejected and | |
| 305 | - * does not merge. | |
| 306 | - * | |
| 307 | 460 | * @since 7.1.0 |
| 308 | 461 | * |
| 309 | - * @param array $fields The field patches, keyed by field id. | |
| 310 | - * @param int $version The schema version the patch was authored against. | |
| 311 | - * @return Gutenberg_View_Config_Data The instance, for chaining. | |
| 462 | + * @param mixed $value The value to strip nulls from. | |
| 463 | + * @return mixed The value with every `null` property removed, recursively. | |
| 312 | 464 | */ |
| 313 | - public function update_form_fields( array $fields, int $version ) { | |
| 314 | - if ( ! $this->check_version( $version, __METHOD__ ) ) { | |
| 315 | - return $this; | |
| 465 | + private function strip_nulls( $value ) { | |
| 466 | + if ( ! is_array( $value ) ) { | |
| 467 | + return $value; | |
| 316 | 468 | } |
| 317 | 469 | |
| 318 | - if ( empty( $fields ) ) { | |
| 319 | - return $this; | |
| 320 | - } | |
| 321 | - if ( array_is_list( $fields ) ) { | |
| 322 | - _doing_it_wrong( | |
| 323 | - __METHOD__, | |
| 324 | - esc_html__( 'A fields patch must be keyed by field "id".', 'gutenberg' ), | |
| 325 | - '7.1.0' | |
| 326 | - ); | |
| 327 | - return $this; | |
| 328 | - } | |
| 470 | + $result = array(); | |
| 471 | + foreach ( $value as $key => $item ) { | |
| 472 | + // A null value drops the property it names. | |
| 473 | + if ( null === $item ) { | |
| 474 | + continue; | |
| 475 | + } | |
| 329 | 476 | |
| 330 | - if ( ! isset( $this->config['form'] ) || ! is_array( $this->config['form'] ) ) { | |
| 331 | - $this->config['form'] = array(); | |
| 477 | + $result[ $key ] = $this->strip_nulls( $item ); | |
| 332 | 478 | } |
| 333 | - $current = isset( $this->config['form']['fields'] ) && is_array( $this->config['form']['fields'] ) ? $this->config['form']['fields'] : array(); | |
| 334 | 479 | |
| 335 | - $this->config['form']['fields'] = $this->merge_fields_by_identity( $current, $fields ); | |
| 336 | - | |
| 337 | - return $this; | |
| 480 | + // Renumber a list so a removed member does not leave a gap. | |
| 481 | + return array_is_list( $value ) ? array_values( $result ) : $result; | |
| 338 | 482 | } |
| 339 | 483 | |
| 340 | 484 | /** |
| 341 | - * Validates a declared patch version, reporting misuse against the given | |
| 342 | - * public method. | |
| 485 | + * Merges an incoming value into the current one, recursing by value shape. | |
| 343 | 486 | * |
| 487 | + * This is the core of the merge algorithm and is applied at every nesting | |
| 488 | + * level: a scalar (or `null`) in $incoming replaces $current outright, an | |
| 489 | + * associative array merges key by key (recursing here for each key, with a | |
| 490 | + * `null` value deleting that key), and a list either replaces $current | |
| 491 | + * wholesale ($replace_lists) or merges into it by member identity. The | |
| 492 | + * $replace_lists flag is carried down through associative nesting so that, | |
| 493 | + * under replace(), every list reached along the way is swapped wholesale. | |
| 494 | + * | |
| 495 | + * An array in $incoming only merges into a current value of the same shape. | |
| 496 | + * A non-empty mismatch — an associative array where a list lives, or a | |
| 497 | + * non-empty list where an associative value lives — is reported with | |
| 498 | + * _doing_it_wrong() and leaves the current value unchanged, so a malformed | |
| 499 | + * patch cannot silently destroy configuration. An empty array is | |
| 500 | + * shape-ambiguous and merges nothing, so it is a no-op: clearing a list is | |
| 501 | + * spelled replace() with an empty list, and resetting a key is spelled | |
| 502 | + * `null`. | |
| 503 | + * | |
| 344 | 504 | * @since 7.1.0 |
| 345 | 505 | * |
| 346 | - * @param int $version The declared version. | |
| 347 | - * @param string $method The public method the patch was passed to. | |
| 348 | - * @return bool Whether the declared version is a supported schema version. | |
| 506 | + * @param mixed $current The current value. | |
| 507 | + * @param mixed $incoming The incoming value. | |
| 508 | + * @param bool $replace_lists Whether a list in $incoming replaces the current list | |
| 509 | + * wholesale instead of merging into it by member identity. | |
| 510 | + * @return mixed The merged value. | |
| 349 | 511 | */ |
| 350 | - private function check_version( int $version, $method ) { | |
| 351 | - if ( $version >= 1 && $version <= self::LATEST_VERSION ) { | |
| 352 | - return true; | |
| 512 | + private function merge_properties( $current, $incoming, $replace_lists ) { | |
| 513 | + // Scalar properties are merged as-is. | |
| 514 | + if ( ! is_array( $incoming ) ) { | |
| 515 | + return $incoming; | |
| 353 | 516 | } |
| 354 | 517 | |
| 355 | - _doing_it_wrong( | |
| 356 | - esc_html( $method ), | |
| 357 | - esc_html__( 'A view configuration contribution must declare a supported schema version.', 'gutenberg' ), | |
| 358 | - '7.1.0' | |
| 359 | - ); | |
| 518 | + // Numerical indexed arrays are expected to be lists (sequential integer keys starting at 0). | |
| 519 | + if ( array_is_list( $incoming ) ) { | |
| 520 | + // A non-empty list only lands where a list (or nothing) lives, under | |
| 521 | + // merge() and replace() alike. An empty array is shape-ambiguous and | |
| 522 | + // exempt, so replace() with an empty list can still clear a list. | |
| 523 | + if ( array() !== $incoming && is_array( $current ) && ! array_is_list( $current ) && array() !== $current ) { | |
| 524 | + _doing_it_wrong( | |
| 525 | + __METHOD__, | |
| 526 | + esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.', 'gutenberg' ), | |
| 527 | + '7.1.0' | |
| 528 | + ); | |
| 529 | + return $current; | |
| 530 | + } | |
| 360 | 531 | |
| 361 | - return false; | |
| 362 | - } | |
| 532 | + // replace() takes an incoming list as-is; merge() merges it by member identity. | |
| 533 | + if ( $replace_lists ) { | |
| 534 | + // As-is except for nulls: a list swapped in wholesale has no | |
| 535 | + // existing leaf for a null to delete (the same rationale as | |
| 536 | + // set()), so a null member is dropped rather than stored. | |
| 537 | + return $this->strip_nulls( $incoming ); | |
| 538 | + } | |
| 363 | 539 | |
| 364 | - /** | |
| 365 | - * Validates a `form` patch value for update_properties() and strips the | |
| 366 | - * `fields` key, which is managed by update_form_fields(). | |
| 367 | - * | |
| 368 | - * @since 7.1.0 | |
| 369 | - * | |
| 370 | - * @param mixed $value The incoming `form` patch value. | |
| 371 | - * @return array|null The form properties to merge, or null when the value | |
| 372 | - * is off-shape. | |
| 373 | - */ | |
| 374 | - private function extract_form_properties( $value ) { | |
| 375 | - if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) { | |
| 376 | - _doing_it_wrong( | |
| 377 | - 'Gutenberg_View_Config_Data::update_properties', | |
| 378 | - esc_html__( 'A "form" patch must be an associative array of form properties.', 'gutenberg' ), | |
| 379 | - '7.1.0' | |
| 540 | + // An empty list has no members to merge, and an empty array is | |
| 541 | + // shape-ambiguous, so merging one is a no-op rather than a reset. | |
| 542 | + if ( array() === $incoming ) { | |
| 543 | + return $current; | |
| 544 | + } | |
| 545 | + | |
| 546 | + return $this->merge_list_by_identity( | |
| 547 | + is_array( $current ) && array_is_list( $current ) ? $current : array(), | |
| 548 | + $incoming | |
| 380 | 549 | ); |
| 381 | - return null; | |
| 382 | 550 | } |
| 383 | - if ( array_key_exists( 'fields', $value ) ) { | |
| 551 | + | |
| 552 | + // Consider any other array as associative (keys are strings). | |
| 553 | + if ( is_array( $current ) && array_is_list( $current ) && array() !== $current ) { | |
| 384 | 554 | _doing_it_wrong( |
| 385 | - 'Gutenberg_View_Config_Data::update_properties', | |
| 386 | - esc_html__( 'The form "fields" are patched by identity. Use update_form_fields() instead.', 'gutenberg' ), | |
| 555 | + __METHOD__, | |
| 556 | + esc_html__( 'A view configuration patch value must match the shape of the value it patches: a list merges into a list, and an associative array into an associative array.', 'gutenberg' ), | |
| 387 | 557 | '7.1.0' |
| 388 | 558 | ); |
| 389 | - unset( $value['fields'] ); | |
| 559 | + return $current; | |
| 390 | 560 | } |
| 391 | 561 | |
| 392 | - return $value; | |
| 393 | - } | |
| 394 | - | |
| 395 | - /** | |
| 396 | - * Recursively merges two values. | |
| 397 | - * | |
| 398 | - * Associative arrays (maps) merge key by key and a null patch value deletes | |
| 399 | - * the key; lists and scalars are replaced wholesale by the incoming value, | |
| 400 | - * since lists without a defined identity cannot be merged member by member. | |
| 401 | - * | |
| 402 | - * @since 7.1.0 | |
| 403 | - * | |
| 404 | - * @param mixed $current The current value. | |
| 405 | - * @param mixed $incoming The incoming value. | |
| 406 | - * @return mixed The merged value. | |
| 407 | - */ | |
| 408 | - private function deep_merge( $current, $incoming ) { | |
| 409 | - // An empty array counts as a list, so patching with array() empties | |
| 410 | - // the key (e.g. 'filters' => array() clears the filters) rather than | |
| 411 | - // being a no-op map merge. | |
| 412 | - if ( ! is_array( $incoming ) || array_is_list( $incoming ) ) { | |
| 413 | - return $incoming; | |
| 414 | - } | |
| 415 | - | |
| 416 | - // Merge onto the current map, or onto an empty base when the current | |
| 417 | - // value is absent, empty, or not a map, so null delete-markers in the | |
| 418 | - // patch are consumed rather than stored as literal values (e.g. | |
| 419 | - // array( 'layout' => null ) merged into an empty layouts entry yields | |
| 420 | - // array(), not array( 'layout' => null )). | |
| 421 | 562 | $result = is_array( $current ) && ! array_is_list( $current ) ? $current : array(); |
| 422 | 563 | foreach ( $incoming as $key => $value ) { |
| 564 | + // A null patch value deletes the property. | |
| 423 | 565 | if ( null === $value ) { |
| 424 | - // A null patch value deletes the key. | |
| 425 | 566 | unset( $result[ $key ] ); |
| 426 | 567 | continue; |
| 427 | 568 | } |
| 428 | - $result[ $key ] = $this->deep_merge( | |
| 569 | + | |
| 570 | + $result[ $key ] = $this->merge_properties( | |
| 429 | 571 | array_key_exists( $key, $result ) ? $result[ $key ] : array(), |
| 430 | - $value | |
| 572 | + $value, | |
| 573 | + $replace_lists | |
| 431 | 574 | ); |
| 432 | 575 | } |
| 576 | + | |
| 433 | 577 | return $result; |
| 434 | 578 | } |
| 435 | 579 | |
| 436 | 580 | /** |
| 437 | - * Merges a map of field patches into a field list by identity. | |
| 581 | + * Removes the properties a spec names from the current value. | |
| 438 | 582 | * |
| 439 | - * Shared by the top-level `form` fields and a group's `children`: a `null` | |
| 440 | - * value removes the matching field (recursing into children), a map value | |
| 441 | - * merges into the matching field wherever it lives, and an unknown id | |
| 442 | - * appends a new field to the end of this list. A `null` for an id that is | |
| 443 | - * not found is a silent no-op. | |
| 583 | + * The mirror of merge_properties(), applied at every nesting level: a list in | |
| 584 | + * $spec names entries to delete from $current — associative keys are unset, | |
| 585 | + * and list members are matched by identity (list_item_identity) and dropped — | |
| 586 | + * while an associative $spec recurses into each named entry to prune from | |
| 587 | + * within it. A name absent from $current is ignored, and a list is renumbered | |
| 588 | + * after members are removed so it keeps sequential keys. | |
| 444 | 589 | * |
| 445 | 590 | * @since 7.1.0 |
| 446 | 591 | * |
| 447 | - * @param array $current The current list of fields. | |
| 448 | - * @param array $patches The field patches, keyed by field id. | |
| 449 | - * @return array The merged list of fields. | |
| 592 | + * @param mixed $current The current value. | |
| 593 | + * @param mixed $spec The names to remove from it. | |
| 594 | + * @return mixed The pruned value. | |
| 450 | 595 | */ |
| 451 | - private function merge_fields_by_identity( array $current, array $patches ) { | |
| 452 | - foreach ( $patches as $id => $value ) { | |
| 453 | - // PHP casts numeric-string array keys to integers; identities are strings. | |
| 454 | - $id = (string) $id; | |
| 596 | + private function remove_properties( $current, $spec ) { | |
| 597 | + if ( ! is_array( $current ) || ! is_array( $spec ) ) { | |
| 598 | + return $current; | |
| 599 | + } | |
| 455 | 600 | |
| 456 | - if ( null === $value ) { | |
| 457 | - $current = $this->reject_fields( $current, array( $id ) ); | |
| 458 | - continue; | |
| 601 | + $current_is_list = array_is_list( $current ); | |
| 602 | + | |
| 603 | + if ( array_is_list( $spec ) ) { | |
| 604 | + // Each entry names something to delete from the current value. | |
| 605 | + foreach ( $spec as $name ) { | |
| 606 | + if ( $current_is_list ) { | |
| 607 | + $current = $this->remove_list_member( $current, $name ); | |
| 608 | + } else { | |
| 609 | + unset( $current[ $name ] ); | |
| 610 | + } | |
| 459 | 611 | } |
| 460 | - if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) { | |
| 461 | - _doing_it_wrong( | |
| 462 | - 'Gutenberg_View_Config_Data::update_form_fields', | |
| 463 | - esc_html__( 'Each field patch must be an associative array of field properties, or null to remove the field.', 'gutenberg' ), | |
| 464 | - '7.1.0' | |
| 465 | - ); | |
| 466 | - continue; | |
| 612 | + } else { | |
| 613 | + // Each key names an entry to recurse into and prune from within. | |
| 614 | + foreach ( $spec as $name => $subspec ) { | |
| 615 | + if ( $current_is_list ) { | |
| 616 | + foreach ( $current as $index => $member ) { | |
| 617 | + if ( $this->list_item_identity( $member ) === (string) $name ) { | |
| 618 | + $current[ $index ] = $this->remove_properties( $member, $subspec ); | |
| 619 | + break; | |
| 620 | + } | |
| 621 | + } | |
| 622 | + } elseif ( array_key_exists( $name, $current ) ) { | |
| 623 | + $current[ $name ] = $this->remove_properties( $current[ $name ], $subspec ); | |
| 624 | + } | |
| 467 | 625 | } |
| 468 | - | |
| 469 | - // The patch key is the identity. | |
| 470 | - unset( $value['id'] ); | |
| 471 | - | |
| 472 | - $merged = $this->merge_field_in_tree( $current, $id, $value ); | |
| 473 | - if ( null !== $merged ) { | |
| 474 | - $current = $merged; | |
| 475 | - continue; | |
| 476 | - } | |
| 477 | - // An unknown id appends: as a bare string reference when the patch | |
| 478 | - // carries no overrides, as an array otherwise. | |
| 479 | - $current[] = array() === $value ? $id : $this->merge_field_item( $id, $id, $value ); | |
| 480 | 626 | } |
| 481 | 627 | |
| 482 | - return $current; | |
| 628 | + // Renumber so a list from which a member was removed keeps sequential keys. | |
| 629 | + return $current_is_list ? array_values( $current ) : $current; | |
| 483 | 630 | } |
| 484 | 631 | |
| 485 | 632 | /** |
| 486 | - * Merges a field patch into the field carrying the given identity, wherever | |
| 487 | - * it lives in the tree. | |
| 633 | + * Removes the first list member matching an identity, leaving the rest. | |
| 488 | 634 | * |
| 489 | - * Fields are visited in document order and a group is checked before its | |
| 490 | - * own children, so when an id appears at both levels the group wins. | |
| 491 | - * | |
| 492 | 635 | * @since 7.1.0 |
| 493 | 636 | * |
| 494 | - * @param array $fields The list of fields to search. | |
| 495 | - * @param string $id The identity of the field to patch. | |
| 496 | - * @param array $value The field patch. | |
| 497 | - * @return array|null The updated list, or null when the id was not found. | |
| 637 | + * @param array $members The current list. | |
| 638 | + * @param mixed $identity The identity of the member to remove. | |
| 639 | + * @return array The list with the matching member removed, if any. | |
| 498 | 640 | */ |
| 499 | - private function merge_field_in_tree( array $fields, $id, array $value ) { | |
| 500 | - foreach ( $fields as $index => $field ) { | |
| 501 | - if ( $this->field_identity( $field ) === $id ) { | |
| 502 | - $fields[ $index ] = $this->merge_field_item( $field, $id, $value ); | |
| 503 | - return $fields; | |
| 641 | + private function remove_list_member( array $members, $identity ) { | |
| 642 | + foreach ( $members as $index => $member ) { | |
| 643 | + if ( $this->list_item_identity( $member ) === (string) $identity ) { | |
| 644 | + unset( $members[ $index ] ); | |
| 645 | + break; | |
| 504 | 646 | } |
| 505 | - if ( is_array( $field ) && isset( $field['children'] ) && is_array( $field['children'] ) ) { | |
| 506 | - $children = $this->merge_field_in_tree( $field['children'], $id, $value ); | |
| 507 | - if ( null !== $children ) { | |
| 508 | - $fields[ $index ]['children'] = $children; | |
| 509 | - return $fields; | |
| 510 | - } | |
| 511 | - } | |
| 512 | 647 | } |
| 513 | 648 | |
| 514 | - return null; | |
| 649 | + return $members; | |
| 515 | 650 | } |
| 516 | 651 | |
| 517 | 652 | /** |
| 518 | - * Merges a field patch into an existing field. | |
| 653 | + * Merges an incoming list into the current one by member identity. | |
| 519 | 654 | * |
| 520 | - * A bare string reference is promoted to an array so the overrides apply. | |
| 521 | - * The `children` key follows the same rules — a map merges into the | |
| 522 | - * group's children by id, a list replaces them wholesale, and `null` | |
| 523 | - * deletes the key — and every other key merges via deep_merge(). | |
| 655 | + * A member of the incoming list whose identity matches one already present | |
| 656 | + * merges into it in place, keeping its position; an unmatched member is | |
| 657 | + * appended to the end, except a literal `null`, which carries no identity | |
| 658 | + * and holds nothing to merge and so is dropped. An appended member has no | |
| 659 | + * existing leaf for a nested `null` to delete (the same rationale as set()), | |
| 660 | + * so its nulls are stripped rather than stored. A matched member's contents | |
| 661 | + * merge recursively with the same rules (merge_properties), so the | |
| 662 | + * identity-aware merge applies at | |
| 663 | + * any nesting level: each key named by the patch is substituted while the | |
| 664 | + * others are left intact, and a list nested inside a member merges by | |
| 665 | + * identity just like the list it lives in. | |
| 524 | 666 | * |
| 525 | 667 | * @since 7.1.0 |
| 526 | 668 | * |
| 527 | - * @param array|string $existing The existing field. | |
| 528 | - * @param string $id The field identity. | |
| 529 | - * @param array $value The field patch. | |
| 530 | - * @return array|string The merged field. | |
| 669 | + * @param array $current The current list. | |
| 670 | + * @param array $incoming The incoming list. | |
| 671 | + * @return array The merged list. | |
| 531 | 672 | */ |
| 532 | - private function merge_field_item( $existing, $id, array $value ) { | |
| 533 | - if ( ! is_array( $existing ) ) { | |
| 534 | - // Nothing to apply: keep the bare string reference. | |
| 535 | - if ( array() === $value ) { | |
| 536 | - return $existing; | |
| 673 | + private function merge_list_by_identity( array $current, array $incoming ) { | |
| 674 | + $result = $current; | |
| 675 | + foreach ( $incoming as $item ) { | |
| 676 | + // A null member carries no identity and holds nothing to merge, | |
| 677 | + // so it is dropped rather than appended as a literal null. | |
| 678 | + if ( null === $item ) { | |
| 679 | + continue; | |
| 537 | 680 | } |
| 538 | - // Promote the reference so the incoming overrides apply. | |
| 539 | - $existing = array( 'id' => $id ); | |
| 540 | - } | |
| 541 | 681 | |
| 542 | - foreach ( $value as $key => $item ) { | |
| 543 | - if ( 'children' === $key ) { | |
| 544 | - if ( null === $item ) { | |
| 545 | - unset( $existing['children'] ); | |
| 546 | - continue; | |
| 682 | + $identity = $this->list_item_identity( $item ); | |
| 683 | + | |
| 684 | + // Find the index of the existing member with the same identity, if any. | |
| 685 | + // If there's none, append the incoming member to the end of the list. | |
| 686 | + $index = null; | |
| 687 | + if ( null !== $identity ) { | |
| 688 | + foreach ( $result as $i => $existing ) { | |
| 689 | + if ( $this->list_item_identity( $existing ) === $identity ) { | |
| 690 | + $index = $i; | |
| 691 | + break; | |
| 692 | + } | |
| 547 | 693 | } |
| 548 | - if ( ! is_array( $item ) ) { | |
| 549 | - _doing_it_wrong( | |
| 550 | - 'Gutenberg_View_Config_Data::update_form_fields', | |
| 551 | - 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.', 'gutenberg' ), | |
| 552 | - '7.1.0' | |
| 553 | - ); | |
| 554 | - continue; | |
| 555 | - } | |
| 556 | - // A list replaces the children wholesale (an empty array counts | |
| 557 | - // as a list, clearing them)... | |
| 558 | - if ( array_is_list( $item ) ) { | |
| 559 | - $existing['children'] = $item; | |
| 560 | - continue; | |
| 561 | - } | |
| 562 | - // ...and a map merges into them by identity. | |
| 563 | - $children = isset( $existing['children'] ) && is_array( $existing['children'] ) ? $existing['children'] : array(); | |
| 564 | - $existing['children'] = $this->merge_fields_by_identity( $children, $item ); | |
| 565 | - continue; | |
| 566 | 694 | } |
| 567 | - if ( null === $item ) { | |
| 568 | - // A null patch value deletes the key. | |
| 569 | - unset( $existing[ $key ] ); | |
| 695 | + if ( null === $index ) { | |
| 696 | + // An appended member has no existing leaf for a nested null to | |
| 697 | + // delete, so nulls are dropped rather than stored. | |
| 698 | + $result[] = $this->strip_nulls( $item ); | |
| 570 | 699 | continue; |
| 571 | 700 | } |
| 572 | - $existing[ $key ] = $this->deep_merge( | |
| 573 | - array_key_exists( $key, $existing ) ? $existing[ $key ] : array(), | |
| 574 | - $item | |
| 575 | - ); | |
| 701 | + | |
| 702 | + // Otherwise, merge the incoming member into the existing one in place. | |
| 703 | + $result[ $index ] = $this->merge_properties( $result[ $index ], $item, false ); | |
| 576 | 704 | } |
| 577 | 705 | |
| 578 | - return $existing; | |
| 579 | - } | |
| 580 | - | |
| 581 | - /** | |
| 582 | - * Returns a field list with the fields matching the given identities removed, | |
| 583 | - * recursing into group children. | |
| 584 | - * | |
| 585 | - * @since 7.1.0 | |
| 586 | - * | |
| 587 | - * @param array $fields The list of fields. | |
| 588 | - * @param string[] $ids The identities of the fields to remove. | |
| 589 | - * @return array The list with the matching fields removed. | |
| 590 | - */ | |
| 591 | - private function reject_fields( array $fields, array $ids ) { | |
| 592 | - $result = array(); | |
| 593 | - foreach ( $fields as $field ) { | |
| 594 | - if ( in_array( $this->field_identity( $field ), $ids, true ) ) { | |
| 595 | - continue; | |
| 596 | - } | |
| 597 | - if ( is_array( $field ) && isset( $field['children'] ) && is_array( $field['children'] ) ) { | |
| 598 | - $field['children'] = $this->reject_fields( $field['children'], $ids ); | |
| 599 | - } | |
| 600 | - $result[] = $field; | |
| 601 | - } | |
| 602 | 706 | return $result; |
| 603 | 707 | } |
| 604 | 708 | |
| 605 | 709 | /** |
| 606 | - * Resolves the identity of a form field. | |
| 710 | + * Resolves the identity used to match a list member against another. | |
| 607 | 711 | * |
| 608 | - * A bare string is its own identity; an object is identified by its `id`. | |
| 712 | + * The identity is simply the member's value cast to a string, regardless of | |
| 713 | + * which key carries it: a bare scalar is its own identity, and a map is | |
| 714 | + * identified by the value of the first of the well-known identity keys | |
| 715 | + * (`id`, `slug`, `field`) it carries. Because the key is not part of | |
| 716 | + * the identity, a bare field like `'f3'` matches any map carrying that | |
| 717 | + * value, whether it appears as `array( 'id' => 'f3' )`, | |
| 718 | + * `array( 'slug' => 'f3' )`, and so on — this lets the same shorthand target | |
| 719 | + * lists keyed by different fields. Casting to string keeps numeric | |
| 720 | + * identities matching whether they arrive as an int or a string. Anything | |
| 721 | + * else (e.g. a nested list) has no identity and never matches, so it is | |
| 722 | + * always appended. | |
| 609 | 723 | * |
| 610 | 724 | * @since 7.1.0 |
| 611 | 725 | * |
| 612 | - * @param mixed $field The field. | |
| 613 | - * @return string|null The identity, or null if it cannot be resolved. | |
| 726 | + * @param mixed $item The list member. | |
| 727 | + * @return string|null The identity, or null when the member has none. | |
| 614 | 728 | */ |
| 615 | - private function field_identity( $field ) { | |
| 616 | - if ( is_string( $field ) ) { | |
| 617 | - return $field; | |
| 729 | + private function list_item_identity( $item ) { | |
| 730 | + if ( is_scalar( $item ) ) { | |
| 731 | + return (string) $item; | |
| 618 | 732 | } |
| 619 | - if ( is_array( $field ) && isset( $field['id'] ) && is_string( $field['id'] ) ) { | |
| 620 | - return $field['id']; | |
| 733 | + | |
| 734 | + if ( is_array( $item ) && ! array_is_list( $item ) ) { | |
| 735 | + foreach ( array( 'id', 'slug', 'field' ) as $key ) { | |
| 736 | + if ( isset( $item[ $key ] ) && is_scalar( $item[ $key ] ) ) { | |
| 737 | + return (string) $item[ $key ]; | |
| 738 | + } | |
| 739 | + } | |
| 621 | 740 | } |
| 741 | + | |
| 622 | 742 | return null; |
| 623 | 743 | } |
| 624 | 744 | } |