Introduce Additional Fields extensibility API (#42695)

* Introduce Additional Fields API for Checkout Block https://github.com/woocommerce/woocommerce-blocks/pull/12073

* add changelog

* Auto load the Blocks/Domain/Services/functions.php file

* add changelog

* revert test to what it was

* Update text domain for translations

* Ensure address data is added on the cart block too

* fix lint problem

---------

Co-authored-by: Thomas Roberts <thomas.roberts@automattic.com>
This commit is contained in:
Seghir Nadir 2023-12-15 13:45:38 +01:00 committed by GitHub
parent 3d7e6bc796
commit 12572ce08c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 1511 additions and 462 deletions

View File

@ -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',

View File

@ -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 ] || {};

View File

@ -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 (
<AddressForm
type={ type }
onChange={ setShippingAddress }
values={ shippingAddress }
fields={ Object.keys( defaultAddressFields ) }
fields={ ADDRESS_FIELDS_KEYS }
/>
);
};

View File

@ -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,

View File

@ -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 } ) => {

View File

@ -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 (

View File

@ -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 (

View File

@ -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,

View File

@ -58,6 +58,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', {} );
@ -112,3 +118,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;

View File

@ -1,160 +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', 'woocommerce' ),
optionalLabel: __( 'First name (optional)', 'woocommerce' ),
autocomplete: 'given-name',
autocapitalize: 'sentences',
required: true,
hidden: false,
index: 10,
},
last_name: {
label: __( 'Last name', 'woocommerce' ),
optionalLabel: __( 'Last name (optional)', 'woocommerce' ),
autocomplete: 'family-name',
autocapitalize: 'sentences',
required: true,
hidden: false,
index: 20,
},
company: {
label: __( 'Company', 'woocommerce' ),
optionalLabel: __( 'Company (optional)', 'woocommerce' ),
autocomplete: 'organization',
autocapitalize: 'sentences',
required: false,
hidden: false,
index: 30,
},
address_1: {
label: __( 'Address', 'woocommerce' ),
optionalLabel: __( 'Address (optional)', 'woocommerce' ),
autocomplete: 'address-line1',
autocapitalize: 'sentences',
required: true,
hidden: false,
index: 40,
},
address_2: {
label: __( 'Apartment, suite, etc.', 'woocommerce' ),
optionalLabel: __( 'Apartment, suite, etc. (optional)', 'woocommerce' ),
autocomplete: 'address-line2',
autocapitalize: 'sentences',
required: false,
hidden: false,
index: 50,
},
country: {
label: __( 'Country/Region', 'woocommerce' ),
optionalLabel: __( 'Country/Region (optional)', 'woocommerce' ),
autocomplete: 'country',
required: true,
hidden: false,
index: 60,
},
city: {
label: __( 'City', 'woocommerce' ),
optionalLabel: __( 'City (optional)', 'woocommerce' ),
autocomplete: 'address-level2',
autocapitalize: 'sentences',
required: true,
hidden: false,
index: 70,
},
state: {
label: __( 'State/County', 'woocommerce' ),
optionalLabel: __( 'State/County (optional)', 'woocommerce' ),
autocomplete: 'address-level1',
autocapitalize: 'sentences',
required: true,
hidden: false,
index: 80,
},
postcode: {
label: __( 'Postal code', 'woocommerce' ),
optionalLabel: __( 'Postal code (optional)', 'woocommerce' ),
autocomplete: 'postal-code',
autocapitalize: 'characters',
required: true,
hidden: false,
index: 90,
},
phone: {
label: __( 'Phone', 'woocommerce' ),
optionalLabel: __( 'Phone (optional)', 'woocommerce' ),
autocomplete: 'tel',
type: 'tel',
required: true,
hidden: false,
index: 100,
},
};
export default defaultAddressFields;

View File

@ -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;

View File

@ -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';

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add support for additional fields in Checkout block

View File

@ -36,4 +36,4 @@
"changelog": "CHANGELOG.md"
}
}
}
}

View File

