Merge branch 'trunk' into fix/readme-php-version

This commit is contained in:
Ron Rennick 2023-08-30 16:36:26 -03:00
commit 5411f094ef
380 changed files with 11490 additions and 2581 deletions

View File

@ -1,4 +1,4 @@
name: Lint JS packages
name: Lint packages
on:
pull_request:
@ -39,16 +39,16 @@ jobs:
# ignore scripts is faster, and postinstall should not be needed for lint.
pnpm install --ignore-scripts
- name: Lint
- name: Lint JS and CSS
run: pnpm run -r --filter='release-posts' --filter='woocommerce/client/admin...' --filter='@woocommerce/monorepo-utils' --filter='!@woocommerce/e2e*' --filter='!@woocommerce/api' --color lint
continue-on-error: true
- name: Collect and Combine Eslint Reports
if: ${{ github.event.pull_request.head.repo.fork != true && always() }}
run: node ./.github/workflows/scripts/collect-eslint-reports.js
- name: Annotate Code Linting Results
uses: ataylorme/eslint-annotate-action@a1bf7cb320a18aa53cb848a267ce9b7417221526
if: ${{ github.event.pull_request.head.repo.fork != true && always() }}
with:
repo-token: '${{ secrets.GITHUB_TOKEN }}'
report-json: 'combined_eslint_report.json'

View File

@ -349,4 +349,4 @@ jobs:
-f plugin="${{ matrix.plugin }}" \
-f slug="${{ matrix.slug }}" \
-f s3_root=public \
--repo woocommerce/woocommerce-test-reports
--repo woocommerce/woocommerce-test-reports

View File

@ -162,7 +162,7 @@ jobs:
report-name: ${{ env.API_WP_LATEST_ARTIFACT }}
tests: hello
env:
BASE_URL: ${{ secrets.RELEASE_TEST_URL }}
API_BASE_URL: ${{ secrets.RELEASE_TEST_URL }}
USER_KEY: ${{ secrets.RELEASE_TEST_ADMIN_USER }}
USER_SECRET: ${{ secrets.RELEASE_TEST_ADMIN_PASSWORD }}

View File

