diff --git a/.github/workflows/test-assistant-release-highlight-tracker.yml b/.github/workflows/test-assistant-release-highlight-tracker.yml index fa58fda2ae48..fb2399cf29aa 100644 --- a/.github/workflows/test-assistant-release-highlight-tracker.yml +++ b/.github/workflows/test-assistant-release-highlight-tracker.yml @@ -81,7 +81,5 @@ jobs: <${{ github.event.pull_request.html_url }}|${{ github.event.pull_request.title }}> *Labels:* ${{ join(github.event.pull_request.labels.*.name, ', ') }} *Monthly Release Milestone:* <${{ github.event.pull_request.milestone.html_url }}|${{ github.event.pull_request.milestone.title }}> (Release Date: ${{ env.MILESTONE_DATE }}) - *WooAF (weekly) Timeline: this PR can be tested from:* ${{ env.TEST_DATE_MESSAGE }} - Please visit the <#${{ secrets.WOO_CORE_RELEASES_SLACK_CHANNEL }}> to obtain the latest WooAF build for testing. slack-optional-unfurl_links: false slack-optional-unfurl_media: false diff --git a/README.md b/README.md index 94f77e93f741..117e0786b8fa 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Welcome to the WooCommerce Monorepo on GitHub. Here you can find all of the plugins, packages, and tools used in the development of the core WooCommerce plugin as well as WooCommerce extensions. You can browse the source, look at open issues, contribute code, and keep tracking of ongoing development. -We recommend all developers to follow the [WooCommerce development blog](https://woocommerce.wordpress.com/) to stay up to date about everything happening in the project. You can also [follow @DevelopWC](https://twitter.com/DevelopWC) on Twitter for the latest development updates. +We recommend all developers to follow the [WooCommerce development blog](https://developer.woo.com/blog/) to stay up to date about everything happening in the project. You can also [follow @DevelopWC](https://twitter.com/DevelopWC) on Twitter for the latest development updates. ## Getting Started diff --git a/docs/docs-manifest.json b/docs/docs-manifest.json index 92808bf5e370..12db6f3324bc 100644 --- a/docs/docs-manifest.json +++ b/docs/docs-manifest.json @@ -458,7 +458,7 @@ "menu_title": "Build your first extension", "tags": "how-to", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/extension-development/building-your-first-extension.md", - "hash": "0b72a7c7a844459c971f10ade3f56e5c172d6f4fe5902f733972aaf7fc2121cb", + "hash": "6b3af5e8e96294df9625e843654adddcf97b26c81ec43b47c41be2b2ad835783", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/extension-development/building-your-first-extension.md", "id": "278c2822fe06f1ab72499a757ef0c4981cfbffb5" }, @@ -1204,5 +1204,5 @@ "categories": [] } ], - "hash": "2453d3ac64b6f1f4f4cd8efddfc166602f7182a9dff17218070fd2dccf8722e5" + "hash": "919aaa18bc145996f6a7dc0259f810f29363cc12721bac513d0d4234b30c50a7" } \ No newline at end of file diff --git a/docs/extension-development/building-your-first-extension.md b/docs/extension-development/building-your-first-extension.md index 840ab0740dc3..6b7613b67678 100644 --- a/docs/extension-development/building-your-first-extension.md +++ b/docs/extension-development/building-your-first-extension.md @@ -3,118 +3,84 @@ post_title: How to build your first extension menu_title: Build your first extension tags: how-to --- +## Introduction -The easiest way to get started building an extension is to use the built-in extension generator that is included alongside WooCommerce Admin. This utility is maintained as part of the codebase for WooCommerce Admin, so it includes up-to-date tools and many preconfigured settings for building modern extensions that take advantage of the [React-powered](https://react.dev/) user experience available in current versions of WordPress and WooCommerce. +This guide will teach you how to use [create-woo-extension](https://www.npmjs.com/package/@woocommerce/create-woo-extension) to scaffold a WooCommerce extension. There are various benefits to using create-woo-extension over manually creating one from scratch, including: -## Using the extension generator +There’s less boilerplate code to write, and less dependencies to manually setup +Modern features such as Blocks are automatically supported +Unit testing, linting, and Prettier IDE configuration are ready to use -Browse to your local WooCommerce Admin repository +Once your extension is set up, we’ll show you how to instantly spin up a development environment using [wp-env](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/). -```sh -cd /your/server/wp-content/plugins/woocommerce-admin -``` +## Requirements -Run the extension generator command +Before getting started, you’ll need the following tools installed on your device: -```sh -npm run create-wc-extension -``` -The extension generator will scaffold out a basic extension and place it in its own plugin directory alongside WooCommerce on your local server. +- [Node.js](https://nodejs.org/en/learn/getting-started/how-to-install-nodejs) with NPM +- [Docker](https://docs.docker.com/engine/install/) (must be running to use wp-env) +- [Composer](https://getcomposer.org/doc/00-intro.md) -The extension that the generator creates contains a simple [boilerplate](https://stackoverflow.com/questions/3992199/what-is-boilerplate-code) that handles much of the configuration needed for setting up a React-powered extension, which you can modify to fit your needs. +This guide also presumes you’re familiar with working with the command line. -## The architecture of a basic WooCommerce extension +## Bootstrapping Your Extension -WooCommerce extensions use a combination of PHP and modern JavaScript to create a seamless user experience for merchants and shoppers that takes advantage of the features and functionality available in the [NodeJS](https://nodejs.org/en) ecosystem while still being a good neighbor within the underlying WordPress application environment. +Open your terminal and run -WordPress plugins (of which WooCommerce extensions are a specialized subset), tend to follow a few common patterns. You can read more about common WordPress plugin architecture in the [Best Practices chapter of the WordPress Plugin Developer Handbook](https://developer.wordpress.org/plugins/plugin-basics/best-practices/#architecture-patterns). +```sh + npx @wordpress/create-block -t + @woocommerce/create-woo-extension my-extension-name +``` -In addition to the main PHP file that all WordPress plugins must contain, a WooCommerce extension will typically contain additional PHP files with classes that assist in server-side functionality. +If you’d like to set a custom extension name, you can replace `my-extension-name` with any slug. Please note that your slug must not have any spaces. -It will also contain files that are JavaScript and CSS assets which shape the client-side behavior and appearance. +If you see a prompt similar to Need to install the following packages: `@wordpress/create-block@4.34.0. Ok to proceed?`, press `Y`. -## File structure generated by the `create-wc-extension script` -When you run the built-in extension generator, it will output something that looks similar to the structure below. +Once the package finishes generating your extension, navigate into the extension’s folder using ```sh -- README.md -- my-great-extension.php -- package.json -- src - - index.js - - index.scss -- webpack.config.js + cd my-extension-name ``` -Here's a breakdown of what these files are and what purpose they serve: - -`README.md` -This file is meant to have a high-level overview of your extension to make it easier for people to use and extend your project. The generator outputs a basic file with some minimal instructions in it to get you started, but you should replace the contents of the file with information specific to your project. It's important to keep in mind that this file is not the same as the readme.txt file required by WordPress.org plugin directory, which must adhere to specific file standads. - -`[your-extension-name].php` -This is your extension's main PHP file. It functions as the entry point for your extension and is where you'll likely include code that hooks your extension into WordPress and WooCommerce. You can read more about the purpose of this file in the Getting Started section of the WordPress Plugin Developer Handbook. - -`package.json` -This is a manifest file that Node uses for a number of different purposes. It can store configuration settings for tools, lists of dependencies, aliases for common scripts, and even metadata about your extension. The WooCommerce extension generator outputs a package.json file that will bundle many helpful dependencies with your extension, as well as a variety of scripts you can use in conjunction with these dependencies to streamline your workflow and make sure your extension conforms to the same standards as other WordPress plugins and WooCommerce extensions. Here's an example of what your package.json file might look like initially: - -```json -{ - "name": "my-great-extension", - "title": "my-great-extension", - "license": "GPL-3.0-or-later", - "version": "0.1.0", - "description": "my-great-extension", - "scripts": { - "build": "wp-scripts build", - "check-engines": "wp-scripts check-engines", - "check-licenses": "wp-scripts check-licenses", - "format:js": "wp-scripts format-js", - "lint:css": "wp-scripts lint-style", - "lint:js": "wp-scripts lint-js", - "lint:md:docs": "wp-scripts lint-md-docs", - "lint:md:js": "wp-scripts lint-md-js", - "lint:pkg-json": "wp-scripts lint-pkg-json", - "packages-update": "wp-scripts packages-update", - "start": "wp-scripts start", - "test:e2e": "wp-scripts test-e2e", - "test:unit": "wp-scripts test-unit-js" - }, - "devDependencies": { - "@wordpress/scripts": "^12.2.1", - "@woocommerce/eslint-plugin": "1.1.0", - "@woocommerce/dependency-extraction-webpack-plugin": "1.1.0" - } -} -``` +You should then install your extension’s dependencies using `npm install` and build it using `npm run build`. -The settings in this autogenerated file tell Webpack to use the default configuration included with the `@wordpress/scripts` package (listed in your `package.json` as a development dependency) and to override the plugin it uses for dependency extraction with one that is tailor-made for WooCommerce extensions. +Congratulations! You just spun up a WooCommerce extension! Your extension will have the following file structure: -## Try out your extension +- `my-extension-name` + - `block.json` - contains metadata used to register your custom blocks with WordPress. Learn more. + - `build` - the built version of your extension which is generated using npm run build. You shouldn’t directly modify any of the files in this folder. + - `composer.json` - contains a list of your PHP dependencies which is referenced by Composer. + - `composer.lock` - this file allows you to control when and how to update these dependencies + - `includes` - The primary purpose of an "includes" folder in PHP development is to store reusable code or files that need to be included or required in multiple parts of a project. This is a PHP developer convention. + - `languages` - contains POT files that are used to localize your plugin. + - `my-extension-name.php` - your plugin’s entry point that registers your plugin with WordPress. + - `node-modules` - help you form the building blocks of your application and write more structured code + - `package.json` - is considered the heart of a Node project. It records metadata, and installs functional dependencies, runs scripts, and defines the entry point of your application. + - `README.md` - An introduction and instructional overview of your application. Any special instructions, updates from the author, and details about the application can be written in text here. + - `src` - keeps the root directory clean and provides a clear separation between the source code and other assets + - `tests` - can hold unit tests for your application, keeps them separate from source files + - `vendor` - holds project dependencies, and 3rd party code that you did not write + - `webpack.config.js` - webpack is a module bundler. Its main purpose is to bundle JavaScript files for usage in a browser -If you used the extension generator to create your extension, you'll need to complete a few final steps to see it in action. -First, navigate to your extension's root directory on your development server: +## Setting Up a Developer Environment -```sh -cd /your/server/wc-content/plugins/your-extension/ -``` +We recommend using `wp-env` to spin up local development environments. You can [learn more about wp-env here](https://make.wordpress.org/core/2020/03/03/wp-env-simple-local-environments-for-wordpress/). If you don’t already have wp-env installed locally, you can install it using +`npm -g i @wordpress/env`. -Then install the project's dependencies. +Once you’ve installed `wp-env`, and while still inside your `my-extension-name` folder, run `wp-env` start. After a few seconds, a test WordPress site with your WooCommerce and your extension installed will be running on `localhost:8888`. -```sh -npm install -``` +If you didn’t set a custom name for your extension, you can visit [here](http://localhost:8888/wp-admin/admin.php?page=wc-admin&path=%2Fmy-extension-name) to see the settings page generated by /src/index.js. The default username/password combination for `wp-env` is `admin` / `password`. -Finally, run the start script to generate an initial build of your extension. This script will also continuously watch your local files for changes. +## Next Steps -```sh -npm start -``` +Now that you’ve bootstrapped your extension, it’s time to add some features! Here’s some simple ones you could include: -Once your initial build is complete, you can browse to the administrative area of your local WordPress environment and activate your extension. If everything worked as it should, you should see a message in your browser's JavaScript console: +[How to add a custom field to simple and variable products](https://developer.woo.com/docs/how-to-add-a-custom-field-to-simple-and-variable-products/) -```sh -hello world -``` +## Reference + +[Visit @woocommerce/create-woo-extension on NPM for package reference](https://www.npmjs.com/package/@woocommerce/create-woo-extension) +[Check out wp-env’s command reference to learn more about advanced functionality](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/#command-reference) diff --git a/packages/js/product-editor/changelog/add-43047 b/packages/js/product-editor/changelog/add-43047 new file mode 100644 index 000000000000..c767d09b4e30 --- /dev/null +++ b/packages/js/product-editor/changelog/add-43047 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add menu item to publish button with 'Move to trash' diff --git a/packages/js/product-editor/changelog/dev-44656_create_title_bar b/packages/js/product-editor/changelog/dev-44656_create_title_bar new file mode 100644 index 000000000000..71f97ed17bee --- /dev/null +++ b/packages/js/product-editor/changelog/dev-44656_create_title_bar @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Modify product header #44711 diff --git a/packages/js/product-editor/changelog/fix-multiple_warning_in_blocks b/packages/js/product-editor/changelog/fix-multiple_warning_in_blocks new file mode 100644 index 000000000000..9d03c97206b7 --- /dev/null +++ b/packages/js/product-editor/changelog/fix-multiple_warning_in_blocks @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Set product editor blocks multiple support to true. diff --git a/packages/js/product-editor/src/blocks/generic/linked-product-list/block.json b/packages/js/product-editor/src/blocks/generic/linked-product-list/block.json index 54b1f2d11309..24eb00ae789f 100644 --- a/packages/js/product-editor/src/blocks/generic/linked-product-list/block.json +++ b/packages/js/product-editor/src/blocks/generic/linked-product-list/block.json @@ -20,7 +20,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/generic/pricing/block.json b/packages/js/product-editor/src/blocks/generic/pricing/block.json index 2a9ad347702b..1402b64c62f8 100644 --- a/packages/js/product-editor/src/blocks/generic/pricing/block.json +++ b/packages/js/product-editor/src/blocks/generic/pricing/block.json @@ -25,7 +25,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/generic/taxonomy/block.json b/packages/js/product-editor/src/blocks/generic/taxonomy/block.json index f0d8a2550ff9..68c10d07c190 100644 --- a/packages/js/product-editor/src/blocks/generic/taxonomy/block.json +++ b/packages/js/product-editor/src/blocks/generic/taxonomy/block.json @@ -5,7 +5,7 @@ "title": "Taxonomy", "category": "widgets", "description": "A block that displays a taxonomy field, allowing searching, selection, and creation of new items", - "keywords": [ "taxonomy"], + "keywords": [ "taxonomy" ], "textdomain": "default", "attributes": { "slug": { @@ -36,7 +36,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/attributes/block.json b/packages/js/product-editor/src/blocks/product-fields/attributes/block.json index 6612386c24e0..9e4ebdf00a13 100644 --- a/packages/js/product-editor/src/blocks/product-fields/attributes/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/attributes/block.json @@ -16,7 +16,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/catalog-visibility/block.json b/packages/js/product-editor/src/blocks/product-fields/catalog-visibility/block.json index cd9aecb69e28..b70967f8fa07 100644 --- a/packages/js/product-editor/src/blocks/product-fields/catalog-visibility/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/catalog-visibility/block.json @@ -21,7 +21,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/downloads/block.json b/packages/js/product-editor/src/blocks/product-fields/downloads/block.json index ac7e4dcd6971..4f081aa6a28b 100644 --- a/packages/js/product-editor/src/blocks/product-fields/downloads/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/downloads/block.json @@ -16,7 +16,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/images/block.json b/packages/js/product-editor/src/blocks/product-fields/images/block.json index e36cb7c5da4a..12fe90069306 100644 --- a/packages/js/product-editor/src/blocks/product-fields/images/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/images/block.json @@ -31,7 +31,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/inventory-email/block.json b/packages/js/product-editor/src/blocks/product-fields/inventory-email/block.json index 77459faf963d..5ac6cf80d4c7 100644 --- a/packages/js/product-editor/src/blocks/product-fields/inventory-email/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/inventory-email/block.json @@ -16,7 +16,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/inventory-quantity/block.json b/packages/js/product-editor/src/blocks/product-fields/inventory-quantity/block.json index eede24ab1a2e..fe649ca6a385 100644 --- a/packages/js/product-editor/src/blocks/product-fields/inventory-quantity/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/inventory-quantity/block.json @@ -16,7 +16,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/name/block.json b/packages/js/product-editor/src/blocks/product-fields/name/block.json index f0d26cad7fda..4196c26f22c0 100644 --- a/packages/js/product-editor/src/blocks/product-fields/name/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/name/block.json @@ -20,7 +20,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/password/block.json b/packages/js/product-editor/src/blocks/product-fields/password/block.json index 63ea5ba1ea65..15f4517dc22f 100644 --- a/packages/js/product-editor/src/blocks/product-fields/password/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/password/block.json @@ -16,7 +16,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/product-list/block.json b/packages/js/product-editor/src/blocks/product-fields/product-list/block.json index 3a670295ebf0..644a6a4a18b4 100644 --- a/packages/js/product-editor/src/blocks/product-fields/product-list/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/product-list/block.json @@ -16,7 +16,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/regular-price/block.json b/packages/js/product-editor/src/blocks/product-fields/regular-price/block.json index 3cf9fd51bc35..fffa0e8ca583 100644 --- a/packages/js/product-editor/src/blocks/product-fields/regular-price/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/regular-price/block.json @@ -26,7 +26,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/sale-price/block.json b/packages/js/product-editor/src/blocks/product-fields/sale-price/block.json index d5b698eaac15..5dfa1a7f5f51 100644 --- a/packages/js/product-editor/src/blocks/product-fields/sale-price/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/sale-price/block.json @@ -22,7 +22,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/shipping-dimensions/block.json b/packages/js/product-editor/src/blocks/product-fields/shipping-dimensions/block.json index d66452bd7817..84a8bea926a9 100644 --- a/packages/js/product-editor/src/blocks/product-fields/shipping-dimensions/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/shipping-dimensions/block.json @@ -16,7 +16,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/summary/block.json b/packages/js/product-editor/src/blocks/product-fields/summary/block.json index b1859bc2ffab..50a10675278d 100644 --- a/packages/js/product-editor/src/blocks/product-fields/summary/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/summary/block.json @@ -47,7 +47,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false diff --git a/packages/js/product-editor/src/blocks/product-fields/tag/block.json b/packages/js/product-editor/src/blocks/product-fields/tag/block.json index 7a897a4d967d..88349967497d 100644 --- a/packages/js/product-editor/src/blocks/product-fields/tag/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/tag/block.json @@ -23,7 +23,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/variation-items/block.json b/packages/js/product-editor/src/blocks/product-fields/variation-items/block.json index f29fc947aeed..7c0ad11950c4 100644 --- a/packages/js/product-editor/src/blocks/product-fields/variation-items/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/variation-items/block.json @@ -16,7 +16,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/blocks/product-fields/variation-options/block.json b/packages/js/product-editor/src/blocks/product-fields/variation-options/block.json index d2c038dca94b..c7aa348db3da 100644 --- a/packages/js/product-editor/src/blocks/product-fields/variation-options/block.json +++ b/packages/js/product-editor/src/blocks/product-fields/variation-options/block.json @@ -16,7 +16,7 @@ "supports": { "align": false, "html": false, - "multiple": false, + "multiple": true, "reusable": false, "inserter": false, "lock": false, diff --git a/packages/js/product-editor/src/components/button-with-dropdown-menu/index.tsx b/packages/js/product-editor/src/components/button-with-dropdown-menu/index.tsx index e9f93799afb9..018dee2b7045 100644 --- a/packages/js/product-editor/src/components/button-with-dropdown-menu/index.tsx +++ b/packages/js/product-editor/src/components/button-with-dropdown-menu/index.tsx @@ -13,11 +13,9 @@ import type { ButtonWithDropdownMenuProps } from './types'; export * from './types'; -export const ButtonWithDropdownMenu: React.FC< - ButtonWithDropdownMenuProps -> = ( { +export function ButtonWithDropdownMenu( { dropdownButtonLabel = __( 'More options', 'woocommerce' ), - controls = [], + controls, defaultOpen = false, popoverProps: { placement = 'bottom-end', @@ -29,8 +27,9 @@ export const ButtonWithDropdownMenu: React.FC< offset: 0, }, className, + renderMenu, ...props -} ) => { +}: ButtonWithDropdownMenuProps ) { return ( + > + { renderMenu } + ); -}; +} diff --git a/packages/js/product-editor/src/components/button-with-dropdown-menu/types.ts b/packages/js/product-editor/src/components/button-with-dropdown-menu/types.ts index efbc5a2a62a7..4cdba2bdefa9 100644 --- a/packages/js/product-editor/src/components/button-with-dropdown-menu/types.ts +++ b/packages/js/product-editor/src/components/button-with-dropdown-menu/types.ts @@ -3,6 +3,7 @@ */ import { Button } from '@wordpress/components'; import type { + Dropdown, // @ts-expect-error no exported member. DropdownOption, } from '@wordpress/components'; @@ -44,4 +45,5 @@ export type ButtonWithDropdownMenuProps = Omit< defaultOpen?: boolean; controls?: DropdownOption[]; popoverProps?: PopoverProps; + renderMenu?( props: Dropdown.RenderProps ): React.ReactElement; }; diff --git a/packages/js/product-editor/src/components/header/header.tsx b/packages/js/product-editor/src/components/header/header.tsx index ec8f2d3b550b..decae5160b4e 100644 --- a/packages/js/product-editor/src/components/header/header.tsx +++ b/packages/js/product-editor/src/components/header/header.tsx @@ -4,12 +4,20 @@ import { WooHeaderItem, useAdminSidebarWidth } from '@woocommerce/admin-layout'; import { useEntityProp } from '@wordpress/core-data'; import { useSelect } from '@wordpress/data'; -import { createElement, useContext, useEffect } from '@wordpress/element'; +import { + createElement, + useContext, + useEffect, + Fragment, +} from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Button, Tooltip } from '@wordpress/components'; -import { chevronLeft, group, Icon } from '@wordpress/icons'; +import { box, chevronLeft, group, Icon } from '@wordpress/icons'; import { getNewPath, navigateTo } from '@woocommerce/navigation'; import { recordEvent } from '@woocommerce/tracks'; +import classNames from 'classnames'; +import { Tag } from '@woocommerce/components'; +import { Product } from '@woocommerce/data'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore No types for this exist yet. // eslint-disable-next-line @woocommerce/dependency-group @@ -28,11 +36,7 @@ import { LoadingState } from './loading-state'; import { Tabs } from '../tabs'; import { HEADER_PINNED_ITEMS_SCOPE, TRACKS_SOURCE } from '../../constants'; import { useShowPrepublishChecks } from '../../hooks/use-show-prepublish-checks'; - -export type HeaderProps = { - onTabSelect: ( tabId: string | null ) => void; - productType?: string; -}; +import { HeaderProps, Image } from './types'; const RETURN_TO_MAIN_PRODUCT = __( 'Return to the main product', @@ -67,6 +71,16 @@ export function Header( { const { showPrepublishChecks } = useShowPrepublishChecks(); + const [ catalogVisibility ] = useEntityProp< + Product[ 'catalog_visibility' ] + >( 'postType', productType, 'catalog_visibility' ); + + const [ productStatus ] = useEntityProp< string >( + 'postType', + productType, + 'status' + ); + const sidebarWidth = useAdminSidebarWidth(); useEffect( () => { @@ -81,11 +95,57 @@ export function Header( { } ); }, [ sidebarWidth ] ); + const isVariation = lastPersistedProduct?.parent_id > 0; + + const [ selectedImage ] = useEntityProp< Image | Image[] | null >( + 'postType', + productType, + isVariation ? 'image' : 'images' + ); + if ( isEditorLoading ) { return ; } - const isVariation = lastPersistedProduct?.parent_id > 0; + const isHeaderImageVisible = + ( ! isVariation && + Array.isArray( selectedImage ) && + selectedImage.length > 0 ) || + ( isVariation && selectedImage ); + + function getImagePropertyValue( + image: Image | Image[], + prop: 'alt' | 'src' + ): string { + if ( Array.isArray( image ) ) { + return image[ 0 ][ prop ] || ''; + } + return image[ prop ] || ''; + } + + function getVisibilityTags() { + const tags = []; + if ( productStatus === 'draft' || productStatus === 'future' ) { + tags.push( + + ); + } + if ( + ( productStatus !== 'future' && catalogVisibility === 'hidden' ) || + ( isVariation && productStatus === 'private' ) + ) { + tags.push( + + ); + } + return tags; + } return (
) } -