@ -2,25 +2,25 @@
## Table of Contents <!-- omit in toc -->
- [General Concepts](#general-concepts)
- [Tracking flow through status](#tracking-flow-through-status)
- [Checkout Data Store Status](#checkout-data-store-status)
- [Special States](#special-states)
- [`ShippingProvider` Exposed Statuses](#shippingprovider-exposed-statuses)
- [Payment Method Data Store Status](#payment-method-data-store-status)
- [Emitting Events](#emitting-events)
- [`onCheckoutValidation`](#oncheckoutvalidation)
- [~~`onPaymentProcessing`~~](#onpaymentprocessing)
- [`onPaymentSetup`](#onpaymentsetup)
- [Success](#success)
- [Fail](#fail)
- [Error](#error)
- [`onCheckoutSuccess`](#oncheckoutsuccess)
- [`onCheckoutFail`](#oncheckoutfail)
- [`onShippingRateSuccess`](#onshippingratesuccess)
- [`onShippingRateFail`](#onshippingratefail)
- [`onShippingRateSelectSuccess`](#onshippingrateselectsuccess)
- [`onShippingRateSelectFail`](#onshippingrateselectfail)
- [General Concepts](#general-concepts)
- [Tracking flow through status](#tracking-flow-through-status)
- [Checkout Data Store Status](#checkout-data-store-status)
- [Special States](#special-states)
- [`ShippingProvider` Exposed Statuses](#shippingprovider-exposed-statuses)
- [Payment Method Data Store Status](#payment-method-data-store-status)
- [Emitting Events](#emitting-events)
- [`onCheckoutValidation`](#oncheckoutvalidation)
- [~~`onPaymentProcessing`~~](#onpaymentprocessing)
- [`onPaymentSetup`](#onpaymentsetup)
- [Success](#success)
- [Fail](#fail)
- [Error](#error)
- [`onCheckoutSuccess`](#oncheckoutsuccess)
- [`onCheckoutFail`](#oncheckoutfail)
- [`onShippingRateSuccess`](#onshippingratesuccess)
- [`onShippingRateFail`](#onshippingratefail)
- [`onShippingRateSelectSuccess`](#onshippingrateselectsuccess)
- [`onShippingRateSelectFail`](#onshippingrateselectfail)
This document gives an overview of the flow for the checkout in the WooCommerce checkout block, and some general architectural overviews.
@ -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.

View File

@ -13,7 +13,7 @@
(see https://github.com/woocommerce/woocommerce-blocks/blob/trunk/.github/release-initial-checklist.md#initial-preparation)
-->
<config name="minimum_supported_wp_version" value="6.3" />
<config name="testVersion" value="7.3-" />
<config name="testVersion" value="7.4-" />
<!-- Rules -->
<rule ref="WooCommerce-Core" />

View File

@ -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 = () => ( {

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Introduce ability to add additional fields to Checkout block

View File

@ -71,7 +71,8 @@
},
"files": [
"src/StoreApi/deprecated.php",
"src/StoreApi/functions.php"
"src/StoreApi/functions.php",
"src/Blocks/Domain/Services/functions.php"
]
},
"autoload-dev": {

View File

@ -13,6 +13,7 @@ use Automattic\WooCommerce\Blocks\Domain\Services\DraftOrders;
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;
@ -129,6 +130,7 @@ class Bootstrap {
$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 ) {
@ -137,6 +139,7 @@ class Bootstrap {
$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.
@ -341,6 +344,12 @@ class Bootstrap {
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 ) {

View File

@ -0,0 +1,670 @@
<?php
namespace Automattic\WooCommerce\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Assets\AssetDataRegistry;
use WC_Customer;
/**
* Service class managing checkout fields and its related extensibility points.
*/
class CheckoutFields {
/**
* Core checkout fields.
*
* @var array
*/
private $core_fields;
/**
* Additional checkout fields.
*
* @var array
*/
private $additional_fields = [];
/**
* Fields locations.
*
* @var array
*/
private $fields_locations;
/**
* Instance of the asset data registry.
*
* @var AssetDataRegistry
*/
private $asset_data_registry;
/**
* Billing fields meta key.
*
* @var string
*/
const BILLING_FIELDS_KEY = '_additional_billing_fields';
/**
* Shipping fields meta key.
*
* @var string
*/
const SHIPPING_FIELDS_KEY = '_additional_shipping_fields';
/**
* Additional fields meta key.
*
* @var string
*/
const ADDITIONAL_FIELDS_KEY = '_additional_fields';
/**
* Sets up core fields.
*
* @param AssetDataRegistry $asset_data_registry Instance of the asset data registry.
*/
public function __construct( AssetDataRegistry $asset_data_registry ) {
$this->asset_data_registry = $asset_data_registry;
$this->core_fields = array(
'email' => array(
'label' => __( 'Email address', 'woocommerce' ),
'optionalLabel' => __(
'Email address (optional)',
'woocommerce'
),
'required' => true,
'hidden' => false,
'autocomplete' => 'email',
'autocapitalize' => 'none',
'index' => 0,
),
'first_name' => array(
'label' => __( 'First name', 'woocommerce' ),
'optionalLabel' => __(
'First name (optional)',
'woocommerce'
),
'required' => true,
'hidden' => false,
'autocomplete' => 'given-name',
'autocapitalize' => 'sentences',
'index' => 10,
),
'last_name' => array(
'label' => __( 'Last name', 'woocommerce' ),
'optionalLabel' => __(
'Last name (optional)',
'woocommerce'
),
'required' => true,
'hidden' => false,
'autocomplete' => 'family-name',
'autocapitalize' => 'sentences',
'index' => 20,
),
'company' => array(
'label' => __( 'Company', 'woocommerce' ),
'optionalLabel' => __(
'Company (optional)',
'woocommerce'
),
'required' => false,
'hidden' => false,
'autocomplete' => 'organization',
'autocapitalize' => 'sentences',
'index' => 30,
),
'address_1' => array(
'label' => __( 'Address', 'woocommerce' ),
'optionalLabel' => __(
'Address (optional)',
'woocommerce'
),
'required' => true,
'hidden' => false,
'autocomplete' => 'address-line1',
'autocapitalize' => 'sentences',
'index' => 40,
),
'address_2' => array(
'label' => __( 'Apartment, suite, etc.', 'woocommerce' ),
'optionalLabel' => __(
'Apartment, suite, etc. (optional)',
'woocommerce'
),
'required' => false,
'hidden' => false,
'autocomplete' => 'address-line2',
'autocapitalize' => 'sentences',
'index' => 50,
),
'country' => array(
'label' => __( 'Country/Region', 'woocommerce' ),
'optionalLabel' => __(
'Country/Region (optional)',
'woocommerce'
),
'required' => true,
'hidden' => false,
'autocomplete' => 'country',
'index' => 50,
),
'city' => array(
'label' => __( 'City', 'woocommerce' ),
'optionalLabel' => __(
'City (optional)',
'woocommerce'
),
'required' => true,
'hidden' => false,
'autocomplete' => 'address-level2',
'autocapitalize' => 'sentences',
'index' => 70,
),
'state' => array(
'label' => __( 'State/County', 'woocommerce' ),
'optionalLabel' => __(
'State/County (optional)',
'woocommerce'
),
'required' => true,
'hidden' => false,
'autocomplete' => 'address-level1',
'autocapitalize' => 'sentences',
'index' => 80,
),
'postcode' => array(
'label' => __( 'Postal code', 'woocommerce' ),
'optionalLabel' => __(
'Postal code (optional)',
'woocommerce'
),
'required' => true,
'hidden' => false,
'autocomplete' => 'postal-code',
'autocapitalize' => 'characters',
'index' => 90,
),
'phone' => array(
'label' => __( 'Phone', 'woocommerce' ),
'optionalLabel' => __(
'Phone (optional)',
'woocommerce'
),
'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_action( 'woocommerce_blocks_cart_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.', 'woocommerce' ) );
}
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.', 'woocommerce' ) );
}
if ( empty( $options['label'] ) ) {
return new \WP_Error( 'woocommerce_blocks_checkout_field_label_required', __( 'The field label is required.', 'woocommerce' ) );
}
if ( empty( $options['location'] ) ) {
return new \WP_Error( 'woocommerce_blocks_checkout_field_location_required', __( 'The field location is required.', 'woocommerce' ) );
}
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.', 'woocommerce' ) );
}
// 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.', 'woocommerce' ) );
}
// 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.', 'woocommerce' ), $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.', 'woocommerce' ), $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.', 'woocommerce' ), $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
);
}
}

View File

@ -0,0 +1,33 @@
<?php
use Automattic\WooCommerce\Blocks\Package;
if ( ! function_exists( 'woocommerce_blocks_register_checkout_field' ) && Package::feature()->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() );
}
}
}

View File

@ -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();
}
/**

View File

@ -6,6 +6,8 @@ use Automattic\WooCommerce\StoreApi\Routes\RouteInterface;
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;
/**

View File

@ -182,6 +182,19 @@ class CartUpdateCustomer extends AbstractCartRoute {
'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 @@ class CartUpdateCustomer extends AbstractCartRoute {
$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 @@ class CartUpdateCustomer extends AbstractCartRoute {
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 @@ class CartUpdateCustomer extends AbstractCartRoute {
* @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
);
}
}

View File

@ -1,6 +1,7 @@
<?php
namespace Automattic\WooCommerce\StoreApi\Routes\V1;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
use Automattic\WooCommerce\StoreApi\Payments\PaymentResult;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidStockLevelsInCartException;
use Automattic\WooCommerce\StoreApi\Exceptions\InvalidCartException;
@ -9,6 +10,9 @@ use Automattic\WooCommerce\StoreApi\Utilities\DraftOrderTrait;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStock;
use Automattic\WooCommerce\Checkout\Helpers\ReserveStockException;
use Automattic\WooCommerce\StoreApi\Utilities\CheckoutTrait;
use Automattic\WooCommerce\StoreApi\SchemaController;
use Automattic\WooCommerce\StoreApi\Schemas\V1\AbstractSchema;
use Automattic\WooCommerce\Blocks\Package;
/**
* Checkout class.
@ -417,6 +421,8 @@ class Checkout extends AbstractCartRoute {
foreach ( $request['billing_address'] as $key => $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 @@ class Checkout extends AbstractCartRoute {
$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 );
}
}

View File

@ -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();
}

View File

@ -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', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
return array_merge(
[
'first_name' => [
'description' => __( 'First name', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'last_name' => [
'description' => __( 'Last name', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'company' => [
'description' => __( 'Company', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'address_1' => [
'description' => __( 'Address', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'address_2' => [
'description' => __( 'Apartment, suite, etc.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'city' => [
'description' => __( 'City', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'state' => [
'description' => __( 'State/County code, or name of the state, county, province, or district.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'postcode' => [
'description' => __( 'Postal code', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'country' => [
'description' => __( 'Country/Region code in ISO 3166-1 alpha-2 format.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'phone' => [
'description' => __( 'Phone', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
],
'last_name' => [
'description' => __( 'Last name', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'company' => [
'description' => __( 'Company', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'address_1' => [
'description' => __( 'Address', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'address_2' => [
'description' => __( 'Apartment, suite, etc.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'city' => [
'description' => __( 'City', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'state' => [
'description' => __( 'State/County code, or name of the state, county, province, or district.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'postcode' => [
'description' => __( 'Postal code', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'country' => [
'description' => __( 'Country/Region code in ISO 3166-1 alpha-2 format.', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
'phone' => [
'description' => __( 'Phone', 'woocommerce' ),
'type' => 'string',
'context' => [ 'view', 'edit' ],
'required' => true,
],
];
$this->get_additional_address_fields_schema(),
);
}
/**
@ -90,18 +114,29 @@ abstract class AbstractAddressSchema extends AbstractSchema {
*/
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 @@ abstract class AbstractAddressSchema extends AbstractSchema {
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;
}
}

View File

@ -3,6 +3,8 @@ namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
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.

View File

@ -99,7 +99,28 @@ class BillingAddressSchema extends AbstractAddressSchema {
$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 @@ class BillingAddressSchema extends AbstractAddressSchema {
'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',

View File

@ -4,7 +4,8 @@ namespace Automattic\WooCommerce\StoreApi\Schemas\V1;
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 @@ class CheckoutSchema extends AbstractSchema {
],
],
],
'additional_fields' => [
'description' => __( 'Additional fields to be persisted on the order.', 'woocommerce' ),
'type' => 'object',
'context' => [ 'view', 'edit' ],
'properties' => $this->get_additional_fields_schema(),
],
self::EXTENDING_KEY => $this->get_extended_schema( self::IDENTIFIER ),
];
}
@ -205,6 +220,7 @@ class CheckoutSchema extends AbstractSchema {
'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 @@ class CheckoutSchema extends AbstractSchema {
$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;
}
}

View File

@ -42,7 +42,28 @@ class ShippingAddressSchema extends AbstractAddressSchema {
$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 @@ class ShippingAddressSchema extends AbstractAddressSchema {
'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(

View File

@ -130,6 +130,7 @@ trait CheckoutTrait {
$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 @@ trait CheckoutTrait {
$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 );
}
}
}

View File

@ -3,6 +3,8 @@ namespace Automattic\WooCommerce\StoreApi\Utilities;
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 @@ use Automattic\WooCommerce\StoreApi\Exceptions\RouteException;
*/
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.
*
@ -108,7 +124,7 @@ class OrderController {
if ( $order->get_customer_id() ) {
$customer = new \WC_Customer( $order->get_customer_id() );
$customer->set_props(
[
array(
'billing_first_name' => $order->get_billing_first_name(),
'billing_last_name' => $order->get_billing_last_name(),
'billing_company' => $order->get_billing_company(),
@ -130,9 +146,14 @@ class OrderController {
'shipping_postcode' => $order->get_shipping_postcode(),
'shipping_country' => $order->get_shipping_country(),
'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();
};
}
@ -173,18 +194,18 @@ class OrderController {
*/
protected function validate_coupons( \WC_Order $order ) {
$coupon_codes = $order->get_coupon_codes();
$coupons = array_filter( array_map( [ $this, 'get_coupon' ], $coupon_codes ) );
$validators = [ 'validate_coupon_email_restriction', 'validate_coupon_usage_limit' ];
$coupon_errors = [];
$coupons = array_filter( array_map( array( $this, 'get_coupon' ), $coupon_codes ) );
$validators = array( 'validate_coupon_email_restriction', 'validate_coupon_usage_limit' );
$coupon_errors = array();
foreach ( $coupons as $coupon ) {
try {
array_walk(
$validators,
function( $validator, $index, $params ) {
call_user_func_array( [ $this, $validator ], $params );
call_user_func_array( array( $this, $validator ), $params );
},
[ $coupon, $order ]
array( $coupon, $order )
);
} catch ( Exception $error ) {
$coupon_errors[ $coupon->get_code() ] = $error->getMessage();
@ -214,9 +235,9 @@ class OrderController {
array_values( $coupon_errors )[0],
),
409,
[
array(
'removed_coupons' => $coupon_errors,
]
)
);
} else {
throw new RouteException(
@ -227,9 +248,9 @@ class OrderController {
implode( '", "', array_keys( $coupon_errors ) )
),
409,
[
array(
'removed_coupons' => $coupon_errors,
]
)
);
}
}
@ -274,49 +295,49 @@ class OrderController {
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)', 'woocommerce' ),
$shipping_address['country']
$shipping_country
),
400,
[
array(
'allowed_countries' => array_keys( wc()->countries->get_shipping_countries() ),
]
)
);
}
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)', 'woocommerce' ),
$billing_address['country']
$billing_country
),
400,
[
array(
'allowed_countries' => array_keys( wc()->countries->get_allowed_countries() ),
]
)
);
}
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;
}
$errors_by_code = [];
$errors_by_code = array();
$error_codes = $errors->get_error_codes();
foreach ( $error_codes as $code ) {
$errors_by_code[ $code ] = $errors->get_error_messages( $code );
@ -332,9 +353,9 @@ class OrderController {
'shipping' === $code ? __( 'shipping address', 'woocommerce' ) : __( 'billing address', 'woocommerce' )
),
400,
[
array(
'errors' => $errors_by_code,
]
)
);
}
}
@ -353,56 +374,33 @@ class OrderController {
/**
* 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();
$current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : [];
$address = $order->get_address( $address_type );
$current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : array();
/**
* 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', 'woocommerce' ),
'required' => true,
],
'last_name' => [
'label' => __( 'Last name', 'woocommerce' ),
'required' => true,
],
'company' => [
'label' => __( 'Company', 'woocommerce' ),
'required' => false,
],
'address_1' => [
'label' => __( 'Address', 'woocommerce' ),
'required' => true,
],
'address_2' => [
'label' => __( 'Apartment, suite, etc.', 'woocommerce' ),
'required' => false,
],
'country' => [
'label' => __( 'Country/Region', 'woocommerce' ),
'required' => true,
],
'city' => [
'label' => __( 'City', 'woocommerce' ),
'required' => true,
],
'state' => [
'label' => __( 'State/County', 'woocommerce' ),
'required' => true,
],
'postcode' => [
'label' => __( 'Postal code', 'woocommerce' ),
'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 ) {
@ -431,7 +429,7 @@ class OrderController {
protected function validate_coupon_email_restriction( \WC_Coupon $coupon, \WC_Order $order ) {
$restrictions = $coupon->get_email_restrictions();
if ( ! empty( $restrictions ) && $order->get_billing_email() && ! wc()->cart->is_coupon_emails_allowed( [ $order->get_billing_email() ], $restrictions ) ) {
if ( ! empty( $restrictions ) && $order->get_billing_email() && ! wc()->cart->is_coupon_emails_allowed( array( $order->get_billing_email() ), $restrictions ) ) {
throw new Exception( $coupon->get_coupon_error( \WC_Coupon::E_WC_COUPON_NOT_YOURS_REMOVED ) );
}
}
@ -455,11 +453,11 @@ class OrderController {
// We get usage per user id and associated emails.
$usage_count = $this->get_usage_per_aliases(
$coupon,
[
array(
$order->get_billing_email(),
$order->get_customer_id(),
$this->get_email_from_user_id( $order->get_customer_id() ),
]
)
);
} else {
// Otherwise we check if the email doesn't belong to an existing user.
@ -469,7 +467,7 @@ class OrderController {
$user_ids = $customer_data_store->get_user_ids_for_billing_email( array( $order->get_billing_email() ) );
// Convert all found user ids to a list of email addresses.
$user_emails = array_map( [ $this, 'get_email_from_user_id' ], $user_ids );
$user_emails = array_map( array( $this, 'get_email_from_user_id' ), $user_ids );
// This matches a user against the given billing email and gets their ID/email/billing email.
$found_user = get_user_by( 'email', $order->get_billing_email() );
@ -550,7 +548,7 @@ class OrderController {
'woocommerce_rest_invalid_shipping_option',
__( 'Sorry, this order requires a shipping option.', 'woocommerce' ),
400,
[]
array()
);
}
}
@ -714,7 +712,7 @@ class OrderController {
*/
protected function update_addresses_from_cart( \WC_Order $order ) {
$order->set_props(
[
array(
'billing_first_name' => wc()->customer->get_billing_first_name(),
'billing_last_name' => wc()->customer->get_billing_last_name(),
'billing_company' => wc()->customer->get_billing_company(),
@ -736,7 +734,11 @@ class OrderController {
'shipping_postcode' => wc()->customer->get_shipping_postcode(),
'shipping_country' => wc()->customer->get_shipping_country(),
'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 );
}
}
}

View File

@ -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();
}
}