diff --git a/.github/workflows/pr-lint-js.yml b/.github/workflows/pr-lint-js.yml index a967043c586..0482a9d8cbf 100644 --- a/.github/workflows/pr-lint-js.yml +++ b/.github/workflows/pr-lint-js.yml @@ -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' diff --git a/.github/workflows/smoke-test-daily.yml b/.github/workflows/smoke-test-daily.yml index 95fb70dbf6a..c547189fe64 100644 --- a/.github/workflows/smoke-test-daily.yml +++ b/.github/workflows/smoke-test-daily.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/smoke-test-release.yml b/.github/workflows/smoke-test-release.yml index 58900aab2ad..c240fc08ff7 100644 --- a/.github/workflows/smoke-test-release.yml +++ b/.github/workflows/smoke-test-release.yml @@ -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 }} diff --git a/.syncpackrc b/.syncpackrc index 15afb9eef61..53bc93c13f5 100644 --- a/.syncpackrc +++ b/.syncpackrc @@ -112,7 +112,10 @@ { "dependencies": [ "@wordpress/block**", - "@wordpress/viewport" + "@wordpress/viewport", + "@wordpress/interface", + "@wordpress/router", + "@wordpress/edit-site" ], "packages": [ "@woocommerce/product-editor", diff --git a/changelog.txt b/changelog.txt index fdec3b2df3f..2ab8551f5ad 100644 --- a/changelog.txt +++ b/changelog.txt @@ -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) diff --git a/docs/data/crud-objects.md b/docs/data/crud-objects.md new file mode 100644 index 00000000000..0ea7dee715a --- /dev/null +++ b/docs/data/crud-objects.md @@ -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 don’t need to know the internals of the data you’re 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 object’s 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 it’s 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(); +``` diff --git a/docs/data/data-stores.md b/docs/data/data-stores.md new file mode 100644 index 00000000000..d70322ff6d2 --- /dev/null +++ b/docs/data/data-stores.md @@ -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. diff --git a/docs/getting-started/debugging.md b/docs/getting-started/debugging.md new file mode 100644 index 00000000000..83fe998a691 --- /dev/null +++ b/docs/getting-started/debugging.md @@ -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) diff --git a/docs/rest-api/_media/insomnia-ssl.png b/docs/rest-api/_media/insomnia-ssl.png new file mode 100644 index 00000000000..3b544ea4d34 Binary files /dev/null and b/docs/rest-api/_media/insomnia-ssl.png differ diff --git a/docs/rest-api/_media/insomnia.png b/docs/rest-api/_media/insomnia.png new file mode 100644 index 00000000000..43681c4e51b Binary files /dev/null and b/docs/rest-api/_media/insomnia.png differ diff --git a/docs/rest-api/_media/keys.png b/docs/rest-api/_media/keys.png new file mode 100644 index 00000000000..843ede531ea Binary files /dev/null and b/docs/rest-api/_media/keys.png differ diff --git a/docs/rest-api/_media/permalinks.webp b/docs/rest-api/_media/permalinks.webp new file mode 100644 index 00000000000..05d59596a84 Binary files /dev/null and b/docs/rest-api/_media/permalinks.webp differ diff --git a/docs/rest-api/_media/postman-ssl.png b/docs/rest-api/_media/postman-ssl.png new file mode 100644 index 00000000000..885a59c93fe Binary files /dev/null and b/docs/rest-api/_media/postman-ssl.png differ diff --git a/docs/rest-api/_media/postman.png b/docs/rest-api/_media/postman.png new file mode 100644 index 00000000000..e84147afecc Binary files /dev/null and b/docs/rest-api/_media/postman.png differ diff --git a/docs/rest-api/_media/sslerror.png b/docs/rest-api/_media/sslerror.png new file mode 100644 index 00000000000..5df05759633 Binary files /dev/null and b/docs/rest-api/_media/sslerror.png differ diff --git a/docs/rest-api/getting-started.md b/docs/rest-api/getting-started.md new file mode 100644 index 00000000000..0777ce5211f --- /dev/null +++ b/docs/rest-api/getting-started.md @@ -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). diff --git a/docs/snippets/README.md b/docs/snippets/README.md index b7ad765ea5a..fbdad9e41a2 100644 --- a/docs/snippets/README.md +++ b/docs/snippets/README.md @@ -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) \ No newline at end of file +- [Rename a country](./rename-a-country.md) +- [Unhook and remove WooCommerce emails](./unhook--remove-woocommerce-emails.md) diff --git a/docs/snippets/add-a-country.md b/docs/snippets/add-a-country.md new file mode 100644 index 00000000000..fc3e2135a9d --- /dev/null +++ b/docs/snippets/add-a-country.md @@ -0,0 +1,38 @@ +# Add a country + + +Add this code to your child theme’s `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 theme’s 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' ); +} +``` diff --git a/docs/snippets/add-a-currency-symbol.md b/docs/snippets/add-a-currency-symbol.md new file mode 100644 index 00000000000..a1655403920 --- /dev/null +++ b/docs/snippets/add-a-currency-symbol.md @@ -0,0 +1,38 @@ +# Add a currency and symbol + +Add this code to your child theme’s `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 theme’s 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); +} +``` diff --git a/docs/snippets/add-or-modify-states.md b/docs/snippets/add-or-modify-states.md new file mode 100644 index 00000000000..990887d84b9 --- /dev/null +++ b/docs/snippets/add-or-modify-states.md @@ -0,0 +1,27 @@ +# Add or modify states + +Add this code to your child theme’s `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 theme’s 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' ); +} +``` diff --git a/docs/snippets/adjust-quantity-input-values.md b/docs/snippets/adjust-quantity-input-values.md new file mode 100644 index 00000000000..7e918891b41 --- /dev/null +++ b/docs/snippets/adjust-quantity-input-values.md @@ -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 theme’s `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 theme’s `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! diff --git a/docs/snippets/change-a-currency-symbol.md b/docs/snippets/change-a-currency-symbol.md new file mode 100644 index 00000000000..38573001de7 --- /dev/null +++ b/docs/snippets/change-a-currency-symbol.md @@ -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 theme’s `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 theme’s 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 ); +} +``` diff --git a/docs/snippets/rename-a-country.md b/docs/snippets/rename-a-country.md new file mode 100644 index 00000000000..1052350e817 --- /dev/null +++ b/docs/snippets/rename-a-country.md @@ -0,0 +1,21 @@ +# Rename a country + + +Add this code to your child theme’s `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 theme’s 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' ); +} +``` diff --git a/docs/utilities/_media/log-critical.jpg b/docs/utilities/_media/log-critical.jpg new file mode 100644 index 00000000000..e18dc617102 Binary files /dev/null and b/docs/utilities/_media/log-critical.jpg differ diff --git a/docs/utilities/logging.md b/docs/utilities/logging.md new file mode 100644 index 00000000000..284a6a23a21 --- /dev/null +++ b/docs/utilities/logging.md @@ -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' ) +); +``` diff --git a/packages/js/admin-layout/changelog/fix-missed-lints b/packages/js/admin-layout/changelog/fix-missed-lints new file mode 100644 index 00000000000..74b456f5e2b --- /dev/null +++ b/packages/js/admin-layout/changelog/fix-missed-lints @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Applied lint auto fixes across monorepo + + diff --git a/packages/js/admin-layout/src/plugins/woo-header-item/index.tsx b/packages/js/admin-layout/src/plugins/woo-header-item/index.tsx index d39cd47d21b..72993075d2d 100644 --- a/packages/js/admin-layout/src/plugins/woo-header-item/index.tsx +++ b/packages/js/admin-layout/src/plugins/woo-header-item/index.tsx @@ -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 ) => { diff --git a/packages/js/components/changelog/fix-39825_new_category_name_persists b/packages/js/components/changelog/fix-39825_new_category_name_persists new file mode 100644 index 00000000000..9c78790ce3b --- /dev/null +++ b/packages/js/components/changelog/fix-39825_new_category_name_persists @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Fix new category name field diff --git a/packages/js/components/changelog/fix-missed-lints b/packages/js/components/changelog/fix-missed-lints new file mode 100644 index 00000000000..74b456f5e2b --- /dev/null +++ b/packages/js/components/changelog/fix-missed-lints @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Applied lint auto fixes across monorepo + + diff --git a/packages/js/components/src/experimental-select-tree-control/select-tree.tsx b/packages/js/components/src/experimental-select-tree-control/select-tree.tsx index 114e696326b..39cd1df38fa 100644 --- a/packages/js/components/src/experimental-select-tree-control/select-tree.tsx +++ b/packages/js/components/src/experimental-select-tree-control/select-tree.tsx @@ -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 ( diff --git a/packages/js/components/src/experimental-tree-control/types.ts b/packages/js/components/src/experimental-tree-control/types.ts index 8c60fd36194..1d6e06549d4 100644 --- a/packages/js/components/src/experimental-tree-control/types.ts +++ b/packages/js/components/src/experimental-tree-control/types.ts @@ -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 ) => ${ item.data.label } } * /> * - * @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} */ diff --git a/packages/js/components/src/image-gallery/utils.ts b/packages/js/components/src/image-gallery/utils.ts index 841fffb0ada..819df76f130 100644 --- a/packages/js/components/src/image-gallery/utils.ts +++ b/packages/js/components/src/image-gallery/utils.ts @@ -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 > >( diff --git a/packages/js/components/src/sortable/utils.ts b/packages/js/components/src/sortable/utils.ts index e9ef5d9cbc6..93c1f66803a 100644 --- a/packages/js/components/src/sortable/utils.ts +++ b/packages/js/components/src/sortable/utils.ts @@ -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 = ( diff --git a/packages/js/data/changelog/add-39452 b/packages/js/data/changelog/add-39452 new file mode 100644 index 00000000000..6c9719332c8 --- /dev/null +++ b/packages/js/data/changelog/add-39452 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add generate slug prop to the QueryProductAttribute type diff --git a/packages/js/data/changelog/fix-missed-lints b/packages/js/data/changelog/fix-missed-lints new file mode 100644 index 00000000000..74b456f5e2b --- /dev/null +++ b/packages/js/data/changelog/fix-missed-lints @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Applied lint auto fixes across monorepo + + diff --git a/packages/js/data/src/crud/utils.ts b/packages/js/data/src/crud/utils.ts index 97e8d1e57d7..34e4a8cbce3 100644 --- a/packages/js/data/src/crud/utils.ts +++ b/packages/js/data/src/crud/utils.ts @@ -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 = ( diff --git a/packages/js/data/src/product-attributes/types.ts b/packages/js/data/src/product-attributes/types.ts index 7222846dead..243544f93b9 100644 --- a/packages/js/data/src/product-attributes/types.ts +++ b/packages/js/data/src/product-attributes/types.ts @@ -15,6 +15,7 @@ export type QueryProductAttribute = { type: string; order_by: string; has_archives: boolean; + generate_slug: boolean; }; type Query = { diff --git a/packages/js/date/changelog/fix-missed-lints b/packages/js/date/changelog/fix-missed-lints new file mode 100644 index 00000000000..74b456f5e2b --- /dev/null +++ b/packages/js/date/changelog/fix-missed-lints @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Applied lint auto fixes across monorepo + + diff --git a/packages/js/date/src/index.ts b/packages/js/date/src/index.ts index b5904e4ca56..710a8b678c9 100644 --- a/packages/js/date/src/index.ts +++ b/packages/js/date/src/index.ts @@ -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, diff --git a/packages/js/onboarding/changelog/remove-onboarding-purchase-task b/packages/js/onboarding/changelog/remove-onboarding-purchase-task new file mode 100644 index 00000000000..8088ed0e6af --- /dev/null +++ b/packages/js/onboarding/changelog/remove-onboarding-purchase-task @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix minor layout shift in the core profiler. diff --git a/packages/js/onboarding/src/components/Loader/Loader.tsx b/packages/js/onboarding/src/components/Loader/Loader.tsx index 4b62590136a..62237f1b105 100644 --- a/packages/js/onboarding/src/components/Loader/Loader.tsx +++ b/packages/js/onboarding/src/components/Loader/Loader.tsx @@ -54,7 +54,14 @@ Loader.Layout = ( { className ) } > - { children } +
+ { children } +
); }; diff --git a/packages/js/product-editor/changelog/add-39421_make_use_of_new_block_template b/packages/js/product-editor/changelog/add-39421_make_use_of_new_block_template new file mode 100644 index 00000000000..e9df69680ae --- /dev/null +++ b/packages/js/product-editor/changelog/add-39421_make_use_of_new_block_template @@ -0,0 +1,5 @@ +Significance: patch +Type: tweak +Comment: Fix mis spelling of variable name visibility. + + diff --git a/packages/js/product-editor/changelog/add-39452 b/packages/js/product-editor/changelog/add-39452 new file mode 100644 index 00000000000..8167f048e72 --- /dev/null +++ b/packages/js/product-editor/changelog/add-39452 @@ -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 diff --git a/packages/js/product-editor/changelog/add-39788 b/packages/js/product-editor/changelog/add-39788 new file mode 100644 index 00000000000..4e489ae588b --- /dev/null +++ b/packages/js/product-editor/changelog/add-39788 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add Pricing item to the Quick Actions menu per Variation item diff --git a/packages/js/product-editor/changelog/add-39789_variation_inventory_quick_actions b/packages/js/product-editor/changelog/add-39789_variation_inventory_quick_actions new file mode 100644 index 00000000000..3e962dcbdb3 --- /dev/null +++ b/packages/js/product-editor/changelog/add-39789_variation_inventory_quick_actions @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add new action menu item to variations list for managing inventory. diff --git a/packages/js/product-editor/changelog/add-39790 b/packages/js/product-editor/changelog/add-39790 new file mode 100644 index 00000000000..42fe333442c --- /dev/null +++ b/packages/js/product-editor/changelog/add-39790 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add Shipping item to the Quick Actions menu per Variation item diff --git a/packages/js/product-editor/changelog/add-39791 b/packages/js/product-editor/changelog/add-39791 new file mode 100644 index 00000000000..c484058315a --- /dev/null +++ b/packages/js/product-editor/changelog/add-39791 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add multiselection to the Variations table under Variations tab diff --git a/packages/js/product-editor/changelog/add-39831 b/packages/js/product-editor/changelog/add-39831 new file mode 100644 index 00000000000..f6cd6e68bb6 --- /dev/null +++ b/packages/js/product-editor/changelog/add-39831 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add tracking events to add edit and update attribute diff --git a/packages/js/product-editor/changelog/add-39832 b/packages/js/product-editor/changelog/add-39832 new file mode 100644 index 00000000000..a4e24218bd0 --- /dev/null +++ b/packages/js/product-editor/changelog/add-39832 @@ -0,0 +1,4 @@ +Significance: minor +Type: tweak + +Change variation option labels cardinality to 3 diff --git a/packages/js/product-editor/changelog/fix-39868_description_toolbar b/packages/js/product-editor/changelog/fix-39868_description_toolbar new file mode 100644 index 00000000000..bdcbefbf3b0 --- /dev/null +++ b/packages/js/product-editor/changelog/fix-39868_description_toolbar @@ -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. diff --git a/packages/js/product-editor/changelog/update-product-middleware-regex b/packages/js/product-editor/changelog/update-product-middleware-regex new file mode 100644 index 00000000000..fb36fbf78a5 --- /dev/null +++ b/packages/js/product-editor/changelog/update-product-middleware-regex @@ -0,0 +1,5 @@ +Significance: patch +Type: update +Comment: Update regex to not match 'product_brand' + + diff --git a/packages/js/product-editor/src/blocks/catalog-visibility/block.json b/packages/js/product-editor/src/blocks/catalog-visibility/block.json index 987aeb070b6..4ccc7f89192 100644 --- a/packages/js/product-editor/src/blocks/catalog-visibility/block.json +++ b/packages/js/product-editor/src/blocks/catalog-visibility/block.json @@ -12,7 +12,7 @@ "type": "string", "__experimentalRole": "content" }, - "visibilty": { + "visibility": { "type": "string", "enum": [ "visible", "catalog", "search", "hidden" ], "default": "visible" diff --git a/packages/js/product-editor/src/blocks/catalog-visibility/edit.tsx b/packages/js/product-editor/src/blocks/catalog-visibility/edit.tsx index c9181d83cc0..b1be896d4ee 100644 --- a/packages/js/product-editor/src/blocks/catalog-visibility/edit.tsx +++ b/packages/js/product-editor/src/blocks/catalog-visibility/edit.tsx @@ -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; } diff --git a/packages/js/product-editor/src/blocks/catalog-visibility/types.ts b/packages/js/product-editor/src/blocks/catalog-visibility/types.ts index 26b3f55e82b..f3041e25b0d 100644 --- a/packages/js/product-editor/src/blocks/catalog-visibility/types.ts +++ b/packages/js/product-editor/src/blocks/catalog-visibility/types.ts @@ -6,5 +6,5 @@ import { BlockAttributes } from '@wordpress/blocks'; export interface CatalogVisibilityBlockAttributes extends BlockAttributes { label: string; - visibilty: Product[ 'catalog_visibility' ]; + visibility: Product[ 'catalog_visibility' ]; } diff --git a/packages/js/product-editor/src/blocks/notice/block.json b/packages/js/product-editor/src/blocks/notice/block.json index 43a78d6d66d..57d4a38f55a 100644 --- a/packages/js/product-editor/src/blocks/notice/block.json +++ b/packages/js/product-editor/src/blocks/notice/block.json @@ -8,9 +8,6 @@ "keywords": [ "products", "notice" ], "textdomain": "default", "attributes": { - "id": { - "type": "string" - }, "title": { "type": "string" }, diff --git a/packages/js/product-editor/src/blocks/variation-options/edit.tsx b/packages/js/product-editor/src/blocks/variation-options/edit.tsx index 481c0ea908f..8894d1783f6 100644 --- a/packages/js/product-editor/src/blocks/variation-options/edit.tsx +++ b/packages/js/product-editor/src/blocks/variation-options/edit.tsx @@ -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={ () => diff --git a/packages/js/product-editor/src/blocks/variations/edit.tsx b/packages/js/product-editor/src/blocks/variations/edit.tsx index 9e6ba9a42f3..31b8b35813f 100644 --- a/packages/js/product-editor/src/blocks/variations/edit.tsx +++ b/packages/js/product-editor/src/blocks/variations/edit.tsx @@ -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(); }; diff --git a/packages/js/product-editor/src/components/attribute-control/attribute-control.tsx b/packages/js/product-editor/src/components/attribute-control/attribute-control.tsx index 4a937d83332..d41721d49d7 100644 --- a/packages/js/product-editor/src/components/attribute-control/attribute-control.tsx +++ b/packages/js/product-editor/src/components/attribute-control/attribute-control.tsx @@ -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 } diff --git a/packages/js/product-editor/src/components/attribute-input-field/attribute-input-field.tsx b/packages/js/product-editor/src/components/attribute-input-field/attribute-input-field.tsx index c47ed08e806..aef34722ea7 100644 --- a/packages/js/product-editor/src/components/attribute-input-field/attribute-input-field.tsx +++ b/packages/js/product-editor/src/components/attribute-input-field/attribute-input-field.tsx @@ -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 ) ? ( diff --git a/packages/js/product-editor/src/components/attribute-input-field/test/attribute-input-field.spec.tsx b/packages/js/product-editor/src/components/attribute-input-field/test/attribute-input-field.spec.tsx index a71ab4b0ee6..5a689e9c740 100644 --- a/packages/js/product-editor/src/components/attribute-input-field/test/attribute-input-field.spec.tsx +++ b/packages/js/product-editor/src/components/attribute-input-field/test/attribute-input-field.spec.tsx @@ -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( diff --git a/packages/js/product-editor/src/components/attribute-list-item/attribute-list-item.tsx b/packages/js/product-editor/src/components/attribute-list-item/attribute-list-item.tsx index 2b21f9d3f66..fa8028c0095 100644 --- a/packages/js/product-editor/src/components/attribute-list-item/attribute-list-item.tsx +++ b/packages/js/product-editor/src/components/attribute-list-item/attribute-list-item.tsx @@ -49,15 +49,17 @@ export const AttributeListItem: React.FC< AttributeListItemProps > = ( { >
{ attribute.name }
- { attribute.options.slice( 0, 2 ).map( ( option, index ) => ( -
- { option } -
- ) ) } - { attribute.options.length > 2 && ( + { attribute.options + .slice( 0, attribute.options.length > 3 ? 2 : 3 ) + .map( ( option, index ) => ( +
+ { option } +
+ ) ) } + { attribute.options.length > 3 && (
{ sprintf( __( '+ %i more', 'woocommerce' ), diff --git a/packages/js/product-editor/src/components/iframe-editor/header-toolbar/header-toolbar.scss b/packages/js/product-editor/src/components/iframe-editor/header-toolbar/header-toolbar.scss index bbbde8eadb1..fc0de45a4db 100644 --- a/packages/js/product-editor/src/components/iframe-editor/header-toolbar/header-toolbar.scss +++ b/packages/js/product-editor/src/components/iframe-editor/header-toolbar/header-toolbar.scss @@ -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; } - } + } } diff --git a/packages/js/product-editor/src/components/iframe-editor/header-toolbar/header-toolbar.tsx b/packages/js/product-editor/src/components/iframe-editor/header-toolbar/header-toolbar.tsx index e037682664e..a898ca3a370 100644 --- a/packages/js/product-editor/src/components/iframe-editor/header-toolbar/header-toolbar.tsx +++ b/packages/js/product-editor/src/components/iframe-editor/header-toolbar/header-toolbar.tsx @@ -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 && ( - - ) } - - - - + { isLargeViewport && ( + ) } + + +
.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; + } } } diff --git a/packages/js/product-editor/src/components/variations-table/inventory-menu-item/index.ts b/packages/js/product-editor/src/components/variations-table/inventory-menu-item/index.ts new file mode 100644 index 00000000000..9233c266f70 --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/inventory-menu-item/index.ts @@ -0,0 +1 @@ +export * from './inventory-menu-item'; diff --git a/packages/js/product-editor/src/components/variations-table/inventory-menu-item/inventory-menu-item.tsx b/packages/js/product-editor/src/components/variations-table/inventory-menu-item/inventory-menu-item.tsx new file mode 100644 index 00000000000..0face899300 --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/inventory-menu-item/inventory-menu-item.tsx @@ -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 ( + ( + { + recordEvent( + 'product_variations_menu_inventory_click', + { + source: TRACKS_SOURCE, + variation_id: variation.id, + } + ); + onToggle(); + } } + aria-expanded={ isOpen } + icon={ chevronRight } + iconPosition="right" + > + { __( 'Inventory', 'woocommerce' ) } + + ) } + renderContent={ () => ( +
+ + { + 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' ) } + + { + 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' ) } + + { + 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' ) } + + { + 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' + ) } + + { + 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' + ) } + + { + 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' ) } + + +
+ ) } + /> + ); +} diff --git a/packages/js/product-editor/src/components/variations-table/pricing-menu-item/index.ts b/packages/js/product-editor/src/components/variations-table/pricing-menu-item/index.ts new file mode 100644 index 00000000000..5d67d02ce75 --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/pricing-menu-item/index.ts @@ -0,0 +1 @@ +export * from './pricing-menu-item'; diff --git a/packages/js/product-editor/src/components/variations-table/pricing-menu-item/pricing-menu-item.tsx b/packages/js/product-editor/src/components/variations-table/pricing-menu-item/pricing-menu-item.tsx new file mode 100644 index 00000000000..919c6a66cf1 --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/pricing-menu-item/pricing-menu-item.tsx @@ -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 ( + ( + { + recordEvent( 'product_variations_menu_pricing_click', { + source: TRACKS_SOURCE, + variation_id: variation.id, + } ); + onToggle(); + } } + aria-expanded={ isOpen } + icon={ chevronRight } + iconPosition="right" + > + { __( 'Pricing', 'woocommerce' ) } + + ) } + renderContent={ () => ( +
+ + { + 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' ) } + + { + 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' ) } + + { + 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' ) } + + + + { + 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' ) } + + { + 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' ) } + + { + 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' ) } + + { + 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' ) } + + +
+ ) } + /> + ); +} diff --git a/packages/js/product-editor/src/components/variations-table/shipping-menu-item/index.ts b/packages/js/product-editor/src/components/variations-table/shipping-menu-item/index.ts new file mode 100644 index 00000000000..32ebccb0d7b --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/shipping-menu-item/index.ts @@ -0,0 +1,2 @@ +export * from './shipping-menu-item'; +export * from './types'; diff --git a/packages/js/product-editor/src/components/variations-table/shipping-menu-item/shipping-menu-item.tsx b/packages/js/product-editor/src/components/variations-table/shipping-menu-item/shipping-menu-item.tsx new file mode 100644 index 00000000000..65d90358a38 --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/shipping-menu-item/shipping-menu-item.tsx @@ -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 ( + ( + { + recordEvent( 'product_variations_menu_shipping_click', { + source: TRACKS_SOURCE, + variation_id: variation.id, + } ); + onToggle(); + } } + aria-expanded={ isOpen } + icon={ chevronRight } + iconPosition="right" + > + { __( 'Shipping', 'woocommerce' ) } + + ) } + renderContent={ () => ( +
+ { + 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' ) } + + { + 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' ) } + + { + 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' ) } + + { + 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' ) } + +
+ ) } + /> + ); +} diff --git a/packages/js/product-editor/src/components/variations-table/shipping-menu-item/types.ts b/packages/js/product-editor/src/components/variations-table/shipping-menu-item/types.ts new file mode 100644 index 00000000000..88a8496812b --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/shipping-menu-item/types.ts @@ -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; +}; diff --git a/packages/js/product-editor/src/components/variations-table/styles.scss b/packages/js/product-editor/src/components/variations-table/styles.scss index fa5479bf71d..78ec5e640bd 100644 --- a/packages/js/product-editor/src/components/variations-table/styles.scss +++ b/packages/js/product-editor/src/components/variations-table/styles.scss @@ -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; diff --git a/packages/js/product-editor/src/components/variations-table/variation-actions-menu/index.ts b/packages/js/product-editor/src/components/variations-table/variation-actions-menu/index.ts new file mode 100644 index 00000000000..d1be3da5230 --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/variation-actions-menu/index.ts @@ -0,0 +1,2 @@ +export * from './variation-actions-menu'; +export * from './types'; diff --git a/packages/js/product-editor/src/components/variations-table/variation-actions-menu/test/variation-actions-menu.test.tsx b/packages/js/product-editor/src/components/variations-table/variation-actions-menu/test/variation-actions-menu.test.tsx new file mode 100644 index 00000000000..609a5159df0 --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/variation-actions-menu/test/variation-actions-menu.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + 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, + } ); + } ); + } ); +} ); diff --git a/packages/js/product-editor/src/components/variations-table/variation-actions-menu/types.ts b/packages/js/product-editor/src/components/variations-table/variation-actions-menu/types.ts new file mode 100644 index 00000000000..66f9e8d3618 --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/variation-actions-menu/types.ts @@ -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; +}; diff --git a/packages/js/product-editor/src/components/variations-table/variation-actions-menu/variation-actions-menu.tsx b/packages/js/product-editor/src/components/variations-table/variation-actions-menu/variation-actions-menu.tsx new file mode 100644 index 00000000000..d35368398b0 --- /dev/null +++ b/packages/js/product-editor/src/components/variations-table/variation-actions-menu/variation-actions-menu.tsx @@ -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 ( + + { ( { onClose } ) => ( + <> + + { + recordEvent( 'product_variations_preview', { + source: TRACKS_SOURCE, + variation_id: variation.id, + } ); + } } + > + { __( 'Preview', 'woocommerce' ) } + + + + + + + + + { + onDelete( variation.id ); + onClose(); + } } + className="woocommerce-product-variations__actions--delete" + > + { __( 'Delete', 'woocommerce' ) } + + + + ) } + + ); +} diff --git a/packages/js/product-editor/src/components/variations-table/variations-table.tsx b/packages/js/product-editor/src/components/variations-table/variations-table.tsx index 5b467eda00d..e6466318d2c 100644 --- a/packages/js/product-editor/src/components/variations-table/variations-table.tsx +++ b/packages/js/product-editor/src/components/variations-table/variations-table.tsx @@ -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 (
@@ -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 (
{ isLoading || @@ -171,9 +190,46 @@ export function VariationsTable() { ) }
) ) } +
+
+ +
+
+ + +
+
{ variations.map( ( variation ) => ( +
+ +
{ 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() { ) } - - - { ( { onClose } ) => ( - <> - - { - recordEvent( - 'product_variations_preview', - { - source: TRACKS_SOURCE, - } - ); - } } - > - { __( - 'Preview', - 'woocommerce' - ) } - - - - { - handleDeleteVariationClick( - variation.id - ); - onClose(); - } } - className="woocommerce-product-variations__actions--delete" - > - { __( - 'Delete', - 'woocommerce' - ) } - - - - ) } - + + handleVariationChange( variation.id, value ) + } + onDelete={ handleDeleteVariationClick } + />
) ) } diff --git a/packages/js/product-editor/src/hooks/use-selection/index.ts b/packages/js/product-editor/src/hooks/use-selection/index.ts new file mode 100644 index 00000000000..01269b639b4 --- /dev/null +++ b/packages/js/product-editor/src/hooks/use-selection/index.ts @@ -0,0 +1 @@ +export * from './use-selection'; diff --git a/packages/js/product-editor/src/hooks/use-selection/types.ts b/packages/js/product-editor/src/hooks/use-selection/types.ts new file mode 100644 index 00000000000..dad78105f01 --- /dev/null +++ b/packages/js/product-editor/src/hooks/use-selection/types.ts @@ -0,0 +1,3 @@ +export type Selection = { + [ itemId: string ]: boolean | undefined; +}; diff --git a/packages/js/product-editor/src/hooks/use-selection/use-selection.tsx b/packages/js/product-editor/src/hooks/use-selection/use-selection.tsx new file mode 100644 index 00000000000..9d733145adc --- /dev/null +++ b/packages/js/product-editor/src/hooks/use-selection/use-selection.tsx @@ -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, + }; +} diff --git a/packages/js/product-editor/src/utils/product-apifetch-middleware.ts b/packages/js/product-editor/src/utils/product-apifetch-middleware.ts index b33c5d8ca71..37996289b18 100644 --- a/packages/js/product-editor/src/utils/product-apifetch-middleware.ts +++ b/packages/js/product-editor/src/utils/product-apifetch-middleware.ts @@ -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 ) && diff --git a/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-developing.svg b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-developing.svg index fcde1ed4897..6ff0b0f38d2 100644 --- a/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-developing.svg +++ b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-developing.svg @@ -1,37 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-layout.svg b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-layout.svg index 0cfb87d4c00..21b31ad0aa1 100644 --- a/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-layout.svg +++ b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-layout.svg @@ -1,53 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-lightbulb.svg b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-lightbulb.svg index 9f8ba0da22a..84a791f5125 100644 --- a/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-lightbulb.svg +++ b/plugins/woocommerce-admin/client/core-profiler/assets/images/loader-lightbulb.svg @@ -1,36 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/plugins/woocommerce-admin/client/core-profiler/components/loader/loader.scss b/plugins/woocommerce-admin/client/core-profiler/components/loader/loader.scss index f86b218f1e2..4779231b4ec 100644 --- a/plugins/woocommerce-admin/client/core-profiler/components/loader/loader.scss +++ b/plugins/woocommerce-admin/client/core-profiler/components/loader/loader.scss @@ -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; + } } } diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/auto-block-preview.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/auto-block-preview.tsx new file mode 100644 index 00000000000..7f99f6a1f6c --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/auto-block-preview.tsx @@ -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 ( + + + + ); +} + +export const AutoHeightBlockPreview = ( + props: Omit< ScaledBlockPreviewProps, 'containerWidth' > +) => { + const [ containerResizeListener, { width: containerWidth } ] = + useResizeObserver(); + + return ( + <> +
+ { containerResizeListener } +
+
+ { !! containerWidth && ( + + ) } +
+ + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-editor.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-editor.tsx new file mode 100644 index 00000000000..df2472e31a5 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-editor.tsx @@ -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 ( +
+ { 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 ( +
+ +
+ ); + } ) } +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-preview.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-preview.tsx new file mode 100644 index 00000000000..374f0df9c49 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/block-preview.tsx @@ -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 ( + + + + ); +}; + +export default memo( BlockPreview ); diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx new file mode 100644 index 00000000000..846d98319d2 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx @@ -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 ? : null } + + + + + + + + } + /> + + + + + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/index.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/index.tsx index 232ea95e3eb..63dc2caa8e3 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/index.tsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/index.tsx @@ -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 ( + + + + + + + + + + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/layout.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/layout.tsx new file mode 100644 index 00000000000..5503ad139e8 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/layout.tsx @@ -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 ( +
+ + + + +
+ + + + + + + { ! isMobileViewport && ( +
+ { canvasResizer } + { !! canvasSize.width && ( + + + + + + + + ) } +
+ ) } +
+
+ ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/index.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/index.tsx new file mode 100644 index 00000000000..a99de4b0433 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/index.tsx @@ -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 ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function Sidebar() { + const { params: urlParams } = useLocation(); + const initialPath = useRef( urlParams.path ?? '/customize-store' ); + return ( + <> + + + + + + ); +} + +export default memo( Sidebar ); diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/save-hub.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/save-hub.tsx new file mode 100644 index 00000000000..f095075e6af --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/save-hub.tsx @@ -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 ( + + ); + } + const disabled = isSaving || ! isDirty; + + if ( ! isSaving && ! isDirty ) { + return ( + + ); + } + + return ( + + ); + }; + + return ( + + { renderButton() } + + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-color-palette.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-color-palette.tsx new file mode 100644 index 00000000000..dbce08bc09f --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-color-palette.tsx @@ -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 ( + Editor | Styles.', + 'woocommerce' + ), + { + EditorLink: ( + + ), + StyleLink: ( + + ), + } + ) } + content={ + <> +
+ + } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-footer.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-footer.tsx new file mode 100644 index 00000000000..4547d77ab26 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-footer.tsx @@ -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 ( + Editor.", + 'woocommerce' + ), + { + EditorLink: ( + + ), + } + ) } + content={ + <> +
+ + } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-header.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-header.tsx new file mode 100644 index 00000000000..cb74d1fc43d --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-header.tsx @@ -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 ( + Editor.", + 'woocommerce' + ), + { + EditorLink: ( + + ), + } + ) } + content={ + <> +
+ + } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-homepage.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-homepage.tsx new file mode 100644 index 00000000000..095cf8f5d8b --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-homepage.tsx @@ -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 ( + Editor.', + 'woocommerce' + ), + { + EditorLink: ( + + ), + } + ) } + content={ + <> +
+ + } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-logo.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-logo.tsx new file mode 100644 index 00000000000..72c20db5ec7 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-logo.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { SidebarNavigationScreen } from './sidebar-navigation-screen'; + +export const SidebarNavigationScreenLogo = () => { + return ( + +
+ + } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-main.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-main.tsx new file mode 100644 index 00000000000..cc0036fb23a --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-main.tsx @@ -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 ( + Editor later.', + 'woocommerce' + ), + { + EditorLink: ( + + ), + } + ) } + content={ + <> +
+ + { __( 'Style', 'woocommerce' ) } + +
+ + + { __( 'Add your logo', 'woocommerce' ) } + + + { __( 'Change the color palette', 'woocommerce' ) } + + + { __( 'Change fonts', 'woocommerce' ) } + + +
+ + { __( 'Layout', 'woocommerce' ) } + +
+ + + { __( 'Change your header', 'woocommerce' ) } + + + { __( 'Change your homepage', 'woocommerce' ) } + + + { __( 'Change your footer', 'woocommerce' ) } + + + { __( 'Add and edit other pages', 'woocommerce' ) } + + + + } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-pages.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-pages.tsx new file mode 100644 index 00000000000..34b1cbb0565 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-pages.tsx @@ -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 ( + Editor | Pages.", + 'woocommerce' + ), + { + EditorLink: ( + + ), + PageLink: ( + + ), + } + ) } + content={ + <> +
+ + } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-typography.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-typography.tsx new file mode 100644 index 00000000000..b1f66f8d2a8 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen-typography.tsx @@ -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 SidebarNavigationScreenTypography = () => { + return ( + Editor | Styles.", + 'woocommerce' + ), + { + EditorLink: ( + + ), + StyleLink: ( + + ), + } + ) } + content={ + <> +
+ + } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen.tsx new file mode 100644 index 00000000000..e1f5f3121e9 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/sidebar/sidebar-navigation-screen.tsx @@ -0,0 +1,141 @@ +// Reference: https://github.com/WordPress/gutenberg/blob/v16.4.0/packages/edit-site/src/components/sidebar-navigation-screen/index.js +/* eslint-disable @woocommerce/dependency-group */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { useContext } from '@wordpress/element'; +import { + // @ts-ignore No types for this exist yet. + __experimentalHStack as HStack, + // @ts-ignore No types for this exist yet. + __experimentalHeading as Heading, + // @ts-ignore No types for this exist yet. + __experimentalUseNavigator as useNavigator, + // @ts-ignore No types for this exist yet. + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { isRTL, __ } from '@wordpress/i18n'; +import { chevronRight, chevronLeft } 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 { unlock } from '@wordpress/edit-site/build-module/lock-unlock'; +// @ts-ignore No types for this exist yet. +import SidebarButton from '@wordpress/edit-site/build-module/components/sidebar-button'; + +/** + * Internal dependencies + */ +import { CustomizeStoreContext } from '../'; +const { useLocation } = unlock( routerPrivateApis ); + +export const SidebarNavigationScreen = ( { + isRoot, + title, + actions, + meta, + content, + footer, + description, + backPath: backPathProp, +}: { + isRoot?: boolean; + title: string; + actions?: React.ReactNode; + meta?: React.ReactNode; + content: React.ReactNode; + footer?: React.ReactNode; + description?: React.ReactNode; + backPath?: string; +} ) => { + const { sendEvent } = useContext( CustomizeStoreContext ); + const location = useLocation(); + const navigator = useNavigator(); + const icon = isRTL() ? chevronRight : chevronLeft; + + return ( + <> + + + { ! isRoot && ( + { + const backPath = + backPathProp ?? location.state?.backPath; + if ( backPath ) { + navigator.goTo( backPath, { + isBack: true, + } ); + } else { + navigator.goToParent(); + } + } } + icon={ icon } + label={ __( 'Back', 'woocommerce' ) } + showTooltip={ false } + /> + ) } + { isRoot && ( + { + sendEvent( 'GO_BACK_TO_DESIGN_WITH_AI' ); + } } + icon={ icon } + label={ __( 'Back', 'woocommerce' ) } + showTooltip={ false } + /> + ) } + + { title } + + { actions && ( +
+ { actions } +
+ ) } +
+ { meta && ( + <> +
+ { meta } +
+ + ) } + +
+ { description && ( +

+ { description } +

+ ) } + { content } +
+
+ { footer && ( +
+ { footer } +
+ ) } + + ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/site-hub.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/site-hub.tsx new file mode 100644 index 00000000000..e0c7d121ed8 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/site-hub.tsx @@ -0,0 +1,127 @@ +// Reference: https://github.com/WordPress/gutenberg/blob/v16.4.0/packages/edit-site/src/components/site-hub/index.js +/* eslint-disable @woocommerce/dependency-group */ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { useSelect } from '@wordpress/data'; +import { + // @ts-ignore No types for this exist yet. + __unstableMotion as motion, + // @ts-ignore No types for this exist yet. + __unstableAnimatePresence as AnimatePresence, + // @ts-ignore No types for this exist yet. + __experimentalHStack as HStack, +} from '@wordpress/components'; +import { useReducedMotion } from '@wordpress/compose'; +// @ts-ignore No types for this exist yet. +import { store as coreStore } from '@wordpress/core-data'; +import { decodeEntities } from '@wordpress/html-entities'; +import { forwardRef } from '@wordpress/element'; +// @ts-ignore No types for this exist yet. +import SiteIcon from '@wordpress/edit-site/build-module/components/site-icon'; + +/** + * Internal dependencies + */ + +const HUB_ANIMATION_DURATION = 0.3; + +export const SiteHub = forwardRef( + ( + { + isTransparent, + ...restProps + }: { + isTransparent: boolean; + className: string; + as: string; + variants: motion.Variants; + }, + ref + ) => { + const { siteTitle } = useSelect( ( select ) => { + // @ts-ignore No types for this exist yet. + const { getSite } = select( coreStore ); + + return { + siteTitle: getSite()?.title, + }; + }, [] ); + + const disableMotion = useReducedMotion(); + + return ( + + + + + + + + + + { decodeEntities( siteTitle ) } + + + + + + ); + } +); diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/style.scss b/plugins/woocommerce-admin/client/customize-store/assembler-hub/style.scss new file mode 100644 index 00000000000..50c2c107a59 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/style.scss @@ -0,0 +1,220 @@ + +@mixin custom-scrollbars-on-hover($handle-color, $handle-color-hover) { + + // WebKit + &::-webkit-scrollbar { + width: 12px; + height: 12px; + } + &::-webkit-scrollbar-track { + background-color: transparent; + } + &::-webkit-scrollbar-thumb { + background-color: $handle-color; + border-radius: 8px; + border: 3px solid transparent; + background-clip: padding-box; + } + &:hover::-webkit-scrollbar-thumb, // This needs specificity. + &:focus::-webkit-scrollbar-thumb, + &:focus-within::-webkit-scrollbar-thumb { + background-color: $handle-color-hover; + } + + // Firefox 109+ and Chrome 111+ + scrollbar-width: thin; + scrollbar-gutter: stable both-edges; + scrollbar-color: $handle-color transparent; // Syntax, "dark", "light", or "#handle-color #track-color" + + &:hover, + &:focus, + &:focus-within { + scrollbar-color: $handle-color-hover transparent; + } + + // Needed to fix a Safari rendering issue. + will-change: transform; + + // Always show scrollbar on Mobile devices. + @media (hover: none) { + & { + scrollbar-color: $handle-color-hover transparent; + } + } +} + +.woocommerce-profile-wizard__step-assemblerHub { + a { + text-decoration: none; + } + + .edit-site-layout { + bottom: 0; + left: 0; + min-height: 100vh; + position: fixed; + right: 0; + top: 0; + background-color: #fcfcfc; + } + + /* Sidebar Header */ + .edit-site-layout__hub { + width: 380px; + height: 64px; + } + + .edit-site-site-hub__view-mode-toggle-container { + height: 64px; + } + + .edit-site-sidebar-navigation-screen__title-icon { + align-items: center; + padding-top: 80px; + padding-bottom: 0; + gap: 0; + } + + .edit-site-sidebar-navigation-screen__title-icon, + .edit-site-site-hub__view-mode-toggle-container, + .edit-site-layout__view-mode-toggle-icon.edit-site-site-icon { + background-color: #fcfcfc; + } + + .edit-site-site-hub__site-title { + color: $gray-900; + font-size: 0.8125rem; + font-style: normal; + font-weight: 500; + line-height: 20px; /* 153.846% */ + margin: 0; + } + + .edit-site-site-icon__image { + border-radius: 2px; + } + + .edit-site-site-hub__view-mode-toggle-container { + padding: 16px 12px 0 16px; + + .edit-site-layout__view-mode-toggle, + .edit-site-layout__view-mode-toggle-icon.edit-site-site-icon, + .edit-site-site-icon__icon { + width: 32px; + height: 32px; + } + } + + /* Sidebar */ + .edit-site-layout__sidebar-region { + width: 380px; + } + + .edit-site-layout__sidebar { + .edit-site-sidebar__content > div { + padding: 0 16px; + overflow-x: hidden; + } + + .edit-site-sidebar-button { + color: $gray-900; + height: 40px; + } + + .edit-site-sidebar-navigation-screen__title { + font-size: 1rem; + color: $gray-900; + text-overflow: ellipsis; + white-space: nowrap; + font-style: normal; + font-weight: 600; + line-height: 24px; /* 150% */ + padding: 0; + } + + .edit-site-sidebar-navigation-screen__description { + color: $gray-700; + font-size: 0.8125rem; + font-style: normal; + font-weight: 400; + line-height: 20px; /* 153.846% */ + } + + .edit-site-sidebar-navigation-screen__content .components-heading { + color: $gray-700; + font-size: 0.6875rem; + font-style: normal; + font-weight: 600; + line-height: 16px; /* 145.455% */ + text-transform: uppercase; + } + + .edit-site-sidebar-navigation-item { + border-radius: 4px; + padding: 8px 8px 8px 16px; + align-items: center; + gap: 8px; + align-self: stretch; + width: 348px; + + &:hover { + background: #ededed; + color: $gray-600; + } + + &:active { + color: #171717; + background: #fcfcfc; + } + + &:focus { + color: #171717; + background: #fcfcfc; + border: 1.5px solid var(--wp-admin-theme-color); + } + + .components-flex-item { + color: $gray-900; + font-size: 0.8125rem; + font-style: normal; + font-weight: 400; + line-height: 16px; /* 123.077% */ + letter-spacing: -0.078px; + } + } + + .edit-site-sidebar-navigation-item.components-item .edit-site-sidebar-navigation-item__drilldown-indicator { + fill: #ccc; + } + + .edit-site-save-hub { + border-top: 0; + padding: 32px 29px 32px 35px; + } + } + + /* Preview Canvas */ + .edit-site-layout__canvas { + bottom: 16px; + top: 16px; + width: calc(100% - 16px); + } + + .edit-site-layout__canvas .components-resizable-box__container { + border-radius: 20px; + box-shadow: 0 0 1px 0 rgba(0, 0, 0, 0.25), 0 6px 10px 0 rgba(0, 0, 0, 0.02), 0 13px 15px 0 rgba(0, 0, 0, 0.03), 0 15px 20px 0 rgba(0, 0, 0, 0.04); + } + + .woocommerce-customize-store__block-editor, + .edit-site-layout:not(.is-full-canvas) .edit-site-layout__canvas > div .interface-interface-skeleton__content { + border-radius: 20px; + } + + .interface-interface-skeleton__content { + @include custom-scrollbars-on-hover(transparent, $gray-600); + } + + .edit-site-resizable-frame__inner-content { + border-radius: 20px !important; + } +} diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/actions.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/actions.ts new file mode 100644 index 00000000000..fb5fc56a3fe --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/actions.ts @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { assign } from 'xstate'; + +/** + * Internal dependencies + */ +import { + designWithAiStateMachineContext, + designWithAiStateMachineEvents, +} from './types'; +import { businessInfoDescriptionCompleteEvent } from './pages'; + +const assignBusinessInfoDescription = assign< + designWithAiStateMachineContext, + designWithAiStateMachineEvents +>( { + businessInfoDescription: ( context, event: unknown ) => { + return { + descriptionText: ( event as businessInfoDescriptionCompleteEvent ) + .payload, + }; + }, +} ); +export const actions = { + assignBusinessInfoDescription, +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/index.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/index.tsx index d7d96a1dd10..bc4878191c3 100644 --- a/plugins/woocommerce-admin/client/customize-store/design-with-ai/index.tsx +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/index.tsx @@ -1,16 +1,87 @@ +/** + * External dependencies + */ +import { useMachine, useSelector } from '@xstate/react'; +import { useEffect, useState } from '@wordpress/element'; +import { Sender } from 'xstate'; + /** * Internal dependencies */ import { CustomizeStoreComponent } from '../types'; +import { designWithAiStateMachineDefinition } from './state-machine'; +import { findComponentMeta } from '~/utils/xstate/find-component'; +import { + BusinessInfoDescription, + ApiCallLoader, + LookAndFeel, + ToneOfVoice, +} from './pages'; +import { customizeStoreStateMachineEvents } from '..'; export type events = { type: 'THEME_SUGGESTED' }; -export const DesignWithAi: CustomizeStoreComponent = ( { sendEvent } ) => { +export type DesignWithAiComponent = + | typeof BusinessInfoDescription + | typeof ApiCallLoader + | typeof LookAndFeel + | typeof ToneOfVoice; +export type DesignWithAiComponentMeta = { + component: DesignWithAiComponent; +}; + +export const DesignWithAiController = ( {}: { + sendEventToParent: Sender< customizeStoreStateMachineEvents >; +} ) => { + const [ state, send, service ] = useMachine( + designWithAiStateMachineDefinition, + { + devTools: process.env.NODE_ENV === 'development', + } + ); + + // eslint-disable-next-line react-hooks/exhaustive-deps -- false positive due to function name match, this isn't from react std lib + const currentNodeMeta = useSelector( service, ( currentState ) => + findComponentMeta< DesignWithAiComponentMeta >( + currentState?.meta ?? undefined + ) + ); + + const [ CurrentComponent, setCurrentComponent ] = + useState< DesignWithAiComponent | null >( null ); + useEffect( () => { + if ( currentNodeMeta?.component ) { + setCurrentComponent( () => currentNodeMeta?.component ); + } + }, [ CurrentComponent, currentNodeMeta?.component ] ); + + const currentNodeCssLabel = + state.value instanceof Object + ? Object.keys( state.value )[ 0 ] + : state.value; + return ( <> -

Design with AI

- +
+ { CurrentComponent ? ( + + ) : ( +
+ ) } +
+ + ); +}; + +//loader should send event 'THEME_SUGGESTED' when it's done +export const DesignWithAi: CustomizeStoreComponent = ( { sendEvent } ) => { + return ( + <> + ); }; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ApiCallLoader.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ApiCallLoader.tsx new file mode 100644 index 00000000000..b7dacb481f8 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ApiCallLoader.tsx @@ -0,0 +1,17 @@ +/** + * Internal dependencies + */ +import { designWithAiStateMachineContext } from '../types'; + +export const ApiCallLoader = ( { + context, +}: { + context: designWithAiStateMachineContext; +} ) => { + return ( +
+

