diff --git a/docs/docs-manifest.json b/docs/docs-manifest.json index 96457b28a0b..c5ec85a7784 100644 --- a/docs/docs-manifest.json +++ b/docs/docs-manifest.json @@ -813,7 +813,7 @@ "post_title": "Product editor development handbook", "menu_title": "Development handbook", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-editor-development/product-editor.md", - "hash": "b574a4a5476899342cd229033a22ecdf9859914ea34446f8276e2b0ad5cb8c7f", + "hash": "bbf230f9f13bc2404096f5b5b2f7394ba5ab391d78e5efa9627342f5b33be7dd", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-editor-development/product-editor.md", "id": "59450404de2750d918137e7cf523e52bedfd7214", "links": { @@ -839,7 +839,30 @@ "id": "0c29c74a7e7e9fd88562df1afa489659f460879e" } ], - "categories": [] + "categories": [ + { + "content": "# How-to Guides for the Product form\n\nThere are several ways to extend and modify the new product form. Below are links to different tutorials.\n\n## Generic fields\n\nOne way to extend the new product form is by making use of generic fields. This allows you to easily modify the form using PHP only. We have a wide variety of generic fields that you can use, like a text, checkbox, pricing or a select field. You can find the full list [here](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/product-editor/src/blocks/generic/README.md).\n\nTo see how you can make use of these fields you can follow the [generic fields tutorial](https://github.com/woocommerce/woocommerce/blob/trunk/docs/product-editor-development/how-to-guides/generic-fields-tutorial.md).\n\n## Writing a custom field\n\nIt is also possible to write your own custom field and render those within the product form. This is helpful if the generic fields don't quite fit your use case.\nTo see an example of how to create a basic dropdown field in the product form you can follow [this tutorial](https://github.com/woocommerce/woocommerce/blob/trunk/docs/product-editor-development/how-to-guides/custom-field-tutorial.md).\n", + "category_slug": "how-to-guides", + "category_title": "How To Guides", + "posts": [ + { + "post_title": "Extending the product form with generic fields", + "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-editor-development/how-to-guides/generic-fields-tutorial.md", + "hash": "dc00134aa0a50c6d2e7aa5fb558c2d307fb45ab23cd5a31ab6133eebf83a12bd", + "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-editor-development/how-to-guides/generic-fields-tutorial.md", + "id": "f221ccb6d42c5e67a0a7916b955253ab7e546641" + }, + { + "post_title": "Extending the product form with custom fields", + "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-editor-development/how-to-guides/custom-field-tutorial.md", + "hash": "fd33aa63a9d397a1df35ef032a6f1f76fd1ab5aac1bccc94e29a56fa8bfc2aa4", + "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-editor-development/how-to-guides/custom-field-tutorial.md", + "id": "fed80efbb225df9054fadd6e1fc45c2cd03e7f99" + } + ], + "categories": [] + } + ] }, { "content": "\nEnsuring the quality of your WooCommerce projects is essential. This section will delve into quality exoectations, best practices, coding standards, and other methodologies to ensure your projects stand out in terms of reliability, efficiency, user experience, and more. \n", @@ -1464,5 +1487,5 @@ "categories": [] } ], - "hash": "d8fe058ebcce4d1f40585f1634b1e98e27063d81dd0dd7783217ab60d0bc915a" + "hash": "013248fe7a892f063140734071d4c29205d819e137991f9ea25676c1364f3d66" } \ No newline at end of file diff --git a/docs/product-editor-development/how-to-guides/README.md b/docs/product-editor-development/how-to-guides/README.md new file mode 100644 index 00000000000..248cae58c27 --- /dev/null +++ b/docs/product-editor-development/how-to-guides/README.md @@ -0,0 +1,14 @@ +# How-to Guides for the Product form + +There are several ways to extend and modify the new product form. Below are links to different tutorials. + +## Generic fields + +One way to extend the new product form is by making use of generic fields. This allows you to easily modify the form using PHP only. We have a wide variety of generic fields that you can use, like a text, checkbox, pricing or a select field. You can find the full list [here](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/product-editor/src/blocks/generic/README.md). + +To see how you can make use of these fields you can follow the [generic fields tutorial](https://github.com/woocommerce/woocommerce/blob/trunk/docs/product-editor-development/how-to-guides/generic-fields-tutorial.md). + +## Writing a custom field + +It is also possible to write your own custom field and render those within the product form. This is helpful if the generic fields don't quite fit your use case. +To see an example of how to create a basic dropdown field in the product form you can follow [this tutorial](https://github.com/woocommerce/woocommerce/blob/trunk/docs/product-editor-development/how-to-guides/custom-field-tutorial.md). diff --git a/docs/product-editor-development/how-to-guides/custom-field-tutorial.md b/docs/product-editor-development/how-to-guides/custom-field-tutorial.md new file mode 100644 index 00000000000..64fb3203025 --- /dev/null +++ b/docs/product-editor-development/how-to-guides/custom-field-tutorial.md @@ -0,0 +1,263 @@ +# Extending the product form with custom fields + +Aside from extending the product form using generic fields it is also possible to use custom fields. This does require knowledge of JavaScript and React. +If you are already familiar with writing blocks for the WordPress site editor this will feel very similar. + +## Getting started + +To get started we would recommend reading through the [fundamentals of block development docs](https://developer.wordpress.org/block-editor/getting-started/fundamentals/) in WordPress. This gives a good overview of working with blocks, the block structure, and the [JavaScript build process](https://developer.wordpress.org/block-editor/getting-started/fundamentals/javascript-in-the-block-editor/). + +This tutorial will use vanilla JavaScript to render a new field in the product form for those that already have a plugin and may not have a JavaScript build process set up yet. +If you want to create a plugin from scratch with the necessary build tools, we recommend using the `@wordpress/create-block` script. We also have a specific template for the product form: [README](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/create-product-editor-block/README.md). + +## Creating a custom field + +### Adding and registering our custom field + +Adding and registering our custom field is very similar as creating a brand new block. + +Inside a new folder within your plugin, let's create a `block.json` file with the contents below. The only main difference between this `block.json` and a `block.json` for the site editor is that we will set `supports.inserter` to false, so it doesn't show up there. We will also be registering this slightly different. + +```json +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "tutorial/new-product-form-field", + "title": "Product form field", + "category": "woocommerce", + "description": "A sample field for the product form", + "keywords": [ "products" ], + "attributes": {}, + "supports": { + "html": false, + "multiple": true, + // Setting inserter to false is important so that it doesn't show in the site editor. + "inserter": false + }, + "textdomain": "woocommerce", + "editorScript": "file:./index.js" +} +``` + +In the same directory, create a `index.js` file, which we can keep simple by just outputting a hello world. +In this case the `edit` function is the part that will get rendered in the form. We are wrapping it with the `createElement` function to keep support for React. + +```javascript +( function ( wp ) { + var el = wp.element.createElement; + + wp.blocks.registerBlockType( 'tutorial/new-product-form-field', { + title: 'Product form field', + attributes: {}, + edit: function () { + return el( 'p', {}, 'Hello World (from the editor).' ); + }, + } ); +} )( window.wp ); +``` + +In React: + +```jsx +import { registerBlockType } from '@wordpress/blocks'; + +function Edit() { + return

