PluginProbe
Gutenberg / trunk
Gutenberg vtrunk
24.1.0 24.0.0 23.9.1 23.9.0 23.8.0 23.7.2 23.7.1 23.7.0 23.6.1 23.6.2 23.6.0 23.5.3 23.5.2 23.5.1 23.5.0 23.4.0 23.3.2 23.3.1 23.3.0 23.2.0 23.2.1 23.2.2 23.1.1 23.1.0 23.0.1 All 404 releases
← 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 }