PluginProbe
Gutenberg / 23.6.0
Gutenberg v23.6.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 12.6.0 7.4.0 All 402 releases
gutenberg / lib / compat / wordpress-7.1 / class-gutenberg-view-config-data.php

class-gutenberg-view-config-data.php in Gutenberg 23.6.0, at lib/compat/wordpress-7.1/class-gutenberg-view-config-data.php

625 lines 19.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Gutenberg_View_Config_Data class
4 *
5 * @package gutenberg
6 */
7
8 /**
9 * Holds an entity's view configuration while it is being built.
10 *
11 * An instance of this class is what `get_entity_view_config_{$kind}_{$name}`
12 * filter callbacks receive: a callback changes the configuration by calling
13 * methods on the instance and returning it. The configuration has four
14 * top-level keys — `default_view`, `default_layouts`, `view_list`, and
15 * `form` — and there are two ways to contribute:
16 *
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.
28 *
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.
36 *
37 * @since 7.1.0
38 */
39 class Gutenberg_View_Config_Data {
40
41 /**
42 * The latest supported configuration schema version.
43 *
44 * @since 7.1.0
45 * @var int
46 */
47 const LATEST_VERSION = 1;
48
49 /**
50 * The documented top-level configuration keys.
51 *
52 * @since 7.1.0
53 * @var string[]
54 */
55 const CONFIG_KEYS = array( 'default_view', 'default_layouts', 'view_list', 'form' );
56
57 /**
58 * The configuration being contributed to.
59 *
60 * @since 7.1.0
61 * @var array
62 */
63 private $config;
64
65 /**
66 * Constructor.
67 *
68 * @since 7.1.0
69 *
70 * @param array $config The base configuration to contribute to.
71 */
72 public function __construct( array $config ) {
73 $this->config = $config;
74 }
75
76 /**
77 * Returns the current configuration array.
78 *
79 * @since 7.1.0
80 *
81 * @return array The configuration.
82 */
83 public function get_config() {
84 return $this->config;
85 }
86
87 /**
88 * Replaces a whole top-level key with a new value.
89 *
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.
93 *
94 * A value that declares an unsupported schema version is rejected and
95 * does not replace anything.
96 *
97 * @since 7.1.0
98 *
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.
102 * @return Gutenberg_View_Config_Data The instance, for chaining.
103 */
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;
124 }
125
126 /**
127 * Merges a partial configuration into `default_view`, `default_layouts`,
128 * and the `form` settings other than its `fields`.
129 *
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.
134 *
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()`.
139 *
140 * A patch that declares an unsupported schema version is rejected and
141 * does not merge.
142 *
143 * @since 7.1.0
144 *
145 * @param array $patch The partial configuration to merge.
146 * @param int $version The schema version the patch was authored against.
147 * @return Gutenberg_View_Config_Data The instance, for chaining.
148 */
149 public function update_properties( array $patch, int $version ) {
150 if ( ! $this->check_version( $version, __METHOD__ ) ) {
151 return $this;
152 }
153
154 foreach ( $patch as $key => $value ) {
155 if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) {
156 _doing_it_wrong(
157 __METHOD__,
158 sprintf(
159 /* translators: %s: the configuration key. */
160 esc_html__( '"%s" is not a documented view configuration key.', 'gutenberg' ),
161 esc_html( $key )
162 ),
163 '7.1.0'
164 );
165 continue;
166 }
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;
172 }
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 }
191
192 return $this;
193 }
194
195 /**
196 * Adds, updates, or removes `view_list` entries, keyed by view `slug`.
197 *
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.
206 *
207 * A patch that declares an unsupported schema version is rejected and
208 * does not merge.
209 *
210 * @since 7.1.0
211 *
212 * @param array $items The view patches, keyed by slug.
213 * @param int $version The schema version the patch was authored against.
214 * @return Gutenberg_View_Config_Data The instance, for chaining.
215 */
216 public function update_view_list_items( array $items, int $version ) {
217 if ( ! $this->check_version( $version, __METHOD__ ) ) {
218 return $this;
219 }
220
221 if ( empty( $items ) ) {
222 return $this;
223 }
224 if ( array_is_list( $items ) ) {
225 _doing_it_wrong(
226 __METHOD__,
227 esc_html__( 'A view list patch must be keyed by view "slug".', 'gutenberg' ),
228 '7.1.0'
229 );
230 return $this;
231 }
232
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 ) ) ) {
250 _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' ),
253 '7.1.0'
254 );
255 continue;
256 }
257
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 }
267 }
268
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 }
278 }
279
280 $this->config['view_list'] = array_values( $view_list );
281
282 return $this;
283 }
284
285 /**
286 * Adds, updates, or removes `form` fields, keyed by field `id`.
287 *
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.
298 *
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 * @since 7.1.0
308 *
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.
312 */
313 public function update_form_fields( array $fields, int $version ) {
314 if ( ! $this->check_version( $version, __METHOD__ ) ) {
315 return $this;
316 }
317
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 }
329
330 if ( ! isset( $this->config['form'] ) || ! is_array( $this->config['form'] ) ) {
331 $this->config['form'] = array();
332 }
333 $current = isset( $this->config['form']['fields'] ) && is_array( $this->config['form']['fields'] ) ? $this->config['form']['fields'] : array();
334
335 $this->config['form']['fields'] = $this->merge_fields_by_identity( $current, $fields );
336
337 return $this;
338 }
339
340 /**
341 * Validates a declared patch version, reporting misuse against the given
342 * public method.
343 *
344 * @since 7.1.0
345 *
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.
349 */
350 private function check_version( int $version, $method ) {
351 if ( $version >= 1 && $version <= self::LATEST_VERSION ) {
352 return true;
353 }
354
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 );
360
361 return false;
362 }
363
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'
380 );
381 return null;
382 }
383 if ( array_key_exists( 'fields', $value ) ) {
384 _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' ),
387 '7.1.0'
388 );
389 unset( $value['fields'] );
390 }
391
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 $result = is_array( $current ) && ! array_is_list( $current ) ? $current : array();
422 foreach ( $incoming as $key => $value ) {
423 if ( null === $value ) {
424 // A null patch value deletes the key.
425 unset( $result[ $key ] );
426 continue;
427 }
428 $result[ $key ] = $this->deep_merge(
429 array_key_exists( $key, $result ) ? $result[ $key ] : array(),
430 $value
431 );
432 }
433 return $result;
434 }
435
436 /**
437 * Merges a map of field patches into a field list by identity.
438 *
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.
444 *
445 * @since 7.1.0
446 *
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.
450 */
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;
455
456 if ( null === $value ) {
457 $current = $this->reject_fields( $current, array( $id ) );
458 continue;
459 }
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;
467 }
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 }
481
482 return $current;
483 }
484
485 /**
486 * Merges a field patch into the field carrying the given identity, wherever
487 * it lives in the tree.
488 *
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 * @since 7.1.0
493 *
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.
498 */
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;
504 }
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 }
513
514 return null;
515 }
516
517 /**
518 * Merges a field patch into an existing field.
519 *
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().
524 *
525 * @since 7.1.0
526 *
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.
531 */
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;
537 }
538 // Promote the reference so the incoming overrides apply.
539 $existing = array( 'id' => $id );
540 }
541
542 foreach ( $value as $key => $item ) {
543 if ( 'children' === $key ) {
544 if ( null === $item ) {
545 unset( $existing['children'] );
546 continue;
547 }
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 }
567 if ( null === $item ) {
568 // A null patch value deletes the key.
569 unset( $existing[ $key ] );
570 continue;
571 }
572 $existing[ $key ] = $this->deep_merge(
573 array_key_exists( $key, $existing ) ? $existing[ $key ] : array(),
574 $item
575 );
576 }
577
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 return $result;
603 }
604
605 /**
606 * Resolves the identity of a form field.
607 *
608 * A bare string is its own identity; an object is identified by its `id`.
609 *
610 * @since 7.1.0
611 *
612 * @param mixed $field The field.
613 * @return string|null The identity, or null if it cannot be resolved.
614 */
615 private function field_identity( $field ) {
616 if ( is_string( $field ) ) {
617 return $field;
618 }
619 if ( is_array( $field ) && isset( $field['id'] ) && is_string( $field['id'] ) ) {
620 return $field['id'];
621 }
622 return null;
623 }
624 }
625