diff --git a/assets/js/base/components/cart-checkout/address-form/address-form.tsx b/assets/js/base/components/cart-checkout/address-form/address-form.tsx index aa7c8b0a3cc..b3f90a26006 100644 --- a/assets/js/base/components/cart-checkout/address-form/address-form.tsx +++ b/assets/js/base/components/cart-checkout/address-form/address-form.tsx @@ -17,32 +17,22 @@ import { import { useEffect, useMemo, useRef } from '@wordpress/element'; import { useInstanceId } from '@wordpress/compose'; import { useShallowEqual } from '@woocommerce/base-hooks'; -import { defaultAddressFields } from '@woocommerce/settings'; import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies */ -import { - AddressFormProps, - FieldType, - FieldConfig, - AddressFormFields, -} from './types'; +import { AddressFormProps, FieldConfig, AddressFormFields } from './types'; import prepareAddressFields from './prepare-address-fields'; import validateShippingCountry from './validate-shipping-country'; import customValidationHandler from './custom-validation-handler'; -const defaultFields = Object.keys( - defaultAddressFields -) as unknown as FieldType[]; - /** * Checkout address form. */ const AddressForm = ( { id = '', - fields = defaultFields, + fields, fieldConfig = {} as FieldConfig, onChange, type = 'shipping', diff --git a/assets/js/base/components/cart-checkout/address-form/prepare-address-fields.ts b/assets/js/base/components/cart-checkout/address-form/prepare-address-fields.ts index c73d9437f42..a9e89f88d51 100644 --- a/assets/js/base/components/cart-checkout/address-form/prepare-address-fields.ts +++ b/assets/js/base/components/cart-checkout/address-form/prepare-address-fields.ts @@ -7,7 +7,7 @@ import { AddressField, AddressFields, CountryAddressFields, - defaultAddressFields, + defaultFields, KeyedAddressField, LocaleSpecificAddressField, } from '@woocommerce/settings'; @@ -114,7 +114,7 @@ const prepareAddressFields = ( return fields .map( ( field ) => { - const defaultConfig = defaultAddressFields[ field ] || {}; + const defaultConfig = defaultFields[ field ] || {}; const localeConfig = localeConfigs[ field ] || {}; const fieldConfig = fieldConfigs[ field ] || {}; diff --git a/assets/js/base/components/cart-checkout/address-form/test/index.js b/assets/js/base/components/cart-checkout/address-form/test/index.js index 3b04d30e847..f594641d5c3 100644 --- a/assets/js/base/components/cart-checkout/address-form/test/index.js +++ b/assets/js/base/components/cart-checkout/address-form/test/index.js @@ -5,6 +5,7 @@ import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { CheckoutProvider } from '@woocommerce/base-context'; import { useCheckoutAddress } from '@woocommerce/base-context/hooks'; +import { ADDRESS_FIELDS_KEYS } from '@woocommerce/block-settings'; /** * Internal dependencies @@ -81,15 +82,14 @@ const inputAddress = async ( { describe( 'AddressForm Component', () => { const WrappedAddressForm = ( { type } ) => { - const { defaultAddressFields, setShippingAddress, shippingAddress } = - useCheckoutAddress(); + const { setShippingAddress, shippingAddress } = useCheckoutAddress(); return ( ); }; diff --git a/assets/js/base/context/hooks/use-checkout-address.ts b/assets/js/base/context/hooks/use-checkout-address.ts index 15d52462c21..308318d73d3 100644 --- a/assets/js/base/context/hooks/use-checkout-address.ts +++ b/assets/js/base/context/hooks/use-checkout-address.ts @@ -2,7 +2,7 @@ * External dependencies */ import { - defaultAddressFields, + defaultFields, AddressFields, ShippingAddress, BillingAddress, @@ -26,7 +26,7 @@ interface CheckoutAddress { setEmail: ( value: string ) => void; useShippingAsBilling: boolean; setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void; - defaultAddressFields: AddressFields; + defaultFields: AddressFields; showShippingFields: boolean; showBillingFields: boolean; forcedBillingAddress: boolean; @@ -74,7 +74,7 @@ export const useCheckoutAddress = (): CheckoutAddress => { setShippingAddress, setBillingAddress, setEmail, - defaultAddressFields, + defaultFields, useShippingAsBilling, setUseShippingAsBilling: __internalSetUseShippingAsBilling, needsShipping, diff --git a/assets/js/base/utils/address.ts b/assets/js/base/utils/address.ts index 9a41a7e93eb..dc016722ef8 100644 --- a/assets/js/base/utils/address.ts +++ b/assets/js/base/utils/address.ts @@ -7,16 +7,12 @@ import type { CartResponseBillingAddress, CartResponseShippingAddress, } from '@woocommerce/types'; -import { - AddressFields, - defaultAddressFields, - ShippingAddress, - BillingAddress, -} from '@woocommerce/settings'; +import { ShippingAddress, BillingAddress } from '@woocommerce/settings'; import { decodeEntities } from '@wordpress/html-entities'; import { SHIPPING_COUNTRIES, SHIPPING_STATES, + ADDRESS_FIELDS_KEYS, } from '@woocommerce/block-settings'; /** @@ -26,10 +22,9 @@ export const isSameAddress = < T extends ShippingAddress | BillingAddress >( address1: T, address2: T ): boolean => { - return Object.keys( defaultAddressFields ).every( - ( field: string ) => - address1[ field as keyof T ] === address2[ field as keyof T ] - ); + return Object.keys( ADDRESS_FIELDS_KEYS ).every( ( field: string ) => { + return address1[ field as keyof T ] === address2[ field as keyof T ]; + } ); }; /** @@ -94,10 +89,11 @@ export const emptyHiddenAddressFields = < >( address: T ): T => { - const fields = Object.keys( - defaultAddressFields - ) as ( keyof AddressFields )[]; - const addressFields = prepareAddressFields( fields, {}, address.country ); + const addressFields = prepareAddressFields( + ADDRESS_FIELDS_KEYS, + {}, + address.country + ); const newAddress = Object.assign( {}, address ) as T; addressFields.forEach( ( { key = '', hidden = false } ) => { @@ -160,10 +156,11 @@ export const isAddressComplete = ( if ( ! address.country ) { return false; } - const fields = Object.keys( - defaultAddressFields - ) as ( keyof AddressFields )[]; - const addressFields = prepareAddressFields( fields, {}, address.country ); + const addressFields = prepareAddressFields( + ADDRESS_FIELDS_KEYS, + {}, + address.country + ); return addressFields.every( ( { key = '', hidden = false, required = false } ) => { diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx index 32adfb5400d..2c367eb8e63 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx @@ -11,6 +11,7 @@ import type { } from '@woocommerce/settings'; import { useSelect } from '@wordpress/data'; import { VALIDATION_STORE_KEY } from '@woocommerce/block-data'; +import { ADDRESS_FIELDS_KEYS } from '@woocommerce/block-settings'; /** * Internal dependencies @@ -26,7 +27,6 @@ const CustomerAddress = ( { defaultEditing?: boolean; } ) => { const { - defaultAddressFields, billingAddress, setShippingAddress, setBillingAddress, @@ -58,10 +58,6 @@ const CustomerAddress = ( { } }, [ editing, hasValidationErrors, invalidProps.length ] ); - const addressFieldKeys = Object.keys( - defaultAddressFields - ) as ( keyof AddressFields )[]; - const onChangeAddress = useCallback( ( values: Partial< BillingAddress > ) => { setBillingAddress( values ); @@ -101,17 +97,12 @@ const CustomerAddress = ( { type="billing" onChange={ onChangeAddress } values={ billingAddress } - fields={ addressFieldKeys } + fields={ ADDRESS_FIELDS_KEYS } fieldConfig={ addressFieldsConfig } /> ), - [ - addressFieldKeys, - addressFieldsConfig, - billingAddress, - onChangeAddress, - ] + [ addressFieldsConfig, billingAddress, onChangeAddress ] ); return ( diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx index cd327901d0c..ce9473b7a39 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx @@ -11,6 +11,7 @@ import type { } from '@woocommerce/settings'; import { useSelect } from '@wordpress/data'; import { VALIDATION_STORE_KEY } from '@woocommerce/block-data'; +import { ADDRESS_FIELDS_KEYS } from '@woocommerce/block-settings'; /** * Internal dependencies @@ -26,7 +27,6 @@ const CustomerAddress = ( { defaultEditing?: boolean; } ) => { const { - defaultAddressFields, shippingAddress, setShippingAddress, setBillingAddress, @@ -57,9 +57,6 @@ const CustomerAddress = ( { } }, [ editing, hasValidationErrors, invalidProps.length ] ); - const addressFieldKeys = Object.keys( - defaultAddressFields - ) as ( keyof AddressFields )[]; const onChangeAddress = useCallback( ( values: Partial< ShippingAddress > ) => { setShippingAddress( values ); @@ -98,16 +95,11 @@ const CustomerAddress = ( { type="shipping" onChange={ onChangeAddress } values={ shippingAddress } - fields={ addressFieldKeys } + fields={ ADDRESS_FIELDS_KEYS } fieldConfig={ addressFieldsConfig } /> ), - [ - addressFieldKeys, - addressFieldsConfig, - onChangeAddress, - shippingAddress, - ] + [ addressFieldsConfig, onChangeAddress, shippingAddress ] ); return ( diff --git a/assets/js/data/cart/default-state.ts b/assets/js/data/cart/default-state.ts index 8ce98387a77..dc1f0e55e12 100644 --- a/assets/js/data/cart/default-state.ts +++ b/assets/js/data/cart/default-state.ts @@ -1,7 +1,14 @@ /** * External dependencies */ -import type { Cart, CartMeta, ApiErrorResponse } from '@woocommerce/types'; +import type { + Cart, + CartMeta, + ApiErrorResponse, + CartShippingAddress, + CartBillingAddress, +} from '@woocommerce/types'; +import { AddressField, defaultFields } from '@woocommerce/settings'; /** * Internal dependencies @@ -30,37 +37,33 @@ export interface CartState { metaData: CartMeta; errors: ApiErrorResponse[]; } + +const shippingAddress: Partial< + CartShippingAddress & { email: AddressField } +> = {}; +Object.keys( defaultFields ).forEach( ( key ) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore the default fields contain keys for each field. + shippingAddress[ key ] = ''; +} ); +delete shippingAddress.email; + +const billingAddress: Partial< CartBillingAddress & { email: AddressField } > = + {}; +Object.keys( defaultFields ).forEach( ( key ) => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore the default fields contain keys for each field. + billingAddress[ key ] = ''; +} ); + export const defaultCartState: CartState = { cartItemsPendingQuantity: EMPTY_PENDING_QUANTITY, cartItemsPendingDelete: EMPTY_PENDING_DELETE, cartData: { coupons: EMPTY_CART_COUPONS, shippingRates: EMPTY_SHIPPING_RATES, - shippingAddress: { - first_name: '', - last_name: '', - company: '', - address_1: '', - address_2: '', - city: '', - state: '', - postcode: '', - country: '', - phone: '', - }, - billingAddress: { - first_name: '', - last_name: '', - company: '', - address_1: '', - address_2: '', - city: '', - state: '', - postcode: '', - country: '', - phone: '', - email: '', - }, + shippingAddress: shippingAddress as CartShippingAddress, + billingAddress: billingAddress as CartBillingAddress, items: EMPTY_CART_ITEMS, itemsCount: 0, itemsWeight: 0, diff --git a/assets/js/settings/blocks/constants.ts b/assets/js/settings/blocks/constants.ts index 8436f26424c..9cff6d62f28 100644 --- a/assets/js/settings/blocks/constants.ts +++ b/assets/js/settings/blocks/constants.ts @@ -57,6 +57,12 @@ type CountryData = { locale: Record< string, LocaleSpecificAddressField >; }; +type FieldsLocations = { + address: string[]; + contact: string[]; + additional: string[]; +}; + // Contains country names. const countries = getSetting< Record< string, string > >( 'countries', {} ); @@ -111,3 +117,35 @@ export const COUNTRY_LOCALE = Object.fromEntries( return [ countryCode, countryData[ countryCode ].locale || [] ]; } ) ); + +const defaultFieldsLocations: FieldsLocations = { + address: [ + 'first_name', + 'last_name', + 'company', + 'address_1', + 'address_2', + 'city', + 'postcode', + 'country', + 'state', + 'phone', + ], + contact: [ 'email' ], + additional: [], +}; + +export const ADDRESS_FIELDS_KEYS = getSetting< FieldsLocations >( + 'addressFieldsLocations', + defaultFieldsLocations +).address; + +export const CONTACT_FIELDS_KEYS = getSetting< FieldsLocations >( + 'addressFieldsLocations', + defaultFieldsLocations +).contact; + +export const ADDITIONAL_FIELDS_KEYS = getSetting< FieldsLocations >( + 'addressFieldsLocations', + defaultFieldsLocations +).additional; diff --git a/assets/js/settings/shared/default-address-fields.ts b/assets/js/settings/shared/default-address-fields.ts deleted file mode 100644 index 64c9a64ee2b..00000000000 --- a/assets/js/settings/shared/default-address-fields.ts +++ /dev/null @@ -1,184 +0,0 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; - -export interface AddressField { - // The label for the field. - label: string; - // The label for the field if made optional. - optionalLabel: string; - // The HTML autocomplete attribute value. See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete - autocomplete: string; - // How this field value is capitalized. - autocapitalize?: string; - // Set to true if the field is required. - required: boolean; - // Set to true if the field should not be rendered. - hidden: boolean; - // Fields will be sorted and render in this order, lowest to highest. - index: number; - // The type of input to render. Defaults to text. - type?: string; -} - -export interface LocaleSpecificAddressField extends Partial< AddressField > { - priority?: number | undefined; -} - -export interface AddressFields { - first_name: AddressField; - last_name: AddressField; - company: AddressField; - address_1: AddressField; - address_2: AddressField; - country: AddressField; - city: AddressField; - state: AddressField; - postcode: AddressField; - phone: AddressField; -} - -export type AddressType = 'billing' | 'shipping'; -export interface ShippingAddress { - first_name: string; - last_name: string; - company: string; - address_1: string; - address_2: string; - country: string; - city: string; - state: string; - postcode: string; - phone: string; -} - -export type KeyedAddressField = AddressField & { - key: keyof AddressFields; - errorMessage?: string; -}; -export interface BillingAddress extends ShippingAddress { - email: string; -} -export type CountryAddressFields = Record< string, AddressFields >; - -/** - * Default address field properties. - */ -export const defaultAddressFields: AddressFields = { - first_name: { - label: __( 'First name', 'woo-gutenberg-products-block' ), - optionalLabel: __( - 'First name (optional)', - 'woo-gutenberg-products-block' - ), - autocomplete: 'given-name', - autocapitalize: 'sentences', - required: true, - hidden: false, - index: 10, - }, - last_name: { - label: __( 'Last name', 'woo-gutenberg-products-block' ), - optionalLabel: __( - 'Last name (optional)', - 'woo-gutenberg-products-block' - ), - autocomplete: 'family-name', - autocapitalize: 'sentences', - required: true, - hidden: false, - index: 20, - }, - company: { - label: __( 'Company', 'woo-gutenberg-products-block' ), - optionalLabel: __( - 'Company (optional)', - 'woo-gutenberg-products-block' - ), - autocomplete: 'organization', - autocapitalize: 'sentences', - required: false, - hidden: false, - index: 30, - }, - address_1: { - label: __( 'Address', 'woo-gutenberg-products-block' ), - optionalLabel: __( - 'Address (optional)', - 'woo-gutenberg-products-block' - ), - autocomplete: 'address-line1', - autocapitalize: 'sentences', - required: true, - hidden: false, - index: 40, - }, - address_2: { - label: __( 'Apartment, suite, etc.', 'woo-gutenberg-products-block' ), - optionalLabel: __( - 'Apartment, suite, etc. (optional)', - 'woo-gutenberg-products-block' - ), - autocomplete: 'address-line2', - autocapitalize: 'sentences', - required: false, - hidden: false, - index: 50, - }, - country: { - label: __( 'Country/Region', 'woo-gutenberg-products-block' ), - optionalLabel: __( - 'Country/Region (optional)', - 'woo-gutenberg-products-block' - ), - autocomplete: 'country', - required: true, - hidden: false, - index: 60, - }, - city: { - label: __( 'City', 'woo-gutenberg-products-block' ), - optionalLabel: __( 'City (optional)', 'woo-gutenberg-products-block' ), - autocomplete: 'address-level2', - autocapitalize: 'sentences', - required: true, - hidden: false, - index: 70, - }, - state: { - label: __( 'State/County', 'woo-gutenberg-products-block' ), - optionalLabel: __( - 'State/County (optional)', - 'woo-gutenberg-products-block' - ), - autocomplete: 'address-level1', - autocapitalize: 'sentences', - required: true, - hidden: false, - index: 80, - }, - postcode: { - label: __( 'Postal code', 'woo-gutenberg-products-block' ), - optionalLabel: __( - 'Postal code (optional)', - 'woo-gutenberg-products-block' - ), - autocomplete: 'postal-code', - autocapitalize: 'characters', - required: true, - hidden: false, - index: 90, - }, - phone: { - label: __( 'Phone', 'woo-gutenberg-products-block' ), - optionalLabel: __( 'Phone (optional)', 'woo-gutenberg-products-block' ), - autocomplete: 'tel', - type: 'tel', - required: true, - hidden: false, - index: 100, - }, -}; - -export default defaultAddressFields; diff --git a/assets/js/settings/shared/default-fields.ts b/assets/js/settings/shared/default-fields.ts new file mode 100644 index 00000000000..2a547f3760a --- /dev/null +++ b/assets/js/settings/shared/default-fields.ts @@ -0,0 +1,76 @@ +/** + * Internal dependencies + */ +import { getSetting } from './utils'; + +export interface AddressField { + // The label for the field. + label: string; + // The label for the field if made optional. + optionalLabel: string; + // The HTML autocomplete attribute value. See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes/autocomplete + autocomplete: string; + // How this field value is capitalized. + autocapitalize?: string; + // Set to true if the field is required. + required: boolean; + // Set to true if the field should not be rendered. + hidden: boolean; + // Fields will be sorted and render in this order, lowest to highest. + index: number; + // The type of input to render. Defaults to text. + type?: string; +} + +export interface LocaleSpecificAddressField extends Partial< AddressField > { + priority?: number | undefined; +} + +export interface CoreAddressFields { + first_name: AddressField; + last_name: AddressField; + company: AddressField; + address_1: AddressField; + address_2: AddressField; + country: AddressField; + city: AddressField; + state: AddressField; + postcode: AddressField; + phone: AddressField; +} + +export type AddressFields = CoreAddressFields & Record< string, AddressField >; + +export type AddressType = 'billing' | 'shipping'; + +export interface CoreAddress { + first_name: string; + last_name: string; + company: string; + address_1: string; + address_2: string; + country: string; + city: string; + state: string; + postcode: string; + phone: string; +} + +export type ShippingAddress = CoreAddress & Record< string, string >; + +export type KeyedAddressField = AddressField & { + key: keyof AddressFields; + errorMessage?: string; +}; +export interface BillingAddress extends ShippingAddress { + email: string; +} +export type CountryAddressFields = Record< string, AddressFields >; + +/** + * Default field properties. + */ +export const defaultFields: AddressFields = + getSetting< AddressFields >( 'defaultFields' ); + +export default defaultFields; diff --git a/assets/js/settings/shared/index.ts b/assets/js/settings/shared/index.ts index 174a13c8cb1..dc4f1ac52bd 100644 --- a/assets/js/settings/shared/index.ts +++ b/assets/js/settings/shared/index.ts @@ -4,6 +4,6 @@ import '../../filters/exclude-draft-status-from-analytics'; export * from './default-constants'; -export * from './default-address-fields'; +export * from './default-fields'; export * from './utils'; export { allSettings } from './settings-init'; diff --git a/composer.json b/composer.json index c42d8f8411f..7404cc8bffd 100644 --- a/composer.json +++ b/composer.json @@ -37,7 +37,8 @@ }, "files": [ "src/StoreApi/deprecated.php", - "src/StoreApi/functions.php" + "src/StoreApi/functions.php", + "src/Domain/Services/functions.php" ] }, "autoload-dev": { @@ -69,4 +70,4 @@ "phpcbf": "Fix coding standards warnings/errors automatically with PHP Code Beautifier" } } -} +} \ No newline at end of file diff --git a/docs/internal-developers/block-client-apis/checkout/checkout-flow-and-events.md b/docs/internal-developers/block-client-apis/checkout/checkout-flow-and-events.md index 15b5c73eff7..757a64425d2 100644 --- a/docs/internal-developers/block-client-apis/checkout/checkout-flow-and-events.md +++ b/docs/internal-developers/block-client-apis/checkout/checkout-flow-and-events.md @@ -276,8 +276,8 @@ const successResponse = { type: 'success' }; When a success response is returned, the payment method context status will be changed to `SUCCESS`. In addition, including any of the additional properties will result in extra actions: - `paymentMethodData`: The contents of this object will be included as the value for `payment_data` when checkout sends a request to the checkout endpoint for processing the order. This is useful if a payment method does additional server side processing. -- `billingAddress`: This allows payment methods to update any billing data information in the checkout (typically used by Express payment methods) so it's included in the checkout processing request to the server. This data should be in the [shape outlined here](../../../../assets/js/settings/shared/default-address-fields.ts). -- `shippingAddress`: This allows payment methods to update any shipping data information for the order (typically used by Express payment methods) so it's included in the checkout processing request to the server. This data should be in the [shape outlined here](../../../../assets/js/settings/shared/default-address-fields.ts). +- `billingAddress`: This allows payment methods to update any billing data information in the checkout (typically used by Express payment methods) so it's included in the checkout processing request to the server. This data should be in the [shape outlined here](../../../../assets/js/settings/shared/default-fields.ts). +- `shippingAddress`: This allows payment methods to update any shipping data information for the order (typically used by Express payment methods) so it's included in the checkout processing request to the server. This data should be in the [shape outlined here](../../../../assets/js/settings/shared/default-fields.ts). If `billingAddress` or `shippingAddress` properties aren't in the response object, then the state for the data is left alone. diff --git a/phpcs.xml b/phpcs.xml index fd69c7936f6..51ada14dae7 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -13,7 +13,7 @@ (see https://github.com/woocommerce/woocommerce-blocks/blob/trunk/.github/release-initial-checklist.md#initial-preparation) --> - + diff --git a/src/Domain/Bootstrap.php b/src/Domain/Bootstrap.php index 4b40fd32ee6..0e10312de84 100644 --- a/src/Domain/Bootstrap.php +++ b/src/Domain/Bootstrap.php @@ -13,6 +13,7 @@ use Automattic\WooCommerce\Blocks\Domain\Services\FeatureGating; use Automattic\WooCommerce\Blocks\Domain\Services\GoogleAnalytics; use Automattic\WooCommerce\Blocks\Domain\Services\Hydration; +use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; use Automattic\WooCommerce\Blocks\InboxNotifications; use Automattic\WooCommerce\Blocks\Installer; use Automattic\WooCommerce\Blocks\Migration; @@ -131,6 +132,7 @@ function() { $this->container->get( CreateAccount::class )->init(); $this->container->get( ShippingController::class )->init(); $this->container->get( TasksController::class )->init(); + $this->container->get( CheckoutFields::class ); // Load assets in admin and on the frontend. if ( ! $is_rest ) { @@ -139,6 +141,7 @@ function() { $this->container->get( AssetsController::class ); $this->container->get( Installer::class )->init(); $this->container->get( GoogleAnalytics::class )->init(); + $this->container->get( CheckoutFields::class )->init(); } // Load assets unless this is a request specifically for the store API. @@ -171,9 +174,9 @@ protected function has_core_dependencies() { if ( $has_needed_dependencies ) { $plugin_data = \get_file_data( $this->package->get_path( 'woocommerce-gutenberg-products-block.php' ), - [ + array( 'RequiredWCVersion' => 'WC requires at least', - ] + ) ); if ( isset( $plugin_data['RequiredWCVersion'] ) && version_compare( \WC()->version, $plugin_data['RequiredWCVersion'], '<' ) ) { $has_needed_dependencies = false; @@ -376,6 +379,12 @@ function( Container $container ) { return new Hydration( $container->get( AssetDataRegistry::class ) ); } ); + $this->container->register( + CheckoutFields::class, + function( Container $container ) { + return new CheckoutFields( $container->get( AssetDataRegistry::class ) ); + } + ); $this->container->register( PaymentsApi::class, function ( Container $container ) { diff --git a/src/Domain/Services/CheckoutFields.php b/src/Domain/Services/CheckoutFields.php new file mode 100644 index 00000000000..a101c3b84bc --- /dev/null +++ b/src/Domain/Services/CheckoutFields.php @@ -0,0 +1,669 @@ +asset_data_registry = $asset_data_registry; + $this->core_fields = array( + 'email' => array( + 'label' => __( 'Email address', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'Email address (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => true, + 'hidden' => false, + 'autocomplete' => 'email', + 'autocapitalize' => 'none', + 'index' => 0, + ), + 'first_name' => array( + 'label' => __( 'First name', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'First name (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => true, + 'hidden' => false, + 'autocomplete' => 'given-name', + 'autocapitalize' => 'sentences', + 'index' => 10, + ), + 'last_name' => array( + 'label' => __( 'Last name', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'Last name (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => true, + 'hidden' => false, + 'autocomplete' => 'family-name', + 'autocapitalize' => 'sentences', + 'index' => 20, + ), + 'company' => array( + 'label' => __( 'Company', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'Company (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => false, + 'hidden' => false, + 'autocomplete' => 'organization', + 'autocapitalize' => 'sentences', + 'index' => 30, + ), + 'address_1' => array( + 'label' => __( 'Address', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'Address (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => true, + 'hidden' => false, + 'autocomplete' => 'address-line1', + 'autocapitalize' => 'sentences', + 'index' => 40, + ), + 'address_2' => array( + 'label' => __( 'Apartment, suite, etc.', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'Apartment, suite, etc. (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => false, + 'hidden' => false, + 'autocomplete' => 'address-line2', + 'autocapitalize' => 'sentences', + 'index' => 50, + ), + 'country' => array( + 'label' => __( 'Country/Region', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'Country/Region (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => true, + 'hidden' => false, + 'autocomplete' => 'country', + 'index' => 50, + ), + 'city' => array( + 'label' => __( 'City', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'City (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => true, + 'hidden' => false, + 'autocomplete' => 'address-level2', + 'autocapitalize' => 'sentences', + 'index' => 70, + ), + 'state' => array( + 'label' => __( 'State/County', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'State/County (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => true, + 'hidden' => false, + 'autocomplete' => 'address-level1', + 'autocapitalize' => 'sentences', + 'index' => 80, + ), + 'postcode' => array( + 'label' => __( 'Postal code', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'Postal code (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => true, + 'hidden' => false, + 'autocomplete' => 'postal-code', + 'autocapitalize' => 'characters', + 'index' => 90, + ), + 'phone' => array( + 'label' => __( 'Phone', 'woo-gutenberg-products-block' ), + 'optionalLabel' => __( + 'Phone (optional)', + 'woo-gutenberg-products-block' + ), + 'required' => false, + 'hidden' => false, + 'type' => 'tel', + 'autocomplete' => 'tel', + 'autocapitalize' => 'characters', + 'index' => 100, + ), + ); + + $this->fields_locations = array( + // omit email from shipping and billing fields. + 'address' => array_merge( \array_diff_key( array_keys( $this->core_fields ), array( 'email' ) ) ), + 'contact' => array( 'email' ), + 'additional' => array(), + ); + + add_filter( 'woocommerce_get_country_locale_default', array( $this, 'update_default_locale_with_fields' ) ); + } + + /** + * Initialize hooks. This is not run Store API requests. + */ + public function init() { + add_action( 'woocommerce_blocks_checkout_enqueue_data', array( $this, 'add_fields_data' ) ); + } + + /** + * Add fields data to the asset data registry. + */ + public function add_fields_data() { + $this->asset_data_registry->add( 'defaultFields', array_merge( $this->get_core_fields(), $this->get_additional_fields() ), true ); + $this->asset_data_registry->add( 'addressFieldsLocations', $this->fields_locations, true ); + } + + /** + * Registers an additional field for Checkout. + * + * @param array $options The field options. + * + * @return \WP_Error|void True if the field was registered, a WP_Error otherwise. + */ + public function register_checkout_field( $options ) { + if ( empty( $options['id'] ) ) { + return new \WP_Error( 'woocommerce_blocks_checkout_field_id_required', __( 'The field id is required.', 'woo-gutenberg-products-block' ) ); + } + + list( $namespace, $name ) = explode( '/', $options['id'] ); + + // Having $name empty means they didn't pass a namespace. + if ( empty( $name ) ) { + return new \WP_Error( 'woocommerce_blocks_checkout_field_namespace_required', __( 'An id must consist of namespace/name.', 'woo-gutenberg-products-block' ) ); + } + + if ( empty( $options['label'] ) ) { + return new \WP_Error( 'woocommerce_blocks_checkout_field_label_required', __( 'The field label is required.', 'woo-gutenberg-products-block' ) ); + } + + if ( empty( $options['location'] ) ) { + return new \WP_Error( 'woocommerce_blocks_checkout_field_location_required', __( 'The field location is required.', 'woo-gutenberg-products-block' ) ); + } + + if ( ! in_array( $options['location'], array_keys( $this->fields_locations ), true ) ) { + return new \WP_Error( 'woocommerce_blocks_checkout_field_location_invalid', __( 'The field location is invalid.', 'woo-gutenberg-products-block' ) ); + } + + // At this point, the essentials fields and its location should be set. + $location = $options['location']; + $id = $options['id']; + // Check to see if field is already in the array. + if ( ! empty( $this->additional_fields[ $id ] ) || in_array( $id, $this->fields_locations[ $location ], true ) ) { + return new \WP_Error( 'woocommerce_blocks_checkout_field_already_registered', __( 'The field is already registered.', 'woo-gutenberg-products-block' ) ); + } + + // Hidden fields are not supported right now. They will be registered with hidden => false. + if ( ! empty( $options['hidden'] ) && true === $options['hidden'] ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_trigger_error + trigger_error( sprintf( 'Registering a field with hidden set to true is not supported. The field "%s" will be registered as visible.', esc_html( $id ) ), E_USER_WARNING ); + } + + // Insert new field into the correct location array. + $this->additional_fields[ $id ] = array( + 'label' => $options['label'], + 'hidden' => false, + 'optionalLabel' => empty( $options['optionalLabel'] ) ? '' : $options['optionalLabel'], + 'required' => empty( $options['required'] ) ? false : $options['required'], + 'autocomplete' => empty( $options['autocomplete'] ) ? '' : $options['autocomplete'], + 'autocapitalize' => empty( $options['autocapitalize'] ) ? '' : $options['autocapitalize'], + ); + + $this->fields_locations[ $location ][] = $id; + } + + /** + * Returns an array of all core fields. + * + * @return array An array of fields. + */ + public function get_core_fields() { + return $this->core_fields; + } + + /** + * Returns an array of all additional fields. + * + * @return array An array of fields. + */ + public function get_additional_fields() { + return $this->additional_fields; + } + + /** + * Update the default locale with additional fields without country limitations. + * + * @param array $locale The locale to update. + * @return mixed + */ + public function update_default_locale_with_fields( $locale ) { + foreach ( $this->fields_locations['address'] as $field_id => $additional_field ) { + if ( empty( $locale[ $field_id ] ) ) { + $locale[ $field_id ] = $additional_field; + } + } + return $locale; + } + + /** + * Returns an array of fields keys for the address group. + * + * @return array An array of fields keys. + */ + public function get_address_fields_keys() { + return $this->fields_locations['address']; + } + + /** + * Returns an array of fields keys for the contact group. + * + * @return array An array of fields keys. + */ + public function get_contact_fields_keys() { + return $this->fields_locations['contact']; + } + + /** + * Returns an array of fields keys for the additional area group. + * + * @return array An array of fields keys. + */ + public function get_additional_fields_keys() { + return $this->fields_locations['additional']; + } + + /** + * Returns an array of fields for a given group. + * + * @param string $location The location to get fields for (address|contact|additional). + * + * @return array An array of fields. + */ + public function get_fields_for_location( $location ) { + if ( in_array( $location, array_keys( $this->fields_locations ), true ) ) { + return $this->fields_locations[ $location ]; + } + } + + /** + * Validates a field value for a given group. + * + * @param string $key The field key. + * @param mixed $value The field value. + * @param string $location The gslocation to validate the field for (address|contact|additional). + * + * @return true|\WP_Error True if the field is valid, a WP_Error otherwise. + */ + public function validate_field_for_location( $key, $value, $location ) { + if ( ! $this->is_field( $key ) ) { + // translators: %s field key. + return new \WP_Error( 'woocommerce_blocks_checkout_field_invalid', \sprintf( __( 'The field %s is invalid.', 'woo-gutenberg-products-block' ), $key ) ); + } + + if ( ! in_array( $key, $this->fields_locations[ $location ], true ) ) { + // translators: %1$s field key, %2$s location. + return new \WP_Error( 'woocommerce_blocks_checkout_field_invalid_location', \sprintf( __( 'The field %1$s is invalid for the location %2$s.', 'woo-gutenberg-products-block' ), $key, $location ) ); + } + + $field = $this->additional_fields[ $key ]; + if ( ! empty( $field['required'] ) && empty( $value ) ) { + // translators: %s field key. + return new \WP_Error( 'woocommerce_blocks_checkout_field_required', \sprintf( __( 'The field %s is required.', 'woo-gutenberg-products-block' ), $key ) ); + } + + return true; + } + + /** + * Returns true if the given key is a valid field. + * + * @param string $key The field key. + * + * @return bool True if the field is valid, false otherwise. + */ + public function is_field( $key ) { + return array_key_exists( $key, $this->additional_fields ); + } + + /** + * Persists a field value for a given order. This would also optionally set the field value on the 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 bool $set_customer Whether to set the field value on the customer or not. + * + * @return void + */ + public function persist_field_for_order( $key, $value, $order, $set_customer = true ) { + $this->set_array_meta( $key, $value, $order ); + if ( $set_customer ) { + if ( isset( wc()->customer ) ) { + $this->set_array_meta( $key, $value, wc()->customer ); + } elseif ( $order->get_customer_id() ) { + $customer = new \WC_Customer( $order->get_customer_id() ); + $this->set_array_meta( $key, $value, $customer ); + } + } + } + + /** + * 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. + * + * @return void + */ + public function persist_field_for_customer( $key, $value, $customer ) { + $this->set_array_meta( $key, $value, $customer ); + } + + /** + * 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 $object The object to set the field value for. + * + * @return void + */ + private function set_array_meta( $key, $value, $object ) { + $meta_key = ''; + + if ( 0 === strpos( $key, '/billing/' ) ) { + $meta_key = self::BILLING_FIELDS_KEY; + $key = str_replace( '/billing/', '', $key ); + } elseif ( 0 === strpos( $key, '/shipping/' ) ) { + $meta_key = self::SHIPPING_FIELDS_KEY; + $key = str_replace( '/shipping/', '', $key ); + } else { + $meta_key = self::ADDITIONAL_FIELDS_KEY; + } + + if ( $object instanceof \WC_Customer ) { + if ( ! $object->get_id() ) { + $meta_data = wc()->session->get( $meta_key, array() ); + } else { + $meta_data = get_user_meta( $object->get_id(), $meta_key, true ); + } + } elseif ( $object instanceof \WC_Order ) { + $meta_data = $object->get_meta( $meta_key, true ); + } + + if ( ! is_array( $meta_data ) ) { + $meta_data = array(); + } + + $meta_data[ $key ] = $value; + if ( $object instanceof \WC_Customer ) { + if ( ! $object->get_id() ) { + wc()->session->set( $meta_key, $meta_data ); + } else { + update_user_meta( $object->get_id(), $meta_key, $meta_data ); + } + } elseif ( $object instanceof \WC_Order ) { + $object->update_meta_data( $meta_key, $meta_data ); + } + + } + + /** + * Returns a field value for a given object. + * + * @param string $key The field key. + * @param \WC_Customer $customer The customer to get the field value for. + * @param string $group The group to get the field value for (shipping|billing|'') in which '' refers to the additional group. + * + * @return mixed The field value. + */ + public function get_field_from_customer( $key, $customer, $group = '' ) { + return $this->get_field_from_object( $key, $customer, $group ); + } + + /** + * Returns a field value for a given order. + * + * @param string $field The field key. + * @param \WC_Order $order The order to get the field value for. + * @param string $group The group to get the field value for (shipping|billing|'') in which '' refers to the additional group. + * + * @return mixed The field value. + */ + public function get_field_from_order( $field, $order, $group = '' ) { + return $this->get_field_from_object( $field, $order, $group ); + } + + /** + * Returns a field value for a given object. + * + * @param string $key The field key. + * @param \WC_Customer|\WC_Order $object The customer to get the field value for. + * @param string $group The group to get the field value for (shipping|billing|'') in which '' refers to the additional group. + * + * @return mixed The field value. + */ + private function get_field_from_object( $key, $object, $group = '' ) { + $meta_key = ''; + if ( 0 === strpos( $key, '/billing/' ) || 'billing' === $group ) { + $meta_key = self::BILLING_FIELDS_KEY; + $key = str_replace( '/billing/', '', $key ); + } elseif ( 0 === strpos( $key, '/shipping/' ) || 'shipping' === $group ) { + $meta_key = self::SHIPPING_FIELDS_KEY; + $key = str_replace( '/shipping/', '', $key ); + } else { + $meta_key = self::ADDITIONAL_FIELDS_KEY; + } + + if ( $object instanceof \WC_Customer ) { + if ( ! $object->get_id() ) { + $meta_data = wc()->session->get( $meta_key, array() ); + } else { + $meta_data = get_user_meta( $object->get_id(), $meta_key, true ); + } + } elseif ( $object instanceof \WC_Order ) { + $meta_data = $object->get_meta( $meta_key, true ); + } + + if ( ! is_array( $meta_data ) ) { + return ''; + } + + if ( ! isset( $meta_data[ $key ] ) ) { + return ''; + } + + return $meta_data[ $key ]; + } + + /** + * Returns an array of all fields values for a given customer. + * + * @param \WC_Customer $customer The customer to get the fields for. + * @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_customer( $customer, $all = false ) { + $customer_id = $customer->get_id(); + $meta_data = [ + 'billing' => [], + 'shipping' => [], + 'additional' => [], + ]; + if ( ! $customer_id ) { + if ( isset( wc()->session ) ) { + $meta_data['billing'] = wc()->session->get( self::BILLING_FIELDS_KEY, [] ); + $meta_data['shipping'] = wc()->session->get( self::SHIPPING_FIELDS_KEY, [] ); + $meta_data['additional'] = wc()->session->get( self::ADDITIONAL_FIELDS_KEY, [] ); + } + } else { + $meta_data['billing'] = get_user_meta( $customer_id, self::BILLING_FIELDS_KEY, true ); + $meta_data['shipping'] = get_user_meta( $customer_id, self::SHIPPING_FIELDS_KEY, true ); + $meta_data['additional'] = get_user_meta( $customer_id, self::ADDITIONAL_FIELDS_KEY, true ); + } + + return $this->format_meta_data( $meta_data, $all ); + } + + /** + * Returns an array of all fields values for a given order. + * + * @param \WC_Order $order The order to get the fields for. + * @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_order( $order, $all = false ) { + $meta_data = [ + 'billing' => [], + 'shipping' => [], + 'additional' => [], + ]; + if ( $order instanceof \WC_Order ) { + $meta_data['billing'] = $order->get_meta( self::BILLING_FIELDS_KEY, true ); + $meta_data['shipping'] = $order->get_meta( self::SHIPPING_FIELDS_KEY, true ); + $meta_data['additional'] = $order->get_meta( self::ADDITIONAL_FIELDS_KEY, true ); + } + return $this->format_meta_data( $meta_data, $all ); + } + + /** + * Returns an array of all fields values for a given meta object. It would add the billing or shipping prefix to the keys. + * + * @param array $meta The meta data to format. + * @param bool $all Whether to return all fields or only the ones that are still registered. Default false. + * + * @return array An array of fields. + */ + private function format_meta_data( $meta, $all = false ) { + $billing_fields = $meta['billing'] ?? []; + $shipping_fields = $meta['shipping'] ?? []; + $additional_fields = $meta['additional'] ?? []; + + $fields = array(); + + if ( is_array( $billing_fields ) ) { + foreach ( $billing_fields as $key => $value ) { + if ( ! $all && ! $this->is_field( $key ) ) { + continue; + } + $fields[ '/billing/' . $key ] = $value; + } + } + + if ( is_array( $shipping_fields ) ) { + foreach ( $shipping_fields as $key => $value ) { + if ( ! $all && ! $this->is_field( $key ) ) { + continue; + } + $fields[ '/shipping/' . $key ] = $value; + } + } + + if ( is_array( $additional_fields ) ) { + foreach ( $additional_fields as $key => $value ) { + if ( ! $all && ! $this->is_field( $key ) ) { + continue; + } + $fields[ $key ] = $value; + } + } + + return $fields; + } + + /** + * From a set of fields, returns only the ones that should be saved to the customer. + * For now, this only supports fields in address location. + * + * @param array $fields The fields to filter. + * + * @return array The filtered fields. + */ + public function filter_fields_for_customer( $fields ) { + $customer_fields_keys = $this->get_address_fields_keys(); + return array_filter( + $fields, + function( $key ) use ( $customer_fields_keys ) { + return in_array( $key, $customer_fields_keys, true ); + }, + ARRAY_FILTER_USE_KEY + ); + } + +} diff --git a/src/Domain/Services/functions.php b/src/Domain/Services/functions.php new file mode 100644 index 00000000000..dfa5c9cb099 --- /dev/null +++ b/src/Domain/Services/functions.php @@ -0,0 +1,33 @@ +is_experimental_build() ) { + + /** + * Register a checkout field. + * + * @param array $options Field arguments. + * @throws Exception If field registration fails. + */ + function woocommerce_blocks_register_checkout_field( $options ) { + + // Check if `woocommerce_blocks_loaded` ran. If not then the CheckoutFields class will not be available yet. + // In that case, re-hook `woocommerce_blocks_loaded` and try running this again. + $woocommerce_blocks_loaded_ran = did_action( 'woocommerce_blocks_loaded' ); + if ( ! $woocommerce_blocks_loaded_ran ) { + add_action( + 'woocommerce_blocks_loaded', + function() use ( $options ) { + woocommerce_blocks_register_checkout_field( $options ); + } + ); + return; + } + $checkout_fields = Package::container()->get( \Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields::class ); + $result = $checkout_fields->register_checkout_field( $options ); + if ( is_wp_error( $result ) ) { + throw new Exception( $result->get_error_message() ); + } + } +} diff --git a/src/StoreApi/Routes/V1/AbstractCartRoute.php b/src/StoreApi/Routes/V1/AbstractCartRoute.php index a60d4b87c1a..135fefe7ff3 100644 --- a/src/StoreApi/Routes/V1/AbstractCartRoute.php +++ b/src/StoreApi/Routes/V1/AbstractCartRoute.php @@ -2,6 +2,8 @@ namespace Automattic\WooCommerce\StoreApi\Routes\V1; +use Automattic\WooCommerce\Blocks\Package; +use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema; @@ -61,6 +63,13 @@ abstract class AbstractCartRoute extends AbstractRoute { */ protected $order_controller; + /** + * Additional fields controller class instance. + * + * @var CheckoutFields + */ + protected $additional_fields_controller; + /** * Constructor. * @@ -70,10 +79,11 @@ abstract class AbstractCartRoute extends AbstractRoute { public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) { parent::__construct( $schema_controller, $schema ); - $this->cart_schema = $this->schema_controller->get( CartSchema::IDENTIFIER ); - $this->cart_item_schema = $this->schema_controller->get( CartItemSchema::IDENTIFIER ); - $this->cart_controller = new CartController(); - $this->order_controller = new OrderController(); + $this->cart_schema = $this->schema_controller->get( CartSchema::IDENTIFIER ); + $this->cart_item_schema = $this->schema_controller->get( CartItemSchema::IDENTIFIER ); + $this->cart_controller = new CartController(); + $this->additional_fields_controller = Package::container()->get( CheckoutFields::class ); + $this->order_controller = new OrderController(); } /** diff --git a/src/StoreApi/Routes/V1/AbstractRoute.php b/src/StoreApi/Routes/V1/AbstractRoute.php index 8563cce0c65..0f8bb8d9d1b 100644 --- a/src/StoreApi/Routes/V1/AbstractRoute.php +++ b/src/StoreApi/Routes/V1/AbstractRoute.php @@ -6,6 +6,8 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException; use Automattic\WooCommerce\StoreApi\Schemas\v1\AbstractSchema; +use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; +use Automattic\WooCommerce\Blocks\Package; use WP_Error; /** diff --git a/src/StoreApi/Routes/V1/CartUpdateCustomer.php b/src/StoreApi/Routes/V1/CartUpdateCustomer.php index a9201b33320..0b5a9e5662c 100644 --- a/src/StoreApi/Routes/V1/CartUpdateCustomer.php +++ b/src/StoreApi/Routes/V1/CartUpdateCustomer.php @@ -182,6 +182,19 @@ protected function get_route_post_response( \WP_REST_Request $request ) { 'shipping_phone' => $shipping['phone'] ?? null, ) ); + // We want to only get additional fields passed, since core ones are already saved. + $core_fields = array_keys( $this->additional_fields_controller->get_core_fields() ); + + $additional_shipping_values = array_diff_key( $shipping, array_flip( $core_fields ) ); + $additional_billing_values = array_diff_key( $billing, array_flip( $core_fields ) ); + + // We save them one by one, and we add the group prefix. + foreach ( $additional_shipping_values as $key => $value ) { + $this->additional_fields_controller->persist_field_for_customer( "/shipping/{$key}", $value, $customer ); + } + foreach ( $additional_billing_values as $key => $value ) { + $this->additional_fields_controller->persist_field_for_customer( "/billing/{$key}", $value, $customer ); + } wc_do_deprecated_action( 'woocommerce_blocks_cart_update_customer_from_request', @@ -222,6 +235,21 @@ protected function get_customer_billing_address( \WC_Customer $customer ) { $billing_country = $customer->get_billing_country(); $billing_state = $customer->get_billing_state(); + $additional_fields = $this->additional_fields_controller->get_all_fields_from_customer( $customer ); + + $additional_fields = array_reduce( + array_keys( $additional_fields ), + function( $carry, $key ) use ( $additional_fields ) { + if ( 0 === strpos( $key, '/billing/' ) ) { + $value = $additional_fields[ $key ]; + $key = str_replace( '/billing/', '', $key ); + $carry[ $key ] = $value; + } + return $carry; + }, + array() + ); + /** * There's a bug in WooCommerce core in which not having a state ("") would result in us validating against the store's state. * This resets the state to an empty string if it doesn't match the country. @@ -231,19 +259,22 @@ protected function get_customer_billing_address( \WC_Customer $customer ) { if ( ! $validation_util->validate_state( $billing_state, $billing_country ) ) { $billing_state = ''; } - return [ - 'first_name' => $customer->get_billing_first_name(), - 'last_name' => $customer->get_billing_last_name(), - 'company' => $customer->get_billing_company(), - 'address_1' => $customer->get_billing_address_1(), - 'address_2' => $customer->get_billing_address_2(), - 'city' => $customer->get_billing_city(), - 'state' => $billing_state, - 'postcode' => $customer->get_billing_postcode(), - 'country' => $billing_country, - 'phone' => $customer->get_billing_phone(), - 'email' => $customer->get_billing_email(), - ]; + return array_merge( + [ + 'first_name' => $customer->get_billing_first_name(), + 'last_name' => $customer->get_billing_last_name(), + 'company' => $customer->get_billing_company(), + 'address_1' => $customer->get_billing_address_1(), + 'address_2' => $customer->get_billing_address_2(), + 'city' => $customer->get_billing_city(), + 'state' => $billing_state, + 'postcode' => $customer->get_billing_postcode(), + 'country' => $billing_country, + 'phone' => $customer->get_billing_phone(), + 'email' => $customer->get_billing_email(), + ], + $additional_fields + ); } /** @@ -253,17 +284,34 @@ protected function get_customer_billing_address( \WC_Customer $customer ) { * @return array */ protected function get_customer_shipping_address( \WC_Customer $customer ) { - return [ - 'first_name' => $customer->get_shipping_first_name(), - 'last_name' => $customer->get_shipping_last_name(), - 'company' => $customer->get_shipping_company(), - 'address_1' => $customer->get_shipping_address_1(), - 'address_2' => $customer->get_shipping_address_2(), - 'city' => $customer->get_shipping_city(), - 'state' => $customer->get_shipping_state(), - 'postcode' => $customer->get_shipping_postcode(), - 'country' => $customer->get_shipping_country(), - 'phone' => $customer->get_shipping_phone(), - ]; + $additional_fields = $this->additional_fields_controller->get_all_fields_from_customer( $customer ); + + $additional_fields = array_reduce( + array_keys( $additional_fields ), + function( $carry, $key ) use ( $additional_fields ) { + if ( 0 === strpos( $key, '/shipping/' ) ) { + $value = $additional_fields[ $key ]; + $key = str_replace( '/shipping/', '', $key ); + $carry[ $key ] = $value; + } + return $carry; + }, + array() + ); + return array_merge( + [ + 'first_name' => $customer->get_shipping_first_name(), + 'last_name' => $customer->get_shipping_last_name(), + 'company' => $customer->get_shipping_company(), + 'address_1' => $customer->get_shipping_address_1(), + 'address_2' => $customer->get_shipping_address_2(), + 'city' => $customer->get_shipping_city(), + 'state' => $customer->get_shipping_state(), + 'postcode' => $customer->get_shipping_postcode(), + 'country' => $customer->get_shipping_country(), + 'phone' => $customer->get_shipping_phone(), + ], + $additional_fields + ); } } diff --git a/src/StoreApi/Routes/V1/Checkout.php b/src/StoreApi/Routes/V1/Checkout.php index c19c86e9973..51c716c2dd7 100644 --- a/src/StoreApi/Routes/V1/Checkout.php +++ b/src/StoreApi/Routes/V1/Checkout.php @@ -1,6 +1,7 @@ $value ) { if ( is_callable( [ $customer, "set_billing_$key" ] ) ) { $customer->{"set_billing_$key"}( $value ); + } elseif ( $this->additional_fields_controller->is_field( $key, 'address' ) ) { + $this->additional_fields_controller->persist_field_for_customer( "/billing/$key", $value, $customer ); } } @@ -428,6 +434,8 @@ private function update_customer_from_request( \WP_REST_Request $request ) { $customer->{"set_shipping_$key"}( $value ); } elseif ( 'phone' === $key ) { $customer->update_meta_data( 'shipping_phone', $value ); + } elseif ( $this->additional_fields_controller->is_field( $key, 'address' ) ) { + $this->additional_fields_controller->persist_field_for_customer( "/shipping/$key", $value, $customer ); } } diff --git a/src/StoreApi/Routes/V1/Order.php b/src/StoreApi/Routes/V1/Order.php index 3fc42d32395..dd39e3c393d 100644 --- a/src/StoreApi/Routes/V1/Order.php +++ b/src/StoreApi/Routes/V1/Order.php @@ -42,7 +42,6 @@ class Order extends AbstractRoute { */ public function __construct( SchemaController $schema_controller, AbstractSchema $schema ) { parent::__construct( $schema_controller, $schema ); - $this->order_controller = new OrderController(); } diff --git a/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/src/StoreApi/Schemas/V1/AbstractAddressSchema.php index 77b2198cbba..cd01eea160e 100644 --- a/src/StoreApi/Schemas/V1/AbstractAddressSchema.php +++ b/src/StoreApi/Schemas/V1/AbstractAddressSchema.php @@ -2,13 +2,34 @@ namespace Automattic\WooCommerce\StoreApi\Schemas\V1; use Automattic\WooCommerce\StoreApi\Utilities\ValidationUtils; - +use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; +use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; +use Automattic\WooCommerce\StoreApi\SchemaController; +use Automattic\WooCommerce\Blocks\Package; /** * AddressSchema class. * * Provides a generic address schema for composition in other schemas. */ abstract class AbstractAddressSchema extends AbstractSchema { + + /** + * Additional fields controller. + * + * @var CheckoutFields + */ + protected $additional_fields_controller; + + /** + * Constructor. + * + * @param ExtendSchema $extend ExtendSchema instance. + * @param SchemaController $controller Schema Controller instance. + */ + public function __construct( ExtendSchema $extend, SchemaController $controller ) { + parent::__construct( $extend, $controller ); + $this->additional_fields_controller = Package::container()->get( CheckoutFields::class ); + } /** * Term properties. * @@ -16,68 +37,71 @@ abstract class AbstractAddressSchema extends AbstractSchema { * @return array */ public function get_properties() { - return [ - 'first_name' => [ - 'description' => __( 'First name', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, - ], - 'last_name' => [ - 'description' => __( 'Last name', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, - ], - 'company' => [ - 'description' => __( 'Company', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, - ], - 'address_1' => [ - 'description' => __( 'Address', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, - ], - 'address_2' => [ - 'description' => __( 'Apartment, suite, etc.', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, - ], - 'city' => [ - 'description' => __( 'City', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, - ], - 'state' => [ - 'description' => __( 'State/County code, or name of the state, county, province, or district.', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, - ], - 'postcode' => [ - 'description' => __( 'Postal code', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, - ], - 'country' => [ - 'description' => __( 'Country/Region code in ISO 3166-1 alpha-2 format.', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, + return array_merge( + [ + 'first_name' => [ + 'description' => __( 'First name', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], + 'last_name' => [ + 'description' => __( 'Last name', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], + 'company' => [ + 'description' => __( 'Company', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], + 'address_1' => [ + 'description' => __( 'Address', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], + 'address_2' => [ + 'description' => __( 'Apartment, suite, etc.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], + 'city' => [ + 'description' => __( 'City', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], + 'state' => [ + 'description' => __( 'State/County code, or name of the state, county, province, or district.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], + 'postcode' => [ + 'description' => __( 'Postal code', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], + 'country' => [ + 'description' => __( 'Country/Region code in ISO 3166-1 alpha-2 format.', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], + 'phone' => [ + 'description' => __( 'Phone', 'woo-gutenberg-products-block' ), + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ], ], - 'phone' => [ - 'description' => __( 'Phone', 'woo-gutenberg-products-block' ), - 'type' => 'string', - 'context' => [ 'view', 'edit' ], - 'required' => true, - ], - ]; + $this->get_additional_address_fields_schema(), + ); } /** @@ -90,18 +114,29 @@ public function get_properties() { */ public function sanitize_callback( $address, $request, $param ) { $validation_util = new ValidationUtils(); + $address = array_merge( array_fill_keys( array_keys( $this->get_properties() ), '' ), (array) $address ); + $address = array_reduce( + array_keys( $address ), + function( $carry, $key ) use ( $address, $validation_util ) { + switch ( $key ) { + case 'country': + $carry[ $key ] = wc_strtoupper( sanitize_text_field( wp_unslash( $address[ $key ] ) ) ); + break; + case 'state': + $carry[ $key ] = $validation_util->format_state( sanitize_text_field( wp_unslash( $address[ $key ] ) ), $address['country'] ); + break; + case 'postcode': + $carry[ $key ] = $address['postcode'] ? wc_format_postcode( sanitize_text_field( wp_unslash( $address['postcode'] ) ), $address['country'] ) : ''; + break; + default: + $carry[ $key ] = sanitize_text_field( wp_unslash( $address[ $key ] ) ); + break; + } + return $carry; + }, + [] + ); - $address = array_merge( array_fill_keys( array_keys( $this->get_properties() ), '' ), (array) $address ); - $address['country'] = wc_strtoupper( sanitize_text_field( wp_unslash( $address['country'] ) ) ); - $address['first_name'] = sanitize_text_field( wp_unslash( $address['first_name'] ) ); - $address['last_name'] = sanitize_text_field( wp_unslash( $address['last_name'] ) ); - $address['company'] = sanitize_text_field( wp_unslash( $address['company'] ) ); - $address['address_1'] = sanitize_text_field( wp_unslash( $address['address_1'] ) ); - $address['address_2'] = sanitize_text_field( wp_unslash( $address['address_2'] ) ); - $address['city'] = sanitize_text_field( wp_unslash( $address['city'] ) ); - $address['state'] = $validation_util->format_state( sanitize_text_field( wp_unslash( $address['state'] ) ), $address['country'] ); - $address['postcode'] = $address['postcode'] ? wc_format_postcode( sanitize_text_field( wp_unslash( $address['postcode'] ) ), $address['country'] ) : ''; - $address['phone'] = sanitize_text_field( wp_unslash( $address['phone'] ) ); return $address; } @@ -160,4 +195,34 @@ public function validate_callback( $address, $request, $param ) { return $errors->has_errors( $errors ) ? $errors : true; } + + /** + * Get additional address fields schema. + * + * @return array + */ + protected function get_additional_address_fields_schema() { + $additional_fields_keys = $this->additional_fields_controller->get_address_fields_keys(); + + $fields = array_merge( $this->additional_fields_controller->get_core_fields(), $this->additional_fields_controller->get_additional_fields() ); + + $address_fields = array_filter( + $fields, + function( $key ) use ( $additional_fields_keys ) { + return in_array( $key, $additional_fields_keys, true ); + }, + ARRAY_FILTER_USE_KEY + ); + + $schema = []; + foreach ( $address_fields as $key => $field ) { + $schema[ $key ] = [ + 'description' => $field['label'], + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ]; + } + return $schema; + } } diff --git a/src/StoreApi/Schemas/V1/AbstractSchema.php b/src/StoreApi/Schemas/V1/AbstractSchema.php index ea72914f5cc..7d3f5ecc56f 100644 --- a/src/StoreApi/Schemas/V1/AbstractSchema.php +++ b/src/StoreApi/Schemas/V1/AbstractSchema.php @@ -3,6 +3,8 @@ use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; +use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; +use Automattic\WooCommerce\Blocks\Package; /** * AbstractSchema class. diff --git a/src/StoreApi/Schemas/V1/BillingAddressSchema.php b/src/StoreApi/Schemas/V1/BillingAddressSchema.php index a166113e8cd..ea08933b60d 100644 --- a/src/StoreApi/Schemas/V1/BillingAddressSchema.php +++ b/src/StoreApi/Schemas/V1/BillingAddressSchema.php @@ -99,7 +99,28 @@ public function get_item_response( $address ) { $billing_state = ''; } - return $this->prepare_html_response( + if ( $address instanceof \WC_Order ) { + // get additional fields from order. + $additional_address_fields = $this->additional_fields_controller->get_all_fields_from_order( $address ); + } elseif ( $address instanceof \WC_Customer ) { + // get additional fields from customer. + $additional_address_fields = $this->additional_fields_controller->get_all_fields_from_customer( $address ); + } + + $additional_address_fields = array_reduce( + array_keys( $additional_address_fields ), + function( $carry, $key ) use ( $additional_address_fields ) { + if ( 0 === strpos( $key, '/billing/' ) ) { + $value = $additional_address_fields[ $key ]; + $key = str_replace( '/billing/', '', $key ); + $carry[ $key ] = $value; + } + return $carry; + }, + [] + ); + + $address_object = \array_merge( [ 'first_name' => $address->get_billing_first_name(), 'last_name' => $address->get_billing_last_name(), @@ -112,8 +133,19 @@ public function get_item_response( $address ) { 'country' => $billing_country, 'email' => $address->get_billing_email(), 'phone' => $address->get_billing_phone(), - ] + ], + $additional_address_fields ); + + // Add any missing keys from additional_fields_controller to the address response. + foreach ( $this->additional_fields_controller->get_address_fields_keys() as $field ) { + if ( isset( $address_object[ $field ] ) ) { + continue; + } + $address_object[ $field ] = ''; + } + + return $this->prepare_html_response( $address_object ); } throw new RouteException( 'invalid_object_type', diff --git a/src/StoreApi/Schemas/V1/CheckoutSchema.php b/src/StoreApi/Schemas/V1/CheckoutSchema.php index 3a84f9c0759..c1305ec35b7 100644 --- a/src/StoreApi/Schemas/V1/CheckoutSchema.php +++ b/src/StoreApi/Schemas/V1/CheckoutSchema.php @@ -4,7 +4,8 @@ use Automattic\WooCommerce\StoreApi\SchemaController; use Automattic\WooCommerce\StoreApi\Payments\PaymentResult; use Automattic\WooCommerce\StoreApi\Schemas\ExtendSchema; - +use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; +use Automattic\WooCommerce\Blocks\Package; /** * CheckoutSchema class. @@ -45,6 +46,13 @@ class CheckoutSchema extends AbstractSchema { */ protected $image_attachment_schema; + /** + * Additional fields controller. + * + * @var CheckoutFields + */ + protected $additional_fields_controller; + /** * Constructor. * @@ -53,9 +61,10 @@ class CheckoutSchema extends AbstractSchema { */ public function __construct( ExtendSchema $extend, SchemaController $controller ) { parent::__construct( $extend, $controller ); - $this->billing_address_schema = $this->controller->get( BillingAddressSchema::IDENTIFIER ); - $this->shipping_address_schema = $this->controller->get( ShippingAddressSchema::IDENTIFIER ); - $this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER ); + $this->billing_address_schema = $this->controller->get( BillingAddressSchema::IDENTIFIER ); + $this->shipping_address_schema = $this->controller->get( ShippingAddressSchema::IDENTIFIER ); + $this->image_attachment_schema = $this->controller->get( ImageAttachmentSchema::IDENTIFIER ); + $this->additional_fields_controller = Package::container()->get( CheckoutFields::class ); } /** @@ -168,6 +177,12 @@ public function get_properties() { ], ], ], + 'additional_fields' => [ + 'description' => __( 'Additional fields to be persisted on the order.', 'woo-gutenberg-products-block' ), + 'type' => 'object', + 'context' => [ 'view', 'edit' ], + 'properties' => $this->get_additional_fields_schema(), + ], self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ), ]; } @@ -205,6 +220,7 @@ protected function get_checkout_response( \WC_Order $order, PaymentResult $payme 'payment_details' => $this->prepare_payment_details_for_response( $payment_result->payment_details ), 'redirect_url' => $payment_result->redirect_url, ], + 'additional_fields' => $this->get_additional_fields_response( $order ), self::EXTENDING_KEY => $this->get_extended_data( self::IDENTIFIER ), ]; } @@ -230,4 +246,54 @@ function( $key, $value ) { $payment_details ); } + + /** + * Get the additional fields response. + * + * @param \WC_Order $order Order object. + * @return array + */ + protected function get_additional_fields_response( \WC_Order $order ) { + $fields = $this->additional_fields_controller->get_all_fields_from_order( $order ); + $response = []; + + foreach ( $fields as $key => $value ) { + if ( 0 === strpos( $key, '/billing/' ) || 0 === strpos( $key, '/shipping/' ) ) { + continue; + } + $response[ $key ] = $value; + } + + return $response; + } + + /** + * Get the schema for additional fields. + * + * @return array + */ + protected function get_additional_fields_schema() { + $additional_fields_keys = $this->additional_fields_controller->get_additional_fields_keys(); + + $fields = $this->additional_fields_controller->get_additional_fields(); + + $additional_fields = array_filter( + $fields, + function( $key ) use ( $additional_fields_keys ) { + return in_array( $key, $additional_fields_keys, true ); + }, + ARRAY_FILTER_USE_KEY + ); + + $schema = []; + foreach ( $additional_fields as $key => $field ) { + $schema[ $key ] = [ + 'description' => $field['label'], + 'type' => 'string', + 'context' => [ 'view', 'edit' ], + 'required' => true, + ]; + } + return $schema; + } } diff --git a/src/StoreApi/Schemas/V1/ShippingAddressSchema.php b/src/StoreApi/Schemas/V1/ShippingAddressSchema.php index 4f7b93d0331..f28f734cae3 100644 --- a/src/StoreApi/Schemas/V1/ShippingAddressSchema.php +++ b/src/StoreApi/Schemas/V1/ShippingAddressSchema.php @@ -42,7 +42,28 @@ public function get_item_response( $address ) { $shipping_state = ''; } - return $this->prepare_html_response( + if ( $address instanceof \WC_Order ) { + // get additional fields from order. + $additional_address_fields = $this->additional_fields_controller->get_all_fields_from_order( $address ); + } elseif ( $address instanceof \WC_Customer ) { + // get additional fields from customer. + $additional_address_fields = $this->additional_fields_controller->get_all_fields_from_customer( $address ); + } + + $additional_address_fields = array_reduce( + array_keys( $additional_address_fields ), + function( $carry, $key ) use ( $additional_address_fields ) { + if ( 0 === strpos( $key, '/shipping/' ) ) { + $value = $additional_address_fields[ $key ]; + $key = str_replace( '/shipping/', '', $key ); + $carry[ $key ] = $value; + } + return $carry; + }, + [] + ); + + $address_object = array_merge( [ 'first_name' => $address->get_shipping_first_name(), 'last_name' => $address->get_shipping_last_name(), @@ -54,9 +75,21 @@ public function get_item_response( $address ) { 'postcode' => $address->get_shipping_postcode(), 'country' => $shipping_country, 'phone' => $address->get_shipping_phone(), - ] + ], + $additional_address_fields ); + + // Add any missing keys from additional_fields_controller to the address response. + foreach ( $this->additional_fields_controller->get_address_fields_keys() as $field ) { + if ( isset( $address_object[ $field ] ) ) { + continue; + } + $address_object[ $field ] = ''; + } + + return $this->prepare_html_response( $address_object ); } + throw new RouteException( 'invalid_object_type', sprintf( diff --git a/src/StoreApi/Utilities/CheckoutTrait.php b/src/StoreApi/Utilities/CheckoutTrait.php index 38a41e97b98..2411f47e938 100644 --- a/src/StoreApi/Utilities/CheckoutTrait.php +++ b/src/StoreApi/Utilities/CheckoutTrait.php @@ -130,6 +130,7 @@ private function update_order_from_request( \WP_REST_Request $request ) { $this->order->set_customer_note( $request['customer_note'] ?? '' ); $this->order->set_payment_method( $this->get_request_payment_method_id( $request ) ); $this->order->set_payment_method_title( $this->get_request_payment_method_title( $request ) ); + $this->persist_additional_fields_for_order( $request ); wc_do_deprecated_action( '__experimental_woocommerce_blocks_checkout_update_order_from_request', @@ -180,4 +181,30 @@ private function get_request_payment_method_title( \WP_REST_Request $request ) { $payment_method = $this->get_request_payment_method( $request ); return is_null( $payment_method ) ? '' : $payment_method->get_title(); } + + /** + * Persist additional fields for the order after validating them. + * + * @param \WP_REST_Request $request Full details about the request. + * + * @throws RouteException On error. + */ + private function persist_additional_fields_for_order( \WP_REST_Request $request ) { + $errors = new \WP_Error(); + $request_fields = $request['additional_fields'] ?? []; + foreach ( $request_fields as $key => $value ) { + try { + $this->additional_fields_controller->validate_field_for_location( $key, $value, 'additional' ); + } catch ( \Exception $e ) { + $errors[] = $e->getMessage(); + continue; + } + $this->additional_fields_controller->persist_field_for_order( $key, $value, $this->order, false ); + } + + if ( $errors->has_errors() ) { + throw new RouteException( 'woocommerce_rest_checkout_invalid_additional_fields', $errors->get_error_messages(), 400 ); + } + + } } diff --git a/src/StoreApi/Utilities/OrderController.php b/src/StoreApi/Utilities/OrderController.php index 018e7b05669..dc4069f5afd 100644 --- a/src/StoreApi/Utilities/OrderController.php +++ b/src/StoreApi/Utilities/OrderController.php @@ -3,6 +3,8 @@ use \Exception; use Automattic\WooCommerce\StoreApi\Exceptions\RouteException; +use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields; +use Automattic\WooCommerce\Blocks\Package; /** * OrderController class. @@ -10,6 +12,20 @@ */ class OrderController { + /** + * Checkout fields controller. + * + * @var CheckoutFields + */ + private CheckoutFields $additional_fields_controller; + + /** + * Constructor. + */ + public function __construct() { + $this->additional_fields_controller = Package::container()->get( CheckoutFields::class ); + } + /** * Create order and set props based on global settings. * @@ -132,7 +148,12 @@ public function sync_customer_data_with_order( \WC_Order $order ) { 'shipping_phone' => $order->get_shipping_phone(), ] ); + $order_fields = $this->additional_fields_controller->get_all_fields_from_order( $order ); + $customer_fields = $this->additional_fields_controller->filter_fields_for_customer( $order_fields ); + foreach ( $customer_fields as $key => $value ) { + $this->additional_fields_controller->persist_field_for_customer( $key, $value, $customer ); + } $customer->save(); }; } @@ -274,16 +295,16 @@ protected function validate_email( \WC_Order $order ) { protected function validate_addresses( \WC_Order $order ) { $errors = new \WP_Error(); $needs_shipping = wc()->cart->needs_shipping(); - $billing_address = $order->get_address( 'billing' ); - $shipping_address = $order->get_address( 'shipping' ); + $billing_country = $order->get_billing_country(); + $shipping_country = $order->get_shipping_country(); - if ( $needs_shipping && ! $this->validate_allowed_country( $shipping_address['country'], (array) wc()->countries->get_shipping_countries() ) ) { + if ( $needs_shipping && ! $this->validate_allowed_country( $shipping_country, (array) wc()->countries->get_shipping_countries() ) ) { throw new RouteException( 'woocommerce_rest_invalid_address_country', sprintf( /* translators: %s country code. */ __( 'Sorry, we do not ship orders to the provided country (%s)', 'woo-gutenberg-products-block' ), - $shipping_address['country'] + $shipping_country ), 400, [ @@ -292,13 +313,13 @@ protected function validate_addresses( \WC_Order $order ) { ); } - if ( ! $this->validate_allowed_country( $billing_address['country'], (array) wc()->countries->get_allowed_countries() ) ) { + if ( ! $this->validate_allowed_country( $billing_country, (array) wc()->countries->get_allowed_countries() ) ) { throw new RouteException( 'woocommerce_rest_invalid_address_country', sprintf( /* translators: %s country code. */ __( 'Sorry, we do not allow orders from the provided country (%s)', 'woo-gutenberg-products-block' ), - $billing_address['country'] + $billing_country ), 400, [ @@ -308,9 +329,9 @@ protected function validate_addresses( \WC_Order $order ) { } if ( $needs_shipping ) { - $this->validate_address_fields( $shipping_address, 'shipping', $errors ); + $this->validate_address_fields( $order, 'shipping', $errors ); } - $this->validate_address_fields( $billing_address, 'billing', $errors ); + $this->validate_address_fields( $order, 'billing', $errors ); if ( ! $errors->has_errors() ) { return; @@ -353,56 +374,33 @@ protected function validate_allowed_country( $country, array $allowed_countries /** * Check all required address fields are set and return errors if not. * - * @param array $address Address array. + * @param \WC_Order $order Order object. * @param string $address_type billing or shipping address, used in error messages. * @param \WP_Error $errors Error object. */ - protected function validate_address_fields( $address, $address_type, \WP_Error $errors ) { + protected function validate_address_fields( \WC_Order $order, $address_type, \WP_Error $errors ) { $all_locales = wc()->countries->get_country_locale(); + $address = $order->get_address( $address_type ); $current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : []; - /** - * We are not using wc()->counties->get_default_address_fields() here because that is filtered. Instead, this array - * is based on assets/js/base/components/cart-checkout/address-form/default-address-fields.js - */ - $address_fields = [ - 'first_name' => [ - 'label' => __( 'First name', 'woo-gutenberg-products-block' ), - 'required' => true, - ], - 'last_name' => [ - 'label' => __( 'Last name', 'woo-gutenberg-products-block' ), - 'required' => true, - ], - 'company' => [ - 'label' => __( 'Company', 'woo-gutenberg-products-block' ), - 'required' => false, - ], - 'address_1' => [ - 'label' => __( 'Address', 'woo-gutenberg-products-block' ), - 'required' => true, - ], - 'address_2' => [ - 'label' => __( 'Apartment, suite, etc.', 'woo-gutenberg-products-block' ), - 'required' => false, - ], - 'country' => [ - 'label' => __( 'Country/Region', 'woo-gutenberg-products-block' ), - 'required' => true, - ], - 'city' => [ - 'label' => __( 'City', 'woo-gutenberg-products-block' ), - 'required' => true, - ], - 'state' => [ - 'label' => __( 'State/County', 'woo-gutenberg-products-block' ), - 'required' => true, - ], - 'postcode' => [ - 'label' => __( 'Postal code', 'woo-gutenberg-products-block' ), - 'required' => true, - ], - ]; + $additional_fields = $this->additional_fields_controller->get_all_fields_from_order( $order ); + + foreach ( $additional_fields as $field_id => $field_value ) { + $prefix = '/' . $address_type . '/'; + if ( strpos( $field_id, $prefix ) === 0 ) { + $address[ str_replace( $prefix, '', $field_id ) ] = $field_value; + } + } + + $fields = $this->additional_fields_controller->get_additional_fields(); + $address_fields_keys = $this->additional_fields_controller->get_address_fields_keys(); + $address_fields = array_filter( + $fields, + function( $key ) use ( $address_fields_keys ) { + return in_array( $key, $address_fields_keys, true ); + }, + ARRAY_FILTER_USE_KEY + ); if ( $current_locale ) { foreach ( $current_locale as $key => $field ) { @@ -738,5 +736,9 @@ protected function update_addresses_from_cart( \WC_Order $order ) { 'shipping_phone' => wc()->customer->get_shipping_phone(), ] ); + $customer_fields = $this->additional_fields_controller->get_all_fields_from_customer( wc()->customer ); + foreach ( $customer_fields as $key => $value ) { + $this->additional_fields_controller->persist_field_for_order( $key, $value, $order, false ); + } } } diff --git a/tests/js/setup-globals.js b/tests/js/setup-globals.js index dc246e79e9e..f5aa5c169a3 100644 --- a/tests/js/setup-globals.js +++ b/tests/js/setup-globals.js @@ -117,6 +117,97 @@ global.wcSettings = { attribute_public: 0, }, ], + defaultFields: { + first_name: { + label: 'First name', + optionalLabel: 'First name (optional)', + autocomplete: 'given-name', + autocapitalize: 'sentences', + required: true, + hidden: false, + index: 10, + }, + last_name: { + label: 'Last name', + optionalLabel: 'Last name (optional)', + autocomplete: 'family-name', + autocapitalize: 'sentences', + required: true, + hidden: false, + index: 20, + }, + company: { + label: 'Company', + optionalLabel: 'Company (optional)', + autocomplete: 'organization', + autocapitalize: 'sentences', + required: false, + hidden: false, + index: 30, + }, + address_1: { + label: 'Address', + optionalLabel: 'Address (optional)', + autocomplete: 'address-line1', + autocapitalize: 'sentences', + required: true, + hidden: false, + index: 40, + }, + address_2: { + label: 'Apartment, suite, etc.', + optionalLabel: 'Apartment, suite, etc. (optional)', + autocomplete: 'address-line2', + autocapitalize: 'sentences', + required: false, + hidden: false, + index: 50, + }, + country: { + label: 'Country/Region', + optionalLabel: 'Country/Region (optional)', + autocomplete: 'country', + required: true, + hidden: false, + index: 60, + }, + city: { + label: 'City', + optionalLabel: 'City (optional)', + autocomplete: 'address-level2', + autocapitalize: 'sentences', + required: true, + hidden: false, + index: 70, + }, + state: { + label: 'State/County', + optionalLabel: 'State/County (optional)', + autocomplete: 'address-level1', + autocapitalize: 'sentences', + required: true, + hidden: false, + index: 80, + }, + postcode: { + label: 'Postal code', + optionalLabel: 'Postal code (optional)', + autocomplete: 'postal-code', + autocapitalize: 'characters', + required: true, + hidden: false, + index: 90, + }, + phone: { + label: 'Phone', + optionalLabel: 'Phone (optional)', + autocomplete: 'tel', + type: 'tel', + required: true, + hidden: false, + index: 100, + }, + }, }; global.jQuery = () => ( { diff --git a/tests/php/Bootstrap/MainFile.php b/tests/php/Bootstrap/MainFile.php index c450ca297c6..fe925c41c60 100644 --- a/tests/php/Bootstrap/MainFile.php +++ b/tests/php/Bootstrap/MainFile.php @@ -28,21 +28,37 @@ class MainFile extends WP_UnitTestCase { * Ensure that container is reset between tests. */ protected function setUp(): void { - // reset container + // reset container. $this->container = Package::container( true ); } + /** + * Test that the container is returned from the main file. + */ public function test_container_returns_same_instance() { $container = Package::container(); $this->assertSame( $container, $this->container ); } + /** + * Test that the container is reset when the reset flag is passed. + */ public function test_container_reset() { $container = Package::container( true ); $this->assertNotSame( $container, $this->container ); } + /** + * Asserts that the bootstrap class is returned from the container. + */ public function wc_blocks_bootstrap() { $this->assertInstanceOf( Bootstrap::class, wc_blocks_bootstrap() ); } + + /** + * Ensure that the init method is called on the bootstrap class. This is a workaround since we're using an anti-pattern for DI. + */ + protected function tearDown(): void { + Package::init(); + } } diff --git a/tests/php/StoreApi/Routes/Cart.php b/tests/php/StoreApi/Routes/Cart.php index 4c415fb400a..95cf81f1716 100644 --- a/tests/php/StoreApi/Routes/Cart.php +++ b/tests/php/StoreApi/Routes/Cart.php @@ -603,7 +603,7 @@ public function test_add_variable_product_to_cart_returns_variation_data() { array( 'variation' => array( // order matters, alphabetical attribute order. array( - 'attribute' => 'color', + 'attribute' => 'Color', 'value' => 'red', ), array(