@ -112,7 +112,10 @@
{
"dependencies": [
"@wordpress/block**",
"@wordpress/viewport"
"@wordpress/viewport",
"@wordpress/interface",
"@wordpress/router",
"@wordpress/edit-site"
],
"packages": [
"@woocommerce/product-editor",

View File

@ -1,5 +1,11 @@
== Changelog ==
= 8.0.3 2023-08-29 =
* Update - Bump WooCommerce Blocks to 10.6.6. [#39853](https://github.com/woocommerce/woocommerce/pull/39853)
* Fix - Avoid extra queries when a WooPayments incentive has been dismissed. [#39882](https://github.com/woocommerce/woocommerce/pull/39882)
= 8.0.2 2023-08-15 =
* Fix - Fix an issue which was causing some attributes to default to a minimum length of 3. [#39686](https://github.com/woocommerce/woocommerce/pull/39686)

211
docs/data/crud-objects.md Normal file
View File

@ -0,0 +1,211 @@
# Developing using WooCommerce CRUD objects
CRUD is an abbreviation of the four basic operations you can do to a database or resource Create, Read, Update, Delete.
[WooCommerce 3.0 introduced CRUD objects](https://woocommerce.wordpress.com/2016/10/27/the-new-crud-classes-in-woocommerce-2-7/) for working with WooCommerce data. **Whenever possible these objects should be used in your code instead of directly updating metadata or using WordPress post objects.**
Each of these objects contains a schema for the data it controls (properties), a getter and setter for each property, and a save/delete method which talks to a data store. The data store handles the actual saving/reading from the database. The object itself does not need to be aware of where the data is stored.
## The benefits of CRUD
* Structure Each object has a pre-defined structure and keeps its own data valid.
* Control We control the flow of data, and any validation needed, so we know when changes occur.
* Ease of development As a developer, you dont need to know the internals of the data youre working with, just the names.
* Abstraction The data can be moved elsewhere, e.g. custom tables, without affecting existing code.
* Unification We can use the same code for updating things in admin as we do in the REST API and CLIs. Everything is unified.
* Simplified code Less procedural code to update objects which reduces likelihood of malfunction and adds more unit test coverage.
## CRUD object structure
The [`WC_Data`](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/abstracts/abstract-wc-data.php) class is the basic implementation for CRUD objects, and all CRUD objects extend it. The most important properties to note are `$data`, which is an array of props supported in each object, and `$id`, which is the objects ID.
The [coupon object class](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/class-wc-coupon.php) is a good example of extending `WC_Data` and adding CRUD functions to all properties.
### Data
`$data` stores the property names, and default values:
```php
/**
* Data array, with defaults.
* @since 3.0.0
* @var array
*/
protected $data = array(
'code' => '',
'amount' => 0,
'date_created' => '',
'date_modified' => '',
'discount_type' => 'fixed_cart',
'description' => '',
'date_expires' => '',
'usage_count' => 0,
'individual_use' => false,
'product_ids' => array(),
'excluded_product_ids' => array(),
'usage_limit' => 0,
'usage_limit_per_user' => 0,
'limit_usage_to_x_items' => 0,
'free_shipping' => false,
'product_categories' => array(),
'excluded_product_categories' => array(),
'exclude_sale_items' => false,
'minimum_amount' => '',
'maximum_amount' => '',
'email_restrictions' => array(),
'used_by' => array(),
);
```
### Getters and setters
Each one of the keys in this array (property) has a getter and setter, e.g. `set_used_by()` and `get_used_by()`. `$data` itself is private, so the getters and setters must be used to access the data.
Example getter:
```php
/**
* Get records of all users who have used the current coupon.
* @since 3.0.0
* @param string $context
* @return array
*/
public function get_used_by( $context = 'view' ) {
return $this->get_prop( 'used_by', $context );
}
```
Example setter:
```php
/**
* Set which users have used this coupon.
* @since 3.0.0
* @param array $used_by
* @throws WC_Data_Exception
*/
public function set_used_by( $used_by ) {
$this->set_prop( 'used_by', array_filter( $used_by ) );
}
```
`set_prop` and `get_prop` are part of `WC_Data`. These apply various filters (based on context) and handle changes, so we can efficiently save only the props that have changed rather than all props.
A note on `$context`: when getting data for use on the front end or display, `view` context is used. This applies filters to the data so extensions can change the values dynamically. `edit` context should be used when showing values to edit in the backend, and for saving to the database. Using `edit` context does not apply any filters to the data.
### The constructor
The constructor of the CRUD objects facilitates the read from the database. The actual read is not done by the CRUD class, but by its data store.
Example:
```php
/**
* Coupon constructor. Loads coupon data.
* @param mixed $data Coupon data, object, ID or code.
*/
public function __construct( $data = '' ) {
parent::__construct( $data );
if ( $data instanceof WC_Coupon ) {
$this->set_id( absint( $data->get_id() ) );
} elseif ( is_numeric( $data ) && 'shop_coupon' === get_post_type( $data ) ) {
$this->set_id( $data );
} elseif ( ! empty( $data ) ) {
$this->set_id( wc_get_coupon_id_by_code( $data ) );
$this->set_code( $data );
} else {
$this->set_object_read( true );
}
$this->data_store = WC_Data_Store::load( 'coupon' );
if ( $this->get_id() > 0 ) {
$this->data_store->read( $this );
}
}
```
Note how it sets the ID based on the data passed to the object, then calls the data store to retrieve the data from the database. Once the data is read via the data store, or if no ID is set, `$this->set_object_read( true );` is set so the data store and CRUD object knows its read. Once this is set, changes are tracked.
### Saving and deleting
Save and delete methods are optional on CRUD child classes because the `WC_Data` parent class can handle it. When `save` is called, the data store is used to store data to the database. Delete removes the object from the database. `save` must be called for changes to persist, otherwise they will be discarded.
The save method in `WC_Data` looks like this:
```php
/**
* Save should create or update based on object existence.
*
* @since 2.6.0
* @return int
*/
public function save() {
if ( $this->data_store ) {
// Trigger action before saving to the DB. Allows you to adjust object props before save.
do_action( 'woocommerce_before_' . $this->object_type . '_object_save', $this, $this->data_store );
if ( $this->get_id() ) {
$this->data_store->update( $this );
} else {
$this->data_store->create( $this );
}
return $this->get_id();
}
}
```
Update/create is used depending on whether the object has an ID yet. The ID will be set after creation.
The delete method is like this:
```php
/**
* Delete an object, set the ID to 0, and return result.
*
* @since 2.6.0
* @param bool $force_delete
* @return bool result
*/
public function delete( $force_delete = false ) {
if ( $this->data_store ) {
$this->data_store->delete( $this, array( 'force_delete' => $force_delete ) );
$this->set_id( 0 );
return true;
}
return false;
}
```
## CRUD usage examples
### Creating a new simple product
```php
$product = new WC_Product_Simple();
$product->set_name( 'My Product' );
$product->set_slug( 'myproduct' );
$product->set_description( 'A new simple product' );
$product->set_regular_price( '9.50' );
$product->save();
$product_id = $product->get_id();
```
### Updating an existing coupon
```php
$coupon = new WC_Coupon( $coupon_id );
$coupon->set_discount_type( 'percent' );
$coupon->set_amount( 25.00 );
$coupon->save();
```
### Retrieving a customer
```php
$customer = new WC_Customer( $user_id );
$email = $customer->get_email();
$address = $customer->get_billing_address();
$name = $customer->get_first_name() . ' ' . $customer->get_last_name();
```

317
docs/data/data-stores.md Normal file
View File

@ -0,0 +1,317 @@
# Data Stores
## Introduction
Data store classes act as a bridge between WooCommerce's data CRUD classes (`WC_Product`, `WC_Order`, `WC_Customer`, etc) and the database layer. With the database logic separate from data, WooCommerce becomes more flexible. The data stores shipped with WooCommerce core (powered by WordPress' custom posts system and some custom tables) can be swapped out for a different database structure, type, or even be powered by an external API.
This guide will walk through the structure of a data store class, how to create a new data store, how to replace a core data store, and how to call a data store from your own code.
The examples in this guide will look at the [`WC_Coupon`](https://github.com/woocommerce/woocommerce/blob/dcecf0f22890f3cd92fbea13a98c11b2537df2a8/includes/class-wc-coupon.php#L19) CRUD data class and [`WC_Coupon_Data_Store_CPT`](https://github.com/woocommerce/woocommerce/blob/dcecf0f22890f3cd92fbea13a98c11b2537df2a8/includes/data-stores/class-wc-coupon-data-store-cpt.php), an implementation of a coupon data store using WordPress custom post types. This is how coupons are currently stored in WooCommerce.
The important thing to know about `WC_Coupon` or any other CRUD data class when working with data stores is which props (properties) they contain. This is defined in the [`data`](https://github.com/woocommerce/woocommerce/blob/dcecf0f22890f3cd92fbea13a98c11b2537df2a8/includes/class-wc-coupon.php#L26) array of each class.
## Structure
Every data store for a CRUD object should implement the `WC_Object_Data_Store_Interface` interface.
`WC_Object_Data_Store_Interface` includes the following methods:
* `create`
* `read`
* `update`
* `delete`
* `read_meta`
* `delete_meta`
* `add_meta`
* `update_meta`
The `create`, `read`, `update`, and `delete` methods should handle the CRUD logic for your props:
* `create` should create a new entry in the database. Example: Create a coupon.
* `read` should query a single entry from the database and set properties based on the response. Example: Read a coupon.
* `update` should make changes to an existing entry. Example: Update or edit a coupon.
* `delete` should remove an entry from the database. Example: Delete a coupon.
All data stores must implement handling for these methods.
In addition to handling your props, other custom data can be passed. This is considered `meta`. For example, coupons can have custom data provided by plugins.
The `read_meta`, `delete_meta`, `add_meta`, and `update_meta` methods should be defined so meta can be read and managed from the correct source. In the case of our WooCommerce core classes, we define them in `WC_Data_Store_WP` and then use the same code for all of our data stores. They all use the WordPress meta system. You can redefine these if meta should come from a different source.
Your data store can also implement other methods to replace direct queries. For example, the coupons data store has a public `get_usage_by_user_id` method. Data stores should always define and implement an interface for the methods they expect, so other developers know what methods they need to write. Put another way, in addition to the `WC_Object_Data_Store_Interface` interface, `WC_Coupon_Data_Store_CPT` also implements `WC_Coupon_Data_Store_Interface`.
## Replacing a data store
Let's look at how we would replace the `WC_Coupon_Data_Store_CPT` class with a `WC_Coupon_Data_Store_Custom_Table` class. Our examples will just provide stub functions, instead of a full working solution. Imagine that we would like to store coupons in a table named `wc_coupons` with the following columns:
```text
id, code, amount, date_created, date_modified, discount_type, description, date_expires, usage_count,individual_use, product_ids, excluded_product_ids, usage_limit, usage_limit_per_user, limit_usage_to_x_items, free_shipping, product_categories, excluded_product_categories, exclude_sale_items, minimum_amount, maximum_amount, email_restrictions, used_by
```
These column names match 1 to 1 with prop names.
First we would need to create a new data store class to contain our logic:
```php
/**
* WC Coupon Data Store: Custom Table.
*/
class WC_Coupon_Data_Store_Custom_Table extends WC_Data_Store_WP implements WC_Coupon_Data_Store_Interface, WC_Object_Data_Store_Interface {
}
```
Note that we implement the main `WC_Object_Data_Store_Interface` interface as well as the ` WC_Coupon_Data_Store_Interface` interface. Together, these represent all the methods we need to provide logic for.
We would then define the CRUD handling for these properties:
```php
/**
* Method to create a new coupon in the database.
*
* @param WC_Coupon
*/
public function create( &$coupon ) {
$coupon->set_date_created( current_time( 'timestamp' ) );
/**
* This is where code for inserting a new coupon would go.
* A query would be built using getters: $coupon->get_code(), $coupon->get_description(), etc.
* After the INSERT operation, we want to pass the new ID to the coupon object.
*/
$coupon->set_id( $coupon_id );
// After creating or updating an entry, we need to also cause our 'meta' to save.
$coupon->save_meta_data();
// Apply changes let's the object know that the current object reflects the database and no "changes" exist between the two.
$coupon->apply_changes();
// It is helpful to provide the same hooks when an action is completed, so that plugins can interact with your data store.
do_action( 'woocommerce_new_coupon', $coupon_id );
}
/**
* Method to read a coupon.
*
* @param WC_Coupon
*/
public function read( &$coupon ) {
$coupon->set_defaults();
// Read should do a check to see if this is a valid coupon
// and otherwise throw an 'Invalid coupon.' exception.
// For valid coupons, set $data to contain our database result.
// All props should be set using set_props with output from the database. This "hydates" the CRUD data object.
$coupon_id = $coupon->get_id();
$coupon->set_props( array(
'code' => $data->code,
'description' => $data->description,
// ..
) );
// We also need to read our meta data into the object.
$coupon->read_meta_data();
// This flag reports if an object has been hydrated or not. If this ends up false, the database hasn't correctly set the object.
$coupon->set_object_read( true );
do_action( 'woocommerce_coupon_loaded', $coupon );
}
/**
* Updates a coupon in the database.
*
* @param WC_Coupon
*/
public function update( &$coupon ) {
// Update coupon query, using the getters.
$coupon->save_meta_data();
$coupon->apply_changes();
do_action( 'woocommerce_update_coupon', $coupon->get_id() );
}
/**
* Deletes a coupon from the database.
*
* @param WC_Coupon
* @param array $args Array of args to pass to the delete method.
*/
public function delete( &$coupon, $args = array() ) {
// A lot of objects in WordPress and WooCommerce support
// the concept of trashing. This usually is a flag to move the object
// to a "recycling bin". Since coupons support trashing, your layer should too.
// If an actual delete occurs, set the coupon ID to 0.
$args = wp_parse_args( $args, array(
'force_delete' => false,
) );
$id = $coupon->get_id();
if ( $args['force_delete'] ) {
// Delete Query
$coupon->set_id( 0 );
do_action( 'woocommerce_delete_coupon', $id );
} else {
// Trash Query
do_action( 'woocommerce_trash_coupon', $id );
}
}
```
We are extending `WC_Data_Store_WP` so our classes will continue to use WordPress' meta system.
As mentioned in the structure section, we are responsible for implementing the methods defined by `WC_Coupon_Data_Store_Interface`. Each interface describes the methods and parameters it accepts, and what your function should do.
A coupons replacement would look like the following:
```php
/**
* Increase usage count for current coupon.
*
* @param WC_Coupon
* @param string $used_by Either user ID or billing email
*/
public function increase_usage_count( &$coupon, $used_by = '' ) {
}
/**
* Decrease usage count for current coupon.
*
* @param WC_Coupon
* @param string $used_by Either user ID or billing email
*/
public function decrease_usage_count( &$coupon, $used_by = '' ) {
}
/**
* Get the number of uses for a coupon by user ID.
*
* @param WC_Coupon
* @param id $user_id
* @return int
*/
public function get_usage_by_user_id( &$coupon, $user_id ) {
}
/**
* Return a coupon code for a specific ID.
* @param int $id
* @return string Coupon Code
*/
public function get_code_by_id( $id ) {
}
/**
* Return an array of IDs for for a specific coupon code.
* Can return multiple to check for existence.
* @param string $code
* @return array Array of IDs.
*/
public function get_ids_by_code( $code ) {
}
```
Once all the data store methods are defined and logic written, we need to tell WooCommerce to load our new class instead of the built-in class. This is done using the `woocommerce_data_stores` filter. An array of data store slugs is mapped to default WooCommerce classes. Example:
```php
'coupon' => 'WC_Coupon_Data_Store_CPT',
'customer' => 'WC_Customer_Data_Store',
'customer-download' => 'WC_Customer_Download_Data_Store',
'customer-session' => 'WC_Customer_Data_Store_Session',
'order' => 'WC_Order_Data_Store_CPT',
'order-refund' => 'WC_Order_Refund_Data_Store_CPT',
'order-item' => 'WC_Order_Item_Data_Store',
'order-item-coupon' => 'WC_Order_Item_Coupon_Data_Store',
'order-item-fee' => 'WC_Order_Item_Fee_Data_Store',
'order-item-product' => 'WC_Order_Item_Product_Data_Store',
'order-item-shipping' => 'WC_Order_Item_Shipping_Data_Store',
'order-item-tax' => 'WC_Order_Item_Tax_Data_Store',
'payment-token' => 'WC_Payment_Token_Data_Store',
'product' => 'WC_Product_Data_Store_CPT',
'product-grouped' => 'WC_Product_Grouped_Data_Store_CPT',
'product-variable' => 'WC_Product_Variable_Data_Store_CPT',
'product-variation' => 'WC_Product_Variation_Data_Store_CPT',
'shipping-zone' => 'WC_Shipping_Zone_Data_Store',
```
We specifically want to target the coupon data store, so we would do something like this:
```php
function myplugin_set_wc_coupon_data_store( $stores ) {
$stores['coupon'] = 'WC_Coupon_Data_Store_Custom_Table';
return $stores;
}
add_filter( 'woocommerce_data_stores', 'myplugin_set_wc_coupon_data_store' );
```
Our class would then be loaded by WooCommerce core, instead of `WC_Coupon_Data_Store_CPT`.
## Creating a new data store
### Defining a new product type
Does your extension create a new product type? Each product type has a data store in addition to a parent product data store. The parent store handles shared properties like name or description and the child handles more specific data.
For example, the external product data store handles "button text" and "external URL". The variable data store handles the relationship between parent products and their variations.
Check out [this walkthrough](https://developer.woocommerce.com/2017/02/06/wc-2-7-extension-compatibility-examples-3-bookings/) for more information on this process.
### Data store for custom data
If your extension introduces a new database table, new custom post type, or some new form of data not related to products, orders, etc, then you should implement your own data store.
Your data store should still implement `WC_Object_Data_Store_Interface` and provide the normal CRUD functions. Your data store should be the main point of entry for interacting with your data, so any other queries or operations should also have methods.
The [shipping zone data store](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/includes/data-stores/class-wc-shipping-zone-data-store.php) serves as a good example for a "simple" data store using a custom table. The coupons code is a good example for a data store using a custom post type.
All you need to do to register your data store is add it to the `woocommerce_data_stores` filter:
```php
function myplugin_set_my_custom_data_store( $stores ) {
$stores['mycustomdata'] = 'WC_My_Custom_Data_Store';
return $stores;
}
add_filter( 'woocommerce_data_stores', 'myplugin_set_my_custom_data_store' );
```
You can then load your data store like any other WooCommerce data store.
## Calling a data store
Calling a data store is as simple as using the static `WC_Data_Store::load()` method:
```php
// Load the shipping zone data store.
$data_store = WC_Data_Store::load( 'shipping-zone' );
// Get the number of shipping methods for zone ID 4.
$num_of_methods = $data_store->get_method_count( 4 );
```
You can also chain methods:
```php
// Get the number of shipping methods for zone ID 4.
$num_of_methods = WC_Data_Store::load( 'shipping-zone' )->get_method_count( 4 );
```
The `::load()` method works for any data store registered to `woocommerce_data_stores`, so you could load your custom data store:
```php
$data_store = WC_Data_Store::load( 'mycustomdata' );
```
## Data store limitations and WP Admin
Currently, several WooCommerce screens still rely on WordPress to list objects. Examples of this include coupons and products.
If you replace data via a data store, some parts of the existing UI may fail. An example of this may be lists of coupons when using the `type` filter. This filter uses meta data, and is in turn passed to WordPress which runs a query using the `WP_Query` class. This cannot handle data outside of the regular meta tables (Ref #19937). To get around this, usage of `WP_Query` would need to be deprecated and replaced with custom query classes and functions.

View File

@ -0,0 +1,19 @@
# Resources for debugging
## WordPress
A good place to start is usually the debugging tools built into WordPress itself:
* [Debugging in WordPress](https://wordpress.org/documentation/article/debugging-in-wordpress/)
## Logging
WooCommerce has a logging system that can be very helpful for finding and tracking errors on your site:
* [Logging in WooCommerce](../utilities/logging.md)
## Xdebug
If you're using `wp-env` to run a local development environment (this is the recommended method for the WooCommerce monorepo), you can activate Xdebug and then use an IDE like VS Code or PhpStorm to set breakpoints and step through the code as it executes:
* [Using Xdebug](https://github.com/WordPress/gutenberg/tree/trunk/packages/env#using-xdebug)

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@ -0,0 +1,92 @@
# Getting started with the REST API
The REST API is a powerful part of WooCommerce which lets you read and write various parts of WooCommerce data such as orders, products, coupons, customers, and shipping zones.
## Requirements
In order to access the REST API using the standard endpoint URI structure (e.g. `wc/v3/products`), you must have your WordPress permalinks configured to something other than "Plain". Go to **Settings > Permalinks** and choose an option.
![Permalinks options](_media/permalinks.webp)
## API reference
[WooCommerce REST API Docs](https://woocommerce.github.io/woocommerce-rest-api-docs/) provides technical details and code samples for each API endpoint.
## Authentication
Authentication is usually the part most developers get stuck on, so this guide will cover a quick way to test that your API is working on your server and you can authenticate.
We'll use both [Postman](https://www.getpostman.com/) and [Insomnia](https://insomnia.rest/) clients in these examples. Both are free and will help you visualise what the API offers.
Before proceeding, please read the [REST API docs on authentication which covers the important parts concerning API Keys and Auth](https://woocommerce.github.io/woocommerce-rest-api-docs/#authentication). We're only covering connecting over HTTPS here since it's the simplest and most secure method. You should avoid HTTP if possible.
## Generate Keys
To start using REST API, you first need to generate API keys.
1. Go to *WooCommerce > Settings > Advanced*
2. Go to the *REST API* tab and click *Add key*.
3. Give the key a description for your own reference, choose a user with access to orders etc, and give the key *read/write* permissions.
4. Click *Generate api key*.
5. Your keys will be shown - do not close this tab yet, the secret will be hidden if you try to view the key again.
![Generated API Keys](_media/keys.png)
## Make a basic request
The request URL we'll test is `wp-json/wc/v3/orders`. On localhost the full URL may look something like this: `https://localhost:8888/wp-json/wc/v3/orders`. Modify this to use your own site URL.
In Postman, you need to set the fields for request type, request URL, and the settings on the authorization tab. For Authorization, choose *basic auth* and enter your *consumer key* and *consumer secret* keys from WooCommerce into the username and password fields
Once done, hit send and you'll see the JSON response from the API if all worked well. You should see something like this:
![Generated API Keys](_media/postman.png)
Insomnia is almost identical to Postman; fill in the same fields and again use basic auth.
![Insomnia](_media/insomnia.png)
Thats it! The API is working.
If you have problems connecting, you may need to disable SSL verification - see the connection issues section below.
## Common connection issues
### Connection issues with localhost and self-signed SSL certificates
If you're having problems connecting to the REST API on your localhost and seeing errors like this:
![SSL Error](_media/sslerror.png)
You need to disable SSL verification. In Postman you can find this in the settings:
![Postman settings](_media/postman-ssl.png)
Insomnia also has this setting the preferences area:
![Insomnia settings](_media/insomnia-ssl.png)
### 401 Unauthorized
Your API keys or signature is wrong. Ensure that:
- The user you generated API keys for actually has access to those resources.
- The username when authenticating is your consumer key.
- The password when authenticating is your consumer secret.
- Make a new set of keys to be sure.
If your server utilizes FastCGI, check that your [authorization headers are properly read](https://web.archive.org/web/20230330133128/https://support.metalocator.com/en/articles/1654091-wp-json-basic-auth-with-fastcgi).
### Consumer key is missing
Occasionally servers may not parse the Authorization header correctly (if you see a “Consumer key is missing” error when authenticating over SSL, you have a server issue).
In this case, you may provide the consumer key/secret as query string parameters instead. Example:
```text
https://local.wordpress.dev/wp-json/wc/v2/orders?consumer_key=XXXX&consumer_secret=XXXX
```
### Server does not support POST/DELETE/PUT
Ideally, your server should be configured to accept these types of API request, but if not you can use the [`_method` property](https://developer.wordpress.org/rest-api/using-the-rest-api/global-parameters/#_method-or-x-http-method-override-header).

View File

@ -2,6 +2,12 @@
Various code snippets you can add to your site to enable custom functionality:
- [Add a country](./add-a-country.md)
- [Add a currency and symbol](./add-a-currency-symbol.md)
- [Add a message above the login / register form](./before-login--register-form.md)
- [Add or modify states](./add-or-modify-states.md)
- [Adjust the quantity input values](./adjust-quantity-input-values.md)
- [Change a currency symbol](./change-a-currency-symbol.md)
- [Change number of related products output](./number-of-products-per-row.md)
- [Unhook and remove WooCommerce emails](./unhook--remove-woocommerce-emails.md)
- [Rename a country](./rename-a-country.md)
- [Unhook and remove WooCommerce emails](./unhook--remove-woocommerce-emails.md)

View File

@ -0,0 +1,38 @@
# Add a country
Add this code to your child themes `functions.php` file or via a plugin that allows custom functions to be added, such as the [Code Snippets](https://wordpress.org/plugins/code-snippets/) plugin. Avoid adding custom code directly to your parent themes functions.php file, as this will be wiped entirely when you update the theme.
```php
if ( ! function_exists( 'YOUR_PREFIX_add_country_to_countries_list' ) ) {
/**
* Add a country to countries list
*
* @param array $countries Existing country list.
* @return array $countries Modified country list.
*/
function YOUR_PREFIX_add_country_to_countries_list( $countries ) {
$new_countries = array(
'NIRE' => __( 'Northern Ireland', 'YOUR-TEXTDOMAIN' ),
);
return array_merge( $countries, $new_countries );
}
add_filter( 'woocommerce_countries', 'YOUR_PREFIX_add_country_to_countries_list' );
}
if ( ! function_exists( 'YOUR_PREFIX_add_country_to_continents_list' ) ) {
/**
* Add a country to continents list
*
* @param array $continents Existing continents list.
* @return array $continents Modified continents list.
*/
function YOUR_PREFIX_add_country_to_continents_list( $continents ) {
$continents['EU']['countries'][] = 'NIRE';
return $continents;
}
add_filter( 'woocommerce_continents', 'YOUR_PREFIX_add_country_to_continents_list' );
}
```

View File

@ -0,0 +1,38 @@
# Add a currency and symbol
Add this code to your child themes `functions.php` file or via a plugin that allows custom functions to be added, such as the [Code Snippets](https://wordpress.org/plugins/code-snippets/) plugin. Avoid adding custom code directly to your parent themes functions.php file, as this will be wiped entirely when you update the theme.
```php
if ( ! function_exists( 'YOUR_PREFIX_add_currency_name' ) ) {
/**
* Add custom currency
*
* @param array $currencies Existing currencies.
* @return array $currencies Updated currencies.
*/
function YOUR_PREFIX_add_currency_name( $currencies ) {
$currencies['ABC'] = __( 'Currency name', 'YOUR-TEXTDOMAIN' );
return $currencies;
}
add_filter( 'woocommerce_currencies', 'YOUR_PREFIX_add_currency_name' );
}
if ( ! function_exists( 'YOUR_PREFIX_add_currency_symbol' ) ) {
/**
* Add custom currency symbol
*
* @param string $currency_symbol Existing currency symbols.
* @param string $currency Currency code.
* @return string $currency_symbol Updated currency symbol(s).
*/
function YOUR_PREFIX_add_currency_symbol( $currency_symbol, $currency ) {
switch( $currency ) {
case 'ABC': $currency_symbol = '$'; break;
}
return $currency_symbol;
}
add_filter('woocommerce_currency_symbol', 'YOUR_PREFIX_add_currency_symbol', 10, 2);
}
```

View File

@ -0,0 +1,27 @@
# Add or modify states
Add this code to your child themes `functions.php` file or via a plugin that allows custom functions to be added, such as the [Code Snippets](https://wordpress.org/plugins/code-snippets/) plugin. Avoid adding custom code directly to your parent themes functions.php file, as this will be wiped entirely when you update the theme.
Add your own or modify shipping states in WooCommerce.
> Note: you **must** replace both instances of XX with your country code. This means each state id in the array must have your two letter country code before the number you assign to the state.
```php
if ( ! function_exists( 'YOUR_PREFIX_add_or_modify_states' ) ) {
/**
* Add or modify States
*
* @param array $states Existing country states.
* @return array $states Modified country states.
*/
function YOUR_PREFIX_add_or_modify_states( $states ) {
$states['XX'] = array(
'XX1' => __( 'State 1', 'YOUR-TEXTDOMAIN' ),
'XX2' => __( 'State 2', 'YOUR-TEXTDOMAIN' ),
);
return $states;
}
add_filter( 'woocommerce_states', 'YOUR_PREFIX_add_or_modify_states' );
}
```

View File

@ -0,0 +1,49 @@
# Adjust the quantity input values
> This is a **Developer level** doc. If you are unfamiliar with code and resolving potential conflicts, select a [WooExpert or Developer](https://woocommerce.com/customizations/) for assistance. We are unable to provide support for customizations under our  [Support Policy](http://www.woocommerce.com/support-policy/).
Set the starting value, maximum value, minimum value, and increment amount for quantity input fields on product pages.
Add this code to your child themes `functions.php` file or via a plugin that allows custom functions to be added, such as the [Code snippets](https://wordpress.org/plugins/code-snippets/) plugin. Avoid adding custom code directly to your parent themes `functions.php` file, as this will be wiped entirely when you update the theme.
```php
if ( ! function_exists( 'YOUR_PREFIX_woocommerce_quantity_input_args' ) ) {
/**
* Adjust the quantity input values for simple products
*/
function YOUR_PREFIX_woocommerce_quantity_input_args( $args, $product ) {
// Only affect the starting value on product pages, not the cart
if ( is_singular( 'product' ) ) {
$args['input_value'] = 4;
}
$args['max_value'] = 10; // Maximum value
$args['min_value'] = 2; // Minimum value
$args['step'] = 2; // Quantity steps
return $args;
}
add_filter( 'woocommerce_quantity_input_args', 'YOUR_PREFIX_woocommerce_quantity_input_args', 10, 2 );
}
if ( ! function_exists( 'YOUR_PREFIX_woocommerce_available_variation' ) ) {
/**
* Adjust the quantity input values for variations
*/
function YOUR_PREFIX_woocommerce_available_variation( $args ) {
$args['max_qty'] = 20; // Maximum value (variations)
$args['min_qty'] = 2; // Minimum value (variations)
// Note: the starting value and step for variations is controlled
// from the 'woocommerce_quantity_input_args' filter shown above for
// simple products
return $args;
}
add_filter( 'woocommerce_available_variation', 'YOUR_PREFIX_woocommerce_available_variation' );
}
```
If you are looking for a little more power, check out our [Min/Max Quantities](http://woocommerce.com/products/minmax-quantities) extension!

View File

@ -0,0 +1,25 @@
# Rename a country
See the [currency list](https://woocommerce.github.io/code-reference/files/woocommerce-includes-wc-core-functions.html#source-view.475) for reference on currency codes.
Add this code to your child themes `functions.php` file or via a plugin that allows custom functions to be added, such as the [Code Snippets](https://wordpress.org/plugins/code-snippets/) plugin. Avoid adding custom code directly to your parent themes functions.php file, as this will be wiped entirely when you update the theme.
```php
if ( ! function_exists( 'YOUR_PREFIX_change_currency_symbol' ) ) {
/**
* Change a currency symbol
*
* @param string $currency_symbol Existing currency symbols.
* @param string $currency Currency code.
* @return string $currency_symbol Updated currency symbol(s).
*/
function YOUR_PREFIX_change_currency_symbol( $currency_symbol, $currency ) {
switch ( $currency ) {
case 'AUD': $currency_symbol = 'AUD$'; break;
}
return $currency_symbol;
}
add_filter( 'woocommerce_currency_symbol', 'YOUR_PREFIX_change_currency_symbol', 10, 2 );
}
```

View File

@ -0,0 +1,21 @@
# Rename a country
Add this code to your child themes `functions.php` file or via a plugin that allows custom functions to be added, such as the [Code Snippets](https://wordpress.org/plugins/code-snippets/) plugin. Avoid adding custom code directly to your parent themes functions.php file, as this will be wiped entirely when you update the theme.
```php
if ( ! function_exists( 'YOUR_PREFIX_rename_country' ) ) {
/**
* Rename a country
*
* @param array $countries Existing country names
* @return array $countries Updated country name(s)
*/
function YOUR_PREFIX_rename_country( $countries ) {
$countries['IE'] = __( 'Ireland (Changed)', 'YOUR-TEXTDOMAIN' );
return $countries;
}
add_filter( 'woocommerce_countries', 'YOUR_PREFIX_rename_country' );
}
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

141
docs/utilities/logging.md Normal file
View File

@ -0,0 +1,141 @@
# Logging in WooCommerce
WooCommerce has its own robust system for logging, which can be used for debugging during development, catching errors on production, or even sending notifications when specific events occur. By default, WooCommerce uses this logger to record errors, warnings, and other notices that may be useful for troubleshooting problems with a store. Many extensions for WooCommerce also make use of the logger for similar purposes.
## Viewing logs
Depending on the log handler(s) used, you can view the entries created by the logger by going to **WooCommerce > Status > Logs**.
![Log file viewer](_media/log-critical.jpg)
## Log levels
Logs have eight different severity levels:
* `emergency`
* `alert`
* `critical`
* `error`
* `warning`
* `notice`
* `info`
* `debug`
Aside from giving a site owner context as to how important a log entry is, these levels also allow logs to be filtered by the handler. If you only want log entries to be recorded for `error` severity and higher, you can set the threshold in the `WC_LOG_THRESHOLD` constant by adding something like this to your `wp-config.php` file:
```php
define( 'WC_LOG_THRESHOLD', 'error' );
```
Note that this threshold will apply to all logs, regardless of which log handler is in use. The `WC_Log_Handler_Email` class, for example, has its own threshold setting, but it is secondary to the global threshold.
## Log handlers
In WooCommerce, a log handler is a PHP class that takes the raw log data and transforms it into a log entry that can be stored or dispatched. WooCommerce ships with three different log handler classes:
* `WC_Log_Handler_File`: The default handler. Records log entries to files. The files are stored in `wp-content/uploads/wc-logs`, but this can be changed by defining the `WC_LOG_DIR` constant in your `wp-config.php` file with a custom path. Log files can be up to 5 MB in size, after which the log file will rotate.
* `WC_Log_Handler_DB`: Records log entries to the database. Entries are stored in the `{$wpdb->prefix}woocommerce_log` table.
* `WC_Log_Handler_Email`: Sends log entries as email messages. Emails are sent to the site admin email address. This handler has [some limitations](https://github.com/woocommerce/woocommerce/blob/fe81a4cf27601473ad5c394a4f0124c785aaa4e6/plugins/woocommerce/includes/log-handlers/class-wc-log-handler-email.php#L15-L27).
### Changing or adding handlers
To switch from the default file log handler to the database handler, you can add an entry like this to your `wp-config.php` file:
```php
define( 'WC_LOG_HANDLER', 'WC_Log_Handler_DB' );
```
In some cases, you may want to have more than one log handler, and/or you might want to modify the settings of a handler. For example, you may want to have most logs saved to files, but log entries that are classified as emergency or critical errors also sent to an email address. For this, you can use the `woocommerce_register_log_handlers` filter hook to create an array of log handler class instances that you want to use. Some handler class constructors have optional parameters that you can use when instantiating the class to change their default behavior.
Example:
```php
function my_wc_log_handlers( $handlers ) {
$size_limit = 10 * 1024 * 1024; // Make the file size limit 10 MB instead of 5.
$handlers[] = new WC_Log_Handler_File( $size_limit );
$recipients = array( 'wayne@example.com', 'garth@example.com' ); // Send logs to multiple recipients.
$threshold = 'critical'; // Only send emails for logs of this level and higher.
$handlers[] = new WC_Log_Handler_Email( $recipients, $threshold );
return $handlers;
}
add_filter( 'woocommerce_register_log_handlers', 'my_wc_log_handlers' );
```
### Creating a custom handler
You may want to create your own log handler class in order to send logs somewhere else, such as a Slack channel or perhaps an InfluxDB instance. Your class must extend the [`WC_Log_Handler`](https://woocommerce.github.io/code-reference/classes/WC-Log-Handler.html) abstract class and implement the [`WC_Log_Handler_Interface`](https://woocommerce.github.io/code-reference/classes/WC-Log-Handler-Interface.html) interface. The [`WC_Log_Handler_Email`](https://github.com/woocommerce/woocommerce/blob/6688c60fe47ad42d49deedab8be971288e4786c1/plugins/woocommerce/includes/log-handlers/class-wc-log-handler-email.php) handler class provides a good example of how to set it up.
## Adding logs
Logs are added via methods in the `WC_Logger` class. The class instance is accessed by using the `wc_get_logger()` function. The basic method for adding a log entry is [`WC_Logger::log( $level, $message, $context )`](https://woocommerce.github.io/code-reference/classes/WC-Logger.html#method_log). There are also shortcut methods for each log severity level, for example `WC_Logger::warning( $message, $context )`. Here is [an example](https://github.com/woocommerce/woocommerce/blob/6688c60fe47ad42d49deedab8be971288e4786c1/plugins/woocommerce/src/Admin/RemoteInboxNotifications/OptionRuleProcessor.php#L53-L64) from the codebase:
```php
$logger = wc_get_logger();
$logger->warning(
sprintf(
'ComparisonOperation "%s" option value "%s" is not an array, defaulting to empty array.',
$rule->operation,
$rule->option_name
),
array(
'option_value' => $option_value,
'rule' => $rule,
)
);
```
## Log sources
Each log entry can include a `source` value, which is intended to provide context about where in the codebase the log was generated, and can be used to filter log entries. A source value can be added to a log by including it in the `context` parameter like so:
```php
$logger->info( 'Time for lunch', array( 'source' => 'your_stomach' ) );
```
Each log handler uses the source information a bit differently.
* `WC_Log_Handler_File`: The source becomes the prefix of the log filename. Thus, log entries with different sources will be stored in different log files. If no source value is given, the handler defaults to `log` as the source.
* `WC_Log_Handler_DB`: The source value is stored in the `source` column in the log database table. When viewing the list table of logs, you can choose a source value from a dropdown as a filter, and only view logs with that source. If no source value is given, the handler uses a stacktrace to determine the name of the file where the log was triggered, and that filename becomes the source.
* `WC_Log_Handler_Email`: This log handler does not use source information.
## Clearing old logs
When WooCommerce is first installed, it sets up a scheduled event to delete logs older than 30 days that runs daily. You can change the log retention period using the `woocommerce_logger_days_to_retain_logs` filter hook:
```php
add_filter( 'woocommerce_logger_days_to_retain_logs', function() { return 90; } );
```
## Turning off noisy logs
If there is a particular log that is recurring frequently and clogging up your log files, you should probably figure out why it keeps getting triggered and resolve the issue. However, if that's not possible, you can add a filter to ignore that particular log while still allowing other logs to get through:
```php
function my_ignored_logs( $message, $level, $context, $handler ) {
if ( false !== strpos( $message, 'Look, a squirrel!' ) ) {
return null;
}
return $message;
}
add_filter( 'woocommerce_logger_log_message', 'my_ignored_logs', 10, 4 );
```
## Debugging with the logger
Sometimes during debugging you need to find out if the runtime reaches a certain line in the code, or you need to see what value a variable contains, and it's not possible to directly observe what's happening with a `var_dump` call or a breakpoint. In these cases you can log the information you need with a one-liner like this:
```php
wc_get_logger()->debug( 'Made it to the conditional!', array( 'source', 'debug-20230825' ) );
```
On the occasion where you need to know what a non-scalar variable (array, object) contains, you may be tempted to put it in the `$context` parameter alongside your `source` value. However, only the database log handler even stores the contents of `$context`, and none of the handlers display it anywhere. Instead, consider outputting it in the `$message` parameter using something like [`wc_print_r`](https://woocommerce.github.io/code-reference/namespaces/default.html#function_wc_print_r):
```php
wc_get_logger()->debug(
wc_print_r( $my_special_array ),
array( 'source', 'debug-20230825' )
);
```

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Applied lint auto fixes across monorepo

View File

@ -14,7 +14,7 @@ export const WC_HEADER_SLOT_NAME = 'woocommerce_header_item';
/**
* Get the slot fill name for the generic header slot or a specific header if provided.
*
* @param name Name of the specific header.
* @param name Name of the specific header.
* @return string
*/
const getSlotFillName = ( name?: string ) => {

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix new category name field

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Applied lint auto fixes across monorepo

View File

@ -70,6 +70,7 @@ export const SelectTree = function SelectTree( {
const [ isFocused, setIsFocused ] = useState( false );
const [ isOpen, setIsOpen ] = useState( false );
const [ inputValue, setInputValue ] = useState( '' );
const isReadOnly = ! isOpen && ! isFocused;
const inputProps: React.InputHTMLAttributes< HTMLInputElement > = {
@ -78,11 +79,19 @@ export const SelectTree = function SelectTree( {
'aria-autocomplete': 'list',
'aria-controls': `${ props.id }-menu`,
autoComplete: 'off',
onFocus: () => {
onFocus: ( event ) => {
if ( ! isOpen ) {
setIsOpen( true );
}
setIsFocused( true );
if (
Array.isArray( props.selected ) &&
props.selected?.some(
( item: Item ) => item.label === event.target.value
)
) {
setInputValue( '' );
}
},
onBlur: ( event ) => {
if ( isOpen && isEventOutside( event ) ) {
@ -107,9 +116,14 @@ export const SelectTree = function SelectTree( {
recalculateInputValue();
}
},
onChange: ( event ) =>
onInputChange && onInputChange( event.target.value ),
onChange: ( event ) => {
if ( onInputChange ) {
onInputChange( event.target.value );
}
setInputValue( event.target.value );
},
placeholder,
value: inputValue,
};
return (

View File

@ -45,7 +45,7 @@ type BaseTreeProps = {
* ancestors and its descendants are also selected. If it's false
* only the clicked item is selected.
*
* @param value The selection
* @param value The selection
*/
onSelect?( value: Item | Item[] ): void;
/**
@ -54,7 +54,7 @@ type BaseTreeProps = {
* are also unselected. If it's false only the clicked item is
* unselected.
*
* @param value The unselection
* @param value The unselection
*/
onRemove?( value: Item | Item[] ): void;
/**
@ -66,8 +66,8 @@ type BaseTreeProps = {
* shouldItemBeHighlighted={ isFirstChild }
* />
*
* @param item The current linked tree item, useful to
* traverse the entire linked tree from this item.
* @param item The current linked tree item, useful to
* traverse the entire linked tree from this item.
*
* @see {@link LinkedTree}
*/
@ -97,7 +97,7 @@ export type TreeProps = BaseTreeProps &
* getItemLabel={ ( item ) => <span>${ item.data.label }</span> }
* />
*
* @param item The current rendering tree item
* @param item The current rendering tree item
*
* @see {@link LinkedTree}
*/
@ -112,7 +112,7 @@ export type TreeProps = BaseTreeProps &
* }
* />
*
* @param item The tree item to determine if should be expanded.
* @param item The tree item to determine if should be expanded.
*
* @see {@link LinkedTree}
*/

View File

@ -6,8 +6,8 @@ import { cloneElement } from '@wordpress/element';
/**
* Remove the item with the selected index from an array of items.
*
* @param items The array to remove the item from.
* @param removeIndex Index to remove.
* @param items The array to remove the item from.
* @param removeIndex Index to remove.
* @return array
*/
export const removeItem = < T >( items: T[], removeIndex: number ) =>
@ -16,8 +16,8 @@ export const removeItem = < T >( items: T[], removeIndex: number ) =>
/**
* Replace the React Element with given index with specific props.
*
* @param items The initial array to operate on.
* @param replaceIndex Index to remove.
* @param items The initial array to operate on.
* @param replaceIndex Index to remove.
* @return array
*/
export const replaceItem = < T extends Record< string, unknown > >(

View File

@ -7,9 +7,9 @@ import { DragEvent } from 'react';
/**
* Move an item from an index in an array to a new index.s
*
* @param fromIndex Index to move the item from.
* @param toIndex Index to move the item to.
* @param arr The array to copy.
* @param fromIndex Index to move the item from.
* @param toIndex Index to move the item to.
* @param arr The array to copy.
* @return array
*/
export const moveIndex = < T >(
@ -27,8 +27,8 @@ export const moveIndex = < T >(
/**
* Check whether the mouse is over the first half of the event target.
*
* @param event Drag event.
* @param isHorizontal Check horizontally or vertically.
* @param event Drag event.
* @param isHorizontal Check horizontally or vertically.
* @return boolean
*/
export const isBefore = (

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add generate slug prop to the QueryProductAttribute type

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Applied lint auto fixes across monorepo

View File

@ -12,9 +12,9 @@ import { IdQuery, IdType, ItemQuery } from './types';
/**
* Get a REST path given a template path and URL params.
*
* @param templatePath Path with variable names.
* @param query Item query.
* @param parameters Array of items to replace in the templatePath.
* @param templatePath Path with variable names.
* @param query Item query.
* @param parameters Array of items to replace in the templatePath.
* @return string REST path.
*/
export const getRestPath = (
@ -39,8 +39,8 @@ export const getRestPath = (
/**
* Get a key from an item ID and optional parent.
*
* @param query Item Query.
* @param urlParameters Parameters used for URL.
* @param query Item Query.
* @param urlParameters Parameters used for URL.
* @return string
*/
export const getKey = ( query: IdQuery, urlParameters: IdType[] = [] ) => {
@ -64,7 +64,7 @@ export const getKey = ( query: IdQuery, urlParameters: IdType[] = [] ) => {
/**
* Parse an ID query into a ID string.
*
* @param query Id Query
* @param query Id Query
* @return string ID.
*/
export const parseId = ( query: IdQuery, urlParameters: IdType[] = [] ) => {
@ -84,8 +84,8 @@ export const parseId = ( query: IdQuery, urlParameters: IdType[] = [] ) => {
/**
* Create a new function that adds in the namespace.
*
* @param fn Function to wrap.
* @param namespace Namespace to pass to last argument of function.
* @param fn Function to wrap.
* @param namespace Namespace to pass to last argument of function.
* @return Wrapped function
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -109,7 +109,7 @@ export const applyNamespace = < T extends ( ...args: any[] ) => unknown >(
/**
* Get the key names from a namespace string.
*
* @param namespace Namespace to get keys from.
* @param namespace Namespace to get keys from.
* @return Array of keys.
*/
export const getNamespaceKeys = ( namespace: string ) => {
@ -126,8 +126,8 @@ export const getNamespaceKeys = ( namespace: string ) => {
/**
* Get URL parameters from namespace and provided query.
*
* @param namespace Namespace string to replace params in.
* @param query Query object with key values.
* @param namespace Namespace string to replace params in.
* @param query Query object with key values.
* @return Array of URL parameter values.
*/
export const getUrlParameters = (
@ -152,8 +152,8 @@ export const getUrlParameters = (
/**
* Check to see if an argument is a valid type of ID query.
*
* @param arg Unknow argument to check.
* @param namespace The namespace string
* @param arg Unknow argument to check.
* @param namespace The namespace string
* @return boolean
*/
export const isValidIdQuery = ( arg: unknown, namespace: string ) => {
@ -179,8 +179,8 @@ export const isValidIdQuery = ( arg: unknown, namespace: string ) => {
/**
* Replace the initial argument with a key if it's a valid ID query.
*
* @param args Args to check.
* @param namespace Namespace.
* @param args Args to check.
* @param namespace Namespace.
* @return Sanitized arguments.
*/
export const maybeReplaceIdQuery = ( args: unknown[], namespace: string ) => {
@ -198,8 +198,8 @@ export const maybeReplaceIdQuery = ( args: unknown[], namespace: string ) => {
/**
* Clean a query of all namespaced params.
*
* @param query Query to clean.
* @param namespace
* @param query Query to clean.
* @param namespace
* @return Cleaned query object.
*/
export const cleanQuery = (
@ -219,8 +219,8 @@ export const cleanQuery = (
/**
* Get the identifier for a request provided its arguments.
*
* @param name Name of action or selector.
* @param args Arguments for the request.
* @param name Name of action or selector.
* @param args Arguments for the request.
* @return Key to identify the request.
*/
export const getRequestIdentifier = ( name: string, ...args: unknown[] ) => {
@ -239,8 +239,8 @@ export const getRequestIdentifier = ( name: string, ...args: unknown[] ) => {
/**
* Get a generic action name from a resource action name if one exists.
*
* @param action Action name to check.
* @param resourceName Resurce name.
* @param action Action name to check.
* @param resourceName Resurce name.
* @return Generic action name if one exists, otherwise the passed action name.
*/
export const getGenericActionName = (

View File

@ -15,6 +15,7 @@ export type QueryProductAttribute = {
type: string;
order_by: string;
has_archives: boolean;
generate_slug: boolean;
};
type Query = {

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Applied lint auto fixes across monorepo

View File

@ -189,7 +189,7 @@ export function getStoreTimeZoneMoment() {
*
* @param {moment.DurationInputArg2} period - the chosen period
* @param {string} compare - `previous_period` or `previous_year`
* @return {DateValue} - DateValue data about the selected period
* @return {DateValue} - DateValue data about the selected period
*/
export function getLastPeriod(
period: moment.DurationInputArg2,
@ -241,7 +241,7 @@ export function getLastPeriod(
*
* @param {moment.DurationInputArg2} period - the chosen period
* @param {string} compare - `previous_period` or `previous_year`
* @return {DateValue} - DateValue data about the selected period
* @return {DateValue} - DateValue data about the selected period
*/
export function getCurrentPeriod(
period: moment.DurationInputArg2,

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix minor layout shift in the core profiler.

View File

@ -54,7 +54,14 @@ Loader.Layout = ( {
className
) }
>
{ children }
<div
className={ classNames(
'woocommerce-onboarding-loader-container',
className
) }
>
{ children }
</div>
</div>
);
};

View File

@ -0,0 +1,5 @@
Significance: patch
Type: tweak
Comment: Fix mis spelling of variable name visibility.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Remove attribute dropdown constraint to let the users create more than one attribute with the same name

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add Pricing item to the Quick Actions menu per Variation item

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add new action menu item to variations list for managing inventory.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add Shipping item to the Quick Actions menu per Variation item

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add multiselection to the Variations table under Variations tab

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add tracking events to add edit and update attribute

View File

@ -0,0 +1,4 @@
Significance: minor
Type: tweak
Change variation option labels cardinality to 3

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix positioning of fixed block toolbar in IFrame editor used for product description, to make work with latest WordPress versions.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: update
Comment: Update regex to not match 'product_brand'

View File

@ -12,7 +12,7 @@
"type": "string",
"__experimentalRole": "content"
},
"visibilty": {
"visibility": {
"type": "string",
"enum": [ "visible", "catalog", "search", "hidden" ],
"default": "visible"

View File

@ -17,7 +17,7 @@ export function Edit( {
}: {
attributes: CatalogVisibilityBlockAttributes;
} ) {
const { label, visibilty } = attributes;
const { label, visibility } = attributes;
const blockProps = useBlockProps();
@ -26,22 +26,22 @@ export function Edit( {
>( 'postType', 'product', 'catalog_visibility' );
const checked =
catalogVisibility === visibilty || catalogVisibility === 'hidden';
catalogVisibility === visibility || catalogVisibility === 'hidden';
function handleChange( selected: boolean ) {
if ( selected ) {
if ( catalogVisibility === 'visible' ) {
setCatalogVisibility( visibilty );
setCatalogVisibility( visibility );
return;
}
setCatalogVisibility( 'hidden' );
} else {
if ( catalogVisibility === 'hidden' ) {
if ( visibilty === 'catalog' ) {
if ( visibility === 'catalog' ) {
setCatalogVisibility( 'search' );
return;
}
if ( visibilty === 'search' ) {
if ( visibility === 'search' ) {
setCatalogVisibility( 'catalog' );
return;
}

View File

@ -6,5 +6,5 @@ import { BlockAttributes } from '@wordpress/blocks';
export interface CatalogVisibilityBlockAttributes extends BlockAttributes {
label: string;
visibilty: Product[ 'catalog_visibility' ];
visibility: Product[ 'catalog_visibility' ];
}

View File

@ -8,9 +8,6 @@
"keywords": [ "products", "notice" ],
"textdomain": "default",
"attributes": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},

View File

@ -74,7 +74,6 @@ export function Edit() {
productId: useEntityId( 'postType', 'product' ),
onChange( values ) {
setEntityAttributes( values );
setEntityDefaultAttributes( manageDefaultAttributes( values ) );
generateProductVariations( values );
},
} );
@ -131,7 +130,12 @@ export function Edit() {
attributes,
entityDefaultAttributes,
] ) }
onChange={ handleChange }
onChange={ ( values ) => {
handleChange( values );
setEntityDefaultAttributes(
manageDefaultAttributes( values )
);
} }
createNewAttributesAsGlobal={ true }
useRemoveConfirmationModal={ true }
onNoticeDismiss={ () =>

View File

@ -6,6 +6,7 @@ import type { BlockEditProps } from '@wordpress/blocks';
import { Button } from '@wordpress/components';
import { Link } from '@woocommerce/components';
import { Product, ProductAttribute } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import {
createElement,
useState,
@ -36,6 +37,7 @@ import {
import { getAttributeId } from '../../components/attribute-control/utils';
import { useProductVariationsHelper } from '../../hooks/use-product-variations-helper';
import { hasAttributesUsedForVariations } from '../../utils';
import { TRACKS_SOURCE } from '../../constants';
function getFirstOptionFromEachAttribute(
attributes: Product[ 'attributes' ]
@ -101,15 +103,22 @@ export function Edit( {
};
const handleAdd = ( newOptions: EnhancedProductAttribute[] ) => {
handleChange( [
...newOptions.filter(
( newAttr ) =>
! variationOptions.find(
( attr: ProductAttribute ) =>
getAttributeId( newAttr ) === getAttributeId( attr )
)
),
] );
const addedAttributesOnly = newOptions.filter(
( newAttr ) =>
! variationOptions.some(
( attr: ProductAttribute ) =>
getAttributeId( newAttr ) === getAttributeId( attr )
)
);
recordEvent( 'product_options_add', {
source: TRACKS_SOURCE,
options: addedAttributesOnly.map( ( attribute ) => ( {
attribute: attribute.name,
values: attribute.options,
} ) ),
} );
handleChange( addedAttributesOnly );
closeNewModal();
};

View File

@ -31,6 +31,7 @@ import {
import { AttributeListItem } from '../attribute-list-item';
import { NewAttributeModal } from './new-attribute-modal';
import { RemoveConfirmationModal } from './remove-confirmation-modal';
import { TRACKS_SOURCE } from '../../constants';
type AttributeControlProps = {
value: EnhancedProductAttribute[];
@ -157,6 +158,10 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
};
const openEditModal = ( attribute: ProductAttribute ) => {
recordEvent( 'product_options_edit', {
source: TRACKS_SOURCE,
attribute: attribute.name,
} );
setCurrentAttributeId( getAttributeId( attribute ) );
onEditModalOpen( attribute );
};
@ -167,21 +172,35 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
};
const handleAdd = ( newAttributes: EnhancedProductAttribute[] ) => {
handleChange( [
...value,
...newAttributes.filter(
( newAttr ) =>
! value.find(
( attr ) =>
getAttributeId( newAttr ) === getAttributeId( attr )
)
),
] );
const addedAttributesOnly = newAttributes.filter(
( newAttr ) =>
! value.some(
( current: ProductAttribute ) =>
getAttributeId( newAttr ) === getAttributeId( current )
)
);
recordEvent( 'product_options_add', {
source: TRACKS_SOURCE,
options: addedAttributesOnly.map( ( attribute ) => ( {
attribute: attribute.name,
values: attribute.options,
} ) ),
} );
handleChange( [ ...value, ...addedAttributesOnly ] );
onAdd( newAttributes );
closeNewModal();
};
const handleEdit = ( updatedAttribute: ProductAttribute ) => {
const handleEdit = ( updatedAttribute: EnhancedProductAttribute ) => {
recordEvent( 'product_options_update', {
source: TRACKS_SOURCE,
attribute: updatedAttribute.name,
values: updatedAttribute.terms?.map( ( term ) => term.name ),
default: updatedAttribute.isDefault,
visible: updatedAttribute.visible,
filter: true, // default true until attribute filter gets implemented
} );
const updatedAttributes = value.map( ( attr ) => {
if (
getAttributeId( attr ) === getAttributeId( updatedAttribute )
@ -221,7 +240,9 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
className="woocommerce-add-attribute-list-item__add-button"
onClick={ () => {
openNewModal();
recordEvent( 'product_add_attributes_click' );
recordEvent( 'product_options_add_button_click', {
source: TRACKS_SOURCE,
} );
} }
>
{ uiStrings.newAttributeListItemLabel }

View File

@ -28,6 +28,7 @@ import { EnhancedProductAttribute } from '../../hooks/use-product-attributes';
import { TRACKS_SOURCE } from '../../constants';
type NarrowedQueryAttribute = Pick< QueryProductAttribute, 'id' | 'name' > & {
slug?: string;
isDisabled?: boolean;
};
@ -109,9 +110,11 @@ export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( {
if (
inputValue.length > 0 &&
! allItems.find(
( item ) => item.name.toLowerCase() === inputValue.toLowerCase()
)
( createNewAttributesAsGlobal ||
! allItems.find(
( item ) =>
item.name.toLowerCase() === inputValue.toLowerCase()
) )
) {
return [
...filteredItems,
@ -130,7 +133,10 @@ export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( {
source: TRACKS_SOURCE,
} );
if ( createNewAttributesAsGlobal ) {
createProductAttribute( { name: attribute.name } ).then(
createProductAttribute( {
name: attribute.name,
generate_slug: true,
} ).then(
( newAttr ) => {
invalidateResolution( 'getProductAttributes' );
onChange( { ...newAttr, options: [] } );
@ -204,7 +210,7 @@ export const AttributeInputField: React.FC< AttributeInputFieldProps > = ( {
tooltipText={
item.isDisabled
? disabledAttributeMessage
: undefined
: item.slug
}
>
{ isNewAttributeListItem( item ) ? (

View File

@ -307,6 +307,7 @@ describe( 'AttributeInputField', () => {
queryByText( 'Create "Co"' )?.click();
expect( createProductAttributeMock ).toHaveBeenCalledWith( {
name: 'Co',
generate_slug: true,
} );
await waitFor( () => {
expect( invalidateResolutionMock ).toHaveBeenCalledWith(
@ -352,6 +353,7 @@ describe( 'AttributeInputField', () => {
queryByText( 'Create "Co"' )?.click();
expect( createProductAttributeMock ).toHaveBeenCalledWith( {
name: 'Co',
generate_slug: true,
} );
await waitFor( () => {
expect( createErrorNoticeMock ).toHaveBeenCalledWith(

View File

@ -49,15 +49,17 @@ export const AttributeListItem: React.FC< AttributeListItemProps > = ( {
>
<div>{ attribute.name }</div>
<div className="woocommerce-attribute-list-item__options">
{ attribute.options.slice( 0, 2 ).map( ( option, index ) => (
<div
className="woocommerce-attribute-list-item__option-chip"
key={ index }
>
{ option }
</div>
) ) }
{ attribute.options.length > 2 && (
{ attribute.options
.slice( 0, attribute.options.length > 3 ? 2 : 3 )
.map( ( option, index ) => (
<div
className="woocommerce-attribute-list-item__option-chip"
key={ index }
>
{ option }
</div>
) ) }
{ attribute.options.length > 3 && (
<div className="woocommerce-attribute-list-item__option-chip">
{ sprintf(
__( '+ %i more', 'woocommerce' ),

View File

@ -1,39 +1,40 @@
.woocommerce-iframe-editor__header-toolbar {
height: 60px;
border: 0;
border-bottom: 1px solid $gray-400;
display: flex;
align-items: center;
height: 60px;
border: 0;
border-bottom: 1px solid $gray-400;
display: flex;
align-items: center;
justify-content: space-between;
.woocommerce-iframe-editor__header-toolbar-inserter-toggle.components-button.has-icon {
height: 32px;
margin-right: 8px;
min-width: 32px;
padding: 0;
width: 32px;
.woocommerce-iframe-editor__header-toolbar-inserter-toggle.components-button.has-icon {
height: 32px;
margin-right: 8px;
min-width: 32px;
padding: 0;
width: 32px;
svg {
transition: transform .2s cubic-bezier(.165,.84,.44,1);
}
svg {
transition: transform 0.2s cubic-bezier(0.165, 0.84, 0.44, 1);
}
&.is-pressed:before {
width: 100%;
left: 0;
}
&.is-pressed:before {
width: 100%;
left: 0;
}
&.is-pressed svg {
transform: rotate(45deg);
}
}
&.is-pressed svg {
transform: rotate(45deg);
}
}
&-left {
padding-left: $gap-small;
}
&-left {
padding-left: $gap-small;
}
&-right {
display: flex;
justify-content: center;
align-items: center;
gap: $gap-smaller;
> .components-dropdown-menu {
margin-right: $gap-small;
width: 48px;
@ -47,8 +48,7 @@
}
button.woocommerce-modal-actions__done-button,
button.woocommerce-modal-actions__cancel-button {
margin-left: $gap-smaller;
height: $gap-larger - $gap-smallest;
}
}
}
}

View File

@ -7,7 +7,6 @@ import { __ } from '@wordpress/i18n';
import { plus } from '@wordpress/icons';
import {
createElement,
Fragment,
useRef,
useCallback,
useContext,
@ -46,7 +45,6 @@ export function HeaderToolbar( {
}: HeaderToolbarProps ) {
const { isInserterOpened, setIsInserterOpened } =
useContext( EditorContext );
const isWideViewport = useViewportMatch( 'wide' );
const isLargeViewport = useViewportMatch( 'medium' );
const inserterButton = useRef< HTMLButtonElement | null >( null );
const { isInserterEnabled, isTextModeEnabled } = useSelect( ( select ) => {
@ -115,19 +113,15 @@ export function HeaderToolbar( {
}
showTooltip
/>
{ isWideViewport && (
<>
{ isLargeViewport && (
<ToolbarItem
as={ ToolSelector }
disabled={ isTextModeEnabled }
/>
) }
<ToolbarItem as={ EditorHistoryUndo } />
<ToolbarItem as={ EditorHistoryRedo } />
<ToolbarItem as={ DocumentOverview } />
</>
{ isLargeViewport && (
<ToolbarItem
as={ ToolSelector }
disabled={ isTextModeEnabled }
/>
) }
<ToolbarItem as={ EditorHistoryUndo } />
<ToolbarItem as={ EditorHistoryRedo } />
<ToolbarItem as={ DocumentOverview } />
</div>
<div className="woocommerce-iframe-editor__header-toolbar-right">
<ToolbarItem

View File

@ -1,8 +1,8 @@
.woocommerce-iframe-editor {
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
flex-direction: column;
height: 100%;
&__main {
align-items: flex-start;
@ -55,5 +55,16 @@
border-bottom: 1px solid $gray-400;
border-right: 1px solid $gray-400;
box-sizing: border-box;
margin-left: 0px;
width: 100%;
min-height: 47px;
> .block-editor-block-toolbar.is-showing-movers:before {
display: none;
}
.block-editor-block-toolbar__group-expand-fixed-toolbar,
.block-editor-block-toolbar__group-collapse-fixed-toolbar {
display: none;
}
}
}

View File

@ -0,0 +1 @@
export * from './inventory-menu-item';

View File

@ -0,0 +1,213 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { Dropdown, MenuGroup, MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { chevronRight } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { TRACKS_SOURCE } from '../../../constants';
import { PRODUCT_STOCK_STATUS_KEYS } from '../../../utils/get-product-stock-status';
export type InventoryMenuItemProps = {
variation: ProductVariation;
handlePrompt(
label?: string,
parser?: ( value: string ) => Partial< ProductVariation > | null
): void;
onChange( values: Partial< ProductVariation > ): void;
onClose(): void;
};
export function InventoryMenuItem( {
variation,
handlePrompt,
onChange,
onClose,
}: InventoryMenuItemProps ) {
return (
<Dropdown
position="middle right"
renderToggle={ ( { isOpen, onToggle } ) => (
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_click',
{
source: TRACKS_SOURCE,
variation_id: variation.id,
}
);
onToggle();
} }
aria-expanded={ isOpen }
icon={ chevronRight }
iconPosition="right"
>
{ __( 'Inventory', 'woocommerce' ) }
</MenuItem>
) }
renderContent={ () => (
<div className="components-dropdown-menu__menu">
<MenuGroup>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
const stockQuantity = Number( value );
if ( Number.isNaN( stockQuantity ) ) {
return {};
}
recordEvent(
'product_variations_menu_inventory_update',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: variation.id,
}
);
return {
stock_quantity: stockQuantity,
manage_stock: true,
};
} );
onClose();
} }
>
{ __( 'Update stock', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'manage_stock_toggle',
variation_id: variation.id,
}
);
onChange( {
manage_stock: ! variation.manage_stock,
} );
onClose();
} }
>
{ __( 'Toggle "track quantity"', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_in_stock',
variation_id: variation.id,
}
);
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.instock,
manage_stock: false,
} );
onClose();
} }
>
{ __( 'Set status to In stock', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_out_of_stock',
variation_id: variation.id,
}
);
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.outofstock,
manage_stock: false,
} );
onClose();
} }
>
{ __(
'Set status to Out of stock',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_on_back_order',
variation_id: variation.id,
}
);
onChange( {
stock_status:
PRODUCT_STOCK_STATUS_KEYS.onbackorder,
manage_stock: false,
} );
onClose();
} }
>
{ __(
'Set status to On back order',
'woocommerce'
) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'low_stock_amount_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'low_stock_amount_set',
variation_id: variation.id,
}
);
const lowStockAmount = Number( value );
if ( Number.isNaN( lowStockAmount ) ) {
return null;
}
return {
low_stock_amount: lowStockAmount,
manage_stock: true,
};
} );
onClose();
} }
>
{ __( 'Edit low stock threshold', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
</div>
) }
/>
);
}

View File

@ -0,0 +1 @@
export * from './pricing-menu-item';

View File

@ -0,0 +1,357 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { Dropdown, MenuGroup, MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { chevronRight } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { TRACKS_SOURCE } from '../../../constants';
function isPercentage( value: string ) {
return value.endsWith( '%' );
}
function parsePercentage( value: string ) {
const stringNumber = value.substring( 0, value.length - 1 );
if ( Number.isNaN( Number( stringNumber ) ) ) {
return undefined;
}
return Number( stringNumber );
}
function addFixedOrPercentage(
value: string,
fixedOrPercentage: string,
increaseOrDecrease: 1 | -1 = 1
) {
if ( isPercentage( fixedOrPercentage ) ) {
if ( Number.isNaN( Number( value ) ) ) {
return 0;
}
const percentage = parsePercentage( fixedOrPercentage );
if ( percentage === undefined ) {
return Number( value );
}
return (
Number( value ) +
Number( value ) * ( percentage / 100 ) * increaseOrDecrease
);
}
if ( Number.isNaN( Number( value ) ) ) {
if ( Number.isNaN( Number( fixedOrPercentage ) ) ) {
return undefined;
}
return Number( fixedOrPercentage );
}
return Number( value ) + Number( fixedOrPercentage ) * increaseOrDecrease;
}
export type PricingMenuItemProps = {
variation: ProductVariation;
handlePrompt(
label?: string,
parser?: ( value: string ) => Partial< ProductVariation >
): void;
onClose(): void;
};
export function PricingMenuItem( {
variation,
handlePrompt,
onClose,
}: PricingMenuItemProps ) {
return (
<Dropdown
position="middle right"
renderToggle={ ( { isOpen, onToggle } ) => (
<MenuItem
onClick={ () => {
recordEvent( 'product_variations_menu_pricing_click', {
source: TRACKS_SOURCE,
variation_id: variation.id,
} );
onToggle();
} }
aria-expanded={ isOpen }
icon={ chevronRight }
iconPosition="right"
>
{ __( 'Pricing', 'woocommerce' ) }
</MenuItem>
) }
renderContent={ () => (
<div className="components-dropdown-menu__menu">
<MenuGroup label={ __( 'List price', 'woocommerce' ) }>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'list_price_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_set',
variation_id: variation.id,
}
);
return {
regular_price: value,
};
} );
onClose();
} }
>
{ __( 'Set list price', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id: variation.id,
}
);
return {
regular_price: addFixedOrPercentage(
variation.regular_price,
value
)?.toFixed( 2 ),
};
}
);
onClose();
} }
>
{ __( 'Increase list price', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'list_price_decrease',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'list_price_increase',
variation_id: variation.id,
}
);
return {
regular_price: addFixedOrPercentage(
variation.regular_price,
value,
-1
)?.toFixed( 2 ),
};
}
);
onClose();
} }
>
{ __( 'Decrease list price', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
<MenuGroup label={ __( 'Sale price', 'woocommerce' ) }>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_set',
variation_id: variation.id,
}
);
return {
sale_price: value,
};
} );
onClose();
} }
>
{ __( 'Set sale price', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_increase',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_increase',
variation_id: variation.id,
}
);
return {
sale_price: addFixedOrPercentage(
variation.sale_price,
value
)?.toFixed( 2 ),
};
}
);
onClose();
} }
>
{ __( 'Increase sale price', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_decrease',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Enter a value (fixed or %)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_decrease',
variation_id: variation.id,
}
);
return {
sale_price: addFixedOrPercentage(
variation.sale_price,
value,
-1
)?.toFixed( 2 ),
};
}
);
onClose();
} }
>
{ __( 'Decrease sale price', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_pricing_select',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id: variation.id,
}
);
handlePrompt(
__(
'Sale start date (YYYY-MM-DD format or leave blank)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id: variation.id,
}
);
return {
date_on_sale_from_gmt: value,
};
}
);
handlePrompt(
__(
'Sale end date (YYYY-MM-DD format or leave blank)',
'woocommerce'
),
( value ) => {
recordEvent(
'product_variations_menu_pricing_update',
{
source: TRACKS_SOURCE,
action: 'sale_price_schedule',
variation_id: variation.id,
}
);
return {
date_on_sale_to_gmt: value,
};
}
);
onClose();
} }
>
{ __( 'Schedule sale', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
</div>
) }
/>
);
}

View File

@ -0,0 +1,2 @@
export * from './shipping-menu-item';
export * from './types';

View File

@ -0,0 +1,165 @@
/**
* External dependencies
*/
import { Dropdown, MenuItem } from '@wordpress/components';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { chevronRight } from '@wordpress/icons';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { ShippingMenuItemProps } from './types';
import { TRACKS_SOURCE } from '../../../constants';
export function ShippingMenuItem( {
variation,
handlePrompt,
onClose,
}: ShippingMenuItemProps ) {
return (
<Dropdown
position="middle right"
renderToggle={ ( { isOpen, onToggle } ) => (
<MenuItem
onClick={ () => {
recordEvent( 'product_variations_menu_shipping_click', {
source: TRACKS_SOURCE,
variation_id: variation.id,
} );
onToggle();
} }
aria-expanded={ isOpen }
icon={ chevronRight }
iconPosition="right"
>
{ __( 'Shipping', 'woocommerce' ) }
</MenuItem>
) }
renderContent={ () => (
<div className="components-dropdown-menu__menu">
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'dimensions_length_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_length_set',
variation_id: variation.id,
}
);
return {
dimensions: {
...variation.dimensions,
length: value,
},
};
} );
onClose();
} }
>
{ __( 'Set length', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'dimensions_width_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_width_set',
variation_id: variation.id,
}
);
return {
dimensions: {
...variation.dimensions,
width: value,
},
};
} );
onClose();
} }
>
{ __( 'Set width', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'dimensions_height_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'dimensions_height_set',
variation_id: variation.id,
}
);
return {
dimensions: {
...variation.dimensions,
height: value,
},
};
} );
onClose();
} }
>
{ __( 'Set height', 'woocommerce' ) }
</MenuItem>
<MenuItem
onClick={ () => {
recordEvent(
'product_variations_menu_shipping_select',
{
source: TRACKS_SOURCE,
action: 'weight_set',
variation_id: variation.id,
}
);
handlePrompt( undefined, ( value ) => {
recordEvent(
'product_variations_menu_shipping_update',
{
source: TRACKS_SOURCE,
action: 'weight_set',
variation_id: variation.id,
}
);
return { weight: value };
} );
onClose();
} }
>
{ __( 'Set weight', 'woocommerce' ) }
</MenuItem>
</div>
) }
/>
);
}

View File

@ -0,0 +1,13 @@
/**
* External dependencies
*/
import { ProductVariation } from '@woocommerce/data';
export type ShippingMenuItemProps = {
variation: ProductVariation;
handlePrompt(
label?: string,
parser?: ( value: string ) => Partial< ProductVariation > | null
): void;
onClose(): void;
};

View File

@ -3,10 +3,19 @@
flex-direction: column;
position: relative;
> div {
&__header {
padding-bottom: $grid-unit-30;
display: flex;
flex-direction: column;
flex-grow: 1;
align-items: center;
border-bottom: 1px solid $gray-200;
}
&__selection {
.components-checkbox-control__input[type="checkbox"] {
&:not(:checked):not(:focus) {
border-color: $gray-600;
}
}
}
&__loading {
@ -90,7 +99,7 @@
.woocommerce-list-item {
display: grid;
grid-template-columns: auto 25% 25% 88px;
grid-template-columns: 44px auto 25% 25% 88px;
padding: 0;
min-height: calc($grid-unit * 9);
border: none;

View File

@ -0,0 +1,2 @@
export * from './variation-actions-menu';
export * from './types';

View File

@ -0,0 +1,319 @@
/**
* External dependencies
*/
import { render, fireEvent } from '@testing-library/react';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import React, { createElement } from 'react';
/**
* Internal dependencies
*/
import { VariationActionsMenu } from '../';
import { TRACKS_SOURCE } from '../../../../constants';
import { PRODUCT_STOCK_STATUS_KEYS } from '../../../../utils/get-product-stock-status';
jest.mock( '@woocommerce/tracks', () => ( {
recordEvent: jest.fn(),
} ) );
const mockVariation = {
id: 10,
manage_stock: false,
attributes: [],
} as ProductVariation;
describe( 'VariationActionsMenu', () => {
let onChangeMock: jest.Mock, onDeleteMock: jest.Mock;
beforeEach( () => {
onChangeMock = jest.fn();
onDeleteMock = jest.fn();
( recordEvent as jest.Mock ).mockClear();
} );
it( 'should trigger product_variations_menu_view track when dropdown toggled', () => {
const { getByRole } = render(
<VariationActionsMenu
variation={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_view',
{
source: TRACKS_SOURCE,
variation_id: 10,
}
);
} );
it( 'should render dropdown with pricing, inventory, and delete options when opened', () => {
const { queryByText, getByRole } = render(
<VariationActionsMenu
variation={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
expect( queryByText( 'Pricing' ) ).toBeInTheDocument();
expect( queryByText( 'Inventory' ) ).toBeInTheDocument();
expect( queryByText( 'Delete' ) ).toBeInTheDocument();
} );
it( 'should call onDelete when Delete menuItem is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ mockVariation }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Delete' ) );
expect( onDeleteMock ).toHaveBeenCalled();
} );
describe( 'Inventory sub-menu', () => {
it( 'should open Inventory sub-menu if Inventory is clicked with click track', async () => {
const { queryByText, getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_click',
{
source: TRACKS_SOURCE,
variation_id: 10,
}
);
expect( queryByText( 'Update stock' ) ).toBeInTheDocument();
expect(
queryByText( 'Toggle "track quantity"' )
).toBeInTheDocument();
expect(
queryByText( 'Set status to In stock' )
).toBeInTheDocument();
} );
it( 'should onChange with stock_quantity when Update stock is clicked', async () => {
window.prompt = jest.fn().mockReturnValue( '10' );
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Update stock' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
stock_quantity: 10,
manage_stock: true,
} );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_update',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: 10,
}
);
} );
it( 'should not call onChange when prompt is cancelled', async () => {
window.prompt = jest.fn().mockReturnValue( null );
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Update stock' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: 10,
}
);
expect( onChangeMock ).not.toHaveBeenCalledWith( {
stock_quantity: 10,
manage_stock: true,
} );
expect( recordEvent ).not.toHaveBeenCalledWith(
'product_variations_menu_inventory_update',
{
source: TRACKS_SOURCE,
action: 'stock_quantity_set',
variation_id: 10,
}
);
} );
it( 'should call onChange with toggled manage_stock when toggle "track quantity" is clicked', async () => {
const { getByRole, getByText, rerender } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Toggle "track quantity"' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'manage_stock_toggle',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
manage_stock: true,
} );
onChangeMock.mockClear();
rerender(
<VariationActionsMenu
variation={ { ...mockVariation, manage_stock: true } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Toggle "track quantity"' ) );
expect( onChangeMock ).toHaveBeenCalledWith( {
manage_stock: false,
} );
} );
it( 'should call onChange with toggled stock_status when toggle "Set status to In stock" is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Set status to In stock' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_in_stock',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
stock_status: PRODUCT_STOCK_STATUS_KEYS.instock,
manage_stock: false,
} );
} );
it( 'should call onChange with toggled stock_status when toggle "Set status to Out of stock" is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Set status to Out of stock' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_out_of_stock',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
stock_status: PRODUCT_STOCK_STATUS_KEYS.outofstock,
manage_stock: false,
} );
} );
it( 'should call onChange with toggled stock_status when toggle "Set status to On back order" is clicked', async () => {
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Set status to On back order' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'set_status_on_back_order',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
stock_status: PRODUCT_STOCK_STATUS_KEYS.onbackorder,
manage_stock: false,
} );
} );
it( 'should call onChange with low_stock_amount when Edit low stock threshold is clicked', async () => {
window.prompt = jest.fn().mockReturnValue( '7' );
const { getByRole, getByText } = render(
<VariationActionsMenu
variation={ { ...mockVariation } }
onChange={ onChangeMock }
onDelete={ onDeleteMock }
/>
);
await fireEvent.click( getByRole( 'button', { name: 'Actions' } ) );
await fireEvent.click( getByText( 'Inventory' ) );
await fireEvent.click( getByText( 'Edit low stock threshold' ) );
expect( recordEvent ).toHaveBeenCalledWith(
'product_variations_menu_inventory_select',
{
source: TRACKS_SOURCE,
action: 'low_stock_amount_set',
variation_id: 10,
}
);
expect( onChangeMock ).toHaveBeenCalledWith( {
low_stock_amount: 7,
manage_stock: true,
} );
} );
} );
} );

View File

@ -0,0 +1,10 @@
/**
* External dependencies
*/
import { ProductVariation } from '@woocommerce/data';
export type VariationActionsMenuProps = {
variation: ProductVariation;
onChange( variation: Partial< ProductVariation > ): void;
onDelete( variationId: number ): void;
};

View File

@ -0,0 +1,110 @@
/**
* External dependencies
*/
import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
import { createElement, Fragment } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { moreVertical } from '@wordpress/icons';
import { ProductVariation } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { VariationActionsMenuProps } from './types';
import { TRACKS_SOURCE } from '../../../constants';
import { ShippingMenuItem } from '../shipping-menu-item';
import { InventoryMenuItem } from '../inventory-menu-item';
import { PricingMenuItem } from '../pricing-menu-item';
export function VariationActionsMenu( {
variation,
onChange,
onDelete,
}: VariationActionsMenuProps ) {
function handlePrompt(
label: string = __( 'Enter a value', 'woocommerce' ),
parser: ( value: string ) => Partial< ProductVariation > | null = () =>
null
) {
// eslint-disable-next-line no-alert
const value = window.prompt( label );
if ( value === null ) return;
const updates = parser( value.trim() );
if ( updates ) {
onChange( updates );
}
}
return (
<DropdownMenu
icon={ moreVertical }
label={ __( 'Actions', 'woocommerce' ) }
toggleProps={ {
onClick() {
recordEvent( 'product_variations_menu_view', {
source: TRACKS_SOURCE,
variation_id: variation.id,
} );
},
} }
>
{ ( { onClose } ) => (
<>
<MenuGroup
label={ sprintf(
/** Translators: Variation ID */
__( 'Variation Id: %s', 'woocommerce' ),
variation.id
) }
>
<MenuItem
href={ variation.permalink }
onClick={ () => {
recordEvent( 'product_variations_preview', {
source: TRACKS_SOURCE,
variation_id: variation.id,
} );
} }
>
{ __( 'Preview', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
<MenuGroup>
<PricingMenuItem
variation={ variation }
handlePrompt={ handlePrompt }
onClose={ onClose }
/>
<InventoryMenuItem
variation={ variation }
handlePrompt={ handlePrompt }
onChange={ onChange }
onClose={ onClose }
/>
<ShippingMenuItem
variation={ variation }
handlePrompt={ handlePrompt }
onClose={ onClose }
/>
</MenuGroup>
<MenuGroup>
<MenuItem
isDestructive
label={ __( 'Delete variation', 'woocommerce' ) }
variant="link"
onClick={ () => {
onDelete( variation.id );
onClose();
} }
className="woocommerce-product-variations__actions--delete"
>
{ __( 'Delete', 'woocommerce' ) }
</MenuItem>
</MenuGroup>
</>
) }
</DropdownMenu>
);
}

View File

@ -4,9 +4,7 @@
import { __, sprintf } from '@wordpress/i18n';
import {
Button,
DropdownMenu,
MenuGroup,
MenuItem,
CheckboxControl,
Spinner,
Tooltip,
} from '@wordpress/components';
@ -16,14 +14,8 @@ import {
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { ListItem, Pagination, Sortable, Tag } from '@woocommerce/components';
import {
useContext,
useState,
createElement,
Fragment,
} from '@wordpress/element';
import { useContext, useState, createElement } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
import { moreVertical } from '@wordpress/icons';
import classnames from 'classnames';
import truncate from 'lodash/truncate';
import { CurrencyContext } from '@woocommerce/currency';
@ -43,6 +35,8 @@ import {
PRODUCT_VARIATION_TITLE_LIMIT,
TRACKS_SOURCE,
} from '../../constants';
import { VariationActionsMenu } from './variation-actions-menu';
import { useSelection } from '../../hooks/use-selection';
const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
const VISIBLE_TEXT = __( 'Visible to customers', 'woocommerce' );
@ -54,6 +48,14 @@ export function VariationsTable() {
const [ isUpdating, setIsUpdating ] = useState< Record< string, boolean > >(
{}
);
const {
areAllSelected,
isSelected,
hasSelection,
onSelectAll,
onSelectItem,
onClearSelection,
} = useSelection();
const productId = useEntityId( 'postType', 'product' );
const context = useContext( CurrencyContext );
@ -99,6 +101,9 @@ export function VariationsTable() {
EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME
);
const { createSuccessNotice, createErrorNotice } =
useDispatch( 'core/notices' );
if ( ! variations && isLoading ) {
return (
<div className="woocommerce-product-variations__loading">
@ -112,25 +117,7 @@ export function VariationsTable() {
);
}
function handleCustomerVisibilityClick(
variationId: number,
status: 'private' | 'publish'
) {
if ( isUpdating[ variationId ] ) return;
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: true,
} ) );
updateProductVariation< Promise< ProductVariation > >(
{ product_id: productId, id: variationId },
{ status }
).finally( () =>
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: false,
} ) )
);
}
const variationIds = variations.map( ( { id } ) => id );
function handleDeleteVariationClick( variationId: number ) {
if ( isUpdating[ variationId ] ) return;
@ -155,6 +142,38 @@ export function VariationsTable() {
);
}
function handleVariationChange(
variationId: number,
variation: Partial< ProductVariation >
) {
if ( isUpdating[ variationId ] ) return;
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: true,
} ) );
updateProductVariation< Promise< ProductVariation > >(
{ product_id: productId, id: variationId },
variation
)
.then( () => {
createSuccessNotice(
/* translators: The updated variations count */
sprintf( __( '%s variation/s updated.', 'woocommerce' ), 1 )
);
} )
.catch( () => {
createErrorNotice(
__( 'Failed to save variation.', 'woocommerce' )
);
} )
.finally( () =>
setIsUpdating( ( prevState ) => ( {
...prevState,
[ variationId ]: false,
} ) )
);
}
return (
<div className="woocommerce-product-variations">
{ isLoading ||
@ -171,9 +190,46 @@ export function VariationsTable() {
) }
</div>
) ) }
<div className="woocommerce-product-variations__header">
<div className="woocommerce-product-variations__selection">
<CheckboxControl
value="all"
checked={ areAllSelected( variationIds ) }
// @ts-expect-error Property 'indeterminate' does not exist
indeterminate={
! areAllSelected( variationIds ) &&
hasSelection( variationIds )
}
onChange={ onSelectAll( variationIds ) }
/>
</div>
<div>
<Button
variant="tertiary"
disabled={ areAllSelected( variationIds ) }
onClick={ () => onSelectAll( variationIds )( true ) }
>
{ __( 'Select all', 'woocommerce' ) }
</Button>
<Button
variant="tertiary"
disabled={ ! hasSelection( variationIds ) }
onClick={ () => onClearSelection() }
>
{ __( 'Clear selection', 'woocommerce' ) }
</Button>
</div>
</div>
<Sortable>
{ variations.map( ( variation ) => (
<ListItem key={ `${ variation.id }` }>
<div className="woocommerce-product-variations__selection">
<CheckboxControl
value={ variation.id }
checked={ isSelected( variation.id ) }
onChange={ onSelectItem( variation.id ) }
/>
</div>
<div className="woocommerce-product-variations__attributes">
{ variation.attributes.map( ( attribute ) => {
const tag = (
@ -251,9 +307,9 @@ export function VariationsTable() {
isUpdating[ variation.id ]
}
onClick={ () =>
handleCustomerVisibilityClick(
handleVariationChange(
variation.id,
'publish'
{ status: 'publish' }
)
}
>
@ -282,9 +338,9 @@ export function VariationsTable() {
isUpdating[ variation.id ]
}
onClick={ () =>
handleCustomerVisibilityClick(
handleVariationChange(
variation.id,
'private'
{ status: 'private' }
)
}
>
@ -296,71 +352,13 @@ export function VariationsTable() {
</Button>
</Tooltip>
) }
<DropdownMenu
icon={ moreVertical }
label={ __( 'Actions', 'woocommerce' ) }
toggleProps={ {
onClick() {
recordEvent(
'product_variations_menu_view',
{
source: TRACKS_SOURCE,
}
);
},
} }
>
{ ( { onClose } ) => (
<>
<MenuGroup
label={ sprintf(
/** Translators: Variation ID */
__(
'Variation Id: %s',
'woocommerce'
),
variation.id
) }
>
<MenuItem
href={ variation.permalink }
onClick={ () => {
recordEvent(
'product_variations_preview',
{
source: TRACKS_SOURCE,
}
);
} }
>
{ __(
'Preview',
'woocommerce'
) }
</MenuItem>
</MenuGroup>
<MenuGroup>
<MenuItem
isDestructive
variant="link"
onClick={ () => {
handleDeleteVariationClick(
variation.id
);
onClose();
} }
className="woocommerce-product-variations__actions--delete"
>
{ __(
'Delete',
'woocommerce'
) }
</MenuItem>
</MenuGroup>
</>
) }
</DropdownMenu>
<VariationActionsMenu
variation={ variation }
onChange={ ( value ) =>
handleVariationChange( variation.id, value )
}
onDelete={ handleDeleteVariationClick }
/>
</div>
</ListItem>
) ) }

View File

@ -0,0 +1 @@
export * from './use-selection';

View File

@ -0,0 +1,3 @@
export type Selection = {
[ itemId: string ]: boolean | undefined;
};

View File

@ -0,0 +1,61 @@
/**
* External dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { Selection } from './types';
export function useSelection() {
const [ selectedItems, setSelectedItems ] = useState< Selection >( {} );
function isSelected( itemId: string ) {
return Boolean( selectedItems[ itemId ] );
}
function areAllSelected( itemIds: string[] ) {
return itemIds.every( ( itemId ) => isSelected( itemId ) );
}
function hasSelection( itemIds: string[] ) {
return itemIds.some( ( itemId ) => isSelected( itemId ) );
}
function onSelectItem( itemId: string ) {
return function onChange( isChecked: boolean ) {
setSelectedItems( ( currentSelection ) => ( {
...currentSelection,
[ itemId ]: isChecked,
} ) );
};
}
function onSelectAll( itemIds: string[] ) {
return function onChange( isChecked: boolean ) {
const selection = itemIds.reduce< Selection >(
( current, id ) => ( {
...current,
[ id ]: isChecked,
} ),
{}
);
setSelectedItems( selection );
};
}
function onClearSelection() {
setSelectedItems( {} );
}
return {
selectedItems,
areAllSelected,
hasSelection,
isSelected,
onSelectItem,
onSelectAll,
onClearSelection,
};
}

View File

@ -18,7 +18,7 @@ export const productApiFetchMiddleware = () => {
// This is needed to ensure that we use the correct namespace for the entity data store
// without disturbing the rest_namespace outside of the product block editor.
apiFetch.use( ( options, next ) => {
const versionTwoRegex = new RegExp( '^/wp/v2/product' );
const versionTwoRegex = new RegExp( '^/wp/v2/product(?!_)' );
if (
options.path &&
versionTwoRegex.test( options?.path ) &&

View File

@ -1,37 +1 @@
<svg width="222" height="144" viewBox="0 0 222 144" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="222" height="144" fill="white"/>
<path d="M66.7225 101.881H4.39697V139.681H66.7225V101.881Z" fill="#BEA0F2"/>
<path d="M43.1444 108.18H4.39697V111.78H43.1444V108.18Z" fill="#271B3D"/>
<path d="M66.7223 108.18H47.4619V111.78H66.7223V108.18Z" fill="#271B3D"/>
<path d="M66.7224 129.779H51.5386V133.379H66.7224V129.779Z" fill="#271B3D"/>
<path d="M31.6522 118.98H4.39697V122.58H31.6522V118.98Z" fill="#271B3D"/>
<path d="M66.7224 118.98H35.9087V122.58H66.7224V118.98Z" fill="#271B3D"/>
<path d="M47.221 129.779H4.39697V133.379H47.221V129.779Z" fill="#271B3D"/>
<path d="M181.785 139.723H217.765V121.723H181.785V139.723Z" fill="#BEA0F2"/>
<path d="M141.468 139.709H177.449V121.709H141.468V139.709Z" fill="#BEA0F2"/>
<path d="M101.239 139.676H137.219V121.676H101.239V139.676Z" fill="#BEA0F2"/>
<path d="M159.476 117.443H195.457V99.4434H159.476V117.443Z" fill="#BEA0F2"/>
<path d="M181.767 95.1191H217.747V77.1191H181.767V95.1191Z" fill="#BEA0F2"/>
<path d="M199.757 117.428H217.747V99.4277H199.757V117.428Z" fill="#BEA0F2"/>
<path d="M199.757 72.8203H217.747V54.8203H199.757V72.8203Z" fill="#BEA0F2"/>
<path d="M90.5991 91.3711H29.4321V94.9711H90.5991V91.3711Z" fill="#271B3D"/>
<path d="M90.5991 69.8398H29.4321V91.4398H90.5991V69.8398Z" fill="#BEA0F2"/>
<path d="M46.7316 85.9065L42.9968 82.1697C43.5221 81.3741 43.828 80.4237 43.828 79.4013C43.828 76.6221 41.5684 74.3613 38.7907 74.3613C36.013 74.3613 33.7534 76.6221 33.7534 79.4013C33.7534 82.1805 36.013 84.4413 38.7907 84.4413C39.8125 84.4413 40.7624 84.1353 41.5576 83.6097L45.2924 87.3465L46.7352 85.9029L46.7316 85.9065ZM38.7871 82.4037C37.132 82.4037 35.7863 81.0573 35.7863 79.4013C35.7863 77.7453 37.132 76.3989 38.7871 76.3989C40.4422 76.3989 41.7879 77.7453 41.7879 79.4013C41.7879 81.0573 40.4422 82.4037 38.7871 82.4037Z" fill="#271B3D"/>
<path d="M51.7402 73.1016V87.3504" stroke="#271B3D" stroke-width="0.71" stroke-miterlimit="10"/>
<path d="M90.4335 45.6051C101.828 45.6051 111.065 36.3632 111.065 24.9627C111.065 13.5622 101.828 4.32031 90.4335 4.32031C79.0392 4.32031 69.8022 13.5622 69.8022 24.9627C69.8022 36.3632 79.0392 45.6051 90.4335 45.6051Z" fill="#BEA0F2"/>
<path d="M90.8398 13.4219H90.0303V36.5051H90.8398V13.4219Z" fill="white"/>
<path d="M101.969 25.3686V24.5586H78.898V25.3686H101.969Z" fill="white"/>
<path d="M144.07 74.8765H139.367C140.202 65.7145 138.363 48.5605 124.345 48.5605C110.327 48.5605 108.489 65.7145 109.323 74.8765H104.632L96.8022 112.738H151.899L144.07 74.8765ZM124.345 54.4609C132.646 54.4609 132.632 68.2489 130.243 74.8765H118.452C116.063 68.2489 116.045 54.4609 124.349 54.4609H124.345Z" fill="#BEA0F2"/>
<path d="M138.057 4.32031H122.956V30.4959C126.025 30.8775 128.774 33.6207 128.774 36.4935C128.774 39.3663 126.856 42.2787 123.453 42.2787C120.779 42.2787 119.218 41.3355 117.235 39.5895L116.505 40.2447C117.833 42.3651 121.035 44.9895 124.55 44.9895C130.174 44.9895 134.729 41.1159 134.729 35.4891C134.729 32.7027 133.977 29.2539 131.498 26.4351L135.006 14.5983L217.758 39.2583V4.32031H138.054H138.057Z" fill="#271B3D"/>
<path d="M126.026 42.6777L100.551 114.044" stroke="#261B3C" stroke-width="0.99" stroke-miterlimit="10"/>
<path d="M122.676 42.6777L148.15 114.044" stroke="#261B3C" stroke-width="0.99" stroke-miterlimit="10"/>
<path d="M65.4848 4.32031H4.31787V65.5203H65.4848V4.32031Z" fill="#271B3D"/>
<path d="M43.695 38.1169C43.695 26.1361 47.588 8.3125 59.6703 8.3125L47.0951 43.2217H10.8159C10.8159 39.2437 16.0943 29.5273 43.695 38.1133V38.1169Z" fill="#BEA0F2"/>
<path d="M15.1118 62.7235L24.1357 28.4227H38.3372L46.6344 62.7235H48.1779L39.226 25.7695H23.265L13.561 62.7235H15.1118Z" fill="white"/>
<path d="M23.2651 25.7695L24.1359 28.4227H38.3374L39.2261 25.7695H23.2651Z" fill="#BEA0F2"/>
<path d="M153.166 83.2743L165.183 87.4827L175.966 80.7075L177.391 68.0463L168.389 59.0391L155.735 60.4647L148.959 71.2503L153.166 83.2743Z" fill="#BEA0F2"/>
<path d="M172.21 25.6863L180.831 28.2567V18.4287L203.369 4.32031H190.197L172.21 15.5343V25.6863Z" fill="#BEA0F2"/>
<path d="M157.49 21.3015L165.553 23.7027V14.2491L181.633 4.32031H169.555L157.49 11.8443V21.3015Z" fill="#BEA0F2"/>
<path d="M169.58 66.9961L157.652 78.9337" stroke="#271B3D" stroke-width="0.99" stroke-miterlimit="10"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M127.641 94.6549C129.793 93.3409 132.351 91.1197 132.351 87.4549C132.351 82.6057 128.652 79.5781 124.345 79.5781C120.038 79.5781 116.339 82.6057 116.339 87.4549C116.339 91.1233 118.898 93.3445 121.049 94.6549L115.264 108.036H133.43L127.645 94.6549H127.641Z" fill="#271B3D"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="216" height="140"><g fill="none" fill-rule="evenodd"><path fill="#FFF" fill-rule="nonzero" d="M0 0h216v140H0z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M64.92 99.127H4.277v36.779H64.92z"/><path fill="#271B3D" fill-rule="nonzero" d="M41.978 105.256h-37.7v3.503h37.7zM64.919 105.256h-18.74v3.503h18.74zM64.92 126.271H50.145v3.503h14.773zM30.797 115.764H4.278v3.503h26.519zM64.92 115.764H34.937v3.503H64.92zM45.945 126.271H4.278v3.503h41.667z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M176.872 135.947h35.007v-17.514h-35.007zM137.645 135.933h35.008V118.42h-35.008zM98.503 135.901h35.007v-17.514H98.503zM155.166 114.269h35.008V96.756h-35.008zM176.854 92.548h35.008V75.035h-35.008zM194.358 114.254h17.504V96.74h-17.504zM194.358 70.852h17.504V53.34h-17.504z"/><path fill="#271B3D" fill-rule="nonzero" d="M88.15 88.902H28.637v3.502H88.15z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M88.15 67.952H28.637v21.016H88.15z"/><path fill="#271B3D" fill-rule="nonzero" d="m45.469 83.585-3.634-3.636a4.87 4.87 0 0 0 .808-2.694 4.908 4.908 0 0 0-4.9-4.903 4.908 4.908 0 0 0-4.902 4.903 4.908 4.908 0 0 0 4.901 4.904c.994 0 1.919-.298 2.692-.809l3.634 3.636 1.404-1.405-.003.004Zm-7.73-3.408c-1.61 0-2.92-1.31-2.92-2.922a2.923 2.923 0 0 1 2.92-2.92c1.61 0 2.92 1.31 2.92 2.92a2.923 2.923 0 0 1-2.92 2.922Z"/><path stroke="#271B3D" stroke-width=".71" d="M50.342 71.126V84.99"/><path fill="#BEA0F2" fill-rule="nonzero" d="M87.99 44.373c11.086 0 20.073-8.993 20.073-20.085S99.076 4.204 87.99 4.204c-11.086 0-20.073 8.992-20.073 20.084 0 11.092 8.987 20.085 20.073 20.085Z"/><path fill="#FFF" fill-rule="nonzero" d="M88.385 13.06h-.788v22.458h.788z"/><path fill="#FFF" fill-rule="nonzero" d="M99.213 24.683v-.788H76.766v.788z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M140.176 72.853H135.6c.813-8.915-.977-25.605-14.616-25.605s-15.427 16.69-14.616 25.605h-4.564l-7.618 36.838h53.608l-7.618-36.838Zm-19.192-19.864c8.077 0 8.063 13.415 5.739 19.864H115.25c-2.325-6.449-2.342-19.864 5.737-19.864h-.004Z"/><path fill="#271B3D" fill-rule="nonzero" d="M134.326 4.204h-14.693v25.468c2.986.371 5.66 3.04 5.66 5.835s-1.866 5.629-5.177 5.629c-2.601 0-4.12-.918-6.05-2.616l-.71.637c1.292 2.063 4.408 4.617 7.828 4.617 5.472 0 9.904-3.77 9.904-9.244 0-2.711-.732-6.067-3.144-8.81l3.413-11.516 80.516 23.993V4.204h-77.55.003Z"/><path stroke="#261B3C" stroke-width=".99" d="m122.62 41.524-24.787 69.438M119.36 41.524l24.786 69.438"/><path fill="#271B3D" fill-rule="nonzero" d="M63.715 4.204H4.2v59.545h59.514z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M42.514 37.087c0-11.657 3.788-29 15.544-29L45.822 42.055H10.524c0-3.87 5.135-13.325 31.99-4.97v.003Z"/><path fill="#FFF" fill-rule="nonzero" d="m14.703 61.028 8.78-33.373h13.818l8.073 33.373h1.502l-8.71-35.955h-15.53l-9.442 35.955z"/><path fill="#BEA0F2" fill-rule="nonzero" d="m22.636 25.073.848 2.582H37.3l.865-2.582zM149.026 81.024l11.693 4.094 10.491-6.592 1.387-12.319-8.76-8.764-12.311 1.388-6.593 10.494zM167.556 24.992l8.388 2.501v-9.562l21.929-13.727h-12.816l-17.501 10.91z"/><path fill="#BEA0F2" fill-rule="nonzero" d="m153.234 20.726 7.845 2.336v-9.198l15.645-9.66h-11.752l-11.738 7.32z"/><path stroke="#271B3D" stroke-width=".99" d="M164.997 65.185 153.391 76.8"/><path fill="#271B3D" d="M124.191 92.097c2.094-1.279 4.583-3.44 4.583-7.006 0-4.718-3.6-7.664-7.79-7.664-4.19 0-7.79 2.946-7.79 7.664 0 3.57 2.49 5.73 4.583 7.006l-5.628 13.02h17.675l-5.629-13.02h-.004Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@ -1,36 +1 @@
<svg width="216" height="140" viewBox="0 0 216 140" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_947_7778)">
<rect width="216" height="140" fill="white"/>
<path d="M177.064 50.0078C155.982 50.0078 142.329 61.0748 142.329 78.0848C142.329 99.2878 164.804 102.165 164.804 113.214H189.324C189.324 102.161 211.799 99.2878 211.799 78.0848C211.799 61.0748 198.143 50.0078 177.064 50.0078Z" fill="#BEA0F2"/>
<path d="M39.0797 4.22656C14.2064 4.22656 0.234706 23.4241 5.19535 37.8686C10.6041 53.6466 27.317 60.8426 27.317 81.5836H50.8494C50.8494 60.8426 67.5588 53.6466 72.971 37.8686C77.9282 23.4241 63.9565 4.22656 39.0797 4.22656Z" fill="#BEA0F2"/>
<path d="M50.0127 48.273C44.2609 48.273 40.8791 52.067 40.8791 57.933V58.661C40.473 58.661 40.0529 58.661 39.6538 58.6645H38.8907C38.3095 58.6645 37.9629 58.6645 37.2838 58.6645V57.9365C37.2838 52.067 33.902 48.273 28.1502 48.273C23.0285 48.273 20.0913 50.205 20.0913 53.565C20.0913 56.0885 22.5489 57.793 27.3975 58.633C30.3627 59.1475 33.2369 59.2735 36.076 59.3015V81.5825H37.2838V59.305C37.9629 59.3085 38.306 59.3085 38.8907 59.305C39.4718 59.305 40.2 59.305 40.8791 59.305V81.579H42.0869V59.298C44.9261 59.27 47.8002 59.144 50.7654 58.6295C55.614 57.7895 58.0716 56.085 58.0716 53.5615C58.0716 50.198 55.1344 48.2695 50.0127 48.2695V48.273ZM36.0795 58.654C33.2684 58.6225 30.4327 58.5 27.52 57.996C23.0355 57.219 21.8207 55.3745 21.8207 53.453C21.8207 50.016 25.395 48.9205 28.1572 48.9205C33.2999 48.9205 36.083 52.459 36.083 57.933V58.6575L36.0795 58.654ZM50.6464 57.996C47.7302 58.5 44.8981 58.626 42.0869 58.654V57.9295C42.0869 52.4555 44.87 48.917 50.0127 48.917C52.7714 48.917 56.3457 50.0125 56.3457 53.4495C56.3457 55.371 55.1274 57.2155 50.6429 57.9925L50.6464 57.996Z" fill="white"/>
<path d="M211.799 4.19922H154.12L148.435 51.8377L160.159 39.4757H207.483L211.799 4.19922Z" fill="#271B3D"/>
<path d="M129.502 106.181C125.826 101.025 123.715 98.5508 119.567 98.5508C115.016 98.5508 113.017 103.703 115.751 106.923C118.758 110.465 129.555 114.161 129.555 114.161C129.555 114.161 105.801 111 99.6471 111C96.2513 111 92.7715 113.135 92.7715 117.188C92.7715 121.241 96.2513 123.376 99.6471 123.376C105.801 123.376 129.555 120.216 129.555 120.216C129.555 120.216 118.754 123.912 115.751 127.454C113.02 130.674 115.016 135.826 119.567 135.826C123.719 135.826 125.83 133.355 129.502 128.196C130.748 126.449 137.078 117.185 137.078 117.185C137.078 117.185 130.748 107.924 129.502 106.174V106.181Z" fill="#271B3D"/>
<path d="M78.0678 85.0508L137.932 85.0508V67.5508H78.0678V85.0508Z" fill="#BEA0F2"/>
<path d="M120.428 67.5508H78.0679V71.0508H120.428V67.5508Z" fill="#271B3D"/>
<path d="M137.756 74.5859H78.2427V78.0859H137.756V74.5859Z" fill="#271B3D"/>
<path d="M111.676 81.5508H78.0679V85.0508H111.676V81.5508Z" fill="#271B3D"/>
<path d="M86.4699 98.3516H49.0112V135.802H86.4699V98.3516Z" fill="#BEA0F2"/>
<path d="M70.4365 123.6L77.1651 117.398L56.3633 117.471V116.446L77.1441 116.516L70.44 110.338L71.0597 109.551L78.863 116.897V117.016L71.0597 124.387L70.44 123.6H70.4365Z" fill="#271B3D"/>
<path d="M171.336 20.5877L175.548 32.7257L196.178 9.69922" stroke="white" stroke-miterlimit="10"/>
<path d="M186.698 90.9335C186.698 84.784 183.015 80.9585 177.085 80.9375C177.085 80.9375 177.071 80.9375 177.064 80.9375C177.057 80.9375 177.05 80.9375 177.043 80.9375C171.113 80.9585 167.43 85.2705 167.43 90.9335C167.43 96.5965 170.913 99.288 175.881 102.83C172.873 105.637 171.263 110.078 171.263 113.211H173.791C173.791 110.306 174.347 106.186 177.064 103.75C179.784 106.186 180.337 110.306 180.337 113.211H182.865C182.865 110.078 181.251 105.633 178.247 102.83C183.215 99.288 186.698 96.2885 186.698 90.9335ZM177.064 102.162C172.138 98.6685 168.564 96.446 168.564 90.9335C168.564 85.421 171.96 81.9665 177.064 81.956C182.168 81.9665 185.564 85.6065 185.564 90.9335C185.564 95.711 181.99 98.6685 177.064 102.162Z" fill="#271B3D"/>
<path d="M42.0099 127.4C42.0099 131.995 38.311 135.73 33.7224 135.8H12.6218C7.97364 135.803 4.20117 132.04 4.20117 127.4C4.20117 122.76 7.97014 119 12.6218 119H33.7329C38.3145 119 42.0064 122.812 42.0064 127.4H42.0099Z" fill="#BEA0F2"/>
<path d="M33.4992 133.349C36.7861 133.349 39.4506 130.685 39.4506 127.399C39.4506 124.113 36.7861 121.449 33.4992 121.449C30.2124 121.449 27.5479 124.113 27.5479 127.399C27.5479 130.685 30.2124 133.349 33.4992 133.349Z" fill="#271B3D"/>
<path d="M137.757 4.22656H78.2432V63.7266H137.757V4.22656Z" fill="#271B3D"/>
<path d="M120.326 43.3729C120.326 39.2569 117.809 35.3159 112.87 32.6279H128.249L113.899 8.96094H102.105L87.7549 32.6279H103.134C98.1943 35.3159 95.6772 39.2569 95.6772 43.3729C95.6772 48.0314 99.3286 52.7319 104.142 55.2484C97.4836 53.9044 96.4404 58.9829 96.4404 58.9829H119.57C119.57 58.9829 118.527 53.9044 111.868 55.2484C116.682 52.7319 120.333 48.0314 120.333 43.3729H120.326Z" fill="white"/>
<path d="M108 8.96484H102.105L87.7515 32.6283H103.131H112.87H128.249L113.896 8.96484H108Z" fill="#BEA0F2"/>
<path d="M123.474 24.7578H92.5266L87.7515 32.6293L123.474 24.7578Z" fill="white"/>
<path d="M118.698 16.8828H97.3015L92.5264 24.7578L118.698 16.8828Z" fill="white"/>
<path d="M113.896 8.96484H102.105L97.3018 16.8818L113.896 8.96484Z" fill="white"/>
<path d="M47.856 25.9769C57.1646 16.5059 65.6226 13.1144 75.1238 11.6094" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M70.6288 29.4444L48.2026 11.3984" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M57.1646 35.1382C60.2138 25.5832 61.327 13.2702 59.3631 4.22266" stroke="#271B3D" stroke-miterlimit="10"/>
<path d="M21.9468 81.5859V85.0859H27.3135C27.3135 85.1314 27.3135 86.3459 27.3135 86.3879C27.394 92.1244 32.7538 97.2694 39.0797 97.2694C45.4057 97.2694 50.7549 92.1349 50.846 86.4019C50.846 86.3529 50.846 85.1349 50.846 85.0859H56.2127V81.5859H21.9468Z" fill="#271B3D"/>
<path d="M193.844 116.715V113.215H160.281V116.715H164.801C164.801 116.97 164.801 117.572 164.801 118.325H160.281V121.825H164.801C164.801 122.917 164.801 123.795 164.801 123.82C164.801 129.406 169.551 135.828 177.064 135.828C184.577 135.828 189.327 129.402 189.327 123.82C189.327 123.792 189.327 122.938 189.327 121.825H193.847V118.325H189.327C189.327 117.572 189.327 116.974 189.327 116.715H193.844Z" fill="#271B3D"/>
</g>
<defs>
<clipPath id="clip0_947_7778">
<rect width="216" height="140" fill="white"/>
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="216" height="140"><g fill="none" fill-rule="evenodd"><path fill="#FFF" fill-rule="nonzero" d="M0 0h216v140H0z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M177.064 50.008c-21.082 0-34.735 11.067-34.735 28.077 0 21.203 22.475 24.08 22.475 35.129h24.52c0-11.053 22.475-13.926 22.475-35.13 0-17.01-13.656-28.076-34.735-28.076ZM39.08 4.227C14.206 4.227.235 23.424 5.195 37.869c5.41 15.778 22.122 22.974 22.122 43.715h23.532c0-20.741 16.71-27.937 22.122-43.715C77.928 23.424 63.956 4.227 39.08 4.227Z"/><path fill="#FFF" fill-rule="nonzero" d="M50.013 48.273c-5.752 0-9.134 3.794-9.134 9.66v.728c-.406 0-.826 0-1.225.003h-2.37v-.727c0-5.87-3.382-9.664-9.134-9.664-5.121 0-8.059 1.932-8.059 5.292 0 2.524 2.458 4.228 7.306 5.068 2.966.514 5.84.64 8.679.668v22.281h1.208V59.305c.679.004 1.022.004 1.607 0h1.988v22.274h1.208V59.298c2.84-.028 5.713-.154 8.678-.669 4.849-.84 7.307-2.544 7.307-5.067 0-3.364-2.938-5.292-8.06-5.292v.003ZM36.08 58.654c-2.812-.032-5.647-.154-8.56-.658-4.485-.777-5.7-2.622-5.7-4.543 0-3.437 3.575-4.533 6.337-4.533 5.143 0 7.926 3.539 7.926 9.013v.724l-.003-.003Zm14.566-.658c-2.916.504-5.748.63-8.56.658v-.725c0-5.474 2.784-9.012 7.927-9.012 2.758 0 6.333 1.096 6.333 4.532 0 1.922-1.219 3.767-5.703 4.544l.003.003Z"/><path fill="#271B3D" fill-rule="nonzero" d="M211.799 4.2H154.12l-5.685 47.638 11.724-12.362h47.324zM129.502 106.181c-3.676-5.156-5.787-7.63-9.935-7.63-4.551 0-6.55 5.152-3.816 8.372 3.007 3.542 13.804 7.238 13.804 7.238S105.801 111 99.647 111c-3.396 0-6.876 2.135-6.876 6.188s3.48 6.188 6.876 6.188c6.154 0 29.908-3.16 29.908-3.16s-10.801 3.696-13.804 7.238c-2.731 3.22-.735 8.372 3.816 8.372 4.152 0 6.263-2.471 9.935-7.63 1.246-1.747 7.576-11.011 7.576-11.011s-6.33-9.261-7.576-11.011v.007Z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M78.068 85.05h59.864v-17.5H78.068z"/><path fill="#271B3D" fill-rule="nonzero" d="M120.428 67.55h-42.36v3.5h42.36zM137.756 74.586H78.243v3.5h59.513zM111.676 81.55H78.068v3.5h33.608z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M86.47 98.352H49.01v37.45H86.47z"/><path fill="#271B3D" fill-rule="nonzero" d="m70.436 123.6 6.73-6.202-20.803.073v-1.025l20.781.07-6.704-6.178.62-.787 7.803 7.346v.119l-7.803 7.371-.62-.787z"/><path stroke="#FFF" d="m171.336 20.588 4.212 12.138 20.63-23.027"/><path fill="#271B3D" fill-rule="nonzero" d="M186.698 90.933c0-6.149-3.683-9.975-9.613-9.996h-.042c-5.93.022-9.613 4.334-9.613 9.996 0 5.663 3.483 8.355 8.451 11.897-3.008 2.807-4.618 7.248-4.618 10.381h2.528c0-2.905.556-7.025 3.273-9.461 2.72 2.436 3.273 6.556 3.273 9.461h2.528c0-3.133-1.614-7.578-4.618-10.381 4.968-3.542 8.451-6.541 8.451-11.897Zm-9.634 11.229c-4.926-3.494-8.5-5.716-8.5-11.229 0-5.512 3.396-8.967 8.5-8.977 5.104.01 8.5 3.65 8.5 8.977 0 4.778-3.574 7.735-8.5 11.229Z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M42.01 127.4c0 4.595-3.699 8.33-8.288 8.4h-21.1c-4.648.003-8.42-3.76-8.42-8.4 0-4.64 3.768-8.4 8.42-8.4h21.11c4.583 0 8.274 3.812 8.274 8.4h.004Z"/><path fill="#271B3D" fill-rule="nonzero" d="M33.5 133.349a5.95 5.95 0 1 0-5.952-5.95 5.95 5.95 0 0 0 5.951 5.95ZM137.757 4.227H78.243v59.5h59.514z"/><path fill="#FFF" fill-rule="nonzero" d="M120.326 43.373c0-4.116-2.517-8.057-7.456-10.745h15.379L113.899 8.96h-11.794l-14.35 23.667h15.379c-4.94 2.688-7.457 6.629-7.457 10.745 0 4.658 3.652 9.359 8.465 11.875-6.658-1.344-7.702 3.735-7.702 3.735h23.13s-1.043-5.079-7.702-3.735c4.814-2.516 8.465-7.217 8.465-11.875h-.007Z"/><path fill="#BEA0F2" fill-rule="nonzero" d="M108 8.965h-5.895L87.751 32.628h40.498L113.896 8.965z"/><path fill="#FFF" fill-rule="nonzero" d="M123.474 24.758H92.527l-4.776 7.871zM118.698 16.883H97.302l-4.776 7.875zM113.896 8.965h-11.791l-4.803 7.917z"/><path stroke="#271B3D" d="M47.856 25.977c9.309-9.471 17.767-12.863 27.268-14.368M70.629 29.444 48.203 11.398"/><path stroke="#271B3D" d="M57.165 35.138c3.049-9.555 4.162-21.868 2.198-30.915"/><path fill="#271B3D" fill-rule="nonzero" d="M21.947 81.586v3.5h5.367v1.302c.08 5.736 5.44 10.881 11.766 10.881s11.675-5.134 11.766-10.867v-1.316h5.367v-3.5H21.947ZM193.844 116.715v-3.5h-33.563v3.5h4.52v1.61h-4.52v3.5h4.52v1.995c0 5.586 4.75 12.008 12.263 12.008s12.263-6.426 12.263-12.008v-1.995h4.52v-3.5h-4.52v-1.61h4.517Z"/></g></svg>

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

@ -1,8 +1,29 @@
// Loader page
.woocommerce-onboarding-loader {
.loader-hearticon {
position: relative;
top: 2px;
left: 2px;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
background-color: #fff;
.woocommerce-onboarding-loader-wrapper {
align-items: flex-start;
display: flex;
flex-direction: column;
justify-content: center;
max-width: 520px;
min-height: 400px;
.woocommerce-onboarding-loader-container {
text-align: center;
min-height: 400px;
}
.loader-hearticon {
position: relative;
top: 2px;
left: 2px;
}
}
}

View File

@ -0,0 +1,255 @@
// Reference: https://github.com/WordPress/gutenberg/blob/release/16.4/packages/block-editor/src/components/block-preview/auto.js
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { useResizeObserver, pure, useRefEffect } from '@wordpress/compose';
import { useMemo } from '@wordpress/element';
import { Disabled } from '@wordpress/components';
import {
__unstableEditorStyles as EditorStyles,
__unstableIframe as Iframe,
BlockList,
// @ts-ignore No types for this exist yet.
} from '@wordpress/block-editor';
const MAX_HEIGHT = 2000;
// @ts-ignore No types for this exist yet.
const { Provider: DisabledProvider } = Disabled.Context;
// This is used to avoid rendering the block list if the sizes change.
let MemoizedBlockList: typeof BlockList | undefined;
export type ScaledBlockPreviewProps = {
viewportWidth?: number;
containerWidth: number;
minHeight?: number;
settings: {
styles: string[];
[ key: string ]: unknown;
};
additionalStyles: string;
onClickNavigationItem: ( event: MouseEvent ) => void;
};
function ScaledBlockPreview( {
viewportWidth,
containerWidth,
minHeight,
settings,
additionalStyles,
onClickNavigationItem,
}: ScaledBlockPreviewProps ) {
if ( ! viewportWidth ) {
viewportWidth = containerWidth;
}
const [ contentResizeListener, { height: contentHeight } ] =
useResizeObserver();
// Avoid scrollbars for pattern previews.
const editorStyles = useMemo( () => {
return [
{
css: 'body{height:auto;overflow:hidden;border:none;padding:0;}',
__unstableType: 'presets',
},
...settings.styles,
];
}, [ settings.styles ] );
// Initialize on render instead of module top level, to avoid circular dependency issues.
MemoizedBlockList = MemoizedBlockList || pure( BlockList );
const scale = containerWidth / viewportWidth;
return (
<DisabledProvider value={ true }>
<Iframe
contentRef={ useRefEffect( ( bodyElement: HTMLBodyElement ) => {
const {
ownerDocument: { documentElement },
} = bodyElement;
documentElement.classList.add(
'block-editor-block-preview__content-iframe'
);
documentElement.style.position = 'absolute';
documentElement.style.width = '100%';
// Necessary for contentResizeListener to work.
bodyElement.style.boxSizing = 'border-box';
bodyElement.style.position = 'absolute';
bodyElement.style.width = '100%';
let navigationContainers: NodeListOf< HTMLDivElement >;
let siteTitles: NodeListOf< HTMLAnchorElement >;
const onClickNavigation = ( event: MouseEvent ) => {
event.preventDefault();
onClickNavigationItem( event );
};
const onMouseMove = ( event: MouseEvent ) => {
event.stopImmediatePropagation();
};
const possiblyRemoveAllListeners = () => {
bodyElement.removeEventListener(
'mousemove',
onMouseMove,
false
);
if ( navigationContainers ) {
navigationContainers.forEach( ( element ) => {
element.removeEventListener(
'click',
onClickNavigation
);
} );
}
if ( siteTitles ) {
siteTitles.forEach( ( element ) => {
element.removeEventListener(
'click',
onClickNavigation
);
} );
}
};
const onChange = () => {
// Remove contenteditable and inert attributes from editable elements so that users can click on navigation links.
bodyElement
.querySelectorAll(
'.block-editor-rich-text__editable[contenteditable="true"]'
)
.forEach( ( element ) => {
element.removeAttribute( 'contenteditable' );
} );
bodyElement
.querySelectorAll( '*[inert="true"]' )
.forEach( ( element ) => {
element.removeAttribute( 'inert' );
} );
possiblyRemoveAllListeners();
navigationContainers = bodyElement.querySelectorAll(
'.wp-block-navigation__container'
);
navigationContainers.forEach( ( element ) => {
element.addEventListener(
'click',
onClickNavigation,
true
);
} );
siteTitles = bodyElement.querySelectorAll(
'.wp-block-site-title a'
);
siteTitles.forEach( ( element ) => {
element.addEventListener(
'click',
onClickNavigation,
true
);
} );
};
// Stop mousemove event listener to disable block tool insertion feature.
bodyElement.addEventListener(
'mousemove',
onMouseMove,
true
);
const observer = new window.MutationObserver( onChange );
observer.observe( bodyElement, {
attributes: true,
characterData: false,
subtree: true,
childList: true,
} );
return () => {
observer.disconnect();
possiblyRemoveAllListeners();
};
}, [] ) }
aria-hidden
tabIndex={ -1 }
style={ {
width: viewportWidth,
height: contentHeight,
// This is a catch-all max-height for patterns.
// Reference: https://github.com/WordPress/gutenberg/pull/38175.
maxHeight: MAX_HEIGHT,
minHeight:
scale !== 0 && scale < 1 && minHeight
? minHeight / scale
: minHeight,
} }
>
<EditorStyles styles={ editorStyles } />
<style>
{ `
.block-editor-block-list__block::before,
.is-selected::after,
.is-hovered::after,
.block-list-appender {
display: none !important;
}
.block-editor-block-list__block.is-selected {
box-shadow: none !important;
}
.block-editor-rich-text__editable {
pointer-events: none !important;
}
.wp-block-site-title .block-editor-rich-text__editable {
pointer-events: all !important;
}
.wp-block-navigation .wp-block-pages-list__item__link {
pointer-events: all !important;
cursor: pointer !important;
}
${ additionalStyles }
` }
</style>
{ contentResizeListener }
<MemoizedBlockList renderAppender={ false } />
</Iframe>
</DisabledProvider>
);
}
export const AutoHeightBlockPreview = (
props: Omit< ScaledBlockPreviewProps, 'containerWidth' >
) => {
const [ containerResizeListener, { width: containerWidth } ] =
useResizeObserver();
return (
<>
<div style={ { position: 'relative', width: '100%', height: 0 } }>
{ containerResizeListener }
</div>
<div className="auto-block-preview__container">
{ !! containerWidth && (
<ScaledBlockPreview
{ ...props }
containerWidth={ containerWidth }
/>
) }
</div>
</>
);
};

View File

@ -0,0 +1,138 @@
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import classNames from 'classnames';
import { useSelect } from '@wordpress/data';
// @ts-ignore No types for this exist yet.
import { useEntityRecords, useEntityBlockEditor } from '@wordpress/core-data';
// @ts-ignore No types for this exist yet.
import { privateApis as routerPrivateApis } from '@wordpress/router';
// @ts-ignore No types for this exist yet.
import { store as editSiteStore } from '@wordpress/edit-site/build-module/store';
// @ts-ignore No types for this exist yet.
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
// @ts-ignore No types for this exist yet.
import useSiteEditorSettings from '@wordpress/edit-site/build-module/components/block-editor/use-site-editor-settings';
import { BlockInstance } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import BlockPreview from './block-preview';
const { useHistory, useLocation } = unlock( routerPrivateApis );
type Page = {
link: string;
title: { rendered: string; raw: string };
[ key: string ]: unknown;
};
// We only show the edit option when page count is <= MAX_PAGE_COUNT
// Performance of Navigation Links is not good past this value.
const MAX_PAGE_COUNT = 100;
export const BlockEditor = ( {} ) => {
const history = useHistory();
const location = useLocation();
const settings = useSiteEditorSettings();
const { templateType } = useSelect( ( select ) => {
const { getEditedPostType } = unlock( select( editSiteStore ) );
return {
templateType: getEditedPostType(),
};
}, [] );
const [ blocks ]: [ BlockInstance[] ] = useEntityBlockEditor(
'postType',
templateType
);
// // See packages/block-library/src/page-list/edit.js.
const { records: pages } = useEntityRecords( 'postType', 'page', {
per_page: MAX_PAGE_COUNT,
_fields: [ 'id', 'link', 'menu_order', 'parent', 'title', 'type' ],
// TODO: When https://core.trac.wordpress.org/ticket/39037 REST API support for multiple orderby
// values is resolved, update 'orderby' to [ 'menu_order', 'post_title' ] to provide a consistent
// sort.
orderby: 'menu_order',
order: 'asc',
} );
const onClickNavigationItem = ( event: MouseEvent ) => {
const clickedPage =
pages.find(
( page: Page ) =>
page.link === ( event.target as HTMLAnchorElement ).href
) ||
// Fallback to page title if the link is not found. This is needed for a bug in the block library
// See https://github.com/woocommerce/team-ghidorah/issues/253#issuecomment-1665106817
pages.find(
( page: Page ) =>
page.title.rendered ===
( event.target as HTMLAnchorElement ).innerText
);
if ( clickedPage ) {
history.push( {
...location.params,
postId: clickedPage.id,
postType: 'page',
} );
} else {
// Home page
const { postId, postType, ...params } = location.params;
history.push( {
...params,
} );
}
};
return (
<div className="woocommerce-customize-store__block-editor">
{ blocks.map( ( block, index ) => {
// Add padding to the top and bottom of the block preview.
let additionalStyles = '';
let hasActionBar = false;
switch ( true ) {
case index === 0:
// header
additionalStyles = `
.editor-styles-wrapper{ padding-top: var(--wp--style--root--padding-top) };'
`;
break;
case index === blocks.length - 1:
// footer
additionalStyles = `
.editor-styles-wrapper{ padding-bottom: var(--wp--style--root--padding-bottom) };
`;
break;
default:
hasActionBar = true;
}
return (
<div
key={ block.clientId }
className={ classNames(
'woocommerce-block-preview-container',
{
'has-action-menu': hasActionBar,
}
) }
>
<BlockPreview
blocks={ block }
settings={ settings }
additionalStyles={ additionalStyles }
onClickNavigationItem={ onClickNavigationItem }
/>
</div>
);
} ) }
</div>
);
};

View File

@ -0,0 +1,40 @@
// Reference: https://github.com/WordPress/gutenberg/blob/release/16.4/packages/block-editor/src/components/block-preview/index.js
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
// @ts-ignore No types for this exist yet.
import { BlockEditorProvider } from '@wordpress/block-editor';
import { memo, useMemo } from '@wordpress/element';
import { BlockInstance } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import {
AutoHeightBlockPreview,
ScaledBlockPreviewProps,
} from './auto-block-preview';
export const BlockPreview = ( {
blocks,
settings,
...props
}: {
blocks: BlockInstance | BlockInstance[];
settings: Record< string, unknown >;
} & Omit< ScaledBlockPreviewProps, 'containerWidth' > ) => {
const renderedBlocks = useMemo(
() => ( Array.isArray( blocks ) ? blocks : [ blocks ] ),
[ blocks ]
);
return (
<BlockEditorProvider value={ renderedBlocks } settings={ settings }>
<AutoHeightBlockPreview settings={ settings } { ...props } />
</BlockEditorProvider>
);
};
export default memo( BlockPreview );

View File

@ -0,0 +1,100 @@
// Reference: https://github.com/WordPress/gutenberg/tree/v16.4.0/packages/edit-site/src/components/editor/index.js
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import classnames from 'classnames';
import { useMemo } from '@wordpress/element';
// @ts-ignore No types for this exist yet.
import { EntityProvider } from '@wordpress/core-data';
// @ts-ignore No types for this exist yet.
import { InterfaceSkeleton } from '@wordpress/interface';
import { useSelect, useDispatch } from '@wordpress/data';
// @ts-ignore No types for this exist yet.
import { BlockContextProvider } from '@wordpress/block-editor';
// @ts-ignore No types for this exist yet.
import { store as editSiteStore } from '@wordpress/edit-site/build-module/store';
// @ts-ignore No types for this exist yet.
import CanvasSpinner from '@wordpress/edit-site/build-module/components/canvas-spinner';
// @ts-ignore No types for this exist yet.
import useEditedEntityRecord from '@wordpress/edit-site/build-module/components/use-edited-entity-record';
// @ts-ignore No types for this exist yet.
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
// @ts-ignore No types for this exist yet.
import { GlobalStylesRenderer } from '@wordpress/edit-site/build-module/components/global-styles-renderer';
/**
* Internal dependencies
*/
import { BlockEditor } from './block-editor';
export const Editor = ( { isLoading }: { isLoading: boolean } ) => {
const { record: template } = useEditedEntityRecord();
const { id: templateId, type: templateType } = template;
const { context, hasPageContentFocus } = useSelect( ( select ) => {
const {
getEditedPostContext,
hasPageContentFocus: _hasPageContentFocus,
} = unlock( select( editSiteStore ) );
// The currently selected entity to display.
// Typically template or template part in the site editor.
return {
context: getEditedPostContext(),
hasPageContentFocus: _hasPageContentFocus,
};
}, [] );
// @ts-ignore No types for this exist yet.
const { setEditedPostContext } = useDispatch( editSiteStore );
const blockContext = useMemo( () => {
const { postType, postId, ...nonPostFields } = context ?? {};
return {
...( hasPageContentFocus ? context : nonPostFields ),
queryContext: [
context?.queryContext || { page: 1 },
( newQueryContext: Record< string, unknown > ) =>
setEditedPostContext( {
...context,
queryContext: {
...context?.queryContext,
...newQueryContext,
},
} ),
],
};
}, [ hasPageContentFocus, context, setEditedPostContext ] );
return (
<>
{ isLoading ? <CanvasSpinner /> : null }
<EntityProvider kind="root" type="site">
<EntityProvider
kind="postType"
type={ templateType }
id={ templateId }
>
<BlockContextProvider value={ blockContext }>
<InterfaceSkeleton
enableRegionNavigation={ false }
className={ classnames(
'woocommerce-customize-store__edit-site-editor',
'edit-site-editor__interface-skeleton',
{
'show-icon-labels': false,
'is-loading': isLoading,
}
) }
content={
<>
<GlobalStylesRenderer />
<BlockEditor />
</>
}
/>
</BlockContextProvider>
</EntityProvider>
</EntityProvider>
</>
);
};

View File

@ -1 +1,142 @@
export type events = { type: 'FINISH_CUSTOMIZATION' };
// Reference: https://github.com/WordPress/gutenberg/tree/v16.4.0/packages/edit-site/src/index.js
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { useEffect, createContext } from '@wordpress/element';
import { dispatch, useDispatch } from '@wordpress/data';
import {
__experimentalFetchLinkSuggestions as fetchLinkSuggestions,
__experimentalFetchUrlData as fetchUrlData,
// @ts-ignore No types for this exist yet.
} from '@wordpress/core-data';
// eslint-disable-next-line @woocommerce/dependency-group
import {
registerCoreBlocks,
__experimentalGetCoreBlocks,
// @ts-ignore No types for this exist yet.
} from '@wordpress/block-library';
// @ts-ignore No types for this exist yet.
import { getBlockType, store as blocksStore } from '@wordpress/blocks';
// @ts-ignore No types for this exist yet.
import { privateApis as routerPrivateApis } from '@wordpress/router';
// @ts-ignore No types for this exist yet.
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
// @ts-ignore No types for this exist yet.
import { ShortcutProvider } from '@wordpress/keyboard-shortcuts';
// @ts-ignore No types for this exist yet.
import { store as preferencesStore } from '@wordpress/preferences';
// @ts-ignore No types for this exist yet.
import { store as editorStore } from '@wordpress/editor';
// @ts-ignore No types for this exist yet.
import { store as editSiteStore } from '@wordpress/edit-site/build-module/store';
// @ts-ignore No types for this exist yet.
import { GlobalStylesProvider } from '@wordpress/edit-site/build-module/components/global-styles/global-styles-provider';
/**
* Internal dependencies
*/
import { CustomizeStoreComponent } from '../types';
import { Layout } from './layout';
import './style.scss';
const { RouterProvider } = unlock( routerPrivateApis );
type CustomizeStoreComponentProps = Parameters< CustomizeStoreComponent >[ 0 ];
export const CustomizeStoreContext = createContext< {
sendEvent: CustomizeStoreComponentProps[ 'sendEvent' ];
context: Partial< CustomizeStoreComponentProps[ 'context' ] >;
} >( {
sendEvent: () => {},
context: {},
} );
export type events =
| { type: 'FINISH_CUSTOMIZATION' }
| { type: 'GO_BACK_TO_DESIGN_WITH_AI' };
export const AssemblerHub: CustomizeStoreComponent = ( props ) => {
const { setCanvasMode } = unlock( useDispatch( editSiteStore ) );
useEffect( () => {
if ( ! window.wcBlockSettings ) {
// eslint-disable-next-line no-console
console.warn(
'window.blockSettings not found. Skipping initialization.'
);
return;
}
// Set up the block editor settings.
const settings = window.wcBlockSettings;
settings.__experimentalFetchLinkSuggestions = (
search: string,
searchOptions: {
isInitialSuggestions: boolean;
type: 'attachment' | 'post' | 'term' | 'post-format';
subtype: string;
page: number;
perPage: number;
}
) => fetchLinkSuggestions( search, searchOptions, settings );
settings.__experimentalFetchRichUrlData = fetchUrlData;
// @ts-ignore No types for this exist yet.
dispatch( blocksStore ).__experimentalReapplyBlockTypeFilters();
const coreBlocks = __experimentalGetCoreBlocks().filter(
( { name }: { name: string } ) =>
name !== 'core/freeform' && ! getBlockType( name )
);
registerCoreBlocks( coreBlocks );
// @ts-ignore No types for this exist yet.
dispatch( blocksStore ).setFreeformFallbackBlockName( 'core/html' );
// @ts-ignore No types for this exist yet.
dispatch( preferencesStore ).setDefaults( 'core/edit-site', {
editorMode: 'visual',
fixedToolbar: false,
focusMode: false,
distractionFree: false,
keepCaretInsideBlock: false,
welcomeGuide: false,
welcomeGuideStyles: false,
welcomeGuidePage: false,
welcomeGuideTemplate: false,
showListViewByDefault: false,
showBlockBreadcrumbs: true,
} );
// @ts-ignore No types for this exist yet.
dispatch( editSiteStore ).updateSettings( settings );
// @ts-ignore No types for this exist yet.
dispatch( editorStore ).updateEditorSettings( {
defaultTemplateTypes: settings.defaultTemplateTypes,
defaultTemplatePartAreas: settings.defaultTemplatePartAreas,
} );
// Prevent the default browser action for files dropped outside of dropzones.
window.addEventListener(
'dragover',
( e ) => e.preventDefault(),
false
);
window.addEventListener( 'drop', ( e ) => e.preventDefault(), false );
setCanvasMode( 'view' );
}, [ setCanvasMode ] );
return (
<CustomizeStoreContext.Provider value={ props }>
<ShortcutProvider style={ { height: '100%' } }>
<GlobalStylesProvider>
<RouterProvider>
<Layout />
</RouterProvider>
</GlobalStylesProvider>
</ShortcutProvider>
</CustomizeStoreContext.Provider>
);
};

View File

@ -0,0 +1,150 @@
// Reference: https://github.com/WordPress/gutenberg/tree/v16.4.0/packages/edit-site/src/components/layout/index.js
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import classnames from 'classnames';
import {
useReducedMotion,
useResizeObserver,
useViewportMatch,
} from '@wordpress/compose';
import { __ } from '@wordpress/i18n';
import {
// @ts-ignore No types for this exist yet.
__unstableMotion as motion,
} from '@wordpress/components';
import {
privateApis as blockEditorPrivateApis,
// @ts-ignore No types for this exist yet.
} from '@wordpress/block-editor';
// @ts-ignore No types for this exist yet.
import ResizableFrame from '@wordpress/edit-site/build-module/components/resizable-frame';
// @ts-ignore No types for this exist yet.
import useInitEditedEntityFromURL from '@wordpress/edit-site/build-module/components/sync-state-with-url/use-init-edited-entity-from-url';
// @ts-ignore No types for this exist yet.
import { useIsSiteEditorLoading } from '@wordpress/edit-site/build-module/components/layout/hooks';
// @ts-ignore No types for this exist yet.
import ErrorBoundary from '@wordpress/edit-site/build-module/components/error-boundary';
// @ts-ignore No types for this exist yet.
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
// @ts-ignore No types for this exist yet.
import { NavigableRegion } from '@wordpress/interface';
/**
* Internal dependencies
*/
import { Editor } from './editor';
import Sidebar from './sidebar';
import { SiteHub } from './site-hub';
const { useGlobalStyle } = unlock( blockEditorPrivateApis );
const ANIMATION_DURATION = 0.5;
export const Layout = () => {
// This ensures the edited entity id and type are initialized properly.
useInitEditedEntityFromURL();
const isMobileViewport = useViewportMatch( 'medium', '<' );
const disableMotion = useReducedMotion();
const [ canvasResizer, canvasSize ] = useResizeObserver();
const isEditorLoading = useIsSiteEditorLoading();
const [ backgroundColor ] = useGlobalStyle( 'color.background' );
const [ gradientValue ] = useGlobalStyle( 'color.gradient' );
return (
<div className={ classnames( 'edit-site-layout' ) }>
<motion.div
className="edit-site-layout__header-container"
animate={ 'view' }
>
<SiteHub
as={ motion.div }
variants={ {
view: { x: 0 },
} }
isTransparent={ false }
className="edit-site-layout__hub"
/>
</motion.div>
<div className="edit-site-layout__content">
<NavigableRegion
ariaLabel={ __( 'Navigation', 'woocommerce' ) }
className="edit-site-layout__sidebar-region"
>
<motion.div
animate={ { opacity: 1 } }
transition={ {
type: 'tween',
duration:
// Disable transitiont in mobile to emulate a full page transition.
disableMotion || isMobileViewport
? 0
: ANIMATION_DURATION,
ease: 'easeOut',
} }
className="edit-site-layout__sidebar"
>
<Sidebar />
</motion.div>
</NavigableRegion>
{ ! isMobileViewport && (
<div
className={ classnames(
'edit-site-layout__canvas-container'
) }
>
{ canvasResizer }
{ !! canvasSize.width && (
<motion.div
whileHover={ {
scale: 1.005,
transition: {
duration: disableMotion ? 0 : 0.5,
ease: 'easeOut',
},
} }
initial={ false }
layout="position"
className={ classnames(
'edit-site-layout__canvas'
) }
transition={ {
type: 'tween',
duration: disableMotion
? 0
: ANIMATION_DURATION,
ease: 'easeOut',
} }
>
<ErrorBoundary>
<ResizableFrame
isReady={ ! isEditorLoading }
isFullWidth={ false }
defaultSize={ {
width:
canvasSize.width -
24 /* $canvas-padding */,
height: canvasSize.height,
} }
isOversized={ false }
innerContentStyle={ {
background:
gradientValue ??
backgroundColor,
} }
>
<Editor isLoading={ isEditorLoading } />
</ResizableFrame>
</ErrorBoundary>
</motion.div>
) }
</div>
) }
</div>
</div>
);
};

View File

@ -0,0 +1,144 @@
// Reference: https://github.com/WordPress/gutenberg/blob/v16.4.0/packages/edit-site/src/components/sidebar/index.js
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { memo, useRef, useEffect } from '@wordpress/element';
import {
// @ts-ignore No types for this exist yet.
__experimentalNavigatorProvider as NavigatorProvider,
// @ts-ignore No types for this exist yet.
__experimentalNavigatorScreen as NavigatorScreen,
// @ts-ignore No types for this exist yet.
__experimentalUseNavigator as useNavigator,
} from '@wordpress/components';
// @ts-ignore No types for this exist yet.
import { privateApis as routerPrivateApis } from '@wordpress/router';
// @ts-ignore No types for this exist yet.
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
/**
* Internal dependencies
*/
import { SidebarNavigationScreenMain } from './sidebar-navigation-screen-main';
import { SidebarNavigationScreenColorPalette } from './sidebar-navigation-screen-color-palette';
import { SidebarNavigationScreenTypography } from './sidebar-navigation-screen-typography';
import { SidebarNavigationScreenHeader } from './sidebar-navigation-screen-header';
import { SidebarNavigationScreenHomepage } from './sidebar-navigation-screen-homepage';
import { SidebarNavigationScreenFooter } from './sidebar-navigation-screen-footer';
import { SidebarNavigationScreenPages } from './sidebar-navigation-screen-pages';
import { SidebarNavigationScreenLogo } from './sidebar-navigation-screen-logo';
import { SaveHub } from './save-hub';
const { useLocation, useHistory } = unlock( routerPrivateApis );
function isSubset(
subset: {
[ key: string ]: string | undefined;
},
superset: {
[ key: string ]: string | undefined;
}
) {
return Object.entries( subset ).every( ( [ key, value ] ) => {
return superset[ key ] === value;
} );
}
function useSyncPathWithURL() {
const history = useHistory();
const { params: urlParams } = useLocation();
const { location: navigatorLocation, params: navigatorParams } =
useNavigator();
const isMounting = useRef( true );
useEffect(
() => {
// The navigatorParams are only initially filled properly when the
// navigator screens mount. so we ignore the first synchronisation.
if ( isMounting.current ) {
isMounting.current = false;
return;
}
function updateUrlParams( newUrlParams: {
[ key: string ]: string | undefined;
} ) {
if ( isSubset( newUrlParams, urlParams ) ) {
return;
}
const updatedParams = {
...urlParams,
...newUrlParams,
};
history.push( updatedParams );
}
updateUrlParams( {
postType: undefined,
postId: undefined,
categoryType: undefined,
categoryId: undefined,
path:
navigatorLocation.path === '/'
? undefined
: navigatorLocation.path,
} );
},
// Trigger only when navigator changes to prevent infinite loops.
// eslint-disable-next-line react-hooks/exhaustive-deps
[ navigatorLocation?.path, navigatorParams ]
);
}
function SidebarScreens() {
useSyncPathWithURL();
return (
<>
<NavigatorScreen path="/customize-store">
<SidebarNavigationScreenMain />
</NavigatorScreen>
<NavigatorScreen path="/customize-store/color-palette">
<SidebarNavigationScreenColorPalette />
</NavigatorScreen>
<NavigatorScreen path="/customize-store/typography">
<SidebarNavigationScreenTypography />
</NavigatorScreen>
<NavigatorScreen path="/customize-store/header">
<SidebarNavigationScreenHeader />
</NavigatorScreen>
<NavigatorScreen path="/customize-store/homepage">
<SidebarNavigationScreenHomepage />
</NavigatorScreen>
<NavigatorScreen path="/customize-store/footer">
<SidebarNavigationScreenFooter />
</NavigatorScreen>
<NavigatorScreen path="/customize-store/pages">
<SidebarNavigationScreenPages />
</NavigatorScreen>
<NavigatorScreen path="/customize-store/logo">
<SidebarNavigationScreenLogo />
</NavigatorScreen>
</>
);
}
function Sidebar() {
const { params: urlParams } = useLocation();
const initialPath = useRef( urlParams.path ?? '/customize-store' );
return (
<>
<NavigatorProvider
className="edit-site-sidebar__content"
initialPath={ initialPath.current }
>
<SidebarScreens />
</NavigatorProvider>
<SaveHub />
</>
);
}
export default memo( Sidebar );

View File

@ -0,0 +1,228 @@
// Reference: https://github.com/WordPress/gutenberg/blob/v16.4.0/packages/edit-site/src/components/save-hub/index.js
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
/**
* External dependencies
*/
import { useContext } from '@wordpress/element';
import { useSelect, useDispatch } from '@wordpress/data';
// @ts-ignore No types for this exist yet.
import { Button, __experimentalHStack as HStack } from '@wordpress/components';
import { __, sprintf, _n } from '@wordpress/i18n';
// @ts-ignore No types for this exist yet.
import { store as coreStore } from '@wordpress/core-data';
// @ts-ignore No types for this exist yet.
import { store as blockEditorStore } from '@wordpress/block-editor';
// @ts-ignore No types for this exist yet.
import { check } from '@wordpress/icons';
// @ts-ignore No types for this exist yet.
import { privateApis as routerPrivateApis } from '@wordpress/router';
// @ts-ignore No types for this exist yet.
import { store as noticesStore } from '@wordpress/notices';
// @ts-ignore No types for this exist yet.
import { unlock } from '@wordpress/edit-site/build-module/lock-unlock';
// @ts-ignore No types for this exist yet.
import SaveButton from '@wordpress/edit-site/build-module/components/save-button';
/**
* Internal dependencies
*/
import { CustomizeStoreContext } from '../';
const { useLocation } = unlock( routerPrivateApis );
const PUBLISH_ON_SAVE_ENTITIES = [
{
kind: 'postType',
name: 'wp_navigation',
},
];
export const SaveHub = () => {
const saveNoticeId = 'site-edit-save-notice';
const { params } = useLocation();
const { sendEvent } = useContext( CustomizeStoreContext );
// @ts-ignore No types for this exist yet.
const { __unstableMarkLastChangeAsPersistent } =
useDispatch( blockEditorStore );
const { createSuccessNotice, createErrorNotice, removeNotice } =
useDispatch( noticesStore );
const { dirtyCurrentEntity, countUnsavedChanges, isDirty, isSaving } =
useSelect(
( select ) => {
const {
// @ts-ignore No types for this exist yet.
__experimentalGetDirtyEntityRecords,
// @ts-ignore No types for this exist yet.
isSavingEntityRecord,
} = select( coreStore );
const dirtyEntityRecords =
__experimentalGetDirtyEntityRecords();
let calcDirtyCurrentEntity = null;
if ( dirtyEntityRecords.length === 1 ) {
// if we are on global styles
if (
params.path?.includes( 'color-palette' ) ||
params.path?.includes( 'fonts' )
) {
calcDirtyCurrentEntity = dirtyEntityRecords.find(
// @ts-ignore No types for this exist yet.
( record ) => record.name === 'globalStyles'
);
}
// if we are on pages
else if ( params.postId ) {
calcDirtyCurrentEntity = dirtyEntityRecords.find(
// @ts-ignore No types for this exist yet.
( record ) =>
record.name === params.postType &&
String( record.key ) === params.postId
);
}
}
return {
dirtyCurrentEntity: calcDirtyCurrentEntity,
isDirty: dirtyEntityRecords.length > 0,
isSaving: dirtyEntityRecords.some(
( record: {
kind: string;
name: string;
key: string;
} ) =>
isSavingEntityRecord(
record.kind,
record.name,
record.key
)
),
countUnsavedChanges: dirtyEntityRecords.length,
};
},
[ params.path, params.postType, params.postId ]
);
const {
// @ts-ignore No types for this exist yet.
editEntityRecord,
// @ts-ignore No types for this exist yet.
saveEditedEntityRecord,
// @ts-ignore No types for this exist yet.
__experimentalSaveSpecifiedEntityEdits: saveSpecifiedEntityEdits,
} = useDispatch( coreStore );
const saveCurrentEntity = async () => {
if ( ! dirtyCurrentEntity ) return;
removeNotice( saveNoticeId );
const { kind, name, key, property } = dirtyCurrentEntity;
try {
if ( dirtyCurrentEntity.kind === 'root' && name === 'site' ) {
await saveSpecifiedEntityEdits( 'root', 'site', undefined, [
property,
] );
} else {
if (
PUBLISH_ON_SAVE_ENTITIES.some(
( typeToPublish ) =>
typeToPublish.kind === kind &&
typeToPublish.name === name
)
) {
editEntityRecord( kind, name, key, { status: 'publish' } );
}
await saveEditedEntityRecord( kind, name, key );
}
__unstableMarkLastChangeAsPersistent();
createSuccessNotice( __( 'Site updated.', 'woocommerce' ), {
type: 'snackbar',
id: saveNoticeId,
} );
} catch ( error ) {
createErrorNotice(
`${ __( 'Saving failed.', 'woocommerce' ) } ${ error }`
);
}
};
const renderButton = () => {
// if we have only one unsaved change and it matches current context, we can show a more specific label
let label = dirtyCurrentEntity
? __( 'Save', 'woocommerce' )
: sprintf(
// translators: %d: number of unsaved changes (number).
_n(
'Review %d change…',
'Review %d changes…',
countUnsavedChanges,
'woocommerce'
),
countUnsavedChanges
);
if ( isSaving ) {
label = __( 'Saving', 'woocommerce' );
}
if ( dirtyCurrentEntity ) {
return (
<Button
variant="primary"
onClick={ saveCurrentEntity }
isBusy={ isSaving }
disabled={ isSaving }
aria-disabled={ isSaving }
className="edit-site-save-hub__button"
// @ts-ignore No types for this exist yet.
__next40pxDefaultSize
>
{ label }
</Button>
);
}
const disabled = isSaving || ! isDirty;
if ( ! isSaving && ! isDirty ) {
return (
<Button
variant="primary"
onClick={ () => {
sendEvent( 'FINISH_CUSTOMIZATION' );
} }
className="edit-site-save-hub__button"
// @ts-ignore No types for this exist yet.
__next40pxDefaultSize
>
{ __( 'Done', 'woocommerce' ) }
</Button>
);
}
return (
<SaveButton
className="edit-site-save-hub__button"
variant={ disabled ? null : 'primary' }
showTooltip={ false }
icon={ disabled && ! isSaving ? check : null }
defaultLabel={ label }
__next40pxDefaultSize
/>
);
};
return (
<HStack className="edit-site-save-hub" alignment="right" spacing={ 4 }>
{ renderButton() }
</HStack>
);
};

View File

@ -0,0 +1,45 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import { Link } from '@woocommerce/components';
/**
* Internal dependencies
*/
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
import { ADMIN_URL } from '~/utils/admin-settings';
export const SidebarNavigationScreenColorPalette = () => {
return (
<SidebarNavigationScreen
title={ __( 'Change the color palette', 'woocommerce' ) }
description={ createInterpolateElement(
__(
'Based on the info you shared, our AI tool recommends using this color palette. Want to change it? You can select or add new colors below, or update them later in <EditorLink>Editor</EditorLink> | <StyleLink>Styles</StyleLink>.',
'woocommerce'
),
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }/site-editor.php` }
type="external"
/>
),
StyleLink: (
<Link
href={ `${ ADMIN_URL }/site-editor.php?path=%2Fwp_global_styles&canvas=edit` }
type="external"
/>
),
}
) }
content={
<>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header"></div>
</>
}
/>
);
};

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import { Link } from '@woocommerce/components';
/**
* Internal dependencies
*/
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
import { ADMIN_URL } from '~/utils/admin-settings';
export const SidebarNavigationScreenFooter = () => {
return (
<SidebarNavigationScreen
title={ __( 'Change your footer', 'woocommerce' ) }
description={ createInterpolateElement(
__(
"Select a new header from the options below. Your header includes your site's navigation and will be added to every page. You can continue customizing this via the <EditorLink>Editor</EditorLink>.",
'woocommerce'
),
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }/site-editor.php` }
type="external"
/>
),
}
) }
content={
<>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header"></div>
</>
}
/>
);
};

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import { Link } from '@woocommerce/components';
/**
* Internal dependencies
*/
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
import { ADMIN_URL } from '~/utils/admin-settings';
export const SidebarNavigationScreenHeader = () => {
return (
<SidebarNavigationScreen
title={ __( 'Change your header', 'woocommerce' ) }
description={ createInterpolateElement(
__(
"Select a new header from the options below. Your header includes your site's navigation and will be added to every page. You can continue customizing this via the <EditorLink>Editor</EditorLink>.",
'woocommerce'
),
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }/site-editor.php` }
type="external"
/>
),
}
) }
content={
<>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header"></div>
</>
}
/>
);
};

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { createInterpolateElement } from '@wordpress/element';
import { Link } from '@woocommerce/components';
/**
* Internal dependencies
*/
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
import { ADMIN_URL } from '~/utils/admin-settings';
export const SidebarNavigationScreenHomepage = () => {
return (
<SidebarNavigationScreen
title={ __( 'Change your homepage', 'woocommerce' ) }
description={ createInterpolateElement(
__(
'Based on the most successful stores in your industry and location, our AI tool has recommended this template for your business. Prefer a different layout? Choose from the templates below now, or later via the <EditorLink>Editor</EditorLink>.',
'woocommerce'
),
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }/site-editor.php` }
type="external"
/>
),
}
) }
content={
<>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header"></div>
</>
}
/>
);
};

View File

@ -0,0 +1,26 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
export const SidebarNavigationScreenLogo = () => {
return (
<SidebarNavigationScreen
title={ __( 'Add your logo', 'woocommerce' ) }
description={ __(
"Ensure your store is on-brand by adding your logo. For best results, upload a SVG or PNG that's a minimum of 300px wide.",
'woocommerce'
) }
content={
<>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header"></div>
</>
}
/>
);
};

View File

@ -0,0 +1,130 @@
/**
* WordPress dependencies
*/
/* eslint-disable @woocommerce/dependency-group */
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { createInterpolateElement } from '@wordpress/element';
import {
// @ts-ignore No types for this exist yet.
__experimentalItemGroup as ItemGroup,
// @ts-ignore No types for this exist yet.
__experimentalNavigatorButton as NavigatorButton,
// @ts-ignore No types for this exist yet.
__experimentalHeading as Heading,
} from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import {
siteLogo,
color,
typography,
header,
home,
footer,
pages,
} from '@wordpress/icons';
// @ts-ignore No types for this exist yet.
import SidebarNavigationItem from '@wordpress/edit-site/build-module/components/sidebar-navigation-item';
import { Link } from '@woocommerce/components';
/**
* Internal dependencies
*/
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
import { ADMIN_URL } from '~/utils/admin-settings';
export const SidebarNavigationScreenMain = () => {
return (
<SidebarNavigationScreen
isRoot
title={ __( "Let's get creative", 'woocommerce' ) }
description={ createInterpolateElement(
__(
'Use our style and layout tools to customize the design of your store. Content and images can be added or changed via the <EditorLink>Editor</EditorLink> later.',
'woocommerce'
),
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }/site-editor.php` }
type="external"
/>
),
}
) }
content={
<>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header">
<Heading level={ 2 }>
{ __( 'Style', 'woocommerce' ) }
</Heading>
</div>
<ItemGroup>
<NavigatorButton
as={ SidebarNavigationItem }
path="/customize-store/logo"
withChevron
icon={ siteLogo }
>
{ __( 'Add your logo', 'woocommerce' ) }
</NavigatorButton>
<NavigatorButton
as={ SidebarNavigationItem }
path="/customize-store/color-palette"
withChevron
icon={ color }
>
{ __( 'Change the color palette', 'woocommerce' ) }
</NavigatorButton>
<NavigatorButton
as={ SidebarNavigationItem }
path="/customize-store/typography"
withChevron
icon={ typography }
>
{ __( 'Change fonts', 'woocommerce' ) }
</NavigatorButton>
</ItemGroup>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header">
<Heading level={ 2 }>
{ __( 'Layout', 'woocommerce' ) }
</Heading>
</div>
<ItemGroup>
<NavigatorButton
as={ SidebarNavigationItem }
path="/customize-store/header"
withChevron
icon={ header }
>
{ __( 'Change your header', 'woocommerce' ) }
</NavigatorButton>
<NavigatorButton
as={ SidebarNavigationItem }
path="/customize-store/homepage"
withChevron
icon={ home }
>
{ __( 'Change your homepage', 'woocommerce' ) }
</NavigatorButton>
<NavigatorButton
as={ SidebarNavigationItem }
path="/customize-store/footer"
withChevron
icon={ footer }
>
{ __( 'Change your footer', 'woocommerce' ) }
</NavigatorButton>
<NavigatorButton
as={ SidebarNavigationItem }
path="/customize-store/pages"
withChevron
icon={ pages }
>
{ __( 'Add and edit other pages', 'woocommerce' ) }
</NavigatorButton>
</ItemGroup>
</>
}
/>
);
};

View File

@ -0,0 +1,45 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Link } from '@woocommerce/components';
import { createInterpolateElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { SidebarNavigationScreen } from './sidebar-navigation-screen';
import { ADMIN_URL } from '~/utils/admin-settings';
export const SidebarNavigationScreenPages = () => {
return (
<SidebarNavigationScreen
title={ __( 'Add more pages', 'woocommerce' ) }
description={ createInterpolateElement(
__(
"Enhance your customers' experience by customizing existing pages or adding new ones. You can continue customizing and adding pages later in <EditorLink>Editor</EditorLink> | <PageLink>Pages</PageLink>.",
'woocommerce'
),
{
EditorLink: (
<Link
href={ `${ ADMIN_URL }/site-editor.php` }
type="external"
/>
),
PageLink: (
<Link
href={ `${ ADMIN_URL }/edit.php?post_type=page` }
type="external"
/>
),
}
) }
content={
<>
<div className="edit-site-sidebar-navigation-screen-patterns__group-header"></div>
</>
}
/>
);
};

Some files were not shown because too many files have changed in this diff Show More