[ Index ]

PHP Cross Reference of WordPress Trunk (Updated Daily)

Search

title

Body

[close]

/wp-includes/ -> class-wp-view-config-data.php (source)

   1  <?php
   2  /**
   3   * WP_View_Config_Data class
   4   *
   5   * @package WordPress
   6   * @since 7.1.0
   7   */
   8  
   9  /**
  10   * Holds an entity's view configuration while it is being built.
  11   *
  12   * An instance of this class is what `get_entity_view_config_{$kind}_{$name}`
  13   * filter callbacks receive: a callback changes the configuration by calling
  14   * methods on the instance and returning it. The configuration has four
  15   * top-level keys — `default_view`, `default_layouts`, `view_list`, and
  16   * `form` — and there are two ways to contribute:
  17   *
  18   * - The `update_*()` methods merge partial changes (patches) into what is
  19   *   already there, each covering one part of the configuration:
  20   *   `update_properties()` for `default_view`, `default_layouts`, and the
  21   *   `form` settings other than its `fields`; `update_view_list_items()` for
  22   *   the `view_list` entries, keyed by view `slug`; and `update_form_fields()`
  23   *   for the `form` fields, keyed by field `id`. This is what plugins should
  24   *   use: patches compose with core's configuration and with other plugins'.
  25   * - `set()` replaces a whole top-level key. It shouldn't be the default
  26   *   choice — a callback using it stops inheriting core's future changes to
  27   *   that key — but it's useful for cases like a post type that doesn't
  28   *   want the default form at all.
  29   *
  30   * Patches follow three shared rules: an associative array merges key by
  31   * key, a numerically indexed array replaces the current value wholesale,
  32   * and `null` deletes what it names — deleting a whole top-level key resets
  33   * it to its default. Each patch and each `set()` value also declares the
  34   * configuration schema version it was written against (currently 1), so a
  35   * future WordPress release that changes the configuration shape can migrate
  36   * existing patches forward instead of breaking them.
  37   *
  38   * @since 7.1.0
  39   */
  40  class WP_View_Config_Data {
  41  
  42      /**
  43       * The latest supported configuration schema version.
  44       *
  45       * @since 7.1.0
  46       * @var int
  47       */
  48      const LATEST_VERSION = 1;
  49  
  50      /**
  51       * The documented top-level configuration keys.
  52       *
  53       * @since 7.1.0
  54       * @var string[]
  55       */
  56      const CONFIG_KEYS = array( 'default_view', 'default_layouts', 'view_list', 'form' );
  57  
  58      /**
  59       * The configuration being contributed to.
  60       *
  61       * @since 7.1.0
  62       * @var array
  63       */
  64      private $config;
  65  
  66      /**
  67       * Constructor.
  68       *
  69       * @since 7.1.0
  70       *
  71       * @param array $config The base configuration to contribute to.
  72       */
  73  	public function __construct( array $config ) {
  74          $this->config = $config;
  75      }
  76  
  77      /**
  78       * Returns the current configuration array.
  79       *
  80       * @since 7.1.0
  81       *
  82       * @return array The configuration.
  83       */
  84  	public function get_config() {
  85          return $this->config;
  86      }
  87  
  88      /**
  89       * Replaces a whole top-level key with a new value.
  90       *
  91       * It shouldn't be the default choice — a callback using it stops
  92       * inheriting core's future changes to that key — but it's useful for
  93       * cases like a post type that doesn't want the default form at all.
  94       *
  95       * A value that declares an unsupported schema version is rejected and
  96       * does not replace anything.
  97       *
  98       * @since 7.1.0
  99       *
 100       * @param string $key     The configuration key to replace.
 101       * @param mixed  $value   The new value.
 102       * @param int    $version The schema version the value was authored against.
 103       * @return WP_View_Config_Data The instance, for chaining.
 104       */
 105  	public function set( $key, $value, int $version ) {
 106          if ( ! $this->check_version( $version, __METHOD__ ) ) {
 107              return $this;
 108          }
 109  
 110          if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) {
 111              _doing_it_wrong(
 112                  __METHOD__,
 113                  sprintf(
 114                      /* translators: %s: the configuration key. */
 115                      esc_html__( '"%s" is not a documented view configuration key.' ),
 116                      esc_html( $key )
 117                  ),
 118                  '7.1.0'
 119              );
 120              return $this;
 121          }
 122  
 123          $this->config[ $key ] = $value;
 124          return $this;
 125      }
 126  
 127      /**
 128       * Merges a partial configuration into `default_view`, `default_layouts`,
 129       * and the `form` settings other than its `fields`.
 130       *
 131       * An associative array merges key by key, a numerically indexed array
 132       * replaces the current value wholesale, and `null` deletes the key it
 133       * names; deleting a whole top-level key (any documented key, including
 134       * `view_list`) resets it to its default.
 135       *
 136       * The keyed collections have dedicated methods and are rejected here: a
 137       * non-null `view_list` value must go through `update_view_list_items()`,
 138       * and a `fields` key inside a `form` value must go through
 139       * `update_form_fields()`.
 140       *
 141       * A patch that declares an unsupported schema version is rejected and
 142       * does not merge.
 143       *
 144       * @since 7.1.0
 145       *
 146       * @param array $patch   The partial configuration to merge.
 147       * @param int   $version The schema version the patch was authored against.
 148       * @return WP_View_Config_Data The instance, for chaining.
 149       */
 150  	public function update_properties( array $patch, int $version ) {
 151          if ( ! $this->check_version( $version, __METHOD__ ) ) {
 152              return $this;
 153          }
 154  
 155          foreach ( $patch as $key => $value ) {
 156              if ( ! in_array( $key, self::CONFIG_KEYS, true ) ) {
 157                  _doing_it_wrong(
 158                      __METHOD__,
 159                      sprintf(
 160                          /* translators: %s: the configuration key. */
 161                          esc_html__( '"%s" is not a documented view configuration key.' ),
 162                          esc_html( $key )
 163                      ),
 164                      '7.1.0'
 165                  );
 166                  continue;
 167              }
 168              // A null patch value drops the whole key from the container rather
 169              // than assigning null.
 170              if ( null === $value ) {
 171                  unset( $this->config[ $key ] );
 172                  continue;
 173              }
 174              if ( 'view_list' === $key ) {
 175                  _doing_it_wrong(
 176                      __METHOD__,
 177                      esc_html__( 'The "view_list" entries are patched by identity. Use update_view_list_items() instead.' ),
 178                      '7.1.0'
 179                  );
 180                  continue;
 181              }
 182              if ( 'form' === $key ) {
 183                  $value = $this->extract_form_properties( $value );
 184                  // Nothing left to merge: the value was off-shape, or held only
 185                  // the rejected `fields` key.
 186                  if ( null === $value || array() === $value ) {
 187                      continue;
 188                  }
 189              }
 190              $this->config[ $key ] = $this->deep_merge( $this->config[ $key ] ?? array(), $value );
 191          }
 192  
 193          return $this;
 194      }
 195  
 196      /**
 197       * Adds, updates, or removes `view_list` entries, keyed by view `slug`.
 198       *
 199       * Each patch key names the `slug` of the view it targets: a matching view
 200       * merges in place and keeps its position (following the shared rules —
 201       * e.g. the view's `filters`, being numerically indexed, replace
 202       * wholesale), an unknown slug appends a new view to the end, and `null`
 203       * removes the view. The patch key is the identity: a `slug` property
 204       * inside the value is ignored. A `null` for a slug that is not found is a
 205       * silent no-op — the view may have been removed by another callback or
 206       * simply not apply to this entity.
 207       *
 208       * A patch that declares an unsupported schema version is rejected and
 209       * does not merge.
 210       *
 211       * @since 7.1.0
 212       *
 213       * @param array $items   The view patches, keyed by slug.
 214       * @param int   $version The schema version the patch was authored against.
 215       * @return WP_View_Config_Data The instance, for chaining.
 216       */
 217  	public function update_view_list_items( array $items, int $version ) {
 218          if ( ! $this->check_version( $version, __METHOD__ ) ) {
 219              return $this;
 220          }
 221  
 222          if ( empty( $items ) ) {
 223              return $this;
 224          }
 225          if ( array_is_list( $items ) ) {
 226              _doing_it_wrong(
 227                  __METHOD__,
 228                  esc_html__( 'A view list patch must be keyed by view "slug".' ),
 229                  '7.1.0'
 230              );
 231              return $this;
 232          }
 233  
 234          $view_list = isset( $this->config['view_list'] ) && is_array( $this->config['view_list'] ) ? $this->config['view_list'] : array();
 235  
 236          foreach ( $items as $slug => $value ) {
 237              // PHP casts numeric-string array keys to integers; identities are strings.
 238              $slug = (string) $slug;
 239  
 240              if ( null === $value ) {
 241                  $view_list = array_values(
 242                      array_filter(
 243                          $view_list,
 244                          static fn( $item ) => ! is_array( $item ) || ! isset( $item['slug'] ) || $item['slug'] !== $slug
 245                      )
 246                  );
 247                  continue;
 248              }
 249  
 250              if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) {
 251                  _doing_it_wrong(
 252                      __METHOD__,
 253                      esc_html__( 'Each view patch must be an associative array of view properties, or null to remove the view.' ),
 254                      '7.1.0'
 255                  );
 256                  continue;
 257              }
 258  
 259              // The patch key is the identity.
 260              unset( $value['slug'] );
 261  
 262              $index = null;
 263              foreach ( $view_list as $i => $item ) {
 264                  if ( is_array( $item ) && isset( $item['slug'] ) && $item['slug'] === $slug ) {
 265                      $index = $i;
 266                      break;
 267                  }
 268              }
 269  
 270              if ( null === $index ) {
 271                  $view_list[] = array_merge( array( 'slug' => $slug ), $value );
 272                  continue;
 273              }
 274              // An empty patch value has nothing to merge (and deep_merge would
 275              // treat an empty array as a list, replacing the whole view).
 276              if ( array() !== $value ) {
 277                  $view_list[ $index ] = $this->deep_merge( $view_list[ $index ], $value );
 278              }
 279          }
 280  
 281          $this->config['view_list'] = array_values( $view_list );
 282  
 283          return $this;
 284      }
 285  
 286      /**
 287       * Adds, updates, or removes `form` fields, keyed by field `id`.
 288       *
 289       * Each patch key names the `id` of the field it targets, and the field is
 290       * found wherever it lives — at the top level or nested inside a group's
 291       * `children`. Fields are visited in document order and a group is checked
 292       * before its own children, so when an id appears at both levels the group
 293       * wins. A matching field merges in place, an unknown id appends a new field
 294       * to the end of the top-level fields, and `null` removes the field. The
 295       * patch key is the identity: an `id` property inside the value is ignored.
 296       * A `null` for an id that is not found is a silent no-op — the field may
 297       * have been removed by another callback or simply not apply to this
 298       * entity.
 299       *
 300       * Inside a field patch, `children` follows the shared rules: an associative
 301       * array merges into the group's children by id (appending unknown ones), a
 302       * numerically indexed array replaces the children wholesale, and `null`
 303       * deletes the key.
 304       *
 305       * A patch that declares an unsupported schema version is rejected and
 306       * does not merge.
 307       *
 308       * @since 7.1.0
 309       *
 310       * @param array $fields  The field patches, keyed by field id.
 311       * @param int   $version The schema version the patch was authored against.
 312       * @return WP_View_Config_Data The instance, for chaining.
 313       */
 314  	public function update_form_fields( array $fields, int $version ) {
 315          if ( ! $this->check_version( $version, __METHOD__ ) ) {
 316              return $this;
 317          }
 318  
 319          if ( empty( $fields ) ) {
 320              return $this;
 321          }
 322          if ( array_is_list( $fields ) ) {
 323              _doing_it_wrong(
 324                  __METHOD__,
 325                  esc_html__( 'A fields patch must be keyed by field "id".' ),
 326                  '7.1.0'
 327              );
 328              return $this;
 329          }
 330  
 331          if ( ! isset( $this->config['form'] ) || ! is_array( $this->config['form'] ) ) {
 332              $this->config['form'] = array();
 333          }
 334          $current = isset( $this->config['form']['fields'] ) && is_array( $this->config['form']['fields'] ) ? $this->config['form']['fields'] : array();
 335  
 336          $this->config['form']['fields'] = $this->merge_fields_by_identity( $current, $fields );
 337  
 338          return $this;
 339      }
 340  
 341      /**
 342       * Validates a declared patch version, reporting misuse against the given
 343       * public method.
 344       *
 345       * @since 7.1.0
 346       *
 347       * @param int    $version The declared version.
 348       * @param string $method  The public method the patch was passed to.
 349       * @return bool Whether the declared version is a supported schema version.
 350       */
 351  	private function check_version( int $version, $method ) {
 352          if ( $version >= 1 && $version <= self::LATEST_VERSION ) {
 353              return true;
 354          }
 355  
 356          _doing_it_wrong(
 357              esc_html( $method ),
 358              esc_html__( 'A view configuration contribution must declare a supported schema version.' ),
 359              '7.1.0'
 360          );
 361  
 362          return false;
 363      }
 364  
 365      /**
 366       * Validates a `form` patch value for update_properties() and strips the
 367       * `fields` key, which is managed by update_form_fields().
 368       *
 369       * @since 7.1.0
 370       *
 371       * @param mixed $value The incoming `form` patch value.
 372       * @return array|null The form properties to merge, or null when the value
 373       *                    is off-shape.
 374       */
 375  	private function extract_form_properties( $value ) {
 376          if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) {
 377              _doing_it_wrong(
 378                  'WP_View_Config_Data::update_properties',
 379                  esc_html__( 'A "form" patch must be an associative array of form properties.' ),
 380                  '7.1.0'
 381              );
 382              return null;
 383          }
 384          if ( array_key_exists( 'fields', $value ) ) {
 385              _doing_it_wrong(
 386                  'WP_View_Config_Data::update_properties',
 387                  esc_html__( 'The form "fields" are patched by identity. Use update_form_fields() instead.' ),
 388                  '7.1.0'
 389              );
 390              unset( $value['fields'] );
 391          }
 392  
 393          return $value;
 394      }
 395  
 396      /**
 397       * Recursively merges two values.
 398       *
 399       * Associative arrays (maps) merge key by key and a null patch value deletes
 400       * the key; lists and scalars are replaced wholesale by the incoming value,
 401       * since lists without a defined identity cannot be merged member by member.
 402       *
 403       * @since 7.1.0
 404       *
 405       * @param mixed $current  The current value.
 406       * @param mixed $incoming The incoming value.
 407       * @return mixed The merged value.
 408       */
 409  	private function deep_merge( $current, $incoming ) {
 410          // An empty array counts as a list, so patching with array() empties
 411          // the key (e.g. 'filters' => array() clears the filters) rather than
 412          // being a no-op map merge.
 413          if ( ! is_array( $incoming ) || array_is_list( $incoming ) ) {
 414              return $incoming;
 415          }
 416  
 417          // Merge onto the current map, or onto an empty base when the current
 418          // value is absent, empty, or not a map, so null delete-markers in the
 419          // patch are consumed rather than stored as literal values (e.g.
 420          // array( 'layout' => null ) merged into an empty layouts entry yields
 421          // array(), not array( 'layout' => null )).
 422          $result = is_array( $current ) && ! array_is_list( $current ) ? $current : array();
 423          foreach ( $incoming as $key => $value ) {
 424              if ( null === $value ) {
 425                  // A null patch value deletes the key.
 426                  unset( $result[ $key ] );
 427                  continue;
 428              }
 429              $result[ $key ] = $this->deep_merge(
 430                  array_key_exists( $key, $result ) ? $result[ $key ] : array(),
 431                  $value
 432              );
 433          }
 434          return $result;
 435      }
 436  
 437      /**
 438       * Merges a map of field patches into a field list by identity.
 439       *
 440       * Shared by the top-level `form` fields and a group's `children`: a `null`
 441       * value removes the matching field (recursing into children), a map value
 442       * merges into the matching field wherever it lives, and an unknown id
 443       * appends a new field to the end of this list. A `null` for an id that is
 444       * not found is a silent no-op.
 445       *
 446       * @since 7.1.0
 447       *
 448       * @param array $current The current list of fields.
 449       * @param array $patches The field patches, keyed by field id.
 450       * @return array The merged list of fields.
 451       */
 452  	private function merge_fields_by_identity( array $current, array $patches ) {
 453          foreach ( $patches as $id => $value ) {
 454              // PHP casts numeric-string array keys to integers; identities are strings.
 455              $id = (string) $id;
 456  
 457              if ( null === $value ) {
 458                  $current = $this->reject_fields( $current, array( $id ) );
 459                  continue;
 460              }
 461              if ( ! is_array( $value ) || ( array() !== $value && array_is_list( $value ) ) ) {
 462                  _doing_it_wrong(
 463                      'WP_View_Config_Data::update_form_fields',
 464                      esc_html__( 'Each field patch must be an associative array of field properties, or null to remove the field.' ),
 465                      '7.1.0'
 466                  );
 467                  continue;
 468              }
 469  
 470              // The patch key is the identity.
 471              unset( $value['id'] );
 472  
 473              $merged = $this->merge_field_in_tree( $current, $id, $value );
 474              if ( null !== $merged ) {
 475                  $current = $merged;
 476                  continue;
 477              }
 478              // An unknown id appends: as a bare string reference when the patch
 479              // carries no overrides, as an array otherwise.
 480              $current[] = array() === $value ? $id : $this->merge_field_item( $id, $id, $value );
 481          }
 482  
 483          return $current;
 484      }
 485  
 486      /**
 487       * Merges a field patch into the field carrying the given identity, wherever
 488       * it lives in the tree.
 489       *
 490       * Fields are visited in document order and a group is checked before its
 491       * own children, so when an id appears at both levels the group wins.
 492       *
 493       * @since 7.1.0
 494       *
 495       * @param array  $fields The list of fields to search.
 496       * @param string $id     The identity of the field to patch.
 497       * @param array  $value  The field patch.
 498       * @return array|null The updated list, or null when the id was not found.
 499       */
 500  	private function merge_field_in_tree( array $fields, $id, array $value ) {
 501          foreach ( $fields as $index => $field ) {
 502              if ( $this->field_identity( $field ) === $id ) {
 503                  $fields[ $index ] = $this->merge_field_item( $field, $id, $value );
 504                  return $fields;
 505              }
 506              if ( is_array( $field ) && isset( $field['children'] ) && is_array( $field['children'] ) ) {
 507                  $children = $this->merge_field_in_tree( $field['children'], $id, $value );
 508                  if ( null !== $children ) {
 509                      $fields[ $index ]['children'] = $children;
 510                      return $fields;
 511                  }
 512              }
 513          }
 514  
 515          return null;
 516      }
 517  
 518      /**
 519       * Merges a field patch into an existing field.
 520       *
 521       * A bare string reference is promoted to an array so the overrides apply.
 522       * The `children` key follows the same rules — a map merges into the
 523       * group's children by id, a list replaces them wholesale, and `null`
 524       * deletes the key — and every other key merges via deep_merge().
 525       *
 526       * @since 7.1.0
 527       *
 528       * @param array|string $existing The existing field.
 529       * @param string       $id       The field identity.
 530       * @param array        $value    The field patch.
 531       * @return array|string The merged field.
 532       */
 533  	private function merge_field_item( $existing, $id, array $value ) {
 534          if ( ! is_array( $existing ) ) {
 535              // Nothing to apply: keep the bare string reference.
 536              if ( array() === $value ) {
 537                  return $existing;
 538              }
 539              // Promote the reference so the incoming overrides apply.
 540              $existing = array( 'id' => $id );
 541          }
 542  
 543          foreach ( $value as $key => $item ) {
 544              if ( 'children' === $key ) {
 545                  if ( null === $item ) {
 546                      unset( $existing['children'] );
 547                      continue;
 548                  }
 549                  if ( ! is_array( $item ) ) {
 550                      _doing_it_wrong(
 551                          'WP_View_Config_Data::update_form_fields',
 552                          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.' ),
 553                          '7.1.0'
 554                      );
 555                      continue;
 556                  }
 557                  // A list replaces the children wholesale (an empty array counts
 558                  // as a list, clearing them)...
 559                  if ( array_is_list( $item ) ) {
 560                      $existing['children'] = $item;
 561                      continue;
 562                  }
 563                  // ...and a map merges into them by identity.
 564                  $children             = isset( $existing['children'] ) && is_array( $existing['children'] ) ? $existing['children'] : array();
 565                  $existing['children'] = $this->merge_fields_by_identity( $children, $item );
 566                  continue;
 567              }
 568              if ( null === $item ) {
 569                  // A null patch value deletes the key.
 570                  unset( $existing[ $key ] );
 571                  continue;
 572              }
 573              $existing[ $key ] = $this->deep_merge(
 574                  array_key_exists( $key, $existing ) ? $existing[ $key ] : array(),
 575                  $item
 576              );
 577          }
 578  
 579          return $existing;
 580      }
 581  
 582      /**
 583       * Returns a field list with the fields matching the given identities removed,
 584       * recursing into group children.
 585       *
 586       * @since 7.1.0
 587       *
 588       * @param array    $fields The list of fields.
 589       * @param string[] $ids    The identities of the fields to remove.
 590       * @return array The list with the matching fields removed.
 591       */
 592  	private function reject_fields( array $fields, array $ids ) {
 593          $result = array();
 594          foreach ( $fields as $field ) {
 595              if ( in_array( $this->field_identity( $field ), $ids, true ) ) {
 596                  continue;
 597              }
 598              if ( is_array( $field ) && isset( $field['children'] ) && is_array( $field['children'] ) ) {
 599                  $field['children'] = $this->reject_fields( $field['children'], $ids );
 600              }
 601              $result[] = $field;
 602          }
 603          return $result;
 604      }
 605  
 606      /**
 607       * Resolves the identity of a form field.
 608       *
 609       * A bare string is its own identity; an object is identified by its `id`.
 610       *
 611       * @since 7.1.0
 612       *
 613       * @param mixed $field The field.
 614       * @return string|null The identity, or null if it cannot be resolved.
 615       */
 616  	private function field_identity( $field ) {
 617          if ( is_string( $field ) ) {
 618              return $field;
 619          }
 620          if ( is_array( $field ) && isset( $field['id'] ) && is_string( $field['id'] ) ) {
 621              return $field['id'];
 622          }
 623          return null;
 624      }
 625  }


Generated : Thu Jul 9 08:20:14 2026 Cross-referenced by PHPXref