Loader

+
{ JSON.stringify( context ) }
+
+ ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/BusinessInfoDescription.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/BusinessInfoDescription.tsx new file mode 100644 index 00000000000..e5de562fa13 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/BusinessInfoDescription.tsx @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { designWithAiStateMachineContext } from '../types'; + +export type businessInfoDescriptionCompleteEvent = { + type: 'BUSINESS_INFO_DESCRIPTION_COMPLETE'; + payload: string; +}; +export const BusinessInfoDescription = ( { + sendEvent, + context, +}: { + sendEvent: ( event: businessInfoDescriptionCompleteEvent ) => void; + context: designWithAiStateMachineContext; +} ) => { + const [ businessInfoDescription, setBusinessInfoDescription ] = useState( + context.businessInfoDescription.descriptionText + ); + + return ( +
+

Business Info Description

+
{ JSON.stringify( context ) }
+ { /* add a controlled text area that saves to state */ } + + setBusinessInfoDescription( e.target.value ) + } + /> + +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/LookAndFeel.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/LookAndFeel.tsx new file mode 100644 index 00000000000..4c5f3083ca1 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/LookAndFeel.tsx @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { designWithAiStateMachineContext } from '../types'; + +export const LookAndFeel = ( { + sendEvent, + context, +}: { + sendEvent: ( event: { type: 'LOOK_AND_FEEL_COMPLETE' } ) => void; + context: designWithAiStateMachineContext; +} ) => { + return ( +
+

Look and Feel

+
{ JSON.stringify( context ) }
+ +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ToneOfVoice.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ToneOfVoice.tsx new file mode 100644 index 00000000000..7119770edd6 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/ToneOfVoice.tsx @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { designWithAiStateMachineContext } from '../types'; + +export const ToneOfVoice = ( { + sendEvent, + context, +}: { + sendEvent: ( event: { type: 'TONE_OF_VOICE_COMPLETE' } ) => void; + context: designWithAiStateMachineContext; +} ) => { + return ( +
+

Tone of Voice

+
{ JSON.stringify( context ) }
+ +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/index.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/index.tsx new file mode 100644 index 00000000000..1641c387b51 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/pages/index.tsx @@ -0,0 +1,4 @@ +export * from './BusinessInfoDescription'; +export * from './LookAndFeel'; +export * from './ToneOfVoice'; +export * from './ApiCallLoader'; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/state-machine.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/state-machine.tsx new file mode 100644 index 00000000000..8e0ce0c3705 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/state-machine.tsx @@ -0,0 +1,152 @@ +/** + * External dependencies + */ +import { createMachine } from 'xstate'; + +/** + * Internal dependencies + */ +import { + designWithAiStateMachineContext, + designWithAiStateMachineEvents, +} from './types'; +import { + BusinessInfoDescription, + LookAndFeel, + ToneOfVoice, + ApiCallLoader, +} from './pages'; +import { actions } from './actions'; + +export const designWithAiStateMachineDefinition = createMachine( + { + id: 'designWithAi', + predictableActionArguments: true, + preserveActionOrder: true, + schema: { + context: {} as designWithAiStateMachineContext, + events: {} as designWithAiStateMachineEvents, + }, + context: { + businessInfoDescription: { + descriptionText: '', + }, + lookAndFeel: { + choice: '', + }, + toneOfVoice: { + choice: '', + }, + }, + initial: 'businessInfoDescription', + states: { + businessInfoDescription: { + id: 'businessInfoDescription', + initial: 'preBusinessInfoDescription', + states: { + preBusinessInfoDescription: { + // if we need to prefetch options, other settings previously populated from core profiler, do it here + always: { + target: 'businessInfoDescription', + }, + }, + businessInfoDescription: { + meta: { + component: BusinessInfoDescription, + }, + on: { + BUSINESS_INFO_DESCRIPTION_COMPLETE: { + actions: [ 'assignBusinessInfoDescription' ], + target: 'postBusinessInfoDescription', + }, + }, + }, + postBusinessInfoDescription: { + always: { + target: '#lookAndFeel', + }, + }, + }, + }, + lookAndFeel: { + id: 'lookAndFeel', + initial: 'preLookAndFeel', + states: { + preLookAndFeel: { + always: { + target: 'lookAndFeel', + }, + }, + lookAndFeel: { + meta: { + component: LookAndFeel, + }, + on: { + LOOK_AND_FEEL_COMPLETE: { + target: 'postLookAndFeel', + }, + }, + }, + postLookAndFeel: { + always: { + target: '#toneOfVoice', + }, + }, + }, + }, + toneOfVoice: { + id: 'toneOfVoice', + initial: 'preToneOfVoice', + states: { + preToneOfVoice: { + always: { + target: 'toneOfVoice', + }, + }, + toneOfVoice: { + meta: { + component: ToneOfVoice, + }, + on: { + TONE_OF_VOICE_COMPLETE: { + target: 'postToneOfVoice', + }, + }, + }, + postToneOfVoice: { + always: { + target: '#apiCallLoader', + }, + }, + }, + }, + apiCallLoader: { + id: 'apiCallLoader', + initial: 'preApiCallLoader', + states: { + preApiCallLoader: { + always: { + target: 'apiCallLoader', + }, + }, + apiCallLoader: { + meta: { + component: ApiCallLoader, + }, + }, + postApiCallLoader: {}, + }, + }, + }, + on: { + AI_WIZARD_CLOSED_BEFORE_COMPLETION: { + // TODO: handle this event when the 'x' is clicked at any point + // probably bail (to where?) and log the tracks for which step it is in plus + // whatever details might be helpful to know why they bailed + }, + }, + }, + { + actions, + } +); diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/ApiCallLoader.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/ApiCallLoader.tsx new file mode 100644 index 00000000000..9abbf9c83e0 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/ApiCallLoader.tsx @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { designWithAiStateMachineContext } from '../types'; +import { ApiCallLoader } from '../pages'; +import { WithCustomizeYourStoreLayout } from './WithCustomizeYourStoreLayout'; + +export const ApiCallLoaderPage = () => ( + +); + +export default { + title: 'WooCommerce Admin/Application/Customize Store/Design with AI/API Call Loader', + component: ApiCallLoader, + decorators: [ WithCustomizeYourStoreLayout ], +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/BusinessInfoDescription.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/BusinessInfoDescription.tsx new file mode 100644 index 00000000000..524fa11f840 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/BusinessInfoDescription.tsx @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ +import { designWithAiStateMachineContext } from '../types'; +import { BusinessInfoDescription } from '../pages'; +import { WithCustomizeYourStoreLayout } from './WithCustomizeYourStoreLayout'; + +export const BusinessInfoDescriptionPage = () => ( + {} } + /> +); + +export default { + title: 'WooCommerce Admin/Application/Customize Store/Design with AI/Business Info Description', + component: BusinessInfoDescription, + decorators: [ WithCustomizeYourStoreLayout ], +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/LookAndFeel.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/LookAndFeel.tsx new file mode 100644 index 00000000000..8f1847dc071 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/LookAndFeel.tsx @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ +import { designWithAiStateMachineContext } from '../types'; +import { LookAndFeel } from '../pages'; +import { WithCustomizeYourStoreLayout } from './WithCustomizeYourStoreLayout'; + +export const LookAndFeelPage = () => ( + {} } + /> +); + +export default { + title: 'WooCommerce Admin/Application/Customize Store/Design with AI/Look and Feel', + component: LookAndFeel, + decorators: [ WithCustomizeYourStoreLayout ], +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/ToneOfVoice.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/ToneOfVoice.tsx new file mode 100644 index 00000000000..11c32cfdf44 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/ToneOfVoice.tsx @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ +import { designWithAiStateMachineContext } from '../types'; +import { ToneOfVoice } from '../pages'; +import { WithCustomizeYourStoreLayout } from './WithCustomizeYourStoreLayout'; + +export const ToneOfVoicePage = () => ( + {} } + /> +); + +export default { + title: 'WooCommerce Admin/Application/Customize Store/Design with AI/Tone of Voice', + component: ToneOfVoice, + decorators: [ WithCustomizeYourStoreLayout ], +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/WithCustomizeYourStoreLayout.tsx b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/WithCustomizeYourStoreLayout.tsx new file mode 100644 index 00000000000..de5a469e4a4 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/stories/WithCustomizeYourStoreLayout.tsx @@ -0,0 +1,12 @@ +/** + * Internal dependencies + */ +import '../../style.scss'; + +export const WithCustomizeYourStoreLayout = ( Story: React.ComponentType ) => { + return ( +
+ +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/customize-store/design-with-ai/types.ts b/plugins/woocommerce-admin/client/customize-store/design-with-ai/types.ts new file mode 100644 index 00000000000..6641a1a1681 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/design-with-ai/types.ts @@ -0,0 +1,31 @@ +export type designWithAiStateMachineContext = { + businessInfoDescription: { + descriptionText: string; + }; + lookAndFeel: { + choice: string; + }; + toneOfVoice: { + choice: string; + }; + // If we require more data from options, previously provided core profiler details, + // we can retrieve them in preBusinessInfoDescription and then assign them here +}; +export type designWithAiStateMachineEvents = + | { type: 'AI_WIZARD_CLOSED_BEFORE_COMPLETION' } + | { + type: 'BUSINESS_INFO_DESCRIPTION_COMPLETE'; + payload: string; + } + | { + type: 'LOOK_AND_FEEL_COMPLETE'; + } + | { + type: 'TONE_OF_VOICE_COMPLETE'; + } + | { + type: 'API_CALL_TO_AI_SUCCCESSFUL'; + } + | { + type: 'API_CALL_TO_AI_FAILED'; + }; diff --git a/plugins/woocommerce-admin/client/customize-store/global.d.ts b/plugins/woocommerce-admin/client/customize-store/global.d.ts new file mode 100644 index 00000000000..5a01dab9a78 --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/global.d.ts @@ -0,0 +1,10 @@ +declare global { + interface Window { + wcBlockSettings: { + [ key: string ]: unknown; + }; + } +} + +/*~ If your module exports nothing, you'll need this line. Otherwise, delete it */ +export {}; diff --git a/plugins/woocommerce-admin/client/customize-store/index.tsx b/plugins/woocommerce-admin/client/customize-store/index.tsx index 26b102faab1..36b068bf86e 100644 --- a/plugins/woocommerce-admin/client/customize-store/index.tsx +++ b/plugins/woocommerce-admin/client/customize-store/index.tsx @@ -16,7 +16,7 @@ import { actions as introActions, } from './intro'; import { DesignWithAi, events as designWithAiEvents } from './design-with-ai'; -import { events as assemblerHubEvents } from './assembler-hub'; +import { AssemblerHub, events as assemblerHubEvents } from './assembler-hub'; import { findComponentMeta } from '~/utils/xstate/find-component'; import { CustomizeStoreComponentMeta, @@ -25,6 +25,8 @@ import { } from './types'; import { ThemeCard } from './intro/theme-cards'; +import './style.scss'; + export type customizeStoreStateMachineEvents = | introEvents | designWithAiEvents @@ -86,10 +88,10 @@ export const customizeStoreStateMachineDefinition = createMachine( { target: 'backToHomescreen', }, SELECTED_NEW_THEME: { - target: '? Appearance Task ?', + target: 'appearanceTask', }, SELECTED_BROWSE_ALL_THEMES: { - target: '? Appearance Task ?', + target: 'appearanceTask', }, }, }, @@ -114,14 +116,20 @@ export const customizeStoreStateMachineDefinition = createMachine( { }, }, assemblerHub: { + meta: { + component: AssemblerHub, + }, on: { FINISH_CUSTOMIZATION: { target: 'backToHomescreen', }, + GO_BACK_TO_DESIGN_WITH_AI: { + target: 'designWithAi', + }, }, }, backToHomescreen: {}, - '? Appearance Task ?': {}, + appearanceTask: {}, }, } ); diff --git a/plugins/woocommerce-admin/client/customize-store/intro/index.tsx b/plugins/woocommerce-admin/client/customize-store/intro/index.tsx index be1f292d042..9e06b0e8c77 100644 --- a/plugins/woocommerce-admin/client/customize-store/intro/index.tsx +++ b/plugins/woocommerce-admin/client/customize-store/intro/index.tsx @@ -37,6 +37,11 @@ export const Intro: CustomizeStoreComponent = ( { sendEvent, context } ) => { + ); }; diff --git a/plugins/woocommerce-admin/client/customize-store/style.scss b/plugins/woocommerce-admin/client/customize-store/style.scss new file mode 100644 index 00000000000..7935f10c6ce --- /dev/null +++ b/plugins/woocommerce-admin/client/customize-store/style.scss @@ -0,0 +1,18 @@ +.woocommerce-layout .woocommerce-layout__main { + @include breakpoint( '<782px' ) { + padding-top: 0 !important; + } +} + +.woocommerce-customize-store { + background-color: #fff; + + #woocommerce-layout__primary { + margin: 0; + width: 100%; + } + + .woocommerce-layout__main { + padding-right: 0; + } +} diff --git a/plugins/woocommerce-admin/client/layout/controller.js b/plugins/woocommerce-admin/client/layout/controller.js index 45c08375ced..6e880876201 100644 --- a/plugins/woocommerce-admin/client/layout/controller.js +++ b/plugins/woocommerce-admin/client/layout/controller.js @@ -319,7 +319,7 @@ export const getPages = () => { if ( window.wcAdminFeatures[ 'customize-store' ] ) { pages.push( { container: CustomizeStore, - path: '/customize-store', + path: '/customize-store/*', breadcrumbs: [ ...initialBreadcrumbs, __( 'Customize Your Store', 'woocommerce' ), diff --git a/plugins/woocommerce-admin/client/layout/store-alerts/index.js b/plugins/woocommerce-admin/client/layout/store-alerts/index.js index 4267b8763ca..633ef579c59 100644 --- a/plugins/woocommerce-admin/client/layout/store-alerts/index.js +++ b/plugins/woocommerce-admin/client/layout/store-alerts/index.js @@ -67,7 +67,7 @@ export class StoreAlerts extends Component { } renderActions( alert ) { - const { triggerNoteAction, updateNote } = this.props; + const { triggerNoteAction, updateNote, createNotice } = this.props; const actions = alert.actions.map( ( action ) => { return (