Hello World (from the editor).

; +} + +registerBlockType( 'tutorial/new-product-form-field', { + title: 'Product form field', + attributes: {}, + edit: Edit, +} ); +``` + +Lastly, in order to make this work the block registration needs to know about the JavaScript dependencies, we can do so by adding a `index.asset.php` file with the below contents: + +```php + array('react', 'wc-product-editor', 'wp-blocks' ) ); +``` + +Now that we have all the for the field we need to register it and add it to the template. + +Registering can be done on `init` by calling `BlockRegistry::get_instance()->register_block_type_from_metadata` like so: + +```php +use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\BlockRegistry; + +function example_custom_product_form_init() { + if ( isset( $_GET['page'] ) && $_GET['page'] === 'wc-admin' ) { + // This points to the directory that contains your block.json. + BlockRegistry::get_instance()->register_block_type_from_metadata( __DIR__ . '/js/sample-block' ); + } +} +add_action( 'init', 'example_custom_product_form_init' ); +``` + +We can add it to the product form by hooking into the `woocommerce_layout_template_after_instantiation` action ( see [block addition and removal](https://github.com/woocommerce/woocommerce/blob/trunk/docs/product-editor-development/block-template-lifecycle.md#block-addition-and-removal) ). + +What we did was the following ( see [here](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/ProductTemplates/README.md#usage) for more related functions ): + +- Get a group by the `general` id, this is the General tab. +- Create a new section on the general tab called `Tutorial Section` +- Add our custom field to the `Tutorial Section` + +```php +add_action( + 'woocommerce_layout_template_after_instantiation', + function( $layout_template_id, $layout_template_area, $layout_template ) { + $general = $layout_template->get_group_by_id( 'general' ); + + if ( $general ) { + // Creating a new section, this is optional. + $tutorial_section = $general->add_section( + array( + 'id' => 'tutorial-section', + 'order' => 15, + 'attributes' => array( + 'title' => __( 'Tutorial Section', 'woocommerce' ), + 'description' => __( 'Fields related to the tutorial', 'woocommerce' ), + ), + ) + ); + $tutorial_section->add_block( + [ + 'id' => 'example-new-product-form-field', + 'blockName' => 'tutorial/new-product-form-field', + 'attributes' => [], + ] + ); + } + }, + 10, + 3 +); +``` + +### Turn field into a dropdown + +We recommend using components from `@wordpress/components` as this will also keep the styling consistent. We will use the [ComboboxControl](https://wordpress.github.io/gutenberg/?path=/docs/components-comboboxcontrol--docs) core component in this field. + +We can add it to our `edit` function pretty easily by making use of `wp.components`. We will also add a constant for the filter options. +**Note:** I also added the `blockProps` to the top element, we still recommend using this as some of these props are being used in the product form. When we add the block props we need to also let the form know it is an interactive element. We do this by adding at-least one attribute with the `__experimentalRole` set to `content`. +So lets add this to our `index.js` attributes: + +```javascript + attributes: { + "message": { + "type": "string", + "__experimentalRole": "content", + "source": "text", + "selector": "div" + } +}, +``` + +Dropdown options, these can live outside of the `edit` function: + +```javascript +const DROPDOWN_OPTIONS = [ + { + value: 'small', + label: 'Small', + }, + { + value: 'normal', + label: 'Normal', + }, + { + value: 'large', + label: 'Large', + }, +]; +``` + +The updated `edit` function: + +```javascript +// edit function. +function ( { attributes } ) { + // useState is a React specific function. + const [ value, setValue ] = wp.element.useState(); + const [ filteredOptions, setFilteredOptions ] = wp.element.useState( DROPDOWN_OPTIONS ); + + const blockProps = window.wc.blockTemplates.useWooBlockProps( attributes ); + + return el( 'div', { ...blockProps }, [ + el( wp.components.ComboboxControl, { + label: "Example dropdown", + value: value, + onChange: setValue, + options: filteredOptions, + onFilterValueChange: function( inputValue ) { + setFilteredOptions( + DROPDOWN_OPTIONS.filter( ( option ) => + option.label + .toLowerCase() + .startsWith( inputValue.toLowerCase() ) + ) + ) + } + } ) + ] ); +}, +``` + +In React: + +```jsx +import { createElement, useState } from '@wordpress/element'; +import { ComboboxControl } from '@wordpress/components'; +import { useWooBlockProps } from '@woocommerce/block-templates'; + +function Edit( { attributes } ) { + const [ value, setValue ] = useState(); + const [ filteredOptions, setFilteredOptions ] = + useState( DROPDOWN_OPTIONS ); + + const blockProps = useWooBlockProps( attributes ); + return ( +
+ + setFilteredOptions( + DROPDOWN_OPTIONS.filter( ( option ) => + option.label + .toLowerCase() + .startsWith( inputValue.toLowerCase() ) + ) + ) + } + /> +
+ ); +} +``` + +### Save field data to the product data + +We can make use of the `__experimentalUseProductEntityProp` for saving the field input to the product. +The function does rely on `postType`, we can hardcode this to `product`, but the `postType` is also exposed through a context. We can do so by adding `"usesContext": [ "postType" ],` to the `block.json` and getting it from the `context` passed into the `edit` function props. + +So the top part of the edit function will look like this, where we also replace the `value, setValue` `useState` line: + +```javascript +// edit function. +function ( { attributes, context } ) { + const [ value, setValue ] = window.wc.productEditor.__experimentalUseProductEntityProp( + 'meta_data.animal_type', + { + postType: context.postType, + fallbackValue: '', + } + ); + // .... Rest of edit function +``` + +Now if you select small, medium, or large from the dropdown and save your product, the value should persist correctly. + +Note, the above function supports the use of `meta_data` by dot notation, but you can also target other fields like `regular_price` or `summary`. diff --git a/docs/product-editor-development/how-to-guides/generic-fields-tutorial.md b/docs/product-editor-development/how-to-guides/generic-fields-tutorial.md new file mode 100644 index 00000000000..e4b2d456fdc --- /dev/null +++ b/docs/product-editor-development/how-to-guides/generic-fields-tutorial.md @@ -0,0 +1,58 @@ +# Extending the product form with generic fields + +We have large list of generic fields that a plugin can use to extend the new product form. You can find the full list [here](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/product-editor/src/blocks/generic/README.md). Each field contains documentation for what attributes the field supports. + +## Using a generic block + +Using a generic block is pretty easy. We have created an template API that allows you to add new fields, the API refers to them as `blocks`. There are a couple actions that allow us to interact with these templates. There is the `woocommerce_layout_template_after_instantiation` that is triggered when a new template is registered. There are also other actions triggered when a specific field/block is added ( see [block addition and removal](https://github.com/woocommerce/woocommerce/blob/trunk/docs/product-editor-development/block-template-lifecycle.md#block-addition-and-removal) ). + +Let's say we want to add something to the basic details section, we can do so by making use of the above mentioned hook: + +This will add a number field called **Animal age** to each template that has a `basic-details` section. + +```php +add_action( + 'woocommerce_layout_template_after_instantiation', + function( $layout_template_id, $layout_template_area, $layout_template ) { + $basic_details = $layout_template->get_section_by_id( 'basic-details' ); + + if ( $basic_details ) { + $basic_details->add_block( + [ + 'id' => 'example-tutorial-animal-age', + // This orders the field, core fields are seperated by sums of 10. + 'order' => 40, + 'blockName' => 'woocommerce/product-number-field', + 'attributes' => [ + // Attributes specific for the product-number-field. + 'label' => 'Animal age', + 'property' => 'meta_data.animal_age', + 'suffix' => 'Yrs', + 'placeholder' => 'Age of animal', + 'required' => true, + 'min' => 1, + 'max' => 20 + ], + ] + ); + } + }, + 10, + 3 +); +``` + +### Dynamically hiding or showing the generic field + +It is also possible to dynamically hide or show your field if data on the product form changes. +We can do this by adding a `hideCondition` ( plural ). For example if we wanted to hide our field if the product price is higher than 20, we can do so by adding this expression: + +```php +'hideConditions' => array( + array( + 'expression' => 'editedProduct.regular_price >= 20', + ), +), +``` + +The `hideConditions` also support targeting meta data by using dot notation. You can do so by writing an expression like this: `! editedProduct.meta_data.animal_type` that will hide a field if the `animal_type` meta data value doesn't exist. diff --git a/docs/product-editor-development/product-editor.md b/docs/product-editor-development/product-editor.md index d6233925b36..a490cca254b 100644 --- a/docs/product-editor-development/product-editor.md +++ b/docs/product-editor-development/product-editor.md @@ -37,6 +37,7 @@ Please note that this check is currently not being enforced: the product editor ## Related documentation - [Examples on Template API usage](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Admin/Features/ProductBlockEditor/ProductTemplates/README.md/) +- [How to guides](https://github.com/woocommerce/woocommerce/blob/trunk/docs/product-editor-development/how-to-guides/README.md) - [Related hooks and Template API documentation](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/src/Admin/BlockTemplates/README.md) - [Generic blocks documentation](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/product-editor/src/blocks/generic/README.md) - [Validations and error handling](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/product-editor/src/contexts/validation-context/README.md) diff --git a/plugins/woocommerce/changelog/fix-cart-token-disable-nonces-42341 b/plugins/woocommerce/changelog/fix-cart-token-disable-nonces-42341 new file mode 100644 index 00000000000..b60b590f644 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-cart-token-disable-nonces-42341 @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Store API: Remove the need for nonces when using cart tokens. Remove deprecated X-WC-Store-API-Nonce header. diff --git a/plugins/woocommerce/changelog/fix-state-requirement b/plugins/woocommerce/changelog/fix-state-requirement new file mode 100644 index 00000000000..2f55beac127 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-state-requirement @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Prevent Store API orders being placed with empty state diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php index 62453b1a5a7..ab9e4c19279 100644 --- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php +++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php @@ -780,7 +780,7 @@ class CheckoutFields { * @return mixed */ public function update_default_locale_with_fields( $locale ) { - foreach ( $this->fields_locations['address'] as $field_id => $additional_field ) { + foreach ( $this->get_fields_for_location( 'address' ) as $field_id => $additional_field ) { if ( empty( $locale[ $field_id ] ) ) { $locale[ $field_id ] = $additional_field; } diff --git a/plugins/woocommerce/src/StoreApi/Authentication.php b/plugins/woocommerce/src/StoreApi/Authentication.php index 463a82d23bb..0e5078887e9 100644 --- a/plugins/woocommerce/src/StoreApi/Authentication.php +++ b/plugins/woocommerce/src/StoreApi/Authentication.php @@ -33,7 +33,6 @@ class Authentication { public function allowed_cors_headers( $allowed_headers ) { $allowed_headers[] = 'Cart-Token'; $allowed_headers[] = 'Nonce'; - $allowed_headers[] = 'X-WC-Store-API-Nonce'; return $allowed_headers; } diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php index a760664327f..e26cb056934 100644 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php +++ b/plugins/woocommerce/src/StoreApi/Routes/V1/AbstractCartRoute.php @@ -70,6 +70,13 @@ abstract class AbstractCartRoute extends AbstractRoute { */ protected $additional_fields_controller; + /** + * True when this route has been requested with a valid cart token. + * + * @var bool|null + */ + protected $has_cart_token = null; + /** * Constructor. * @@ -151,9 +158,6 @@ abstract class AbstractCartRoute extends AbstractRoute { $response->header( 'User-ID', get_current_user_id() ); $response->header( 'Cart-Token', $this->get_cart_token() ); - // The following headers are deprecated and should be removed in a future version. - $response->header( 'X-WC-Store-API-Nonce', $nonce ); - return $response; } @@ -163,9 +167,7 @@ abstract class AbstractCartRoute extends AbstractRoute { * @param \WP_REST_Request $request Request object. */ protected function load_cart_session( \WP_REST_Request $request ) { - $cart_token = $request->get_header( 'Cart-Token' ); - - if ( $cart_token && JsonWebToken::validate( $cart_token, $this->get_cart_token_secret() ) ) { + if ( $this->has_cart_token( $request ) ) { // Overrides the core session class. add_filter( 'woocommerce_session_handler', @@ -174,7 +176,6 @@ abstract class AbstractCartRoute extends AbstractRoute { } ); } - $this->cart_controller->load_cart(); } @@ -222,6 +223,19 @@ abstract class AbstractCartRoute extends AbstractRoute { return time() + intval( apply_filters( 'wc_session_expiration', DAY_IN_SECONDS * 2 ) ); } + /** + * Checks if the request has a valid cart token. + * + * @param \WP_REST_Request $request Request object. + * @return bool + */ + protected function has_cart_token( \WP_REST_Request $request ) { + if ( is_null( $this->has_cart_token ) ) { + $this->has_cart_token = JsonWebToken::validate( $request->get_header( 'Cart-Token' ) ?? '', $this->get_cart_token_secret() ); + } + return $this->has_cart_token; + } + /** * Checks if a nonce is required for the route. * @@ -230,7 +244,7 @@ abstract class AbstractCartRoute extends AbstractRoute { * @return bool */ protected function requires_nonce( \WP_REST_Request $request ) { - return $this->is_update_request( $request ); + return $this->is_update_request( $request ) && ! $this->has_cart_token( $request ); } /** @@ -286,12 +300,6 @@ abstract class AbstractCartRoute extends AbstractRoute { if ( $request->get_header( 'Nonce' ) ) { $nonce = $request->get_header( 'Nonce' ); - } elseif ( $request->get_header( 'X-WC-Store-API-Nonce' ) ) { - $nonce = $request->get_header( 'X-WC-Store-API-Nonce' ); - - // @todo Remove handling and sending of deprecated X-WC-Store-API-Nonce Header (Blocks 7.5.0) - wc_deprecated_argument( 'X-WC-Store-API-Nonce', '7.2.0', 'Use the "Nonce" Header instead. This header will be removed after Blocks release 7.5' ); - rest_handle_deprecated_argument( 'X-WC-Store-API-Nonce', 'Use the "Nonce" Header instead. This header will be removed after Blocks release 7.5', '7.2.0' ); } /** diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php index 144bd861ad2..44dd3118b06 100644 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php +++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Batch.php @@ -128,7 +128,6 @@ class Batch extends AbstractRoute implements RouteInterface { $nonce = wp_create_nonce( 'wc_store_api' ); $response->header( 'Nonce', $nonce ); - $response->header( 'X-WC-Store-API-Nonce', $nonce ); $response->header( 'Nonce-Timestamp', time() ); $response->header( 'User-ID', get_current_user_id() ); diff --git a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php index dbdf838fb28..243403488f5 100644 --- a/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php +++ b/plugins/woocommerce/src/StoreApi/Routes/V1/Checkout.php @@ -1,12 +1,14 @@ has_cart_token( $request ); } /** @@ -174,6 +176,54 @@ class Checkout extends AbstractCartRoute { ); } + /** + * Validate required additional fields on request. + * + * @param \WP_REST_Request $request Request object. + * + * @throws RouteException When a required additional field is missing. + */ + public function validate_required_additional_fields( \WP_REST_Request $request ) { + $contact_fields = $this->additional_fields_controller->get_fields_for_location( 'contact' ); + $order_fields = $this->additional_fields_controller->get_fields_for_location( 'order' ); + $order_and_contact_fields = array_merge( $contact_fields, $order_fields ); + + if ( ! empty( $order_and_contact_fields ) ) { + foreach ( $order_and_contact_fields as $field_key => $order_and_contact_field ) { + if ( $order_and_contact_field['required'] && ! isset( $request['additional_fields'][ $field_key ] ) ) { + throw new RouteException( + 'woocommerce_rest_checkout_missing_required_field', + /* translators: %s: is the field label */ + esc_html( sprintf( __( 'There was a problem with the provided additional fields: %s is required', 'woocommerce' ), $order_and_contact_field['label'] ) ), + 400 + ); + } + } + } + + $address_fields = $this->additional_fields_controller->get_fields_for_location( 'address' ); + if ( ! empty( $address_fields ) ) { + foreach ( $address_fields as $field_key => $address_field ) { + if ( $address_field['required'] && ! isset( $request['billing_address'][ $field_key ] ) ) { + throw new RouteException( + 'woocommerce_rest_checkout_missing_required_field', + /* translators: %s: is the field label */ + esc_html( sprintf( __( 'There was a problem with the provided billing address: %s is required', 'woocommerce' ), $address_field['label'] ) ), + 400 + ); + } + if ( $address_field['required'] && ! isset( $request['shipping_address'][ $field_key ] ) ) { + throw new RouteException( + 'woocommerce_rest_checkout_missing_required_field', + /* translators: %s: is the field label */ + esc_html( sprintf( __( 'There was a problem with the provided shipping address: %s is required', 'woocommerce' ), $address_field['label'] ) ), + 400 + ); + } + } + } + } + /** * Process an order. * @@ -201,6 +251,11 @@ class Checkout extends AbstractCartRoute { */ $this->cart_controller->validate_cart(); + /** + * Validate additional fields on request. + */ + $this->validate_required_additional_fields( $request ); + /** * Persist customer session data from the request first so that OrderController::update_addresses_from_cart * uses the up to date customer address. diff --git a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php index 2ecf7ebc3e7..cdcf28e56f1 100644 --- a/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php +++ b/plugins/woocommerce/src/StoreApi/Schemas/V1/AbstractAddressSchema.php @@ -164,7 +164,8 @@ abstract class AbstractAddressSchema extends AbstractSchema { $address = (array) $address; $validation_util = new ValidationUtils(); $schema = $this->get_properties(); - // omit all keys from address that are not in the schema. This should account for email. + + // Omit all keys from address that are not in the schema. This should account for email. $address = array_intersect_key( $address, $schema ); // The flow is Validate -> Sanitize -> Re-Validate @@ -179,6 +180,7 @@ abstract class AbstractAddressSchema extends AbstractSchema { if ( empty( $schema[ $key ] ) || empty( $address[ $key ] ) ) { continue; } + if ( is_wp_error( rest_validate_value_from_schema( $value, $schema[ $key ], $key ) ) ) { $errors->add( 'invalid_' . $key, diff --git a/plugins/woocommerce/src/StoreApi/Utilities/JsonWebToken.php b/plugins/woocommerce/src/StoreApi/Utilities/JsonWebToken.php index b7848dcac5c..88a831f1de3 100644 --- a/plugins/woocommerce/src/StoreApi/Utilities/JsonWebToken.php +++ b/plugins/woocommerce/src/StoreApi/Utilities/JsonWebToken.php @@ -50,6 +50,10 @@ final class JsonWebToken { * @return bool */ public static function validate( string $token, string $secret ) { + if ( ! $token ) { + return false; + } + /** * Confirm the structure of a JSON Web Token, it has three parts separated * by dots and complies with Base64URL standards. diff --git a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php index 4ef0342b28d..1a2cbbec879 100644 --- a/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php +++ b/plugins/woocommerce/src/StoreApi/Utilities/OrderController.php @@ -381,30 +381,16 @@ class OrderController { $address = $order->get_address( $address_type ); $current_locale = isset( $all_locales[ $address['country'] ] ) ? $all_locales[ $address['country'] ] : array(); + foreach ( $all_locales['default'] as $key => $value ) { + $default_value = empty( $current_locale[ $key ] ) ? [] : $current_locale[ $key ]; + $current_locale[ $key ] = wp_parse_args( $default_value, $value ); + } + $additional_fields = $this->additional_fields_controller->get_all_fields_from_object( $order, $address_type ); $address = array_merge( $address, $additional_fields ); - $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 ) { - if ( isset( $address_fields[ $key ] ) ) { - $address_fields[ $key ]['label'] = isset( $field['label'] ) ? $field['label'] : $address_fields[ $key ]['label']; - $address_fields[ $key ]['required'] = isset( $field['required'] ) ? $field['required'] : $address_fields[ $key ]['required']; - } - } - } - - foreach ( $address_fields as $address_field_key => $address_field ) { + foreach ( $current_locale as $address_field_key => $address_field ) { if ( empty( $address[ $address_field_key ] ) && $address_field['required'] ) { /* translators: %s Field label. */ $errors->add( $address_type, sprintf( __( '%s is required', 'woocommerce' ), $address_field['label'] ), $address_field_key ); diff --git a/plugins/woocommerce/src/StoreApi/docs/cart-tokens.md b/plugins/woocommerce/src/StoreApi/docs/cart-tokens.md new file mode 100644 index 00000000000..0002e00f000 --- /dev/null +++ b/plugins/woocommerce/src/StoreApi/docs/cart-tokens.md @@ -0,0 +1,37 @@ +# Cart Tokens + +## Table of Contents + +- [Obtaining a Cart Token](#obtaining-a-cart-token) +- [How to use a Cart-Token](#how-to-use-a-cart-token) + +Cart tokens can be used instead of cookies based sessions for headless interaction with carts. When using a `Cart-Token` a [Nonce Token](nonce-tokens.md) is not required. + +## Obtaining a Cart Token + +Requests to `/cart` endpoints return a `Cart-Token` header alongside the response. This contains a token which can later be sent as a request header to the Store API Cart and Checkout endpoints to identify the cart. + +The quickest method of obtaining a Cart Token is to make a GET request `/wp-json/wc/store/v1/cart` and observe the response headers. You should see a `Cart-Token` header there. + +## How to use a Cart-Token + +To use a `Cart-Token`, include it as a header with your request. The response will contain the current cart state from the session associated with the `Cart-Token`. + +**Example:** + +```sh +curl --header "Cart-Token: 12345" --request GET https://example-store.com/wp-json/wc/store/v1/cart +``` + +The same method will allow you to checkout using a `Cart-Token` on the `/checkout` route. + + + +--- + +[We're hiring!](https://woocommerce.com/careers/) Come work with us! + +🐞 Found a mistake, or have a suggestion? [Leave feedback about this document here.](https://github.com/woocommerce/woocommerce-blocks/issues/new?assignees=&labels=type%3A+documentation&template=--doc-feedback.md&title=Feedback%20on%20./src/StoreApi/docs/cart-tokens.md) + + + diff --git a/plugins/woocommerce/src/StoreApi/docs/cart.md b/plugins/woocommerce/src/StoreApi/docs/cart.md index dfc78215069..1990cf7f6bf 100644 --- a/plugins/woocommerce/src/StoreApi/docs/cart.md +++ b/plugins/woocommerce/src/StoreApi/docs/cart.md @@ -16,7 +16,7 @@ The cart API returns the current state of the cart for the current session or logged in user. -All POST endpoints require [Nonce Tokens](nonce-tokens.md) and return the updated state of the full cart once complete. +All POST endpoints require a [Nonce Token](nonce-tokens.md) or a [Cart Token](cart-tokens.md) and return the updated state of the full cart once complete. ## Get Cart @@ -391,7 +391,7 @@ This allows the client to remain in sync with the cart data without additional r Add an item to the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/add-item @@ -494,7 +494,7 @@ The JSON payload for adding multiple items to the cart would look like this: Remove an item from the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/remove-item @@ -514,7 +514,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Update an item in the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/update-item @@ -535,7 +535,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Apply a coupon to the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/apply-coupon/ @@ -555,7 +555,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Remove a coupon from the cart and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/remove-coupon/ @@ -575,7 +575,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Update customer data and return the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/update-customer @@ -610,7 +610,7 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon Selects an available shipping rate for a package, then returns the full cart response, or an error. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. +This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) or [Cart Token](cart-tokens.md) is provided. ```http POST /cart/select-shipping-rate diff --git a/plugins/woocommerce/src/StoreApi/docs/checkout-order.md b/plugins/woocommerce/src/StoreApi/docs/checkout-order.md index 168f679a6c0..53c2a4437af 100644 --- a/plugins/woocommerce/src/StoreApi/docs/checkout-order.md +++ b/plugins/woocommerce/src/StoreApi/docs/checkout-order.md @@ -7,15 +7,13 @@ The checkout order API facilitates the processing of existing orders and handling payments. -All checkout order endpoints require [Nonce Tokens](nonce-tokens.md). +All checkout order endpoints require a [Nonce Token](nonce-tokens.md) or a [Cart Token](cart-tokens.md) otherwise these endpoints will return an error. ## Process Order and Payment Accepts the final chosen payment method, and any additional payment data, then attempts payment and returns the result. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. - ```http POST /wc/store/v1/checkout/{ORDER_ID} ``` diff --git a/plugins/woocommerce/src/StoreApi/docs/checkout.md b/plugins/woocommerce/src/StoreApi/docs/checkout.md index 418bca01b29..4dcae15739b 100644 --- a/plugins/woocommerce/src/StoreApi/docs/checkout.md +++ b/plugins/woocommerce/src/StoreApi/docs/checkout.md @@ -7,17 +7,12 @@ The checkout API facilitates the creation of orders (from the current cart) and handling payments for payment methods. -All checkout endpoints require [Nonce Tokens](nonce-tokens.md). - -- [Get Checkout Data](#get-checkout-data) -- [Process Order and Payment](#process-order-and-payment) +All checkout endpoints require either a [Nonce Token](nonce-tokens.md) or a [Cart Token](cart-tokens.md) otherwise these endpoints will return an error. ## Get Checkout Data Returns data required for the checkout. This includes a draft order (created from the current cart) and customer billing and shipping addresses. The payment information will be empty, as it's only persisted when the order gets updated via POST requests (right before payment processing). -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. - ```http GET /wc/store/v1/checkout ``` @@ -75,8 +70,6 @@ curl --header "Nonce: 12345" --request GET https://example-store.com/wp-json/wc/ Accepts the final customer addresses and chosen payment method, and any additional payment data, then attempts payment and returns the result. -This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) is provided. - ```http POST /wc/store/v1/checkout ``` diff --git a/plugins/woocommerce/src/StoreApi/docs/nonce-tokens.md b/plugins/woocommerce/src/StoreApi/docs/nonce-tokens.md index 0558a999a13..e6ba7788c4c 100644 --- a/plugins/woocommerce/src/StoreApi/docs/nonce-tokens.md +++ b/plugins/woocommerce/src/StoreApi/docs/nonce-tokens.md @@ -11,7 +11,7 @@ Nonces are generated numbers used to verify origin and intent of requests for se ## Store API Endpoints that Require Nonces -POST requests to the `/cart` endpoints and all requests to the `/checkout` endpoints require a nonce to function. Failure to provide a valid nonce will return an error response. +POST requests to the `/cart` endpoints and all requests to the `/checkout` endpoints require a nonce to function. Failure to provide a valid nonce will return an error response, unless you're using [Cart Tokens](cart-tokens.md) instead. ## Sending Nonce Tokens with requests diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php index a44265d57cf..ff1e5bab87e 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php +++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/AdditionalFields.php @@ -81,6 +81,8 @@ class AdditionalFields extends MockeryTestCase { */ protected function tearDown(): void { parent::tearDown(); + unset( wc()->countries->locale ); + remove_all_filters( 'woocommerce_get_country_locale' ); global $wp_rest_server; $wp_rest_server = null; $this->unregister_fields(); @@ -1545,18 +1547,65 @@ class AdditionalFields extends MockeryTestCase { $request->set_body_params( array( 'billing_address' => (object) array( - 'first_name' => 'test', - 'last_name' => 'test', - 'company' => '', - 'address_1' => 'test', - 'address_2' => '', - 'city' => 'test', - 'state' => '', - 'postcode' => 'cb241ab', - 'country' => 'GB', - 'phone' => '', - 'email' => 'testaccount@test.com', - 'plugin-namespace/gov-id' => 'gov id', + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'gov id', + 'plugin-namespace/my-required-field' => 'req. field', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'gov id', + 'plugin-namespace/my-required-field' => 'req. field', + ), + 'payment_method' => 'bacs', + 'additional_fields' => array( + 'plugin-namespace/job-function' => 'engineering', + ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + $this->assertEquals( 200, $response->get_status(), print_r( $data, true ) ); + + WC()->cart->add_to_cart( $this->products[0]->get_id(), 2 ); + WC()->cart->add_to_cart( $this->products[1]->get_id(), 1 ); + + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'gov id', + 'plugin-namespace/my-required-field' => 'gov id', ), 'shipping_address' => (object) array( 'first_name' => 'test', @@ -1583,6 +1632,50 @@ class AdditionalFields extends MockeryTestCase { $this->assertEquals( 400, $response->get_status(), print_r( $data, true ) ); $this->assertEquals( \sprintf( 'There was a problem with the provided shipping address: %s is required', $label ), $data['message'], print_r( $data, true ) ); + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + 'plugin-namespace/gov-id' => 'gov id', + 'plugin-namespace/my-required-field' => 'gov id', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'plugin-namespace/gov-id' => 'gov id', + 'plugin-namespace/my-required-field' => 'gov id', + ), + 'payment_method' => 'bacs', + 'additional_fields' => array( + 'plugin-namespace/job-function' => '', + ), + ) + ); + $response = rest_get_server()->dispatch( $request ); + $data = $response->get_data(); + + $this->assertEquals( 400, $response->get_status(), print_r( $data, true ) ); + \__internal_woocommerce_blocks_deregister_checkout_field( $id ); // Ensures the field isn't registered. diff --git a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php index e7814c25d42..3aae1fa3851 100644 --- a/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php +++ b/plugins/woocommerce/tests/php/src/Blocks/StoreApi/Routes/Checkout.php @@ -102,17 +102,271 @@ class Checkout extends MockeryTestCase { */ protected function tearDown(): void { parent::tearDown(); + unset( wc()->countries->locale ); $default_zone = \WC_Shipping_Zones::get_zone( 0 ); $shipping_methods = $default_zone->get_shipping_methods(); foreach ( $shipping_methods as $method ) { $default_zone->delete_shipping_method( $method->instance_id ); } $default_zone->save(); + remove_all_filters( 'woocommerce_get_country_locale' ); global $wp_rest_server; $wp_rest_server = null; } + /** + * Ensure that orders can be placed. + */ + public function test_post_data() { + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + 'email' => 'testaccount@test.com', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => 'cb241ab', + 'country' => 'GB', + 'phone' => '', + ), + 'payment_method' => 'bacs', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Ensure that orders cannot be placed with invalid data. + */ + public function test_invalid_post_data() { + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + + // Test with empty state. + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => '', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => '90210', + 'country' => 'US', + 'phone' => '', + 'email' => 'testaccount@test.com', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => '', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => '90210', + 'country' => 'US', + 'phone' => '', + ), + 'payment_method' => 'bacs', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + + // Test with invalid state. + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => '', + 'address_2' => '', + 'city' => 'test', + 'state' => 'GG', + 'postcode' => '90210', + 'country' => 'US', + 'phone' => '', + 'email' => 'testaccount@test.com', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => '', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => 'GG', + 'postcode' => '90210', + 'country' => 'US', + 'phone' => '', + ), + 'payment_method' => 'bacs', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + + // Test with no state passed. + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => '', + 'address_2' => '', + 'city' => 'test', + 'postcode' => '90210', + 'country' => 'US', + 'phone' => '', + 'email' => 'testaccount@test.com', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => '', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'postcode' => '90210', + 'country' => 'US', + 'phone' => '', + ), + 'payment_method' => 'bacs', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 400, $response->get_status() ); + } + + /** + * Ensure that validation respects locale filtering. + */ + public function test_locale_required_filtering_post_data() { + add_filter( + 'woocommerce_get_country_locale', + function ( $locale ) { + $locale['US']['state']['required'] = false; + return $locale; + } + ); + + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + + // Test that a country that usually requires state can be overridden with woocommerce_get_country_locale filter. + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test lane', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => '90210', + 'country' => 'US', + 'phone' => '123456', + 'email' => 'testaccount@test.com', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => '90210', + 'country' => 'US', + 'phone' => '123456', + ), + 'payment_method' => 'bacs', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Ensure that labels respect locale filtering. + */ + public function test_locale_label_filtering_post_data() { + add_filter( + 'woocommerce_get_country_locale', + function ( $locale ) { + $locale['FR']['state']['label'] = 'French state'; + $locale['FR']['state']['required'] = true; + $locale['DE']['state']['label'] = 'German state'; + $locale['DE']['state']['required'] = true; + return $locale; + } + ); + + $request = new \WP_REST_Request( 'POST', '/wc/store/v1/checkout' ); + $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); + + // Test that a country that usually requires state can be overridden with woocommerce_get_country_locale filter. + $request->set_body_params( + array( + 'billing_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test lane', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => '90210', + 'country' => 'FR', + 'phone' => '123456', + 'email' => 'testaccount@test.com', + ), + 'shipping_address' => (object) array( + 'first_name' => 'test', + 'last_name' => 'test', + 'company' => '', + 'address_1' => 'test', + 'address_2' => '', + 'city' => 'test', + 'state' => '', + 'postcode' => '90210', + 'country' => 'DE', + 'phone' => '123456', + ), + 'payment_method' => 'bacs', + ) + ); + $response = rest_get_server()->dispatch( $request ); + $this->assertEquals( 'French state is required', $response->get_data()['data']['errors']['billing'][0] ); + $this->assertEquals( 'German state is required', $response->get_data()['data']['errors']['shipping'][0] ); + } + /** * Ensure that registered extension data is correctly shown on options requests. */