- { isVariation ? ( -
- - +
+
+ { isHeaderImageVisible ? ( + { + ) : ( + + ) } +
+

+ { isVariation ? ( + <> { lastPersistedProduct?.name } - - - # { lastPersistedProduct?.id } - + + # { lastPersistedProduct?.id } + + + ) : ( + getHeaderTitle( + editedProductName, + lastPersistedProduct?.name + ) + ) } +
+ { getVisibilityTags() }
- ) : ( - getHeaderTitle( - editedProductName, - lastPersistedProduct?.name - ) - ) } -

+

+
{ ! isVariation && ( diff --git a/packages/js/product-editor/src/components/header/hooks/use-publish/use-publish.tsx b/packages/js/product-editor/src/components/header/hooks/use-publish/use-publish.tsx index d8c22ee672ee..f4805ee9e47f 100644 --- a/packages/js/product-editor/src/components/header/hooks/use-publish/use-publish.tsx +++ b/packages/js/product-editor/src/components/header/hooks/use-publish/use-publish.tsx @@ -4,18 +4,17 @@ import { MouseEvent } from 'react'; import { Button } from '@wordpress/components'; import { useEntityProp } from '@wordpress/core-data'; -import { useDispatch, useSelect } from '@wordpress/data'; import { __ } from '@wordpress/i18n'; -import type { Product, ProductVariation } from '@woocommerce/data'; +import type { Product } from '@woocommerce/data'; /** * Internal dependencies */ -import { useValidations } from '../../../../contexts/validation-context'; +import { useProductManager } from '../../../../hooks/use-product-manager'; import type { WPError } from '../../../../utils/get-product-error-message'; import type { PublishButtonProps } from '../../publish-button'; -export function usePublish( { +export function usePublish< T = Product >( { productType = 'product', disabled, onClick, @@ -23,20 +22,11 @@ export function usePublish( { onPublishError, ...props }: PublishButtonProps & { - onPublishSuccess?( product: Product ): void; + onPublishSuccess?( product: T ): void; onPublishError?( error: WPError ): void; -} ): Button.ButtonProps & { - publish( - productOrVariation?: Partial< Product | ProductVariation > - ): Promise< Product | ProductVariation | undefined >; -} { - const { isValidating, validate } = useValidations< Product >(); - - const [ productId ] = useEntityProp< number >( - 'postType', - productType, - 'id' - ); +} ): Button.ButtonProps { + const { isValidating, isDirty, isPublishing, publish } = + useProductManager( productType ); const [ status, , prevStatus ] = useEntityProp< Product[ 'status' ] >( 'postType', @@ -44,97 +34,10 @@ export function usePublish( { 'status' ); - const { isSaving, isDirty } = useSelect( - ( select ) => { - const { - // @ts-expect-error There are no types for this. - isSavingEntityRecord, - // @ts-expect-error There are no types for this. - hasEditsForEntityRecord, - } = select( 'core' ); - - return { - isSaving: isSavingEntityRecord< boolean >( - 'postType', - productType, - productId - ), - isDirty: hasEditsForEntityRecord( - 'postType', - productType, - productId - ), - }; - }, - [ productId ] - ); - - const isBusy = isSaving || isValidating; + const isBusy = isPublishing || isValidating; const isDisabled = disabled || isBusy || ! isDirty; - // @ts-expect-error There are no types for this. - const { editEntityRecord, saveEditedEntityRecord } = useDispatch( 'core' ); - - async function publish( - productOrVariation: Partial< Product | ProductVariation > = {} - ) { - const isPublished = status === 'publish' || status === 'future'; - - try { - // The publish button click not only change the status of the product - // but also save all the pending changes. So even if the status is - // publish it's possible to save the product too. - const data = ! isPublished - ? { status: 'publish', ...productOrVariation } - : productOrVariation; - - await validate( data as Partial< Product > ); - - await editEntityRecord( 'postType', productType, productId, data ); - - const publishedProduct = await saveEditedEntityRecord< - Product | ProductVariation - >( 'postType', productType, productId, { - throwOnError: true, - } ); - - if ( publishedProduct && onPublishSuccess ) { - onPublishSuccess( publishedProduct ); - } - - return publishedProduct as Product | ProductVariation; - } catch ( error ) { - if ( onPublishError ) { - let wpError = error as WPError; - if ( ! wpError.code ) { - wpError = { - code: isPublished - ? 'product_publish_error' - : 'product_create_error', - } as WPError; - if ( ( error as Record< string, string > ).variations ) { - wpError.code = 'variable_product_no_variation_prices'; - wpError.message = ( - error as Record< string, string > - ).variations; - } else { - const errorMessage = Object.values( - error as Record< string, string > - ).find( ( value ) => value !== undefined ) as - | string - | undefined; - if ( errorMessage !== undefined ) { - wpError.code = 'product_form_field_error'; - wpError.message = errorMessage; - } - } - } - onPublishError( wpError ); - } - } - } - - async function handleClick( event: MouseEvent< HTMLButtonElement > ) { + function handleClick( event: MouseEvent< HTMLButtonElement > ) { if ( isDisabled ) { event.preventDefault?.(); return; @@ -144,7 +47,7 @@ export function usePublish( { onClick( event ); } - await publish(); + publish().then( onPublishSuccess ).catch( onPublishError ); } function getButtonText() { @@ -169,6 +72,5 @@ export function usePublish( { 'aria-disabled': isDisabled, variant: 'primary', onClick: handleClick, - publish, }; } diff --git a/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/index.ts b/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/index.ts new file mode 100644 index 000000000000..d1fdefc59ecb --- /dev/null +++ b/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/index.ts @@ -0,0 +1,2 @@ +export * from './publish-button-menu'; +export * from './types'; diff --git a/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/publish-button-menu.tsx b/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/publish-button-menu.tsx new file mode 100644 index 000000000000..4a08bb1ab255 --- /dev/null +++ b/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/publish-button-menu.tsx @@ -0,0 +1,155 @@ +/** + * External dependencies + */ +import { Dropdown, MenuGroup, MenuItem } from '@wordpress/components'; +import { useEntityProp } from '@wordpress/core-data'; +import { useDispatch } from '@wordpress/data'; +import { createElement, Fragment, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import type { ProductStatus } from '@woocommerce/data'; +import { navigateTo } from '@woocommerce/navigation'; +import { getAdminLink } from '@woocommerce/settings'; + +/** + * Internal dependencies + */ +import { useProductManager } from '../../../../hooks/use-product-manager'; +import { useProductScheduled } from '../../../../hooks/use-product-scheduled'; +import { recordProductEvent } from '../../../../utils/record-product-event'; +import { getProductErrorMessage } from '../../../../utils/get-product-error-message'; +import { ButtonWithDropdownMenu } from '../../../button-with-dropdown-menu'; +import { SchedulePublishModal } from '../../../schedule-publish-modal'; +import { showSuccessNotice } from '../utils'; +import type { PublishButtonMenuProps } from './types'; + +export function PublishButtonMenu( { + postType, + ...props +}: PublishButtonMenuProps ) { + const { isScheduled, schedule, date, formattedDate } = + useProductScheduled( postType ); + const [ showScheduleModal, setShowScheduleModal ] = useState< + 'schedule' | 'edit' | undefined + >(); + const { trash } = useProductManager( postType ); + const { createErrorNotice, createSuccessNotice } = + useDispatch( 'core/notices' ); + const [ , , prevStatus ] = useEntityProp< ProductStatus >( + 'postType', + postType, + 'status' + ); + + function scheduleProduct( dateString?: string ) { + schedule( dateString ) + .then( ( scheduledProduct ) => { + recordProductEvent( 'product_schedule', scheduledProduct ); + + showSuccessNotice( scheduledProduct ); + } ) + .catch( ( error ) => { + const message = getProductErrorMessage( error ); + createErrorNotice( message ); + } ) + .finally( () => { + setShowScheduleModal( undefined ); + } ); + } + + function renderSchedulePublishModal() { + return ( + showScheduleModal && ( + setShowScheduleModal( undefined ) } + onSchedule={ scheduleProduct } + /> + ) + ); + } + + function renderMenu( { onClose }: Dropdown.RenderProps ) { + return ( + <> + + { isScheduled ? ( + <> + { + scheduleProduct(); + onClose(); + } } + > + { __( 'Publish now', 'woocommerce' ) } + + { + setShowScheduleModal( 'edit' ); + onClose(); + } } + > + { __( 'Edit schedule', 'woocommerce' ) } + + + ) : ( + { + setShowScheduleModal( 'schedule' ); + onClose(); + } } + > + { __( 'Schedule publish', 'woocommerce' ) } + + ) } + + + { prevStatus !== 'trash' && ( + + { + trash() + .then( ( deletedProduct ) => { + recordProductEvent( + 'product_delete', + deletedProduct + ); + createSuccessNotice( + __( + 'Product successfully deleted', + 'woocommerce' + ) + ); + const productListUrl = getAdminLink( + 'edit.php?post_type=product' + ); + navigateTo( { + url: productListUrl, + } ); + } ) + .catch( ( error ) => { + const message = + getProductErrorMessage( error ); + createErrorNotice( message ); + } ); + onClose(); + } } + > + { __( 'Move to trash', 'woocommerce' ) } + + + ) } + + ); + } + + return ( + <> + + + { renderSchedulePublishModal() } + + ); +} diff --git a/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/types.ts b/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/types.ts new file mode 100644 index 000000000000..1acf4529136a --- /dev/null +++ b/packages/js/product-editor/src/components/header/publish-button/publish-button-menu/types.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { ButtonWithDropdownMenuProps } from '../../../button-with-dropdown-menu'; + +export type PublishButtonMenuProps = ButtonWithDropdownMenuProps & { + postType: string; +}; diff --git a/packages/js/product-editor/src/components/header/publish-button/publish-button.tsx b/packages/js/product-editor/src/components/header/publish-button/publish-button.tsx index cb37b2f04f45..106f43e85264 100644 --- a/packages/js/product-editor/src/components/header/publish-button/publish-button.tsx +++ b/packages/js/product-editor/src/components/header/publish-button/publish-button.tsx @@ -1,12 +1,11 @@ /** * External dependencies */ -import { MouseEvent, useState } from 'react'; -import { Button } from '@wordpress/components'; +import type { MouseEvent } from 'react'; +import { Button, Dropdown } from '@wordpress/components'; import { useEntityProp } from '@wordpress/core-data'; -import { dispatch, useDispatch } from '@wordpress/data'; -import { createElement, Fragment } from '@wordpress/element'; -import { __, sprintf } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; +import { createElement } from '@wordpress/element'; import { type Product } from '@woocommerce/data'; import { getNewPath, navigateTo } from '@woocommerce/navigation'; import { recordEvent } from '@woocommerce/tracks'; @@ -19,58 +18,10 @@ import { getProductErrorMessage } from '../../../utils/get-product-error-message import { recordProductEvent } from '../../../utils/record-product-event'; import { useFeedbackBar } from '../../../hooks/use-feedback-bar'; import { TRACKS_SOURCE } from '../../../constants'; -import { ButtonWithDropdownMenu } from '../../button-with-dropdown-menu'; import { usePublish } from '../hooks/use-publish'; -import { PublishButtonProps } from './types'; -import { useProductScheduled } from '../../../hooks/use-product-scheduled'; -import { SchedulePublishModal } from '../../schedule-publish-modal'; -import { formatScheduleDatetime } from '../../../utils'; - -function getNoticeContent( product: Product, prevStatus: Product[ 'status' ] ) { - if ( - window.wcAdminFeatures[ 'product-pre-publish-modal' ] && - product.status === 'future' - ) { - return sprintf( - // translators: %s: The datetime the product is scheduled for. - __( 'Product scheduled for %s.', 'woocommerce' ), - formatScheduleDatetime( product.date_created ) - ); - } - - if ( prevStatus === 'publish' || prevStatus === 'future' ) { - return __( 'Product updated.', 'woocommerce' ); - } - - return __( 'Product published.', 'woocommerce' ); -} - -function showSuccessNotice( - product: Product, - prevStatus: Product[ 'status' ] -) { - const { createSuccessNotice } = dispatch( 'core/notices' ); - - const noticeContent = getNoticeContent( product, prevStatus ); - const noticeOptions = { - icon: '🎉', - actions: [ - { - label: __( 'View in store', 'woocommerce' ), - // Leave the url to support a11y. - url: product.permalink, - onClick( event: MouseEvent< HTMLAnchorElement > ) { - event.preventDefault(); - // Notice actions do not support target anchor prop, - // so this forces the page to be opened in a new tab. - window.open( product.permalink, '_blank' ); - }, - }, - ], - }; - - createSuccessNotice( noticeContent, noticeOptions ); -} +import { PublishButtonMenu } from './publish-button-menu'; +import { showSuccessNotice } from './utils'; +import type { PublishButtonProps } from './types'; export function PublishButton( { productType = 'product', @@ -87,7 +38,7 @@ export function PublishButton( { 'status' ); - const { publish, ...publishButtonProps } = usePublish( { + const publishButtonProps = usePublish( { productType, ...props, onPublishSuccess( savedProduct: Product ) { @@ -114,70 +65,16 @@ export function PublishButton( { }, } ); - const { isScheduled, schedule, date, formattedDate } = - useProductScheduled( productType ); - const [ showScheduleModal, setShowScheduleModal ] = useState< - 'schedule' | 'edit' | undefined - >(); - if ( productType === 'product' && window.wcAdminFeatures[ 'product-pre-publish-modal' ] && prePublish ) { - function getPublishButtonControls() { - return [ - isScheduled - ? [ - { - title: __( 'Publish now', 'woocommerce' ), - async onClick() { - await schedule( publish ); - }, - }, - { - title: ( -
-
- { __( - 'Edit schedule', - 'woocommerce' - ) } -
-
{ formattedDate }
-
- ), - onClick() { - setShowScheduleModal( 'edit' ); - }, - }, - ] - : [ - { - title: __( 'Schedule publish', 'woocommerce' ), - onClick() { - setShowScheduleModal( 'schedule' ); - }, - }, - ], - ]; - } - - function renderSchedulePublishModal() { + function renderPublishButtonMenu( + menuProps: Dropdown.RenderProps + ): React.ReactElement { return ( - showScheduleModal && ( - setShowScheduleModal( undefined ) } - onSchedule={ async ( value ) => { - await schedule( publish, value ); - setShowScheduleModal( undefined ); - } } - /> - ) + ); } @@ -198,27 +95,23 @@ export function PublishButton( { } return ( - <> - - - { renderSchedulePublishModal() } - + ); } return ( - <> - - - { renderSchedulePublishModal() } - + ); } diff --git a/packages/js/product-editor/src/components/header/publish-button/utils/index.ts b/packages/js/product-editor/src/components/header/publish-button/utils/index.ts new file mode 100644 index 000000000000..e49c733ead00 --- /dev/null +++ b/packages/js/product-editor/src/components/header/publish-button/utils/index.ts @@ -0,0 +1 @@ +export * from './show-success-notice'; diff --git a/packages/js/product-editor/src/components/header/publish-button/utils/show-success-notice.ts b/packages/js/product-editor/src/components/header/publish-button/utils/show-success-notice.ts new file mode 100644 index 000000000000..a112cf4c39f7 --- /dev/null +++ b/packages/js/product-editor/src/components/header/publish-button/utils/show-success-notice.ts @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import { dispatch } from '@wordpress/data'; +import { __, sprintf } from '@wordpress/i18n'; +import type { Product, ProductStatus } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { formatScheduleDatetime } from '../../../../utils'; + +function getNoticeContent( product: Product, prevStatus?: ProductStatus ) { + if ( + window.wcAdminFeatures[ 'product-pre-publish-modal' ] && + product.status === 'future' + ) { + return sprintf( + // translators: %s: The datetime the product is scheduled for. + __( 'Product scheduled for %s.', 'woocommerce' ), + formatScheduleDatetime( `${ product.date_created_gmt }+00:00` ) + ); + } + + if ( prevStatus === 'publish' || prevStatus === 'future' ) { + return __( 'Product updated.', 'woocommerce' ); + } + + return __( 'Product published.', 'woocommerce' ); +} + +export function showSuccessNotice( + product: Product, + prevStatus?: ProductStatus +) { + const { createSuccessNotice } = dispatch( 'core/notices' ); + + const noticeContent = getNoticeContent( product, prevStatus ); + const noticeOptions = { + icon: '🎉', + actions: [ + { + label: __( 'View in store', 'woocommerce' ), + // Leave the url to support a11y. + url: product.permalink, + onClick( event: React.MouseEvent< HTMLAnchorElement > ) { + event.preventDefault(); + // Notice actions do not support target anchor prop, + // so this forces the page to be opened in a new tab. + window.open( product.permalink, '_blank' ); + }, + }, + ], + }; + + createSuccessNotice( noticeContent, noticeOptions ); +} diff --git a/packages/js/product-editor/src/components/header/style.scss b/packages/js/product-editor/src/components/header/style.scss index f3aa7bf51401..ba89ceae5d7c 100644 --- a/packages/js/product-editor/src/components/header/style.scss +++ b/packages/js/product-editor/src/components/header/style.scss @@ -12,20 +12,70 @@ } } - .woocommerce-product-header__title { - text-align: center; - font-size: 16px; - font-weight: 600; - padding: 0; - color: #000000; - max-width: 500px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + .woocommerce-product-header-title-bar { + display: flex; + align-items: center; + background-color: $gray-100; + justify-content: center; + width: 400px; + border-radius: $gap-smallest; + padding: $gap-smallest $gap-small; + gap: $gap-small; + + &.is-variation { + display: flex; + align-items: center; + background-color: $white; + + .woocommerce-product-header__title { + font-size: 16px; + font-weight: 600; + align-items: center; + } + } @include breakpoint("<960px") { display: none; } + + &__image { + width: $gap-large; + height: $gap-large; + img { + width: 100%; + height: 100%; + } + svg { + fill: $gray-600; + } + } + + + .woocommerce-product-header__title { + text-align: center; + font-size: 13px; + font-weight: 400; + padding: 0; + color: #000000; + max-width: 500px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: $gap-small; + + .woocommerce-tag__text { + background-color: #DCDCDE; // $gray-5 + border-radius: $gap-smallest; + font-weight: 400; + } + + @include breakpoint( '<960px' ) { + display: none; + } + } + } &__actions { @@ -64,20 +114,7 @@ width: fit-content; } - &__variable-product-title { - display: flex; - align-items: center; - svg { - fill: $gray-600; - } - } - - &__variable-product-name { - margin-left: 10px; - } - &__variable-product-id { - margin-left: 10px; color: $gray-700; } } diff --git a/packages/js/product-editor/src/components/header/types.ts b/packages/js/product-editor/src/components/header/types.ts new file mode 100644 index 000000000000..22312557e85d --- /dev/null +++ b/packages/js/product-editor/src/components/header/types.ts @@ -0,0 +1,11 @@ +export type HeaderProps = { + onTabSelect: ( tabId: string | null ) => void; + productType?: string; +}; + +export interface Image { + id: number; + src: string; + name: string; + alt: string; +} diff --git a/packages/js/product-editor/src/components/prepublish-panel/schedule-section/schedule-section.tsx b/packages/js/product-editor/src/components/prepublish-panel/schedule-section/schedule-section.tsx index 67205bd1b9f3..4e1861ff2eb1 100644 --- a/packages/js/product-editor/src/components/prepublish-panel/schedule-section/schedule-section.tsx +++ b/packages/js/product-editor/src/components/prepublish-panel/schedule-section/schedule-section.tsx @@ -2,7 +2,6 @@ * External dependencies */ import { PanelBody } from '@wordpress/components'; -import { useDispatch } from '@wordpress/data'; import { createElement } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { @@ -13,22 +12,15 @@ import { /** * Internal dependencies */ -import useProductEntityProp from '../../../hooks/use-product-entity-prop'; import { useProductScheduled } from '../../../hooks/use-product-scheduled'; import { isSiteSettingsTime12HourFormatted } from '../../../utils'; import { ScheduleSectionProps } from './types'; export function ScheduleSection( { postType }: ScheduleSectionProps ) { - const [ productId ] = useProductEntityProp< number >( 'id' ); - const { schedule, date, formattedDate } = useProductScheduled( postType ); - - // @ts-expect-error There are no types for this. - const { editEntityRecord } = useDispatch( 'core' ); + const { setDate, date, formattedDate } = useProductScheduled( postType ); async function handlePublishDateTimePickerChange( value: string | null ) { - await schedule( ( product ) => { - return editEntityRecord( 'postType', postType, productId, product ); - }, value ?? undefined ); + await setDate( value ?? undefined ); } return ( diff --git a/packages/js/product-editor/src/hooks/index.ts b/packages/js/product-editor/src/hooks/index.ts index 5893d641a58e..e7fea059614b 100644 --- a/packages/js/product-editor/src/hooks/index.ts +++ b/packages/js/product-editor/src/hooks/index.ts @@ -7,3 +7,4 @@ export { default as __experimentalUseProductEntityProp } from './use-product-ent export { default as __experimentalUseProductMetadata } from './use-product-metadata'; export { useProductTemplate as __experimentalUseProductTemplate } from './use-product-template'; export { useProductScheduled as __experimentalUseProductScheduled } from './use-product-scheduled'; +export { useProductManager as __experimentalUseProductManager } from './use-product-manager'; diff --git a/packages/js/product-editor/src/hooks/use-product-manager/index.ts b/packages/js/product-editor/src/hooks/use-product-manager/index.ts new file mode 100644 index 000000000000..3c28c51297b9 --- /dev/null +++ b/packages/js/product-editor/src/hooks/use-product-manager/index.ts @@ -0,0 +1 @@ +export * from './use-product-manager'; diff --git a/packages/js/product-editor/src/hooks/use-product-manager/use-product-manager.ts b/packages/js/product-editor/src/hooks/use-product-manager/use-product-manager.ts new file mode 100644 index 000000000000..ea392fdcfcfc --- /dev/null +++ b/packages/js/product-editor/src/hooks/use-product-manager/use-product-manager.ts @@ -0,0 +1,152 @@ +/** + * External dependencies + */ +import { useEntityProp } from '@wordpress/core-data'; +import { dispatch, useSelect } from '@wordpress/data'; +import { useState } from '@wordpress/element'; +import type { Product, ProductStatus } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { useValidations } from '../../contexts/validation-context'; +import type { WPError } from '../../utils/get-product-error-message'; + +function errorHandler( error: WPError, productStatus: ProductStatus ) { + if ( error.code ) { + return error; + } + + if ( 'variations' in error && error.variations ) { + return { + code: 'variable_product_no_variation_prices', + message: error.variations, + }; + } + + const errorMessage = Object.values( error ).find( + ( value ) => value !== undefined + ) as string | undefined; + + if ( errorMessage !== undefined ) { + return { + code: 'product_form_field_error', + message: errorMessage, + }; + } + + return { + code: + productStatus === 'publish' || productStatus === 'future' + ? 'product_publish_error' + : 'product_create_error', + }; +} + +export function useProductManager< T = Product >( postType: string ) { + const [ id ] = useEntityProp< number >( 'postType', postType, 'id' ); + const [ status ] = useEntityProp< ProductStatus >( + 'postType', + postType, + 'status' + ); + const [ isSaving, setIsSaving ] = useState( false ); + const [ isTrashing, setTrashing ] = useState( false ); + const { isValidating, validate } = useValidations< T >(); + const { isDirty } = useSelect( + ( select ) => ( { + // @ts-expect-error There are no types for this. + isDirty: select( 'core' ).hasEditsForEntityRecord( + 'postType', + postType, + id + ), + } ), + [ postType, id ] + ); + + async function save( extraProps: Partial< T > = {} ) { + try { + setIsSaving( true ); + + await validate( extraProps ); + + // @ts-expect-error There are no types for this. + const { editEntityRecord, saveEditedEntityRecord } = + dispatch( 'core' ); + + await editEntityRecord< T >( 'postType', postType, id, extraProps ); + + const savedProduct = await saveEditedEntityRecord< T >( + 'postType', + postType, + id, + { + throwOnError: true, + } + ); + + return savedProduct as T; + } catch ( error ) { + throw errorHandler( error as WPError, status ); + } finally { + setIsSaving( false ); + } + } + + async function publish( extraProps: Partial< T > = {} ) { + const isPublished = status === 'publish' || status === 'future'; + + // The publish button click not only change the status of the product + // but also save all the pending changes. So even if the status is + // publish it's possible to save the product too. + const data: Partial< T > = isPublished + ? extraProps + : { status: 'publish', ...extraProps }; + + return save( data ); + } + + async function trash( force = false ) { + try { + setTrashing( true ); + + await validate(); + + // @ts-expect-error There are no types for this. + const { deleteEntityRecord, saveEditedEntityRecord } = + dispatch( 'core' ); + + await saveEditedEntityRecord< T >( 'postType', postType, id, { + throwOnError: true, + } ); + + const deletedProduct = await deleteEntityRecord< T >( + 'postType', + postType, + id, + { + force, + throwOnError: true, + } + ); + + return deletedProduct as T; + } catch ( error ) { + throw errorHandler( error as WPError, status ); + } finally { + setTrashing( false ); + } + } + + return { + isValidating, + isDirty, + isSaving, + isPublishing: isSaving, + isTrashing, + save, + publish, + trash, + }; +} diff --git a/packages/js/product-editor/src/hooks/use-product-scheduled/use-product-scheduled.ts b/packages/js/product-editor/src/hooks/use-product-scheduled/use-product-scheduled.ts index 95839e014ca6..5f1afcba2896 100644 --- a/packages/js/product-editor/src/hooks/use-product-scheduled/use-product-scheduled.ts +++ b/packages/js/product-editor/src/hooks/use-product-scheduled/use-product-scheduled.ts @@ -3,38 +3,33 @@ */ import { useEntityProp } from '@wordpress/core-data'; import { getDate, isInTheFuture, date as parseDate } from '@wordpress/date'; -import { Product, ProductStatus, ProductVariation } from '@woocommerce/data'; +import type { ProductStatus } from '@woocommerce/data'; /** * Internal dependencies */ import { formatScheduleDatetime, getSiteDatetime } from '../../utils'; +import { useProductManager } from '../use-product-manager'; export const TIMEZONELESS_FORMAT = 'Y-m-d\\TH:i:s'; export function useProductScheduled( postType: string ) { - const [ date ] = useEntityProp< string >( + const { isSaving, save } = useProductManager( postType ); + + const [ date, set ] = useEntityProp< string >( 'postType', postType, 'date_created_gmt' ); - const [ editedStatus, , prevStatus ] = useEntityProp< ProductStatus >( - 'postType', - postType, - 'status' - ); + const [ editedStatus, setStatus, prevStatus ] = + useEntityProp< ProductStatus >( 'postType', postType, 'status' ); const gmtDate = `${ date }+00:00`; const siteDate = getSiteDatetime( gmtDate ); - async function schedule( - publish: ( - productOrVariation?: Partial< Product | ProductVariation > - ) => Promise< Product | ProductVariation | undefined >, - value?: string - ) { + function calcDateAndStatus( value?: string ) { const newSiteDate = getDate( value ?? null ); const newGmtDate = parseDate( TIMEZONELESS_FORMAT, newSiteDate, 'GMT' ); @@ -45,16 +40,28 @@ export function useProductScheduled( postType: string ) { status = 'publish'; } - return publish( { - status, - date_created_gmt: newGmtDate, - } ); + return { status, date_created_gmt: newGmtDate }; + } + + async function setDate( value?: string ) { + const result = calcDateAndStatus( value ); + + set( result.date_created_gmt ); + setStatus( result.status ); + } + + async function schedule( value?: string ) { + const result = calcDateAndStatus( value ); + + return save( result ); } return { + isScheduling: isSaving, isScheduled: editedStatus === 'future' || isInTheFuture( siteDate ), date: siteDate, formattedDate: formatScheduleDatetime( gmtDate ), + setDate, schedule, }; } 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 index 1f7cae0b4774..f42c1db8cc50 100644 --- 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 @@ -54,7 +54,7 @@ export const SidebarNavigationScreen = ( { backPath?: string; onNavigateBackClick?: () => void; } ) => { - const { sendEvent, context } = useContext( CustomizeStoreContext ); + const { context } = useContext( CustomizeStoreContext ); const [ openWarningModal, setOpenWarningModal ] = useState< boolean >( false ); const location = useLocation(); @@ -148,10 +148,10 @@ export const SidebarNavigationScreen = ( { { - sendEvent( + window.parent.__wcCustomizeStore.sendEventToIntroMachine( flowType && isAIFlow( flowType ) - ? 'GO_BACK_TO_DESIGN_WITH_AI' - : 'GO_BACK_TO_DESIGN_WITHOUT_AI' + ? { type: 'GO_BACK_TO_DESIGN_WITH_AI' } + : { type: 'GO_BACK_TO_DESIGN_WITHOUT_AI' } ); } } /> 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 index 099d26733dea..aa555e3069b1 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/site-hub.tsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/site-hub.tsx @@ -78,21 +78,13 @@ export const SiteHub = forwardRef( className="edit-site-site-hub__text-content" spacing="0" > - - +
{ - const assemblerUrl = getNewPath( {}, '/customize-store/assembler-hub', {} ); - const iframe = document.createElement( 'iframe' ); - iframe.classList.add( 'cys-fullscreen-iframe' ); - iframe.src = assemblerUrl; - - const showIframe = () => { - if ( iframe.style.opacity === '1' ) { - // iframe is already visible - return; - } - - const loader = document.getElementsByClassName( - 'woocommerce-onboarding-loader' - ); - if ( loader[ 0 ] ) { - ( loader[ 0 ] as HTMLElement ).style.display = 'none'; - } - - iframe.style.opacity = '1'; - - if ( context.startLoadingTime ) { - const endLoadingTime = performance.now(); - const timeToLoad = endLoadingTime - context.startLoadingTime; - recordEvent( 'customize_your_store_ai_wizard_loading_time', { - time_in_s: ( timeToLoad / 1000 ).toFixed( 2 ), - } ); - } - }; - - iframe.onload = () => { - // Hide loading UI - attachIframeListeners( iframe ); - onIframeLoad( showIframe ); - - // Ceiling wait time set to 60 seconds - setTimeout( showIframe, 60 * 1000 ); - window.history?.pushState( {}, '', assemblerUrl ); - }; - - document.body.appendChild( iframe ); - +const redirectToAssemblerHub = async () => { // This is a workaround to update the "activeThemeHasMods" in the parent's machine // state context. We should find a better way to do this using xstate actions, // since state machines should rely only on their context. @@ -345,33 +297,6 @@ const redirectToAssemblerHub = async ( // than the parent window. // Check https://github.com/woocommerce/woocommerce/pull/44206 for more details. window.parent.__wcCustomizeStore.activeThemeHasMods = true; - - // Listen for back button click - window.addEventListener( - 'popstate', - () => { - const apiLoaderUrl = getNewPath( - {}, - '/customize-store/design-with-ai/api-call-loader', - {} - ); - - // Only catch the back button click when the user is on the main assember hub page - // and trying to go back to the api loader page - if ( 'admin.php' + window.location.search === apiLoaderUrl ) { - iframe.contentWindow?.postMessage( - { - type: 'assemberBackButtonClicked', - }, - '*' - ); - // When the user clicks the back button, push state changes to the previous step - // Set it back to the assembler hub - window.history?.pushState( {}, '', assemblerUrl ); - } - }, - false - ); }; export const actions = { 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 index fd86fb4c1640..e369d2dc51b0 100644 --- 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 @@ -3,7 +3,8 @@ */ import { Loader } from '@woocommerce/onboarding'; import { __ } from '@wordpress/i18n'; -import { useEffect, useState } from '@wordpress/element'; +import { useEffect, useRef, useState } from '@wordpress/element'; +import { getNewPath } from '@woocommerce/navigation'; /** * Internal dependencies @@ -15,7 +16,11 @@ import assemblingAiOptimizedStore from '../../assets/images/loader-assembling-ai import applyingFinishingTouches from '../../assets/images/loader-applying-the-finishing-touches.svg'; import generatingContent from '../../assets/images/loader-generating-content.svg'; import openingTheDoors from '../../assets/images/loader-opening-the-doors.svg'; -import { createAugmentedSteps } from '~/customize-store/utils'; +import { + attachIframeListeners, + createAugmentedSteps, + onIframeLoad, +} from '~/customize-store/utils'; const loaderSteps = [ { @@ -150,6 +155,62 @@ export const ApiCallLoader = () => { ); }; +const AssemblerHub = () => { + const assemblerUrl = getNewPath( {}, '/customize-store/assembler-hub', {} ); + const iframe = useRef< HTMLIFrameElement | null >( null ); + const [ isVisible, setIsVisible ] = useState( false ); + + useEffect( () => { + const currentIframe = iframe.current; + if ( currentIframe === null ) { + return; + } + window.addEventListener( + 'popstate', + () => { + const apiLoaderUrl = getNewPath( + {}, + '/customize-store/design-with-ai/api-call-loader', + {} + ); + + // Only catch the back button click when the user is on the main assember hub page + // and trying to go back to the api loader page + if ( 'admin.php' + window.location.search === apiLoaderUrl ) { + currentIframe.contentWindow?.postMessage( + { + type: 'assemberBackButtonClicked', + }, + '*' + ); + // When the user clicks the back button, push state changes to the previous step + // Set it back to the assembler hub + window.history?.pushState( {}, '', assemblerUrl ); + } + }, + false + ); + }, [ assemblerUrl, iframe ] ); + + return ( +