Commit 40c0aa61eee for woocommerce
commit 40c0aa61eee4211af04bbf64f657aa99da548ba2
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date: Tue Sep 8 00:38:30 2026 +0200
Refactor and breakdown CheckoutFields class into subclasses and traits (#68081)
Extract additional checkout field type behavior into per-type classes
Moves the per-type branching in CheckoutFields into a field type class per
supported type, moves value storage into a CheckoutFieldsStorage trait, and
moves the core field definitions into CoreCheckoutFields.
diff --git a/plugins/woocommerce/changelog/wooplug-6456-checkout-field-types b/plugins/woocommerce/changelog/wooplug-6456-checkout-field-types
new file mode 100644
index 00000000000..5202aaf7fe4
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6456-checkout-field-types
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Move additional checkout field type behavior into per-type classes, and field value storage into a trait, with no change in behavior.
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index b0323bab2f7..6df50b7b695 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -52767,12 +52767,6 @@ parameters:
count: 1
path: src/Blocks/Domain/Package.php
- -
- message: '#^Instanceof between WC_Data and WC_Data will always evaluate to true\.$#'
- identifier: instanceof.alwaysTrue
- count: 1
- path: src/Blocks/Domain/Services/CheckoutFields.php
-
-
message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:add_fields_data\(\) has no return type specified\.$#'
identifier: missingType.return
@@ -52791,36 +52785,6 @@ parameters:
count: 1
path: src/Blocks/Domain/Services/CheckoutFields.php
- -
- message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:process_checkbox_field\(\) never returns false so it can be removed from the return type\.$#'
- identifier: return.unusedType
- count: 1
- path: src/Blocks/Domain/Services/CheckoutFields.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:process_field_options\(\) should return array but returns array\|false\.$#'
- identifier: return.type
- count: 1
- path: src/Blocks/Domain/Services/CheckoutFields.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:sync_customer_additional_fields_with_order\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: src/Blocks/Domain/Services/CheckoutFields.php
-
- -
- message: '#^Method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFields\:\:sync_order_additional_fields_with_customer\(\) has no return type specified\.$#'
- identifier: missingType.return
- count: 1
- path: src/Blocks/Domain/Services/CheckoutFields.php
-
- -
- message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, array given\.$#'
- identifier: argument.type
- count: 2
- path: src/Blocks/Domain/Services/CheckoutFields.php
-
-
message: '#^Parameter \#3 \$context of static method Automattic\\WooCommerce\\Blocks\\Domain\\Services\\CheckoutFieldsSchema\\Validation\:\:get_field_schema_with_context\(\) expects string, string\|null given\.$#'
identifier: argument.type
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php
new file mode 100644
index 00000000000..9daa9bc25e3
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/AbstractFieldType.php
@@ -0,0 +1,213 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\Validation;
+use WP_Error;
+
+/**
+ * Base class for additional checkout field types.
+ *
+ * Provides passthrough defaults so each field type only overrides the behavior it needs. New
+ * type-level behavior should be added here with a default implementation so existing subclasses
+ * keep working.
+ *
+ * phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter -- The passthrough defaults keep the
+ * signature subclasses override.
+ */
+abstract class AbstractFieldType {
+
+ /**
+ * Validates the options that apply to every field type: callbacks, hidden state, and rule schemas.
+ *
+ * Subclasses adding checks should call this parent method first.
+ *
+ * @param array $options The options supplied during field registration.
+ * @return bool False if an error should prevent registration, true otherwise.
+ */
+ public function validate_options( array $options ): bool {
+ $id = $options['id'];
+
+ if ( empty( $options['label'] ) ) {
+ return $this->registration_error( $id, 'The field label is required.', '8.6.0' );
+ }
+
+ foreach ( array( 'sanitize_callback', 'validate_callback' ) as $callback ) {
+ if ( ! empty( $options[ $callback ] ) && ! is_callable( $options[ $callback ] ) ) {
+ return $this->registration_error( $id, sprintf( 'The %s must be a valid callback.', $callback ), '8.6.0' );
+ }
+ }
+
+ if ( ! empty( $options['hidden'] ) && true === $options['hidden'] ) {
+ // Not an error: the field is still registered, just as a visible one.
+ $this->doing_it_wrong( sprintf( 'Registering a field with hidden set to true is not supported. The field "%s" will be registered as visible.', $id ), '8.6.0' );
+ }
+
+ foreach ( array( 'required', 'hidden', 'validation' ) as $rule_field ) {
+ if ( empty( $options[ $rule_field ] ) || ( 'validation' !== $rule_field && is_bool( $options[ $rule_field ] ) ) ) {
+ continue;
+ }
+
+ $valid = Validation::is_valid_schema( $options[ $rule_field ] );
+
+ if ( is_wp_error( $valid ) ) {
+ return $this->registration_error( $id, $rule_field . ': ' . $valid->get_error_message(), '8.6.0' );
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Processes and validates the options supplied during field registration.
+ *
+ * @param array $field_data The field data array to be updated.
+ * @param array $options The options supplied during field registration.
+ * @return array|false The updated $field_data array, or false if an error should prevent registration.
+ */
+ final public function process_options( array $field_data, array $options ) {
+ $field_data['attributes'] = $this->process_attributes( $field_data['id'], $field_data['attributes'] );
+
+ return $this->process_type_options( $field_data, $options );
+ }
+
+ /**
+ * Processes the type-specific options supplied during field registration.
+ *
+ * @param array $field_data The field data array, with the options common to every type already applied.
+ * @param array $options The options supplied during field registration.
+ * @return array|false The updated $field_data array, or false if an error should prevent registration.
+ */
+ protected function process_type_options( array $field_data, array $options ) {
+ return $field_data;
+ }
+
+ /**
+ * Processes the attributes supplied during field registration.
+ *
+ * Invalid attributes are dropped with a warning rather than preventing registration.
+ *
+ * @param string $id The field ID.
+ * @param mixed $attributes The attributes supplied during field registration.
+ * @return array The processed attributes.
+ */
+ protected function process_attributes( $id, $attributes ): array {
+ if ( empty( $attributes ) ) {
+ return [];
+ }
+
+ if ( ! is_array( $attributes ) ) {
+ $this->doing_it_wrong( sprintf( 'An invalid attributes value was supplied when registering field with id: "%s". Attributes must be a non-empty array.', $id ), '8.6.0' );
+ return [];
+ }
+
+ // These are formatted in camelCase because React components expect them that way.
+ $allowed_attributes = [ 'maxLength', 'readOnly', 'pattern', 'autocomplete', 'autocapitalize', 'title' ];
+
+ $valid_attributes = array_filter(
+ $attributes,
+ function ( $_, $key ) use ( $allowed_attributes ) {
+ return in_array( $key, $allowed_attributes, true ) || strpos( $key, 'aria-' ) === 0 || strpos( $key, 'data-' ) === 0;
+ },
+ ARRAY_FILTER_USE_BOTH
+ );
+
+ if ( count( $attributes ) !== count( $valid_attributes ) ) {
+ $invalid_attributes = array_keys( array_diff_key( $attributes, $valid_attributes ) );
+ $this->doing_it_wrong( sprintf( 'Invalid attribute found when registering field with id: "%s". Attributes: %s are not allowed.', $id, implode( ', ', $invalid_attributes ) ), '8.6.0' );
+ }
+
+ return array_map( 'esc_attr', $valid_attributes );
+ }
+
+ /**
+ * Sanitizes a submitted value for this field type.
+ *
+ * @param mixed $value The submitted value.
+ * @param array $field The field.
+ * @return mixed The sanitized value.
+ */
+ public function sanitize( $value, array $field ) {
+ return $value;
+ }
+
+ /**
+ * The validate_callback a field gets when it does not declare its own: rejects empty required fields.
+ *
+ * Unlike validate(), a field's validate_callback can be replaced at registration, so nothing here is
+ * mandatory for the type.
+ *
+ * @param mixed $value The submitted value.
+ * @param array $field The field.
+ * @return WP_Error|void An error if the value is invalid.
+ */
+ public function default_validate( $value, $field ) {
+ if ( true === $field['required'] && empty( $value ) ) {
+ return new WP_Error(
+ 'woocommerce_required_checkout_field',
+ sprintf(
+ // translators: %s is field key.
+ __( 'The field %s is required.', 'woocommerce' ),
+ $field['id']
+ )
+ );
+ }
+ }
+
+ /**
+ * Validates a submitted value against the constraints of this field type.
+ *
+ * @param mixed $value The submitted value.
+ * @param array $field The field.
+ * @return \WP_Error|null Error if the value is not valid for the field type, null otherwise.
+ */
+ public function validate( $value, array $field ) {
+ return null;
+ }
+
+ /**
+ * Formats a stored value for display based on the field type.
+ *
+ * @param mixed $value The stored value.
+ * @param array $field The field.
+ * @return mixed The formatted value.
+ */
+ public function format_value( $value, array $field ) {
+ return $value;
+ }
+
+ /**
+ * Applies type-specific arguments to a field before it is rendered with woocommerce_form_field().
+ *
+ * @param array $form_field The woocommerce_form_field() arguments built from the field.
+ * @return array The updated arguments.
+ */
+ public function prepare_form_field( array $form_field ): array {
+ return $form_field;
+ }
+
+ /**
+ * Reports a field registration error and prevents the field from being registered.
+ *
+ * @param string $id The ID of the field being registered.
+ * @param string $reason The reason the field cannot be registered.
+ * @param string $version The version the misuse was introduced in.
+ * @return false
+ */
+ protected function registration_error( string $id, string $reason, string $version ): bool {
+ return $this->doing_it_wrong( sprintf( 'Unable to register field with id: "%s". %s', $id, $reason ), $version );
+ }
+
+ /**
+ * Reports a field registration misuse that does not prevent the field from being registered.
+ *
+ * @param string $message The message describing the misuse.
+ * @param string $version The version the misuse was introduced in.
+ * @return false
+ */
+ protected function doing_it_wrong( string $message, string $version ): bool {
+ _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), esc_html( $version ) );
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
new file mode 100644
index 00000000000..b2233c62848
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/CheckboxFieldType.php
@@ -0,0 +1,64 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+/**
+ * The "checkbox" additional checkout field type.
+ */
+class CheckboxFieldType extends AbstractFieldType {
+
+ /**
+ * Processes the options for a checkbox field and returns the new field_options array.
+ *
+ * @param array $field_data The field data array to be updated.
+ * @param array $options The options supplied during field registration.
+ * @return array|false The updated $field_data array, or false if an error was encountered.
+ */
+ protected function process_type_options( array $field_data, array $options ) {
+ $id = $options['id'];
+ $field_data['required'] = $options['required'] ?? false;
+
+ if ( false === $field_data['required'] && ! empty( $options['error_message'] ) ) {
+ $this->doing_it_wrong( sprintf( 'Passing an error message to a non-required checkbox "%s" will have no effect. The error message has been removed from the field.', $id ), '9.8.0' );
+ unset( $field_data['error_message'] );
+ }
+
+ if ( isset( $options['error_message'] ) && ! is_string( $options['error_message'] ) ) {
+ $this->doing_it_wrong( sprintf( 'The error_message property for field with id: "%s" must be a string, you passed %s. A default message will be shown.', $id, gettype( $options['error_message'] ) ), '9.8.0' );
+ unset( $field_data['error_message'] );
+ }
+
+ // The client expects the error message in camelCase.
+ if ( isset( $field_data['error_message'] ) ) {
+ $field_data['errorMessage'] = $field_data['error_message'];
+ unset( $field_data['error_message'] );
+ }
+
+ return $field_data;
+ }
+
+ /**
+ * Formats a stored checkbox value as Yes/No for display.
+ *
+ * @param mixed $value The stored value.
+ * @param array $field The field.
+ * @return string
+ */
+ public function format_value( $value, array $field ) {
+ return $value ? __( 'Yes', 'woocommerce' ) : __( 'No', 'woocommerce' );
+ }
+
+ /**
+ * Sets the checked and unchecked values woocommerce_form_field() should submit.
+ *
+ * @param array $form_field The woocommerce_form_field() arguments built from the field.
+ * @return array The updated arguments.
+ */
+ public function prepare_form_field( array $form_field ): array {
+ $form_field['checked_value'] = '1';
+ $form_field['unchecked_value'] = '0';
+
+ return $form_field;
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
new file mode 100644
index 00000000000..e8c96fb140e
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
@@ -0,0 +1,88 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+use Automattic\WooCommerce\Utilities\TimeUtil;
+use WP_Error;
+
+/**
+ * The "date" additional checkout field type.
+ *
+ * Values are calendar dates in YYYY-MM-DD format with no time or timezone component.
+ */
+class DateFieldType extends AbstractFieldType {
+
+ /**
+ * Trims whitespace from submitted date values before they are validated and stored.
+ *
+ * @param mixed $value The submitted value.
+ * @param array $field The field.
+ * @return mixed The sanitized value.
+ */
+ public function sanitize( $value, array $field ) {
+ return is_string( $value ) ? trim( $value ) : $value;
+ }
+
+ /**
+ * Validates that a submitted value is a real calendar date.
+ *
+ * @param mixed $value The submitted value.
+ * @param array $field The field.
+ * @return WP_Error|null Error if the value is not valid, null otherwise.
+ */
+ public function validate( $value, array $field ) {
+ // An empty value is not a type error. Required fields are handled by the field's validation callback.
+ if ( null === $value || '' === $value ) {
+ return null;
+ }
+
+ if ( ! is_string( $value ) || ! TimeUtil::is_valid_date( $value, 'Y-m-d' ) ) {
+ return new WP_Error(
+ 'woocommerce_invalid_checkout_field',
+ sprintf(
+ /* translators: %s: is the field label */
+ __( 'Please provide a valid %s in YYYY-MM-DD format.', 'woocommerce' ),
+ $field['label']
+ )
+ );
+ }
+
+ return null;
+ }
+
+ /**
+ * Formats a stored YYYY-MM-DD date using the store's date format.
+ *
+ * @param mixed $value The stored value.
+ * @param array $field The field.
+ * @return mixed The formatted date, or the value unchanged if it could not be parsed.
+ */
+ public function format_value( $value, array $field ) {
+ $date = is_string( $value ) ? $this->parse_date( $value ) : null;
+
+ if ( null === $date ) {
+ return $value;
+ }
+
+ $formatted = wp_date( wc_date_format(), $date->getTimestamp() );
+
+ return false === $formatted ? $value : $formatted;
+ }
+
+ /**
+ * Parses a YYYY-MM-DD date in the store's timezone.
+ *
+ * @param string $value The date to parse.
+ * @return \DateTimeImmutable|null The date, or null if it is not a real calendar date.
+ */
+ private function parse_date( string $value ) {
+ if ( ! TimeUtil::is_valid_date( $value, 'Y-m-d' ) ) {
+ return null;
+ }
+
+ $date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value, wp_timezone() );
+
+ return $date ? $date : null;
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php
new file mode 100644
index 00000000000..bc263f29eeb
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/SelectFieldType.php
@@ -0,0 +1,82 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+/**
+ * The "select" additional checkout field type.
+ */
+class SelectFieldType extends AbstractFieldType {
+
+ /**
+ * Processes the options for a select field and returns the new field_options array.
+ *
+ * @param array $field_data The field data array to be updated.
+ * @param array $options The options supplied during field registration.
+ * @return array|false The updated $field_data array, or false if an error was encountered.
+ */
+ protected function process_type_options( array $field_data, array $options ) {
+ $id = $options['id'];
+
+ if ( empty( $options['options'] ) || ! is_array( $options['options'] ) ) {
+ return $this->registration_error( $id, 'Fields of type "select" must have an array of "options".', '8.6.0' );
+ }
+
+ $cleaned_options = [];
+
+ foreach ( $options['options'] as $option ) {
+ if ( ! isset( $option['value'], $option['label'] ) ) {
+ return $this->registration_error( $id, 'Fields of type "select" must have an array of "options" and each option must contain a "value" and "label" member.', '8.6.0' );
+ }
+
+ $value = sanitize_text_field( $option['value'] );
+
+ if ( isset( $cleaned_options[ $value ] ) ) {
+ $this->doing_it_wrong( sprintf( 'Duplicate key found when registering field with id: "%s". The value in each option of "select" fields must be unique. Duplicate value "%s" found. The duplicate key will be removed.', $id, $value ), '8.6.0' );
+ continue;
+ }
+
+ $cleaned_options[ $value ] = [
+ 'value' => $value,
+ 'label' => sanitize_text_field( $option['label'] ),
+ ];
+ }
+
+ $field_data['options'] = array_values( $cleaned_options );
+
+ if ( isset( $field_data['placeholder'] ) ) {
+ $field_data['placeholder'] = sanitize_text_field( $field_data['placeholder'] );
+ }
+
+ return $field_data;
+ }
+
+ /**
+ * Formats a stored option value as its registered label for display.
+ *
+ * @param mixed $value The stored value.
+ * @param array $field The field.
+ * @return mixed The option label, or the value unchanged if it is not a registered option.
+ */
+ public function format_value( $value, array $field ) {
+ $options = array_column( $field['options'], 'label', 'value' );
+
+ return $options[ $value ] ?? $value;
+ }
+
+ /**
+ * Maps the registered options to the value => label format woocommerce_form_field() expects.
+ *
+ * @param array $form_field The woocommerce_form_field() arguments built from the field.
+ * @return array The updated arguments.
+ */
+ public function prepare_form_field( array $form_field ): array {
+ $form_field['options'] = array_column( $form_field['options'], 'label', 'value' );
+
+ if ( ! empty( $form_field['placeholder'] ) && ! array_key_exists( '', $form_field['options'] ) ) {
+ $form_field['options'] = array( '' => $form_field['placeholder'] ) + $form_field['options'];
+ }
+
+ return $form_field;
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/TextFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/TextFieldType.php
new file mode 100644
index 00000000000..d6de4f9b12f
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/TextFieldType.php
@@ -0,0 +1,12 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes;
+
+/**
+ * The "text" additional checkout field type.
+ *
+ * Text fields have no type-specific options or constraints, so the AbstractFieldType defaults apply.
+ */
+class TextFieldType extends AbstractFieldType {
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
index aa93f77127b..0b2221db759 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
@@ -5,10 +5,12 @@ namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
-use Automattic\WooCommerce\Utilities\TimeUtil;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\{
DocumentObject, Validation
};
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes\{
+ AbstractFieldType, CheckboxFieldType, DateFieldType, SelectFieldType, TextFieldType
+};
use WC_Customer;
use WC_Data;
use WC_Order;
@@ -19,6 +21,8 @@ use WP_Error;
*/
class CheckoutFields {
+ use CheckoutFieldsStorage;
+
/**
* Additional checkout fields.
*
@@ -34,11 +38,11 @@ class CheckoutFields {
private $fields_locations;
/**
- * Supported field types
+ * Supported field types, keyed by their type slug.
*
- * @var array
+ * @var array<string, AbstractFieldType>
*/
- private $supported_field_types = [ 'text', 'select', 'checkbox', 'date' ];
+ private $field_types = [];
/**
* Groups of fields to be saved.
@@ -90,7 +94,15 @@ class CheckoutFields {
*/
public function __construct( AssetDataRegistry $asset_data_registry ) {
$this->asset_data_registry = $asset_data_registry;
- $this->fields_locations = [
+
+ $this->field_types = [
+ 'text' => new TextFieldType(),
+ 'select' => new SelectFieldType(),
+ 'checkbox' => new CheckboxFieldType(),
+ 'date' => new DateFieldType(),
+ ];
+
+ $this->fields_locations = [
// omit email from shipping and billing fields.
'address' => array_merge( \array_diff_key( $this->get_core_fields_keys(), array( 'email' ) ) ),
'contact' => array( 'email' ),
@@ -154,36 +166,37 @@ class CheckoutFields {
/**
* If a field does not declare a sanitization callback, this is the default sanitization callback.
*
+ * @deprecated 11.2.0 Fields are wired to their field type's sanitize() method instead.
+ *
* @param mixed $value Value to sanitize.
* @param array $field Field data.
* @return mixed
*/
public function default_sanitize_callback( $value, $field ) {
- if ( 'date' === ( $field['type'] ?? '' ) && is_string( $value ) ) {
- return trim( $value );
- }
+ return $this->get_field_type( $field )->sanitize( $value, $field );
+ }
- return $value;
+ /**
+ * Returns the field type handling a field, falling back to text for core and unknown types.
+ *
+ * @param array $field The field, or the options supplied during field registration.
+ * @return AbstractFieldType
+ */
+ private function get_field_type( array $field ): AbstractFieldType {
+ return $this->field_types[ $field['type'] ?? '' ] ?? $this->field_types['text'];
}
/**
* If a field does not declare a validation callback, this is the default validation callback.
*
+ * @deprecated 11.2.0 Fields are wired to their field type's default_validate() method instead.
+ *
* @param mixed $value Value to sanitize.
* @param array $field Field data.
* @return WP_Error|void If there is a validation error, return an WP_Error object.
*/
public function default_validate_callback( $value, $field ) {
- if ( true === $field['required'] && empty( $value ) ) {
- return new WP_Error(
- 'woocommerce_required_checkout_field',
- sprintf(
- // translators: %s is field key.
- __( 'The field %s is required.', 'woocommerce' ),
- $field['id']
- )
- );
- }
+ return $this->get_field_type( $field )->default_validate( $value, $field );
}
/**
@@ -204,6 +217,8 @@ class CheckoutFields {
return;
}
+ $field_type = $this->get_field_type( $options );
+
// The above validate_options function ensures these options are valid. Type might not be supplied but then it defaults to text.
$field_data = wp_parse_args(
$options,
@@ -218,14 +233,13 @@ class CheckoutFields {
'required' => false,
'attributes' => [],
'show_in_order_confirmation' => true,
- 'sanitize_callback' => array( $this, 'default_sanitize_callback' ),
- 'validate_callback' => array( $this, 'default_validate_callback' ),
+ 'sanitize_callback' => array( $field_type, 'sanitize' ),
+ 'validate_callback' => array( $field_type, 'default_validate' ),
'validation' => [],
],
);
- $field_data['attributes'] = $this->register_field_attributes( $field_data['id'], $field_data['attributes'] );
- $field_data = $this->process_field_options( $field_data, $options );
+ $field_data = $this->process_field_options( $field_data, $options );
// $field_data will be false if an error that will prevent the field being registered is encountered.
if ( false === $field_data ) {
@@ -400,12 +414,6 @@ class CheckoutFields {
return false;
}
- if ( empty( $options['label'] ) ) {
- $message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], 'The field label is required.' );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- return false;
- }
-
if ( empty( $options['location'] ) ) {
$message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], 'The field location is required.' );
_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
@@ -434,58 +442,18 @@ class CheckoutFields {
return false;
}
- if ( ! empty( $options['type'] ) ) {
- if ( ! in_array( $options['type'], $this->supported_field_types, true ) ) {
- $message = sprintf(
- 'Unable to register field with id: "%s". Registering a field with type "%s" is not supported. The supported types are: %s.',
- $id,
- $options['type'],
- implode( ', ', $this->supported_field_types )
- );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- return false;
- }
- }
-
- if ( ! empty( $options['sanitize_callback'] ) && ! is_callable( $options['sanitize_callback'] ) ) {
- $message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'The sanitize_callback must be a valid callback.' );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- return false;
- }
-
- if ( ! empty( $options['validate_callback'] ) && ! is_callable( $options['validate_callback'] ) ) {
- $message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'The validate_callback must be a valid callback.' );
+ if ( ! empty( $options['type'] ) && ! isset( $this->field_types[ $options['type'] ] ) ) {
+ $message = sprintf(
+ 'Unable to register field with id: "%s". Registering a field with type "%s" is not supported. The supported types are: %s.',
+ $id,
+ $options['type'],
+ implode( ', ', array_keys( $this->field_types ) )
+ );
_doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
return false;
}
- if ( ! empty( $options['hidden'] ) && true === $options['hidden'] ) {
- // Hidden fields are not supported right now. They will be registered with hidden => false.
- $message = sprintf( 'Registering a field with hidden set to true is not supported. The field "%s" will be registered as visible.', $id );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- // Don't return here unlike the other fields because this is not an issue that will prevent registration.
- }
-
- $rule_fields = [ 'required', 'hidden', 'validation' ];
- $allow_bool = [ 'required', 'hidden' ];
-
- foreach ( $rule_fields as $rule_field ) {
- if ( ! empty( $options[ $rule_field ] ) ) {
- if ( in_array( $rule_field, $allow_bool, true ) && is_bool( $options[ $rule_field ] ) ) {
- continue;
- }
-
- $valid = Validation::is_valid_schema( $options[ $rule_field ] );
-
- if ( is_wp_error( $valid ) ) {
- $message = sprintf( 'Unable to register field with id: "%s". %s', $options['id'], $rule_field . ': ' . $valid->get_error_message() );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- return false;
- }
- }
- }
-
- return true;
+ return $this->get_field_type( $options )->validate_options( $options );
}
/**
@@ -493,156 +461,10 @@ class CheckoutFields {
*
* @param array $field_data The field data array to be updated.
* @param array $options The options supplied during field registration.
- * @return array The updated $field_data array.
+ * @return array|false The updated $field_data array, or false if an error should prevent registration.
*/
private function process_field_options( $field_data, $options ) {
- if ( 'checkbox' === $field_data['type'] ) {
- $field_data = $this->process_checkbox_field( $field_data, $options );
- } elseif ( 'select' === $field_data['type'] ) {
- $field_data = $this->process_select_field( $field_data, $options );
- }
- return $field_data;
- }
-
- /**
- * Processes the options for a select field and returns the new field_options array.
- *
- * @param array $field_data The field data array to be updated.
- * @param array $options The options supplied during field registration.
- *
- * @return array|false The updated $field_data array or false if an error was encountered.
- */
- private function process_select_field( $field_data, $options ) {
- $id = $options['id'];
-
- if ( empty( $options['options'] ) || ! is_array( $options['options'] ) ) {
- $message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'Fields of type "select" must have an array of "options".' );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- return false;
- }
- $cleaned_options = [];
- $added_values = [];
-
- // Check all entries in $options['options'] has a key and value member.
- foreach ( $options['options'] as $option ) {
- if ( ! isset( $option['value'] ) || ! isset( $option['label'] ) ) {
- $message = sprintf( 'Unable to register field with id: "%s". %s', $id, 'Fields of type "select" must have an array of "options" and each option must contain a "value" and "label" member.' );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- return false;
- }
-
- $sanitized_value = sanitize_text_field( $option['value'] );
- $sanitized_label = sanitize_text_field( $option['label'] );
-
- if ( in_array( $sanitized_value, $added_values, true ) ) {
- $message = sprintf( 'Duplicate key found when registering field with id: "%s". The value in each option of "select" fields must be unique. Duplicate value "%s" found. The duplicate key will be removed.', $id, $sanitized_value );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- continue;
- }
-
- $added_values[] = $sanitized_value;
-
- $cleaned_options[] = [
- 'value' => $sanitized_value,
- 'label' => $sanitized_label,
- ];
- }
-
- $field_data['options'] = $cleaned_options;
-
- if ( isset( $field_data['placeholder'] ) ) {
- $field_data['placeholder'] = sanitize_text_field( $field_data['placeholder'] );
- }
-
- return $field_data;
- }
-
- /**
- * Processes the options for a checkbox field and returns the new field_options array.
- *
- * @param array $field_data The field data array to be updated.
- * @param array $options The options supplied during field registration.
- *
- * @return array|false The updated $field_data array or false if an error was encountered.
- */
- private function process_checkbox_field( $field_data, $options ) {
- $id = $options['id'];
- $field_data['required'] = $options['required'] ?? false;
-
- if ( false === $field_data['required'] && ! empty( $options['error_message'] ) ) {
- $message = sprintf( 'Passing an error message to a non-required checkbox "%s" will have no effect. The error message has been removed from the field.', $id );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '9.8.0' );
- unset( $field_data['error_message'] );
- }
-
- if ( isset( $options['error_message'] ) && ! is_string( $options['error_message'] ) ) {
- $message = sprintf( 'The error_message property for field with id: "%s" must be a string, you passed %s. A default message will be shown.', $id, gettype( $options['error_message'] ) );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '9.8.0' );
- unset( $field_data['error_message'] );
- }
-
- // Get the error message property and set it to errorMessage for use in JS.
- if ( isset( $field_data['error_message'] ) ) {
- $field_data['errorMessage'] = $field_data['error_message'];
- unset( $field_data['error_message'] );
- }
-
- return $field_data;
- }
-
- /**
- * Processes the attributes supplied during field registration.
- *
- * @param array $id The field ID.
- * @param array $attributes The attributes supplied during field registration.
- *
- * @return array The processed attributes.
- */
- private function register_field_attributes( $id, $attributes ) {
- // We check if attributes are valid. This is done to prevent too much nesting and also to allow field registration
- // even if the attributes property is invalid. We can just skip it and register the field without attributes.
- if ( empty( $attributes ) ) {
- return [];
- }
-
- if ( ! is_array( $attributes ) || 0 === count( $attributes ) ) {
- $message = sprintf( 'An invalid attributes value was supplied when registering field with id: "%s". %s', $id, 'Attributes must be a non-empty array.' );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- return [];
- }
-
- // These are formatted in camelCase because React components expect them that way.
- $allowed_attributes = [
- 'maxLength',
- 'readOnly',
- 'pattern',
- 'autocomplete',
- 'autocapitalize',
- 'title',
- ];
-
- $valid_attributes = array_filter(
- $attributes,
- function ( $_, $key ) use ( $allowed_attributes ) {
- return in_array( $key, $allowed_attributes, true ) || strpos( $key, 'aria-' ) === 0 || strpos( $key, 'data-' ) === 0;
- },
- ARRAY_FILTER_USE_BOTH
- );
-
- // Any invalid attributes should show a doing_it_wrong warning. It shouldn't stop field registration, though.
- if ( count( $attributes ) !== count( $valid_attributes ) ) {
- $invalid_attributes = array_keys( array_diff_key( $attributes, $valid_attributes ) );
- $message = sprintf( 'Invalid attribute found when registering field with id: "%s". Attributes: %s are not allowed.', $id, implode( ', ', $invalid_attributes ) );
- _doing_it_wrong( 'woocommerce_register_additional_checkout_field', esc_html( $message ), '8.6.0' );
- }
-
- // Escape attributes to remove any malicious code and return them.
- return array_map(
- function ( $value ) {
- return esc_attr( $value );
- },
- $valid_attributes
- );
+ return $this->get_field_type( $field_data )->process_options( $field_data, $options );
}
/**
@@ -651,19 +473,7 @@ class CheckoutFields {
* @return array An array of field keys.
*/
public function get_core_fields_keys() {
- return [
- 'email',
- 'country',
- 'first_name',
- 'last_name',
- 'company',
- 'address_1',
- 'address_2',
- 'city',
- 'state',
- 'postcode',
- 'phone',
- ];
+ return CoreCheckoutFields::get_keys();
}
/**
@@ -672,141 +482,7 @@ class CheckoutFields {
* @return array An array of fields.
*/
public function get_core_fields() {
- return [
- 'email' => [
- 'label' => __( 'Email address', 'woocommerce' ),
- 'optionalLabel' => __(
- 'Email address (optional)',
- 'woocommerce'
- ),
- 'required' => true,
- 'hidden' => false,
- 'autocomplete' => 'email',
- 'autocapitalize' => 'none',
- 'type' => 'email',
- 'index' => 0,
- ],
- 'country' => [
- 'label' => __( 'Country/Region', 'woocommerce' ),
- 'optionalLabel' => __(
- 'Country/Region (optional)',
- 'woocommerce'
- ),
- 'required' => true,
- 'hidden' => false,
- 'autocomplete' => 'country',
- 'index' => 1,
- ],
- 'first_name' => [
- 'label' => __( 'First name', 'woocommerce' ),
- 'optionalLabel' => __(
- 'First name (optional)',
- 'woocommerce'
- ),
- 'required' => true,
- 'hidden' => false,
- 'autocomplete' => 'given-name',
- 'autocapitalize' => 'sentences',
- 'index' => 10,
- ],
- 'last_name' => [
- 'label' => __( 'Last name', 'woocommerce' ),
- 'optionalLabel' => __(
- 'Last name (optional)',
- 'woocommerce'
- ),
- 'required' => true,
- 'hidden' => false,
- 'autocomplete' => 'family-name',
- 'autocapitalize' => 'sentences',
- 'index' => 20,
- ],
- 'company' => [
- 'label' => __( 'Company', 'woocommerce' ),
- 'optionalLabel' => __(
- 'Company (optional)',
- 'woocommerce'
- ),
- 'required' => 'required' === CartCheckoutUtils::get_company_field_visibility(),
- 'hidden' => 'hidden' === CartCheckoutUtils::get_company_field_visibility(),
- 'autocomplete' => 'organization',
- 'autocapitalize' => 'sentences',
- 'index' => 30,
- ],
- 'address_1' => [
- 'label' => __( 'Address', 'woocommerce' ),
- 'optionalLabel' => __(
- 'Address (optional)',
- 'woocommerce'
- ),
- 'required' => true,
- 'hidden' => false,
- 'autocomplete' => 'address-line1',
- 'autocapitalize' => 'sentences',
- 'index' => 40,
- ],
- 'address_2' => [
- 'label' => __( 'Apartment, suite, etc.', 'woocommerce' ),
- 'optionalLabel' => __(
- 'Apartment, suite, etc. (optional)',
- 'woocommerce'
- ),
- 'required' => 'required' === CartCheckoutUtils::get_address_2_field_visibility(),
- 'hidden' => 'hidden' === CartCheckoutUtils::get_address_2_field_visibility(),
- 'autocomplete' => 'address-line2',
- 'autocapitalize' => 'sentences',
- 'index' => 50,
- ],
- 'city' => [
- 'label' => __( 'City', 'woocommerce' ),
- 'optionalLabel' => __(
- 'City (optional)',
- 'woocommerce'
- ),
- 'required' => true,
- 'hidden' => false,
- 'autocomplete' => 'address-level2',
- 'autocapitalize' => 'sentences',
- 'index' => 70,
- ],
- 'state' => [
- 'label' => __( 'State/County', 'woocommerce' ),
- 'optionalLabel' => __(
- 'State/County (optional)',
- 'woocommerce'
- ),
- 'required' => true,
- 'hidden' => false,
- 'autocomplete' => 'address-level1',
- 'autocapitalize' => 'sentences',
- 'index' => 80,
- ],
- 'postcode' => [
- 'label' => __( 'Postal code', 'woocommerce' ),
- 'optionalLabel' => __(
- 'Postal code (optional)',
- 'woocommerce'
- ),
- 'required' => true,
- 'hidden' => false,
- 'autocomplete' => 'postal-code',
- 'autocapitalize' => 'characters',
- 'index' => 90,
- ],
- 'phone' => [
- 'label' => __( 'Phone', 'woocommerce' ),
- 'optionalLabel' => __(
- 'Phone (optional)',
- 'woocommerce'
- ),
- 'required' => 'required' === CartCheckoutUtils::get_phone_field_visibility(),
- 'hidden' => 'hidden' === CartCheckoutUtils::get_phone_field_visibility(),
- 'type' => 'tel',
- 'autocomplete' => 'tel',
- 'autocapitalize' => 'characters',
- 'index' => 100,
- ],
- ];
+ return CoreCheckoutFields::get_fields();
}
/**
@@ -901,23 +577,7 @@ class CheckoutFields {
* @return WP_Error|null Error if the value is not valid for the field type, null otherwise.
*/
private function validate_field_type( $field, $field_value ) {
- // An empty value is not a type error. Required fields are handled by the field's validation callback.
- if ( 'date' !== ( $field['type'] ?? '' ) || null === $field_value || '' === $field_value ) {
- return null;
- }
-
- if ( ! is_string( $field_value ) || ! TimeUtil::is_valid_date( $field_value, 'Y-m-d' ) ) {
- return new WP_Error(
- 'woocommerce_invalid_checkout_field',
- sprintf(
- /* translators: %s: is the field label */
- __( 'Please provide a valid %s in YYYY-MM-DD format.', 'woocommerce' ),
- $field['label']
- )
- );
- }
-
- return null;
+ return $this->get_field_type( $field )->validate( $field_value, $field );
}
/**
@@ -1215,196 +875,6 @@ class CheckoutFields {
return in_array( $key, array_intersect( array_merge( $this->get_address_fields_keys(), $this->get_contact_fields_keys() ), array_keys( $this->additional_fields ) ), true );
}
- /**
- * Persists a field value for a given order. This would also optionally set the field value on the customer object if the order is linked to a registered customer.
- *
- * @param string $key The field key.
- * @param mixed $value The field value.
- * @param WC_Order $order The order to persist the field for.
- * @param string $group The group to persist the field for (shipping|billing|other).
- * @param bool $set_customer Whether to set the field value on the customer or not.
- *
- * @return void
- */
- public function persist_field_for_order( string $key, $value, WC_Order $order, string $group = 'other', bool $set_customer = true ) {
- $group = $this->prepare_group_name( $group );
- $this->set_array_meta( $key, $value, $order, $group );
- if ( $set_customer && $order->get_customer_id() ) {
- $customer = new WC_Customer( $order->get_customer_id() );
- $this->persist_field_for_customer( $key, $value, $customer, $group );
- }
- }
-
- /**
- * Persists a field value for a given customer.
- *
- * @param string $key The field key.
- * @param mixed $value The field value.
- * @param WC_Customer $customer The customer to persist the field for.
- * @param string $group The group to persist the field for (shipping|billing|other).
- *
- * @return void
- */
- public function persist_field_for_customer( string $key, $value, WC_Customer $customer, string $group = 'other' ) {
- $group = $this->prepare_group_name( $group );
- $this->set_array_meta( $key, $value, $customer, $group );
- }
-
- /**
- * Sets a field value in an array meta, supporting routing things to billing, shipping, or additional fields, based on a prefix for the key.
- *
- * @param string $key The field key.
- * @param mixed $value The field value.
- * @param WC_Customer|WC_Order $wc_object The object to set the field value for.
- * @param string $group The group to set the field value for (shipping|billing|other).
- *
- * @return void
- */
- private function set_array_meta( string $key, $value, WC_Data $wc_object, string $group ) {
- $meta_key = self::get_group_key( $group ) . $key;
-
- /**
- * Allow reacting for saving an additional field value.
- *
- * @param string $key The key of the field being saved.
- * @param mixed $value The value of the field being saved.
- * @param string $group The group of this location (shipping|billing|other).
- * @param WC_Customer|WC_Order $wc_object The object to set the field value for.
- *
- * @since 8.9.0
- */
- do_action( 'woocommerce_set_additional_field_value', $key, $value, $group, $wc_object );
- // Convert boolean values to strings because Data Stores will skip false values.
- if ( is_bool( $value ) ) {
- $value = $value ? '1' : '0';
- }
- $wc_object->update_meta_data( $meta_key, $value );
- }
-
- /**
- * Returns a field value for a given object.
- *
- * @param string $key The field key.
- * @param WC_Customer|WC_Order $wc_object The customer or order to get the field value for.
- * @param string $group The group to get the field value for (shipping|billing|other).
- *
- * @return mixed The field value.
- */
- public function get_field_from_object( string $key, WC_Data $wc_object, string $group = 'other' ) {
- $group = $this->prepare_group_name( $group );
- $meta_key = self::get_group_key( $group ) . $key;
- $value = $wc_object->get_meta( $meta_key, true );
-
- if ( ! $value && '0' !== $value ) {
- /**
- * Allow providing a default value for additional fields if no value is already set.
- *
- * @param null $value The default value for the filter, always null.
- * @param string $group The group of this key (shipping|billing|other).
- * @param WC_Data $wc_object The object to get the field value for.
- *
- * @since 8.9.0
- */
- $value = apply_filters( "woocommerce_get_default_value_for_{$key}", null, $group, $wc_object );
- }
-
- // We cast the value to a boolean if the field is a checkbox.
- if ( $this->is_field( $key ) && 'checkbox' === $this->additional_fields[ $key ]['type'] ) {
- return '1' === $value;
- }
-
- if ( null === $value ) {
- return '';
- }
-
- return $value;
- }
-
- /**
- * Returns an array of all fields values for a given object in a group.
- *
- * @param WC_Data $wc_object The object or order to get the fields for.
- * @param string $group The group to get the fields for (shipping|billing|other).
- * @param bool $all Whether to return all fields or only the ones that are still registered. Default false.
- * @return array An array of fields.
- */
- public function get_all_fields_from_object( WC_Data $wc_object, string $group = 'other', bool $all = false ) {
- $meta_data = [];
- $group = $this->prepare_group_name( $group );
- $prefix = self::get_group_key( $group );
-
- if ( $wc_object instanceof WC_Data ) {
- $meta = $wc_object->get_meta_data();
- foreach ( $meta as $meta_data_object ) {
- if ( 0 === \strpos( $meta_data_object->key, $prefix ) ) {
- $key = \str_replace( $prefix, '', $meta_data_object->key );
- if ( $all || $this->is_field( $key ) ) {
- $meta_data[ $key ] = $meta_data_object->value;
- }
- }
- }
- }
-
- $missing_fields = array_diff( array_keys( $this->get_fields_for_group( $group ) ), array_keys( $meta_data ) );
-
- foreach ( $missing_fields as $missing_field ) {
- /**
- * Allow providing a default value for additional fields if no value is already set.
- *
- * @param null $value The default value for the filter, always null.
- * @param string $group The group of this key (shipping|billing|other).
- * @param WC_Data $wc_object The object to get the field value for.
- *
- * @since 8.9.0
- */
- $value = apply_filters( "woocommerce_get_default_value_for_{$missing_field}", null, $group, $wc_object );
-
- if ( isset( $value ) ) {
- $meta_data[ $missing_field ] = $value;
- }
- }
-
- return $meta_data;
- }
-
- /**
- * Copies additional fields from an order to a customer.
- *
- * @param WC_Order $order The order to sync the fields for.
- * @param WC_Customer $customer The customer to sync the fields for.
- */
- public function sync_customer_additional_fields_with_order( WC_Order $order, WC_Customer $customer ) {
- foreach ( $this->groups as $group ) {
- $order_additional_fields = $this->get_all_fields_from_object( $order, $group, true );
-
- // Sync customer additional fields with order additional fields.
- foreach ( $order_additional_fields as $key => $value ) {
- if ( $this->is_customer_field( $key ) ) {
- $this->persist_field_for_customer( $key, $value, $customer, $group );
- }
- }
- }
- }
-
- /**
- * Copies additional fields from a customer to an order.
- *
- * @param WC_Order $order The order to sync the fields for.
- * @param WC_Customer $customer The customer to sync the fields for.
- */
- public function sync_order_additional_fields_with_customer( WC_Order $order, WC_Customer $customer ) {
- foreach ( $this->groups as $group ) {
- $customer_additional_fields = $this->get_all_fields_from_object( $customer, $group, true );
-
- // Sync order additional fields with customer additional fields.
- foreach ( $customer_additional_fields as $key => $value ) {
- if ( $this->is_field( $key ) ) {
- $this->persist_field_for_order( $key, $value, $order, $group, false );
- }
- }
- }
- }
-
/**
* From a set of fields, returns only the ones for a given location.
*
@@ -1509,25 +979,19 @@ class CheckoutFields {
* @return string
*/
public function format_additional_field_value( $value, $field ) {
- if ( 'checkbox' === $field['type'] ) {
- $value = $value ? __( 'Yes', 'woocommerce' ) : __( 'No', 'woocommerce' );
- }
-
- if ( 'select' === $field['type'] ) {
- $options = array_column( $field['options'], 'label', 'value' );
- $value = isset( $options[ $value ] ) ? $options[ $value ] : $value;
- }
-
- if ( 'date' === $field['type'] && is_string( $value ) && TimeUtil::is_valid_date( $value, 'Y-m-d' ) ) {
- // Parsed in the site timezone so the stored calendar date cannot shift a day when it is formatted.
- $date = \DateTime::createFromFormat( '!Y-m-d', $value, wp_timezone() );
-
- if ( $date ) {
- $value = wp_date( wc_date_format(), $date->getTimestamp() );
- }
- }
+ return $this->get_field_type( $field )->format_value( $value, $field );
+ }
- return $value;
+ /**
+ * Applies type-specific arguments to a field before it is rendered with woocommerce_form_field().
+ *
+ * Used by the server-rendered My Account forms: maps select options and sets checkbox submit values.
+ *
+ * @param array $form_field The woocommerce_form_field() arguments built from the field.
+ * @return array The updated arguments.
+ */
+ public function prepare_form_field( array $form_field ): array {
+ return $this->get_field_type( $form_field )->prepare_form_field( $form_field );
}
/**
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
index 4e2c3a2d2e9..5ff7f251fc5 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
@@ -172,14 +172,7 @@ class CheckoutFieldsFrontend {
$form_field['id'] = $field_key;
$form_field['value'] = $this->checkout_fields_controller->get_field_from_object( $key, $customer, 'contact' );
- if ( 'select' === $field['type'] ) {
- $form_field['options'] = array_column( $field['options'], 'label', 'value' );
- }
-
- if ( 'checkbox' === $field['type'] ) {
- $form_field['checked_value'] = '1';
- $form_field['unchecked_value'] = '0';
- }
+ $form_field = $this->checkout_fields_controller->prepare_form_field( $form_field );
woocommerce_form_field( $field_key, $form_field, wc_get_post_data_by_key( $key, $form_field['value'] ) );
}
@@ -205,22 +198,7 @@ class CheckoutFieldsFrontend {
$address[ $field_key ] = $field;
$address[ $field_key ]['value'] = $this->checkout_fields_controller->get_field_from_object( $key, $customer, $address_type );
- if ( 'select' === $field['type'] ) {
- $address[ $field_key ]['options'] = array_column( $field['options'], 'label', 'value' );
-
- // If a placeholder is set, add a placeholder option if it doesn't exist already.
- if (
- ! empty( $address[ $field_key ]['placeholder'] )
- && ! array_key_exists( '', $address[ $field_key ]['options'] )
- ) {
- $address[ $field_key ]['options'] = array( '' => $address[ $field_key ]['placeholder'] ) + $address[ $field_key ]['options'];
- }
- }
-
- if ( 'checkbox' === $field['type'] ) {
- $address[ $field_key ]['checked_value'] = '1';
- $address[ $field_key ]['unchecked_value'] = '0';
- }
+ $address[ $field_key ] = $this->checkout_fields_controller->prepare_form_field( $address[ $field_key ] );
}
return $address;
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
new file mode 100644
index 00000000000..fca1363808f
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsStorage.php
@@ -0,0 +1,209 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services;
+
+use WC_Customer;
+use WC_Data;
+use WC_Order;
+
+/**
+ * Reads, writes, and syncs additional checkout field values stored as order and customer meta.
+ *
+ * Used by CheckoutFields, which owns the field definitions and validation; this trait only covers
+ * where values are persisted and how they are read back.
+ */
+trait CheckoutFieldsStorage {
+
+ /**
+ * Persists a field value for a given order. This would also optionally set the field value on the customer object if the order is linked to a registered customer.
+ *
+ * @param string $key The field key.
+ * @param mixed $value The field value.
+ * @param WC_Order $order The order to persist the field for.
+ * @param string $group The group to persist the field for (shipping|billing|other).
+ * @param bool $set_customer Whether to set the field value on the customer or not.
+ *
+ * @return void
+ */
+ public function persist_field_for_order( string $key, $value, WC_Order $order, string $group = 'other', bool $set_customer = true ) {
+ $group = $this->prepare_group_name( $group );
+ $this->set_array_meta( $key, $value, $order, $group );
+ if ( $set_customer && $order->get_customer_id() ) {
+ $customer = new WC_Customer( $order->get_customer_id() );
+ $this->persist_field_for_customer( $key, $value, $customer, $group );
+ }
+ }
+
+ /**
+ * Persists a field value for a given customer.
+ *
+ * @param string $key The field key.
+ * @param mixed $value The field value.
+ * @param WC_Customer $customer The customer to persist the field for.
+ * @param string $group The group to persist the field for (shipping|billing|other).
+ *
+ * @return void
+ */
+ public function persist_field_for_customer( string $key, $value, WC_Customer $customer, string $group = 'other' ) {
+ $group = $this->prepare_group_name( $group );
+ $this->set_array_meta( $key, $value, $customer, $group );
+ }
+
+ /**
+ * Sets a field value in an array meta, supporting routing things to billing, shipping, or additional fields, based on a prefix for the key.
+ *
+ * @param string $key The field key.
+ * @param mixed $value The field value.
+ * @param WC_Customer|WC_Order $wc_object The object to set the field value for.
+ * @param string $group The group to set the field value for (shipping|billing|other).
+ *
+ * @return void
+ */
+ private function set_array_meta( string $key, $value, WC_Data $wc_object, string $group ) {
+ $meta_key = self::get_group_key( $group ) . $key;
+
+ /**
+ * Allow reacting for saving an additional field value.
+ *
+ * @param string $key The key of the field being saved.
+ * @param mixed $value The value of the field being saved.
+ * @param string $group The group of this location (shipping|billing|other).
+ * @param WC_Customer|WC_Order $wc_object The object to set the field value for.
+ *
+ * @since 8.9.0
+ */
+ do_action( 'woocommerce_set_additional_field_value', $key, $value, $group, $wc_object );
+ // Convert boolean values to strings because Data Stores will skip false values.
+ if ( is_bool( $value ) ) {
+ $value = $value ? '1' : '0';
+ }
+ $wc_object->update_meta_data( $meta_key, $value );
+ }
+
+ /**
+ * Returns a field value for a given object.
+ *
+ * @param string $key The field key.
+ * @param WC_Customer|WC_Order $wc_object The customer or order to get the field value for.
+ * @param string $group The group to get the field value for (shipping|billing|other).
+ *
+ * @return mixed The field value.
+ */
+ public function get_field_from_object( string $key, WC_Data $wc_object, string $group = 'other' ) {
+ $group = $this->prepare_group_name( $group );
+ $meta_key = self::get_group_key( $group ) . $key;
+ $value = $wc_object->get_meta( $meta_key, true );
+
+ if ( ! $value && '0' !== $value ) {
+ /**
+ * Allow providing a default value for additional fields if no value is already set.
+ *
+ * @param null $value The default value for the filter, always null.
+ * @param string $group The group of this key (shipping|billing|other).
+ * @param WC_Data $wc_object The object to get the field value for.
+ *
+ * @since 8.9.0
+ */
+ $value = apply_filters( "woocommerce_get_default_value_for_{$key}", null, $group, $wc_object );
+ }
+
+ // We cast the value to a boolean if the field is a checkbox.
+ if ( $this->is_field( $key ) && 'checkbox' === $this->additional_fields[ $key ]['type'] ) {
+ return '1' === $value;
+ }
+
+ if ( null === $value ) {
+ return '';
+ }
+
+ return $value;
+ }
+
+ /**
+ * Returns an array of all fields values for a given object in a group.
+ *
+ * @param WC_Data $wc_object The object or order to get the fields for.
+ * @param string $group The group to get the fields for (shipping|billing|other).
+ * @param bool $all Whether to return all fields or only the ones that are still registered. Default false.
+ * @return array An array of fields.
+ */
+ public function get_all_fields_from_object( WC_Data $wc_object, string $group = 'other', bool $all = false ) {
+ $meta_data = [];
+ $group = $this->prepare_group_name( $group );
+ $prefix = self::get_group_key( $group );
+
+ $meta = $wc_object->get_meta_data();
+ foreach ( $meta as $meta_data_object ) {
+ if ( 0 === \strpos( $meta_data_object->key, $prefix ) ) {
+ $key = \str_replace( $prefix, '', $meta_data_object->key );
+ if ( $all || $this->is_field( $key ) ) {
+ $meta_data[ $key ] = $meta_data_object->value;
+ }
+ }
+ }
+
+ $missing_fields = array_diff( array_keys( $this->get_fields_for_group( $group ) ), array_keys( $meta_data ) );
+
+ foreach ( $missing_fields as $missing_field ) {
+ /**
+ * Allow providing a default value for additional fields if no value is already set.
+ *
+ * @param null $value The default value for the filter, always null.
+ * @param string $group The group of this key (shipping|billing|other).
+ * @param WC_Data $wc_object The object to get the field value for.
+ *
+ * @since 8.9.0
+ */
+ $value = apply_filters( "woocommerce_get_default_value_for_{$missing_field}", null, $group, $wc_object );
+
+ if ( isset( $value ) ) {
+ $meta_data[ $missing_field ] = $value;
+ }
+ }
+
+ return $meta_data;
+ }
+
+ /**
+ * Copies additional fields from an order to a customer.
+ *
+ * @param WC_Order $order The order to sync the fields for.
+ * @param WC_Customer $customer The customer to sync the fields for.
+ *
+ * @return void
+ */
+ public function sync_customer_additional_fields_with_order( WC_Order $order, WC_Customer $customer ) {
+ foreach ( $this->groups as $group ) {
+ $order_additional_fields = $this->get_all_fields_from_object( $order, $group, true );
+
+ // Sync customer additional fields with order additional fields.
+ foreach ( $order_additional_fields as $key => $value ) {
+ if ( $this->is_customer_field( $key ) ) {
+ $this->persist_field_for_customer( $key, $value, $customer, $group );
+ }
+ }
+ }
+ }
+
+ /**
+ * Copies additional fields from a customer to an order.
+ *
+ * @param WC_Order $order The order to sync the fields for.
+ * @param WC_Customer $customer The customer to sync the fields for.
+ *
+ * @return void
+ */
+ public function sync_order_additional_fields_with_customer( WC_Order $order, WC_Customer $customer ) {
+ foreach ( $this->groups as $group ) {
+ $customer_additional_fields = $this->get_all_fields_from_object( $customer, $group, true );
+
+ // Sync order additional fields with customer additional fields.
+ foreach ( $customer_additional_fields as $key => $value ) {
+ if ( $this->is_field( $key ) ) {
+ $this->persist_field_for_order( $key, $value, $order, $group, false );
+ }
+ }
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CoreCheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CoreCheckoutFields.php
new file mode 100644
index 00000000000..6debbc199d2
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CoreCheckoutFields.php
@@ -0,0 +1,176 @@
+<?php
+declare( strict_types = 1);
+
+namespace Automattic\WooCommerce\Blocks\Domain\Services;
+
+use Automattic\WooCommerce\Blocks\Utils\CartCheckoutUtils;
+
+/**
+ * Defines the core checkout fields: the address, contact, and order fields every store starts with.
+ */
+class CoreCheckoutFields {
+
+ /**
+ * Returns the keys of all core fields.
+ *
+ * @return array An array of field keys.
+ */
+ public static function get_keys() {
+ return [
+ 'email',
+ 'country',
+ 'first_name',
+ 'last_name',
+ 'company',
+ 'address_1',
+ 'address_2',
+ 'city',
+ 'state',
+ 'postcode',
+ 'phone',
+ ];
+ }
+
+ /**
+ * Returns an array of all core fields.
+ *
+ * @return array An array of fields.
+ */
+ public static function get_fields() {
+ return [
+ 'email' => [
+ 'label' => __( 'Email address', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'Email address (optional)',
+ 'woocommerce'
+ ),
+ 'required' => true,
+ 'hidden' => false,
+ 'autocomplete' => 'email',
+ 'autocapitalize' => 'none',
+ 'type' => 'email',
+ 'index' => 0,
+ ],
+ 'country' => [
+ 'label' => __( 'Country/Region', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'Country/Region (optional)',
+ 'woocommerce'
+ ),
+ 'required' => true,
+ 'hidden' => false,
+ 'autocomplete' => 'country',
+ 'index' => 1,
+ ],
+ 'first_name' => [
+ 'label' => __( 'First name', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'First name (optional)',
+ 'woocommerce'
+ ),
+ 'required' => true,
+ 'hidden' => false,
+ 'autocomplete' => 'given-name',
+ 'autocapitalize' => 'sentences',
+ 'index' => 10,
+ ],
+ 'last_name' => [
+ 'label' => __( 'Last name', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'Last name (optional)',
+ 'woocommerce'
+ ),
+ 'required' => true,
+ 'hidden' => false,
+ 'autocomplete' => 'family-name',
+ 'autocapitalize' => 'sentences',
+ 'index' => 20,
+ ],
+ 'company' => [
+ 'label' => __( 'Company', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'Company (optional)',
+ 'woocommerce'
+ ),
+ 'required' => 'required' === CartCheckoutUtils::get_company_field_visibility(),
+ 'hidden' => 'hidden' === CartCheckoutUtils::get_company_field_visibility(),
+ 'autocomplete' => 'organization',
+ 'autocapitalize' => 'sentences',
+ 'index' => 30,
+ ],
+ 'address_1' => [
+ 'label' => __( 'Address', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'Address (optional)',
+ 'woocommerce'
+ ),
+ 'required' => true,
+ 'hidden' => false,
+ 'autocomplete' => 'address-line1',
+ 'autocapitalize' => 'sentences',
+ 'index' => 40,
+ ],
+ 'address_2' => [
+ 'label' => __( 'Apartment, suite, etc.', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'Apartment, suite, etc. (optional)',
+ 'woocommerce'
+ ),
+ 'required' => 'required' === CartCheckoutUtils::get_address_2_field_visibility(),
+ 'hidden' => 'hidden' === CartCheckoutUtils::get_address_2_field_visibility(),
+ 'autocomplete' => 'address-line2',
+ 'autocapitalize' => 'sentences',
+ 'index' => 50,
+ ],
+ 'city' => [
+ 'label' => __( 'City', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'City (optional)',
+ 'woocommerce'
+ ),
+ 'required' => true,
+ 'hidden' => false,
+ 'autocomplete' => 'address-level2',
+ 'autocapitalize' => 'sentences',
+ 'index' => 70,
+ ],
+ 'state' => [
+ 'label' => __( 'State/County', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'State/County (optional)',
+ 'woocommerce'
+ ),
+ 'required' => true,
+ 'hidden' => false,
+ 'autocomplete' => 'address-level1',
+ 'autocapitalize' => 'sentences',
+ 'index' => 80,
+ ],
+ 'postcode' => [
+ 'label' => __( 'Postal code', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'Postal code (optional)',
+ 'woocommerce'
+ ),
+ 'required' => true,
+ 'hidden' => false,
+ 'autocomplete' => 'postal-code',
+ 'autocapitalize' => 'characters',
+ 'index' => 90,
+ ],
+ 'phone' => [
+ 'label' => __( 'Phone', 'woocommerce' ),
+ 'optionalLabel' => __(
+ 'Phone (optional)',
+ 'woocommerce'
+ ),
+ 'required' => 'required' === CartCheckoutUtils::get_phone_field_visibility(),
+ 'hidden' => 'hidden' === CartCheckoutUtils::get_phone_field_visibility(),
+ 'type' => 'tel',
+ 'autocomplete' => 'tel',
+ 'autocapitalize' => 'characters',
+ 'index' => 100,
+ ],
+ ];
+ }
+}