Merge branch 'trunk' into fix/wccom-18029-suppress-double-scrollbar

This commit is contained in:
Dan Q 2023-09-18 13:34:32 +01:00
commit da7755a7c1
412 changed files with 23202 additions and 13110 deletions

View File

@ -37,6 +37,10 @@ Please take a moment to review the [project readme](https://github.com/woocommer
- Run our build process described in the document on [how to set up WooCommerce development environment](https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment), it will install our pre-commit hook, code sniffs, dependencies, and more.
- Before pushing commits to GitHub, check your code against our code standards. For PHP code in the WooCommerce Core project you can do this by running `pnpm --filter=woocommerce run lint:php:changes:branch`.
- Whenever possible, please fix pre-existing code standards errors in code that you change.
- Please consider adding appropriate tests related to your change if applicable such as unit, API and E2E tests. You can check the following guides for this purpose:
- [Writing unit tests](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/tests/README.md#guide-for-writing-unit-tests).
- [Writing API tests](https://github.com/woocommerce/woocommerce/tree/trunk/plugins/woocommerce/tests/api-core-tests#guide-for-writing-api-tests).
- [Writing E2E tests](https://github.com/woocommerce/woocommerce/tree/trunk/plugins/woocommerce/tests/e2e-pw#guide-for-writing-e2e-tests).
- Ensure you use LF line endings in your code editor. Use [EditorConfig](http://editorconfig.org/) if your editor supports it so that indentation, line endings and other settings are auto configured.
- When committing, reference your issue number (#1234) and include a note about the fix.
- Ensure that your code supports the minimum supported versions of PHP and WordPress; this is shown at the top of the `readme.txt` file.

View File

@ -67,11 +67,6 @@
'plugin: woo-ai':
- plugins/woo-ai/**/*
'focus: react admin [team:Ghidorah]':
- plugins/woocommerce/src/Admin/**/*
- plugins/woocommerce/src/Internal/Admin/**/*
- plugins/woocommerce-admin/**/*
'focus: performance tests [team:Solaris]':
- plugins/woocommerce/tests/performance/**/*

View File

@ -32,6 +32,7 @@ jobs:
CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }}
CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }}
DEFAULT_TIMEOUT_OVERRIDE: 120000
UPDATE_WC: 'nightly'
steps:
- uses: actions/checkout@v3
@ -45,12 +46,6 @@ jobs:
working-directory: plugins/woocommerce
run: pnpm exec playwright install chromium
- name: Run 'Update WooCommerce' test.
working-directory: plugins/woocommerce
env:
UPDATE_WC: nightly
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js update-woocommerce.spec.js
- name: Run API tests.
working-directory: plugins/woocommerce
env:
@ -79,7 +74,7 @@ jobs:
runs-on: ubuntu-20.04
permissions:
contents: read
needs: [api-tests]
# needs: [api-tests]
env:
ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }}
ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }}
@ -349,4 +344,4 @@ jobs:
-f plugin="${{ matrix.plugin }}" \
-f slug="${{ matrix.slug }}" \
-f s3_root=public \
--repo woocommerce/woocommerce-test-reports
--repo woocommerce/woocommerce-test-reports

View File

@ -307,64 +307,31 @@ jobs:
env-slug: wp-latest
release-version: ${{ needs.get-tag.outputs.tag }}
get-wp-versions:
name: Get WP L-1 & L-2 version numbers
needs: [get-tag]
test-wp-latest-1:
name: Test against WP Latest-1
runs-on: ubuntu-20.04
permissions:
contents: read
outputs:
matrix: ${{ steps.get-versions.outputs.versions }}
tag: ${{ needs.get-tag.outputs.tag }}
created: ${{ needs.get-tag.outputs.created }}
steps:
- name: Create dirs
run: |
mkdir script
mkdir repo
- name: Checkout
uses: actions/checkout@v3
with:
path: repo
- name: Copy script to get previous WP versions
run: cp repo/plugins/woocommerce/tests/e2e-pw/utils/wordpress.js script
- name: Install axios
working-directory: script
run: npm install axios
- name: Get version numbers
id: get-versions
uses: actions/github-script@v6
with:
script: |
const { getPreviousTwoVersions } = require('./script/wordpress');
const versions = await getPreviousTwoVersions();
console.log(versions);
core.setOutput('versions', versions);
test-wp-versions:
name: Test against ${{ matrix.version.description }} (${{ matrix.version.number }})
runs-on: ubuntu-20.04
needs: [get-wp-versions]
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.get-wp-versions.outputs.matrix) }}
needs: [ get-tag ]
env:
API_ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/api/allure-report
API_ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/api/allure-results
API_WP_LATEST_X_ARTIFACT: API test on wp-env with WordPress ${{ matrix.version.number }} (run ${{ github.run_number }})
API_WP_LATEST_X_ARTIFACT: API test on wp-env with WordPress L-1 (run ${{ github.run_number }})
E2E_ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/e2e/allure-report
E2E_ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/e2e/allure-results
E2E_WP_LATEST_X_ARTIFACT: E2E test on wp-env with WordPress ${{ matrix.version.number }} (run ${{ github.run_number }})
E2E_WP_LATEST_X_ARTIFACT: E2E test on wp-env with WordPress L-1 (run ${{ github.run_number }})
permissions:
contents: read
steps:
- name: Checkout WooCommerce repo
uses: actions/checkout@v3
- name: Get WP Latest-1 version number
id: get-wp-latest-1
uses: actions/github-script@v6
with:
script: |
const { getVersionWPLatestMinusOne } = require( './plugins/woocommerce/tests/e2e-pw/utils/wordpress' );
await getVersionWPLatestMinusOne( { core, github } );
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
@ -373,29 +340,17 @@ jobs:
- name: Launch WP Env
working-directory: plugins/woocommerce
run: pnpm run env:test
env:
WP_ENV_CORE: WordPress/WordPress#${{ steps.get-wp-latest-1.outputs.version }}
- name: Download release zip
env:
GH_TOKEN: ${{ secrets.E2E_GH_TOKEN }}
run: gh release download ${{ needs.get-wp-versions.outputs.tag }} --dir tmp
run: gh release download ${{ steps.get-wp-latest-1.outputs.version }} --dir tmp
- name: Replace `plugins/woocommerce` with unzipped woocommerce release build
run: unzip -d plugins -o tmp/woocommerce.zip
- name: Downgrade WordPress version to ${{ matrix.version.number }}
working-directory: plugins/woocommerce
run: |
pnpm exec wp-env run tests-cli -- wp core update --version=${{ matrix.version.number }} --force
pnpm exec wp-env run tests-cli wp core update-db
- name: Verify environment details
working-directory: plugins/woocommerce
run: |
pnpm exec wp-env run tests-cli wp core version
pnpm exec wp-env run tests-cli wp plugin list
pnpm exec wp-env run tests-cli wp theme list
pnpm exec wp-env run tests-cli wp user list
- name: Run API tests
id: run-api-composite-action
uses: ./.github/actions/tests/run-api-tests
@ -423,13 +378,13 @@ jobs:
if: success() || ( failure() && steps.run-api-composite-action.conclusion == 'failure' )
env:
GITHUB_TOKEN: ${{ secrets.REPORTS_TOKEN }}
ENV_DESCRIPTION: ${{ matrix.version.env_description }}
ENV_DESCRIPTION: wp-latest-1
run: |
gh workflow run publish-test-reports-release.yml \
-f created_at="${{ needs.get-wp-versions.outputs.created }}" \
-f created_at="${{ needs.get-tag.outputs.created }}" \
-f run_id=${{ github.run_id }} \
-f run_number=${{ github.run_number }} \
-f release_tag=${{ needs.get-wp-versions.outputs.tag }} \
-f release_tag=${{ needs.get-tag.outputs.tag }} \
-f artifact="${{ env.API_WP_LATEST_X_ARTIFACT }}" \
-f env_description="${{ env.ENV_DESCRIPTION }}" \
-f test_type="api" \
@ -464,13 +419,13 @@ jobs:
if: success() || ( failure() && steps.run-e2e-composite-action.conclusion == 'failure' )
env:
GITHUB_TOKEN: ${{ secrets.REPORTS_TOKEN }}
ENV_DESCRIPTION: ${{ matrix.version.env_description }}
ENV_DESCRIPTION: wp-latest-1
run: |
gh workflow run publish-test-reports-release.yml \
-f created_at="${{ needs.get-wp-versions.outputs.created }}" \
-f created_at="${{ needs.get-tag.outputs.created }}" \
-f run_id=${{ github.run_id }} \
-f run_number=${{ github.run_number }} \
-f release_tag=${{ needs.get-wp-versions.outputs.tag }} \
-f release_tag=${{ needs.get-tag.outputs.tag }} \
-f artifact="${{ env.E2E_WP_LATEST_X_ARTIFACT }}" \
-f env_description="${{ env.ENV_DESCRIPTION }}" \
-f test_type="e2e" \
@ -486,11 +441,11 @@ jobs:
)
uses: ./.github/actions/tests/slack-summary-on-release/slack-blocks
with:
test-name: ${{ matrix.version.description }} (${{ matrix.version.number }})
test-name: WP Latest-1 (${{ steps.get-wp-latest-1.outputs.version }})
api-result: ${{ steps.run-api-composite-action.outputs.result }}
e2e-result: ${{ steps.run-e2e-composite-action.outputs.result }}
env-slug: ${{ matrix.version.env_description }}
release-version: ${{ needs.get-wp-versions.outputs.tag }}
env-slug: wp-latest-1
release-version: ${{ needs.get-tag.outputs.tag }}
test-php-versions:
name: Test against PHP ${{ matrix.php_version }}
@ -759,7 +714,7 @@ jobs:
- get-tag
- test-php-versions
- test-plugins
- test-wp-versions
- test-wp-latest-1
steps:
- uses: actions/checkout@v3

View File

@ -1,5 +1,95 @@
== Changelog ==
= 8.1.0 2023-09-12 =
**WooCommerce**
* Fix - Update modified date when a metadata is saved for HPOS. [#39911](https://github.com/woocommerce/woocommerce/pull/39911)
* Fix - Fix edgecase performance issues around incentives caching. [#39958](https://github.com/woocommerce/woocommerce/pull/39958)
* Fix - Add migration to move incorrectly stored payment token IDS to HPOS tables from postmeta. [#39724](https://github.com/woocommerce/woocommerce/pull/39724)
* Fix - Address more PHP 8.1+ deprecation warnings in wc-admin code. [#38774](https://github.com/woocommerce/woocommerce/pull/38774)
* Fix - Adds display of postcodes to Vietnam addresses. [#39403](https://github.com/woocommerce/woocommerce/pull/39403)
* Fix - Always return bool values from WPCacheEngine functions when expected. [#39819](https://github.com/woocommerce/woocommerce/pull/39819)
* Fix - Be more precise when checking submission data from the email verification form on the order confirmation screen. [#39479](https://github.com/woocommerce/woocommerce/pull/39479)
* Fix - Bring HPOS order hooks in line with the posts implementation. [#39694](https://github.com/woocommerce/woocommerce/pull/39694)
* Fix - Connect WC_Install's create_tables to HPOS tables when its active. [#39682](https://github.com/woocommerce/woocommerce/pull/39682)
* Fix - Disable read on sync while backfilling. [#39450](https://github.com/woocommerce/woocommerce/pull/39450)
* Fix - Ensure refund meta data is saved correctly when HPOS is enabled. [#39700](https://github.com/woocommerce/woocommerce/pull/39700)
* Fix - Ensure that the full discount is ignored in free shipping minimum order calculations when ignore_discount setting is enabled [#39155](https://github.com/woocommerce/woocommerce/pull/39155)
* Fix - Fixed a race condition that was causing page views on intro-opt-in page to be sent before tracks was enabled. [#39508](https://github.com/woocommerce/woocommerce/pull/39508)
* Fix - Fixes WooCommerce knowledge base API returning empty posts. [#39809](https://github.com/woocommerce/woocommerce/pull/39809)
* Fix - Fix failure due to multiple h2 tags in the Product Vendors plugin [#38717](https://github.com/woocommerce/woocommerce/pull/38717)
* Fix - Fix Storefront recommendation link and missing image in Marketplace [#39294](https://github.com/woocommerce/woocommerce/pull/39294)
* Fix - include post_ID in HPOS order edit screen [#39321](https://github.com/woocommerce/woocommerce/pull/39321)
* Fix - Limit index length to 191 characters by default, additionally connect HPOS to verify DB tooling. [#39250](https://github.com/woocommerce/woocommerce/pull/39250)
* Fix - Onboarding payments task not completed after setting up WooPayments [#39786](https://github.com/woocommerce/woocommerce/pull/39786)
* Fix - Prevent possible error when refreshing order edit locks. [#39498](https://github.com/woocommerce/woocommerce/pull/39498)
* Fix - Prevent possible fatal error when edit lock is held on deleted order. [#39497](https://github.com/woocommerce/woocommerce/pull/39497)
* Fix - Store transactional data in order tables with HPOS. [#39381](https://github.com/woocommerce/woocommerce/pull/39381)
* Fix - Support inserting NULL values for strict DB mode for DataBase Util's insert_on_duplicate_key_update method. [#39396](https://github.com/woocommerce/woocommerce/pull/39396)
* Fix - Update CSS prop 'end' to 'flex-end' when using flexbox. [#39419](https://github.com/woocommerce/woocommerce/pull/39419)
* Fix - Use admin theme color for select2, instead of hardcoded theme values. [#39451](https://github.com/woocommerce/woocommerce/pull/39451)
* Fix - Use admin theme color instead of old pink. Update old pink to the new brand color. [#39182](https://github.com/woocommerce/woocommerce/pull/39182)
* Fix - [Product Block Editor] remove digital products from the target list #39769 [#39801](https://github.com/woocommerce/woocommerce/pull/39801)
* Add - Add block template registry and controller [#39698](https://github.com/woocommerce/woocommerce/pull/39698)
* Add - Add delete option to generate variations API, to auto delete unmatched variations. [#39733](https://github.com/woocommerce/woocommerce/pull/39733)
* Add - Added feature flag that removes store appearance task and adds customize store task when enabled [#39397](https://github.com/woocommerce/woocommerce/pull/39397)
* Add - Add filter for adding new user preference option for notice to user data fields. [#39685](https://github.com/woocommerce/woocommerce/pull/39685)
* Add - Add plugin installation request track for core profiler [#39533](https://github.com/woocommerce/woocommerce/pull/39533)
* Add - Add post_password for products for REST API V3 [#39438](https://github.com/woocommerce/woocommerce/pull/39438)
* Add - Add support for Japan and UAE in WooPayments [#39431](https://github.com/woocommerce/woocommerce/pull/39431)
* Add - Add woocommerce/product-password-field block to new product editor [#39464](https://github.com/woocommerce/woocommerce/pull/39464)
* Add - API for block-based templates. [#39470](https://github.com/woocommerce/woocommerce/pull/39470)
* Add - Register product catalog and search visibility blocks [#39477](https://github.com/woocommerce/woocommerce/pull/39477)
* Add - Register the product variation items block [#39657](https://github.com/woocommerce/woocommerce/pull/39657)
* Add - [E2E test coverage]: Disable block product editor #39417 [#39493](https://github.com/woocommerce/woocommerce/pull/39493)
* Add - [E2E test coverage]: Enable new product management experience [#39463](https://github.com/woocommerce/woocommerce/pull/39463)
* Add - [E2E test coverage]: General tab #39411 [#39493](https://github.com/woocommerce/woocommerce/pull/39493)
* Update - Add 'variable' to supported post types for product block editor [#39256](https://github.com/woocommerce/woocommerce/pull/39256)
* Update - Added xstate scaffolding for customize your store feature [#39619](https://github.com/woocommerce/woocommerce/pull/39619)
* Update - add time support to product import on sale dates [#39372](https://github.com/woocommerce/woocommerce/pull/39372)
* Update - On the order confirmation screen, show the 'thank you' message regardless of whether the viewer is verified or not. [#39758](https://github.com/woocommerce/woocommerce/pull/39758)
* Update - Support `first_used` and `installation_date` mobile usage data for WCTracker. [#39605](https://github.com/woocommerce/woocommerce/pull/39605)
* Update - Updates Action Scheduler to 3.6.2 (bug fixes and improvements to help debug problems). [#39665](https://github.com/woocommerce/woocommerce/pull/39665)
* Update - Update task list to show a spinner on item click [#39270](https://github.com/woocommerce/woocommerce/pull/39270)
* Update - Update WCPay banners for WooPay in eligible countries. [#39596](https://github.com/woocommerce/woocommerce/pull/39596)
* Update - Update WooCommerce Blocks to 10.9.0 [#39783](https://github.com/woocommerce/woocommerce/pull/39783)
* Update - Update WooCommerce Blocks to 10.9.2 [#39828](https://github.com/woocommerce/woocommerce/pull/39828)
* Update - Use the same checkbox style on the platform selctor [#39469](https://github.com/woocommerce/woocommerce/pull/39469)
* Dev - Added a unit test for plugin feature compatibility data in WC Tracker [#38931](https://github.com/woocommerce/woocommerce/pull/38931)
* Dev - Added storybook for core profiler pages [#39046](https://github.com/woocommerce/woocommerce/pull/39046)
* Dev - Fixed TS type error for state machine Context in Core Profiler that only got caught after TS5 upgrade [#39749](https://github.com/woocommerce/woocommerce/pull/39749)
* Dev - Fixes a failing e2e test in our daily test runs. [#39674](https://github.com/woocommerce/woocommerce/pull/39674)
* Dev - Fix flaky E2E tests in analytics-overview.spec.js. [#39308](https://github.com/woocommerce/woocommerce/pull/39308)
* Dev - Optimized the System Status Report unit tests. [#39363](https://github.com/woocommerce/woocommerce/pull/39363)
* Dev - Refactored some core profiler utils out to reuse them in customise your store. [#39581](https://github.com/woocommerce/woocommerce/pull/39581)
* Dev - Remove dependency on e2e-environment and e2e-utils in wc-admin. [#39746](https://github.com/woocommerce/woocommerce/pull/39746)
* Dev - Remove the non-existing method from TaskList docs. [#39454](https://github.com/woocommerce/woocommerce/pull/39454)
* Dev - Remove unused variation option components [#39673](https://github.com/woocommerce/woocommerce/pull/39673)
* Dev - Runs all API tests on daily run. Skips failing tests on CI. [#39351](https://github.com/woocommerce/woocommerce/pull/39351)
* Dev - Shard the unit tests into two test suites. [#39362](https://github.com/woocommerce/woocommerce/pull/39362)
* Dev - Simplify user id retrieval in analytics-overview.spec.js. [#39472](https://github.com/woocommerce/woocommerce/pull/39472)
* Dev - Update pnpm to 8.6.7 [#39245](https://github.com/woocommerce/woocommerce/pull/39245)
* Dev - Upgrade TypeScript to 5.1.6 [#39531](https://github.com/woocommerce/woocommerce/pull/39531)
* Dev - [Product Block Editor] Disable tabs in parent product page with variations #39459 [#39675](https://github.com/woocommerce/woocommerce/pull/39675)
* Dev - [Product Block Editor] Disable the new editor for variable products. [#39780](https://github.com/woocommerce/woocommerce/pull/39780)
* Tweak - Add loading indicator when submitting location in Tax task [#39613](https://github.com/woocommerce/woocommerce/pull/39613)
* Tweak - Center align checkbox, logo, and the title on the plugins page (core profiler) [#39394](https://github.com/woocommerce/woocommerce/pull/39394)
* Tweak - Do not run 'woocommerce_process_shop_order_meta' for order post when HPOS is authoritative. [#39587](https://github.com/woocommerce/woocommerce/pull/39587)
* Tweak - Fix TikTok naming. [#39748](https://github.com/woocommerce/woocommerce/pull/39748)
* Tweak - Modified the error message shown to customers in the event that no payment gateways are available. [#39348](https://github.com/woocommerce/woocommerce/pull/39348)
* Tweak - Remove subheading letter-spacing from the core profiler pages. [#39526](https://github.com/woocommerce/woocommerce/pull/39526)
* Tweak - Run A/B test on the core profiler plugins page -- suggest Jetpack or Jetpack Boost [#39799](https://github.com/woocommerce/woocommerce/pull/39799)
* Tweak - Safety measures to prevent future breakages when executing bulk actions in the order list table (HPOS). [#39524](https://github.com/woocommerce/woocommerce/pull/39524)
* Tweak - When all plugins are deselected, but Jetpack is already installed and not connected, redirect users to the Jetpack Connect page. [#39109](https://github.com/woocommerce/woocommerce/pull/39109)
* Tweak - When HPOS is authoritative, execute order update logic earlier during the request. [#39590](https://github.com/woocommerce/woocommerce/pull/39590)
* Performance - Use direct meta calls for backfilling instead of expensive object update. [#39450](https://github.com/woocommerce/woocommerce/pull/39450)
* Enhancement - Add filter `woocommerce_pre_delete_{object_type}` which allows preventing deletion.' [#39650](https://github.com/woocommerce/woocommerce/pull/39650)
* Enhancement - Create the Organization tab [#39232](https://github.com/woocommerce/woocommerce/pull/39232)
* Enhancement - Modify order index to also include date for faster order list query. [#39682](https://github.com/woocommerce/woocommerce/pull/39682)
* Enhancement - Update the admin's menu remaining tasks bubble CSS class and handling [#39273](https://github.com/woocommerce/woocommerce/pull/39273)
= 8.0.3 2023-08-29 =
* Update - Bump WooCommerce Blocks to 10.6.6. [#39853](https://github.com/woocommerce/woocommerce/pull/39853)

View File

@ -63,7 +63,7 @@ Starting with these three broad lifecycle areas, you can begin to break your ext
## Handling activation and deactivation
A common pattern in WooCommerce extensions is to create dedicated functions in your main PHP file to serve as activation and deactivation hooks. You then register these hooks with WordPress using the applicable registration function. This tells WordPess to call the function when the plugin is activated or deactivated. Consider the following examples:
A common pattern in WooCommerce extensions is to create dedicated functions in your main PHP file to serve as activation and deactivation hooks. You then register these hooks with WordPress using the applicable registration function. This tells WordPress to call the function when the plugin is activated or deactivated. Consider the following examples:
```php
function my_extension_activate() {

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Allow jest to pass with no tests

View File

@ -73,7 +73,7 @@
},
"scripts": {
"turbo:build": "pnpm run build:js && pnpm run build:css",
"turbo:test": "jest --config ./jest.config.json",
"turbo:test": "jest --config ./jest.config.json --passWithNoTests",
"prepare": "composer install",
"changelog": "composer exec -- changelogger",
"clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*",

View File

@ -10,8 +10,6 @@ Install the module
pnpm install @woocommerce/components --save
```
View [the full Component documentation](https://woocommerce.github.io/woocommerce-admin/#/components/) for usage information.
## Usage
```jsx

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add tags (or general taxonomy ) block

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add AI wizard business info step for Customize Your Store task

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add ProgressBar component

View File

@ -0,0 +1,4 @@
Significance: minor
Type: tweak
Remove unnecessary use of woocommerce-page selector for DropdownButton styling.

View File

@ -0,0 +1,5 @@
Significance: patch
Type: fix
Comment: Decode HTML escaped string for tree-item and selected-items components

View File

@ -0,0 +1,5 @@
Significance: patch
Type: tweak
Comment: Just a minor README change.

View File

@ -1,4 +1,4 @@
.woocommerce-page .woocommerce-dropdown-button {
.woocommerce-dropdown-button {
background-color: $studio-white;
position: relative;
border: 1px solid $gray-700;

View File

@ -3,6 +3,7 @@
*/
import classnames from 'classnames';
import { createElement } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
@ -43,7 +44,7 @@ export const SelectedItems = < ItemType, >( {
<div className={ classes }>
{ items
.map( ( item ) => {
return getItemLabel( item );
return decodeEntities( getItemLabel( item ) );
} )
.join( ', ' ) }
</div>

View File

@ -3,7 +3,7 @@
*/
import { chevronDown } from '@wordpress/icons';
import classNames from 'classnames';
import { createElement, useState } from '@wordpress/element';
import { createElement, useEffect, useState } from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { BaseControl, TextControl } from '@wordpress/components';
@ -25,6 +25,7 @@ interface SelectTreeProps extends TreeControlProps {
isLoading?: boolean;
label: string | JSX.Element;
onInputChange?: ( value: string | undefined ) => void;
initialInputValue?: string | undefined;
}
export const SelectTree = function SelectTree( {
@ -33,6 +34,7 @@ export const SelectTree = function SelectTree( {
suffix = <SuffixIcon icon={ chevronDown } />,
placeholder,
isLoading,
initialInputValue,
onInputChange,
shouldShowCreateButton,
...props
@ -73,6 +75,12 @@ export const SelectTree = function SelectTree( {
const [ inputValue, setInputValue ] = useState( '' );
const isReadOnly = ! isOpen && ! isFocused;
useEffect( () => {
if ( initialInputValue !== undefined && isFocused ) {
setInputValue( initialInputValue as string );
}
}, [ isFocused ] );
const inputProps: React.InputHTMLAttributes< HTMLInputElement > = {
className: 'woocommerce-experimental-select-control__input',
id: `${ props.id }-input`,
@ -211,6 +219,7 @@ export const SelectTree = function SelectTree( {
className={ menuInstanceId.toString() }
ref={ ref }
isEventOutside={ isEventOutside }
isLoading={ isLoading }
isOpen={ isOpen }
items={ linkedTree }
shouldShowCreateButton={ shouldShowCreateButton }

View File

@ -6,6 +6,7 @@ import { __ } from '@wordpress/i18n';
import { chevronDown, chevronUp } from '@wordpress/icons';
import classNames from 'classnames';
import { createElement, forwardRef } from 'react';
import { decodeEntities } from '@wordpress/html-entities';
/**
* Internal dependencies
@ -72,7 +73,7 @@ export const TreeItem = forwardRef( function ForwardedTreeItem(
{ typeof getLabel === 'function' ? (
getLabel( item )
) : (
<span>{ item.data.label }</span>
<span>{ decodeEntities( item.data.label ) }</span>
) }
</label>

View File

@ -111,3 +111,4 @@ export {
ProductFieldSection as __experimentalProductFieldSection,
} from './product-section-layout';
export { DisplayState } from './display-state';
export { ProgressBar } from './progress-bar';

View File

@ -0,0 +1,46 @@
/**
* External dependencies
*/
import { HTMLAttributes, createElement } from 'react';
/**
* Internal dependencies
*/
type ProgressBarProps = {
className?: string;
percent?: number;
color?: string;
bgcolor?: string;
};
export const ProgressBar = ( {
className = '',
percent = 0,
color = '#674399',
bgcolor = 'var(--wp-admin-theme-color)',
}: ProgressBarProps ) => {
const containerStyles = {
backgroundColor: bgcolor,
};
const fillerStyles: HTMLAttributes< HTMLDivElement >[ 'style' ] = {
backgroundColor: color,
width: `${ percent }%`,
display: percent === 0 ? 'none' : 'inherit',
};
return (
<div className={ `woocommerce-progress-bar ${ className }` }>
<div
className="woocommerce-progress-bar__container"
style={ containerStyles }
>
<div
className="woocommerce-progress-bar__filler"
style={ fillerStyles }
/>
</div>
</div>
);
};

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { ProgressBar } from '@woocommerce/components';
import { createElement } from '@wordpress/element';
export const Basic = () => (
<div style={ { background: '#fff', height: '200px', padding: '20px' } }>
<ProgressBar percent={ 20 } bgcolor={ '#eeeeee' } color={ '#007cba' } />
</div>
);
export default {
title: 'WooCommerce Admin/components/ProgressBar',
component: ProgressBar,
};

View File

@ -0,0 +1,10 @@
.woocommerce-progress-bar__container {
height: 8px;
width: 100%;
}
// Min width equal to height. This means small values look like each other, but all bars have a consistent radius.
.woocommerce-progress-bar__filler {
height: 100%;
min-width: 8px;
}

View File

@ -59,3 +59,4 @@
@import 'experimental-select-tree-control/select-tree.scss';
@import 'product-section-layout/style.scss';
@import 'tree-select-control/index.scss';
@import 'progress-bar/style.scss';

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Update to use the template API.

View File

@ -31,6 +31,9 @@
* @package {{namespace}}
*/
use Automattic\WooCommerce\Admin\BlockTemplates\BlockTemplateInterface;
use Automattic\WooCommerce\Admin\Features\ProductBlockEditor\ProductTemplates\ProductFormTemplateInterface;
/**
* Registers the block using the metadata loaded from the `block.json` file.
* Behind the scenes, it registers also all assets so they can be enqueued
@ -45,53 +48,22 @@ function {{namespaceSnakeCase}}_{{slugSnakeCase}}_block_init() {
}
add_action( 'init', '{{namespaceSnakeCase}}_{{slugSnakeCase}}_block_init' );
function {{namespaceSnakeCase}}_{{slugSnakeCase}}_add_block_to_product_editor( $args ) {
// if the product block editor is not enabled, return the args as-is
if ( ! class_exists( '\Automattic\WooCommerce\Utilities\FeaturesUtil' ) ||
! \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) {
return $args;
}
function {{namespaceSnakeCase}}_{{slugSnakeCase}}_add_block_to_product_editor( BlockTemplateInterface $template ) {
if ( $template instanceof ProductFormTemplateInterface && 'simple-product' === $template->get_id() ) {
$basic_details = $template->get_section_by_id( 'basic-details' );
// if the template is not set or is not an array, return the args as-is
if ( ! isset( $args['template'] ) || ! is_array( $args['template'] ) ) {
return $args;
}
$template = $args['template'];
// find the 'Basic details' section and add our block to the end of it
foreach ( $template as $tab_index => $tab ) {
$tab_properties = $tab[1];
if ( 'general' === $tab_properties['id'] ) {
$tab_sections = $tab[2];
foreach ( $tab_sections as $section_index => $section ) {
$section_properties = $section[1];
// TODO: this is not the right way to do this, since it is checking a localized string.
if ( 'Basic details' === $section_properties['title'] ) {
$section_fields = $section[2];
// add our block to the end of the section
$section_fields[] = [
'{{namespace}}/{{slug}}',
[
'message' => '{{title}}',
]
];
// update the template with our new block
$args[ 'template' ][ $tab_index ][2][ $section_index ][2] = $section_fields;
break;
}
}
break;
if ( $basic_details ) {
$basic_details->add_block(
[
'id' => '{{namespace}}-{{slug}}',
'order' => 40,
'blockName' => '{{namespace}}/{{slug}}',
'attributes' => [
'message' => '{{title}}',
]
]
);
}
}
return $args;
}
add_filter( 'woocommerce_register_post_type_product', '{{namespaceSnakeCase}}_{{slugSnakeCase}}_add_block_to_product_editor', 100 );
add_filter( 'woocommerce_block_template_register', '{{namespaceSnakeCase}}_{{slugSnakeCase}}_add_block_to_product_editor', 100 );

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add tags (or general taxonomy ) block

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Update TaskItem type to include `badge` prop.

View File

@ -107,6 +107,7 @@ export {
ProductCategorySelectors,
} from './product-categories/types';
export { TaxClass } from './tax-classes/types';
export { ProductTag, Query } from './product-tags/types';
/**
* Internal dependencies

View File

@ -26,6 +26,7 @@ export type TaskType = {
eventPrefix: string;
level: number;
recordViewEvent: boolean;
badge?: string;
additionalData?: {
woocommerceTaxCountries?: string[];
taxJarActivated?: boolean;

View File

@ -8,7 +8,7 @@ import { DispatchFromMap } from '@automattic/data-stores';
*/
import { CrudActions, CrudSelectors } from '../crud/types';
type ProductTag = {
export type ProductTag = {
id: number;
slug: string;
name: string;
@ -17,7 +17,7 @@ type ProductTag = {
count: number;
};
type Query = {
export type Query = {
context?: string;
page: number;
per_page: number;

View File

@ -7,6 +7,7 @@ import { Schema } from '@wordpress/core-data';
* Internal dependencies
*/
import { ProductCategory } from '../product-categories/types';
import { ProductTag } from '../product-tags/types';
import { BaseQueryParams } from '../types';
export type ProductType = 'simple' | 'grouped' | 'external' | 'variable';
@ -120,6 +121,7 @@ export type Product< Status = ProductStatus, Type = ProductType > = Omit<
status: Status;
stock_quantity: number;
stock_status: 'instock' | 'outofstock' | 'onbackorder';
tags: Pick< ProductTag, 'id' | 'name' >[];
tax_class: 'standard' | 'reduced-rate' | 'zero-rate' | undefined;
tax_status: 'taxable' | 'shipping' | 'none';
total_sales: number;

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Update TaskItem to include a badge next to the title. Update also related components TaskList and SetupTaskList, as well as docs, storybook, and tests.

View File

@ -140,6 +140,7 @@ export const TaskItemExample: Story = ( args ) => (
}
showActionButton={ false }
title="A high-priority task without `Primary action`"
badge="Badge content"
/>
<TaskItem
action={ () => {} }

View File

@ -117,6 +117,10 @@ a.woocommerce-experimental-list__item {
color: $foreground-color-hover;
}
.woocommerce-task-list__item-badge {
background-color: $white;
}
.woocommerce-list__item-before > svg {
fill: $foreground-color-hover;
}

View File

@ -9,6 +9,7 @@ Use `TaskItem` to display a task item.
action={ () => alert( '"My action" button has been clicked' ) }
actionLabel="My action"
additionalInfo="Additional task information"
badge="Task badge"
completed={ true }
content="Task content"
expandable={ false }
@ -33,6 +34,7 @@ Use `TaskItem` to display a task item.
| `action` | Function | `null` | A function to be called when the primary action is triggered |
| `actionLabel` | String | `null` | Primary action label |
| `additionalInfo` | String | `null` | Additional task information |
| `badge` | String | `null` | Task badge to show next to title |
| `completed` | Boolean | `null` | Whether the task is completed or not |
| `content` | String | `null` | Task content |
| `expandable` | Boolean | `false` | Whether it's an expandable task |

View File

@ -45,6 +45,7 @@ type TaskItemProps = {
onDismiss?: () => void;
onSnooze?: () => void;
onExpand?: () => void;
badge?: string;
additionalInfo?: string;
time?: string;
content: string;
@ -107,6 +108,7 @@ const OptionalExpansionWrapper: React.FC< {
export const TaskItem: React.FC< TaskItemProps > = ( {
completed,
title,
badge,
onDelete,
onCollapse,
onDismiss,
@ -185,6 +187,11 @@ export const TaskItem: React.FC< TaskItemProps > = ( {
>
<span className="woocommerce-task-list__item-title">
{ title }
{ badge && (
<span className="woocommerce-task-list__item-badge">
{ badge }
</span>
) }
</span>
<OptionalExpansionWrapper
expandable={ expandable }

View File

@ -39,6 +39,20 @@ $task-alert-yellow: #f0b849;
.woocommerce-task-list__item-title {
color: $foreground-color;
display: flex;
column-gap: $gap-small;
row-gap: $gap-smallest;
flex-wrap: wrap;
}
.woocommerce-task-list__item-badge {
padding: 0 10px;
background-color: #f6f7f7;
border-radius: 2px;
color: $gray-800;
font-size: 11px;
font-weight: 500;
line-height: 20px;
}
.woocommerce-task__additional-info,

View File

@ -3,6 +3,16 @@
*/
const path = require( 'path' );
// These modules need to be transformed because they are not transpiled to CommonJS.
const transformModules = [ 'is-plain-obj', 'memize' ];
// Ignore all node_modules except for the ones we need to transform.
const transformIgnorePatterns = [
`node_modules/(?!.pnpm/${ transformModules.join(
'|.pnpm/'
) }|${ transformModules.join( '|' ) })`,
'/build/',
];
module.exports = {
moduleNameMapper: {
tinymce: path.resolve( __dirname, 'build/mocks/tinymce' ),
@ -44,12 +54,10 @@ module.exports = {
'<rootDir>/.*/build-module/',
'<rootDir>/tests/e2e/',
],
transformIgnorePatterns: [
`node_modules/(?!.pnpm/is-plain-obj|is-plain-obj)`,
'/build/',
],
transformIgnorePatterns,
transform: {
'^.+\\is-plain-obj/index\\.js$': 'babel-jest',
'^.+\\memize/dist/index\\.js$': 'babel-jest',
'^.+\\.[jt]sx?$': 'ts-jest',
},
testEnvironment: 'jest-environment-jsdom',

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add tags (or general taxonomy ) block

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Auto select one or more attribute terms when selecting an attribute

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add 'Show in product details' checkbox under Edit attribute modal

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Show sale price and list price in each variation row when the variation is on sale

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add dialogNameHelpText attribute to product-taxonomy-field block

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Remove use of head prop with IFrame component as it has been removed.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix delete variation in block product editor

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix infinite category loading state

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Rename woocommerce/taxonomy-field to woocommerce/product-taxonomy-field

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Remove __experimentalDetailsCategoriesField and woocommerce/product-category-field block

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Remove visibility toggle from variations table and remove with tooltip icon.

View File

@ -1,5 +1,4 @@
export { init as initCatalogVisibility } from './catalog-visibility';
export { init as initCategory } from './category';
export { init as initCheckbox } from './checkbox';
export { init as initCollapsible } from './collapsible';
export { init as initConditional } from './conditional';
@ -18,6 +17,7 @@ export { init as initShippingClass } from './shipping-class';
export { init as initShippingDimensions } from './shipping-dimensions';
export { init as initSummary } from './summary';
export { init as initTab } from './tab';
export { init as initTag } from './tag';
export { init as initInventoryQuantity } from './inventory-quantity';
export { init as initToggle } from './toggle';
export { init as attributesInit } from './attributes';

View File

@ -1,5 +1,4 @@
@import 'attributes/editor.scss';
@import 'category/editor.scss';
@import 'checkbox/editor.scss';
@import 'images/editor.scss';
@import 'inventory-email/editor.scss';

View File

@ -1,11 +1,11 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/product-category-field",
"title": "Product Category",
"name": "woocommerce/product-tag-field",
"title": "Product Tag",
"category": "widgets",
"description": "A field to select product categories.",
"keywords": [ "products", "category" ],
"description": "A field to select product tags.",
"keywords": [ "products", "tag" ],
"textdomain": "default",
"attributes": {
"name": {

View File

@ -5,7 +5,9 @@ import { __ } from '@wordpress/i18n';
import { createElement } from '@wordpress/element';
import { BlockAttributes } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import { ProductCategory } from '@woocommerce/data';
import { BaseControl } from '@wordpress/components';
import { ProductTag } from '@woocommerce/data';
import { useInstanceId } from '@wordpress/compose';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore No types for this exist yet.
// eslint-disable-next-line @woocommerce/dependency-group
@ -14,7 +16,7 @@ import { useEntityProp } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { CategoryField } from '../../components/details-categories-field';
import { TagField } from '../../components/tags-field';
export function Edit( {
attributes,
@ -25,21 +27,26 @@ export function Edit( {
} ) {
const blockProps = useBlockProps();
const { name, label, placeholder } = attributes;
const [ categories, setCategories ] = useEntityProp<
Pick< ProductCategory, 'name' | 'id' | 'parent' >[]
>( 'postType', context?.postType || 'product', name || 'categories' );
const [ tags, setTags ] = useEntityProp<
Pick< ProductTag, 'id' | 'name' >[]
>( 'postType', context?.postType || 'product', name || 'tags' );
const tagFieldId = useInstanceId( BaseControl, 'tag-field' ) as string;
return (
<div { ...blockProps }>
<CategoryField
label={ label || __( 'Categories', 'woocommerce' ) }
placeholder={
placeholder ||
__( 'Search or create category…', 'woocommerce' )
}
onChange={ setCategories }
value={ categories || [] }
/>
{
<TagField
id={ tagFieldId }
label={ label || __( 'Tags', 'woocommerce' ) }
placeholder={
placeholder ||
__( 'Search or create tags…', 'woocommerce' )
}
onChange={ setTags }
value={ tags || [] }
/>
}
</div>
);
}

View File

@ -1,8 +1,10 @@
.wp-block-woocommerce-product-category-field {
.wp-block-woocommerce-product-tag-field {
.woocommerce-experimental-select-tree-control__menu {
.experimental-woocommerce-tree-item {
font-size: 13px;
.components-checkbox-control__input-container, input[type='checkbox'], .components-checkbox-control__checked {
.components-checkbox-control__input-container,
input[type="checkbox"],
.components-checkbox-control__checked {
width: 20px;
height: 20px;
}

View File

@ -1,4 +1,4 @@
# woocommerce/taxonomy-field block
# woocommerce/product-taxonomy-field block
This is a block that displays a taxonomy field, allowing searching, selection, and creation of new items, to be used in a product context.

View File

@ -1,7 +1,7 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/taxonomy-field",
"name": "woocommerce/product-taxonomy-field",
"title": "Taxonomy",
"category": "widgets",
"description": "A block that displays a taxonomy field, allowing searching, selection, and creation of new items",
@ -23,6 +23,14 @@
"createTitle": {
"type": "string",
"__experimentalRole": "content"
},
"dialogNameHelpText": {
"type": "string",
"__experimentalRole": "content"
},
"parentTaxonomyText": {
"type": "string",
"__experimentalRole": "content"
}
},
"supports": {

View File

@ -26,6 +26,8 @@ import useTaxonomySearch from './use-taxonomy-search';
type CreateTaxonomyModalProps = {
initialName?: string;
dialogNameHelpText?: string;
parentTaxonomyText?: string;
hierarchical: boolean;
slug: string;
title: string;
@ -39,6 +41,8 @@ export const CreateTaxonomyModal: React.FC< CreateTaxonomyModalProps > = ( {
initialName,
slug,
hierarchical,
dialogNameHelpText,
parentTaxonomyText,
title,
} ) => {
const [ categoryParentTypedValue, setCategoryParentTypedValue ] =
@ -67,6 +71,7 @@ export const CreateTaxonomyModal: React.FC< CreateTaxonomyModalProps > = ( {
const onSave = async () => {
setErrorMessage( null );
setIsCreating( true );
try {
const newTaxonomy: Taxonomy = await saveEntityRecord(
'taxonomy',
@ -79,6 +84,7 @@ export const CreateTaxonomyModal: React.FC< CreateTaxonomyModalProps > = ( {
throwOnError: true,
}
);
setIsCreating( false );
onCreate( newTaxonomy );
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch ( e: any ) {
@ -111,7 +117,7 @@ export const CreateTaxonomyModal: React.FC< CreateTaxonomyModalProps > = ( {
<BaseControl
id={ id }
label={ __( 'Name', 'woocommerce' ) }
help={ errorMessage }
help={ errorMessage || dialogNameHelpText }
className={ classNames( {
'has-error': errorMessage,
} ) }
@ -126,10 +132,13 @@ export const CreateTaxonomyModal: React.FC< CreateTaxonomyModalProps > = ( {
<SelectTree
isLoading={ isResolving }
label={ createInterpolateElement(
__( 'Parent <optional/>', 'woocommerce' ),
`${
parentTaxonomyText ||
__( 'Parent', 'woocommerce' )
} <optional/>`,
{
optional: (
<span className="woocommerce-product-form__optional-input">
<span className="woocommerce-create-new-taxonomy-modal__optional">
{ __( '(optional)', 'woocommerce' ) }
</span>
),
@ -171,19 +180,19 @@ export const CreateTaxonomyModal: React.FC< CreateTaxonomyModalProps > = ( {
) }
<div className="woocommerce-create-new-taxonomy-modal__buttons">
<Button
isSecondary
variant="tertiary"
onClick={ onCancel }
disabled={ isCreating }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<Button
isPrimary
variant="primary"
disabled={ name.length === 0 || isCreating }
isBusy={ isCreating }
onClick={ onSave }
>
{ __( 'Save', 'woocommerce' ) }
{ __( 'Create', 'woocommerce' ) }
</Button>
</div>
</div>

View File

@ -28,6 +28,8 @@ interface TaxonomyBlockAttributes extends BlockAttributes {
slug: string;
property: string;
createTitle: string;
dialogNameHelpText?: string;
parentTaxonomyText?: string;
}
export function Edit( {
@ -42,7 +44,14 @@ export function Edit( {
hierarchical: false,
}
);
const { label, slug, property, createTitle } = attributes;
const {
label,
slug,
property,
createTitle,
dialogNameHelpText,
parentTaxonomyText,
} = attributes;
const [ searchValue, setSearchValue ] = useState( '' );
const [ allEntries, setAllEntries ] = useState< Taxonomy[] >( [] );
@ -162,6 +171,8 @@ export function Edit( {
slug={ slug }
hierarchical={ hierarchical }
title={ createTitle }
dialogNameHelpText={ dialogNameHelpText }
parentTaxonomyText={ parentTaxonomyText }
onCancel={ () => setShowCreateNewModal( false ) }
onCreate={ ( taxonomy ) => {
setShowCreateNewModal( false );

View File

@ -10,7 +10,21 @@
gap: $gap-smaller;
justify-content: flex-end;
}
&__optional {
color: $gray-700;
}
}
.components-base-control {
margin-bottom: $gap;
&__field {
.components-base-control {
margin-bottom: 0;
}
}
}
.has-error {
.components-text-control__input {
border-color: $studio-red-50;

View File

@ -69,7 +69,7 @@ const useTaxonomySearch = (
taxonomyName
);
}
} catch ( e ) {
} finally {
setIsSearching( false );
}
return taxonomies;

View File

@ -120,6 +120,7 @@ export function Edit() {
disabledAttributeIds={ entityAttributes
.filter( ( attr ) => ! attr.variation )
.map( ( attr ) => attr.id ) }
termsAutoSelection="all"
uiStrings={ {
notice,
globalAttributeHelperMessage: '',

View File

@ -158,6 +158,7 @@ export function Edit( {
disabledAttributeIds={ productAttributes
.filter( ( attr ) => ! attr.variation )
.map( ( attr ) => attr.id ) }
termsAutoSelection="all"
/>
) }
</div>

View File

@ -50,6 +50,8 @@ type AttributeControlProps = {
createNewAttributesAsGlobal?: boolean;
useRemoveConfirmationModal?: boolean;
disabledAttributeIds?: number[];
termsAutoSelection?: 'first' | 'all';
defaultVisibility?: boolean;
uiStrings?: {
notice?: string | React.ReactElement;
emptyStateSubtitle?: string;
@ -84,6 +86,8 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
createNewAttributesAsGlobal = false,
useRemoveConfirmationModal = false,
disabledAttributeIds = [],
termsAutoSelection,
defaultVisibility = false,
} ) => {
uiStrings = {
newAttributeListItemLabel: __( 'Add new', 'woocommerce' ),
@ -307,6 +311,8 @@ export const AttributeControl: React.FC< AttributeControlProps > = ( {
disabledAttributeMessage={
uiStrings.disabledAttributeMessage
}
termsAutoSelection={ termsAutoSelection }
defaultVisibility={ defaultVisibility }
/>
) }
<SelectControlMenuSlot />

View File

@ -65,9 +65,9 @@ export const EditAttributeModal: React.FC< EditAttributeModalProps > = ( {
'Check to allow customers to search and filter by this option in your store.',
'woocommerce'
),
visibleLabel = __( 'Visible to customers', 'woocommerce' ),
visibleLabel = __( 'Show in product details', 'woocommerce' ),
visibleTooltip = __(
'Show or hide this attribute on the product page',
'Check to show this option and its values in the product details section on the product page.',
'woocommerce'
),
cancelAccessibleLabel = __( 'Cancel', 'woocommerce' ),

View File

@ -8,12 +8,17 @@ import {
Fragment,
useEffect,
} from '@wordpress/element';
import { resolveSelect } from '@wordpress/data';
import { trash } from '@wordpress/icons';
import {
Form,
__experimentalSelectControlMenuSlot as SelectControlMenuSlot,
} from '@woocommerce/components';
import { ProductAttributeTerm } from '@woocommerce/data';
import {
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME,
ProductAttribute,
ProductAttributeTerm,
} from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import {
Button,
@ -57,6 +62,8 @@ type NewAttributeModalProps = {
createNewAttributesAsGlobal?: boolean;
disabledAttributeIds?: number[];
disabledAttributeMessage?: string;
termsAutoSelection?: 'first' | 'all';
defaultVisibility?: boolean;
};
type AttributeForm = {
@ -95,6 +102,8 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
'Already used in Attributes',
'woocommerce'
),
termsAutoSelection,
defaultVisibility = false,
} ) => {
const scrollAttributeIntoView = ( index: number ) => {
setTimeout( () => {
@ -152,7 +161,7 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
};
const getVisibleOrTrue = ( attribute: EnhancedProductAttribute ) =>
attribute.visible !== undefined ? attribute.visible : true;
attribute.visible !== undefined ? attribute.visible : defaultVisibility;
const onAddingAttributes = ( values: AttributeForm ) => {
const newAttributesToAdd: EnhancedProductAttribute[] = [];
@ -242,6 +251,61 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setValue: ( name: string, value: any ) => void;
} ) => {
function getAttributeOnChange( index: number ) {
return function handleAttributeChange(
value?:
| Omit<
ProductAttribute,
'position' | 'visible' | 'variation'
>
| string
) {
if (
termsAutoSelection &&
value &&
! ( typeof value === 'string' )
) {
resolveSelect(
EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME
)
.getProductAttributeTerms<
ProductAttributeTerm[]
>( {
// Send search parameter as empty to avoid a second
// request when focusing the attribute-term-input-field
// which perform the same request to get all the terms
search: '',
attribute_id: value.id,
} )
.then( ( terms ) => {
const selectedAttribute =
getProductAttributeObject(
value
) as EnhancedProductAttribute;
if ( termsAutoSelection === 'all' ) {
selectedAttribute.terms = terms;
} else if ( terms.length > 0 ) {
selectedAttribute.terms = [
terms[ 0 ],
];
}
setValue( 'attributes[' + index + ']', {
...selectedAttribute,
} );
focusValueField( index );
} );
} else {
setValue(
'attributes[' + index + ']',
value && getProductAttributeObject( value )
);
if ( value ) {
focusValueField( index );
}
}
};
}
return (
<Modal
title={ title }
@ -289,24 +353,9 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
label={
attributeLabel
}
onChange={ (
val
) => {
setValue(
'attributes[' +
index +
']',
val &&
getProductAttributeObject(
val
)
);
if ( val ) {
focusValueField(
index
);
}
} }
onChange={ getAttributeOnChange(
index
) }
ignoredAttributeIds={ [
...selectedAttributeIds,
...values.attributes
@ -336,7 +385,7 @@ export const NewAttributeModal: React.FC< NewAttributeModalProps > = ( {
/>
</td>
<td className="woocommerce-new-attribute-modal__table-attribute-value-column">
{ attribute === null ||
{ ! attribute ||
attribute.id !== 0 ? (
<AttributeTermInputField
placeholder={

View File

@ -12,9 +12,9 @@ import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import HiddenIcon from '../variations-table/hidden-icon';
import HelpIcon from './help-icon';
import HelpIcon from '../../icons/help-icon';
import NotFilterableIcon from './not-filterable-icon';
import HiddenWithHelpIcon from '../../icons/hidden-with-help-icon';
type AttributeListItemProps = {
attribute: ProductAttribute;
@ -89,10 +89,7 @@ export const AttributeListItem: React.FC< AttributeListItemProps > = ( {
position="top center"
text={ NOT_VISIBLE_TEXT }
>
<div className="woocommerce-attribute-list-item__actions-icon-wrapper">
<HiddenIcon className="woocommerce-attribute-list-item__actions-icon-wrapper-icon" />
<HelpIcon className="woocommerce-attribute-list-item__actions-icon-wrapper-help-icon" />
</div>
<HiddenWithHelpIcon />
</Tooltip>
) }
{ typeof onEditClick === 'function' && (

View File

@ -67,6 +67,8 @@ export const Attributes: React.FC< AttributesProps > = ( {
'product_remove_attribute_confirmation_cancel_click'
)
}
termsAutoSelection="first"
defaultVisibility={ true }
/>
);
};

View File

@ -67,26 +67,22 @@ export function ContentPreview( { content }: ContentPreviewProps ) {
return (
<div className="woocommerce-content-preview">
<Iframe
head={
<>
<EditorStyles styles={ parentEditorSettings?.styles } />
<style>
{ `body {
<Iframe className="woocommerce-content-preview__iframe">
<>
<EditorStyles styles={ parentEditorSettings?.styles } />
<style>
{ `body {
overflow: hidden;
}` }
</style>
</>
}
className="woocommerce-content-preview__iframe"
>
<div
className="woocommerce-content-preview__content"
dangerouslySetInnerHTML={ sanitizeHTML( content, {
tags: CONTENT_TAGS,
attr: CONTENT_ATTR,
} ) }
/>
</style>
<div
className="woocommerce-content-preview__content"
dangerouslySetInnerHTML={ sanitizeHTML( content, {
tags: CONTENT_TAGS,
attr: CONTENT_ATTR,
} ) }
/>
</>
</Iframe>
</div>
);

View File

@ -1,48 +0,0 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { Icon } from '@wordpress/components';
import { plus } from '@wordpress/icons';
import classNames from 'classnames';
import { ProductCategory } from '@woocommerce/data';
import { __experimentalSelectControlMenuItemProps as MenuItemProps } from '@woocommerce/components';
import { createElement } from '@wordpress/element';
type CategoryFieldAddNewItemProps = {
item: Pick< ProductCategory, 'id' | 'name' >;
highlightedIndex: number;
items: Pick< ProductCategory, 'id' | 'name' >[];
} & Pick<
MenuItemProps< Pick< ProductCategory, 'id' | 'name' > >,
'getItemProps'
>;
export const CategoryFieldAddNewItem: React.FC<
CategoryFieldAddNewItemProps
> = ( { item, highlightedIndex, getItemProps, items } ) => {
const index = items.findIndex( ( i ) => i.id === item.id );
return (
<li
{ ...getItemProps( {
item,
index,
} ) }
className={ classNames(
'woocommerce-category-field-dropdown__item is-new',
{
item_highlighted: highlightedIndex === index,
}
) }
>
<div className="woocommerce-category-field-dropdown__item-content">
<Icon
className="category-field-dropdown__toggle"
icon={ plus }
size={ 20 }
/>
{ sprintf( __( 'Create "%s"', 'woocommerce' ), item.name ) }
</div>
</li>
);
};

View File

@ -1,121 +0,0 @@
/**
* External dependencies
*/
import { CheckboxControl, Icon } from '@wordpress/components';
import { useEffect, useState, createElement } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import { chevronDown, chevronUp } from '@wordpress/icons';
import { ProductCategory } from '@woocommerce/data';
import { __experimentalSelectControlMenuItemProps as MenuItemProps } from '@woocommerce/components';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import { ProductCategoryNode } from './use-category-search';
export type CategoryTreeItem = {
data: ProductCategory;
children: CategoryTreeItem[];
parentID: number;
isOpen: boolean;
};
type CategoryFieldItemProps = {
item: CategoryTreeItem;
selectedIds: number[];
items: ProductCategoryNode[];
highlightedIndex: number;
openParent?: () => void;
} & Pick< MenuItemProps< ProductCategoryNode >, 'getItemProps' >;
export const CategoryFieldItem: React.FC< CategoryFieldItemProps > = ( {
item,
selectedIds = [],
items,
highlightedIndex,
openParent,
getItemProps,
} ) => {
const [ isOpen, setIsOpen ] = useState( item.isOpen || false );
const index = items.findIndex( ( i ) => i.id === item.data.id );
const children = item.children.filter( ( child ) =>
items.includes( child.data )
);
useEffect( () => {
if ( highlightedIndex === index && children.length > 0 && ! isOpen ) {
setIsOpen( true );
} else if ( highlightedIndex === index && openParent ) {
// Make sure the parent is also open when the item is highlighted.
openParent();
}
}, [ highlightedIndex ] );
useEffect( () => {
if ( item.isOpen !== isOpen ) {
setIsOpen( item.isOpen );
}
}, [ item.isOpen ] );
return (
<li
className={ classNames(
'woocommerce-category-field-dropdown__item',
{
item_highlighted: index === highlightedIndex,
}
) }
>
<div
className="woocommerce-category-field-dropdown__item-content"
{ ...getItemProps( {
item: item.data,
index,
} ) }
>
{ children.length > 0 ? (
<Icon
className="woocommerce-category-field-dropdown__toggle"
icon={ isOpen ? chevronUp : chevronDown }
size={ 20 }
onClick={ ( e: React.MouseEvent ) => {
e.stopPropagation();
setIsOpen( ! isOpen );
} }
/>
) : (
<div className="woocommerce-category-field-dropdown__toggle-placeholder"></div>
) }
<CheckboxControl
label={ decodeEntities( item.data.name ) }
checked={ selectedIds.includes( item.data.id ) }
onChange={ () => item.data }
/>
</div>
{ children.length > 0 ? (
<ul
className={ classNames(
'woocommerce-category-field-dropdown__item-children',
{
'woocommerce-category-field-dropdown__item-open':
isOpen,
}
) }
>
{ children.map( ( child ) => (
<CategoryFieldItem
key={ child.data.id }
item={ child }
selectedIds={ selectedIds }
items={ items }
highlightedIndex={ highlightedIndex }
openParent={ () => ! isOpen && setIsOpen( true ) }
getItemProps={ getItemProps }
/>
) ) }
</ul>
) : null }
</li>
);
};

View File

@ -1,196 +0,0 @@
/**
* External dependencies
*/
import { useMemo, useState, createElement, Fragment } from '@wordpress/element';
import {
TreeItemType,
__experimentalSelectTreeControl as SelectTree,
} from '@woocommerce/components';
import { ProductCategory } from '@woocommerce/data';
import { debounce } from 'lodash';
/**
* Internal dependencies
*/
import { CategoryTreeItem } from './category-field-item';
import { useCategorySearch, ProductCategoryNode } from './use-category-search';
import { CreateCategoryModal } from './create-category-modal';
type CategoryFieldProps = {
label: string;
placeholder: string;
value?: ProductCategoryNode[];
onChange: ( value: ProductCategoryNode[] ) => void;
};
/**
* Recursive function that adds the current item to the selected list and all it's parents
* if not included already.
*/
function getSelectedWithParents(
selected: ProductCategoryNode[] = [],
item: ProductCategory,
treeKeyValues: Record< number, CategoryTreeItem >
): ProductCategoryNode[] {
selected.push( { id: item.id, name: item.name, parent: item.parent } );
const parentId =
item.parent !== undefined
? item.parent
: treeKeyValues[ item.id ].parentID;
if (
parentId > 0 &&
treeKeyValues[ parentId ] &&
! selected.find(
( selectedCategory ) => selectedCategory.id === parentId
)
) {
getSelectedWithParents(
selected,
treeKeyValues[ parentId ].data,
treeKeyValues
);
}
return selected;
}
export function mapFromCategoryToTreeItem(
val: ProductCategoryNode
): TreeItemType {
return val.parent
? {
value: String( val.id ),
label: val.name,
parent: String( val.parent ),
}
: {
value: String( val.id ),
label: val.name,
};
}
export function mapFromTreeItemToCategory(
val: TreeItemType
): ProductCategoryNode {
return {
id: +val.value,
name: val.label,
parent: val.parent ? +val.parent : 0,
};
}
export function mapFromCategoriesToTreeItems(
categories: ProductCategoryNode[]
): TreeItemType[] {
return categories.map( mapFromCategoryToTreeItem );
}
export function mapFromTreeItemsToCategories(
categories: TreeItemType[]
): ProductCategoryNode[] {
return categories.map( mapFromTreeItemToCategory );
}
export const CategoryField: React.FC< CategoryFieldProps > = ( {
label,
placeholder,
value = [],
onChange,
} ) => {
const {
isSearching,
categoriesSelectList,
categoryTreeKeyValues,
searchCategories,
getFilteredItemsForSelectTree,
} = useCategorySearch();
const [ showCreateNewModal, setShowCreateNewModal ] = useState( false );
const [ searchValue, setSearchValue ] = useState( '' );
const onInputChange = ( searchString?: string ) => {
setSearchValue( searchString || '' );
searchCategories( searchString || '' );
};
const searchDelayed = useMemo(
() => debounce( onInputChange, 150 ),
[ onInputChange ]
);
return (
<>
<SelectTree
id="category-field"
multiple
shouldNotRecursivelySelect
createValue={ searchValue }
label={ label }
isLoading={ isSearching }
onInputChange={ searchDelayed }
placeholder={ value.length === 0 ? placeholder : '' }
onCreateNew={ () => {
setShowCreateNewModal( true );
} }
shouldShowCreateButton={ ( typedValue ) =>
! typedValue ||
categoriesSelectList.findIndex(
( item ) => item.name === typedValue
) === -1
}
items={ getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems( categoriesSelectList ),
searchValue,
mapFromCategoriesToTreeItems( value )
) }
selected={ mapFromCategoriesToTreeItems( value ) }
onSelect={ ( selectedItems ) => {
if ( Array.isArray( selectedItems ) ) {
const newItems: ProductCategoryNode[] =
mapFromTreeItemsToCategories(
selectedItems.filter(
( { value: selectedItemValue } ) =>
! value.some(
( item ) =>
item.id === +selectedItemValue
)
)
);
onChange( [ ...value, ...newItems ] );
}
} }
onRemove={ ( removedItems ) => {
const newValues = Array.isArray( removedItems )
? value.filter(
( item ) =>
! removedItems.some(
( { value: removedValue } ) =>
item.id === +removedValue
)
)
: value.filter(
( item ) => item.id !== +removedItems.value
);
onChange( newValues );
} }
></SelectTree>
{ showCreateNewModal && (
<CreateCategoryModal
initialCategoryName={ searchValue }
onCancel={ () => setShowCreateNewModal( false ) }
onCreate={ ( newCategory ) => {
onChange(
getSelectedWithParents(
[ ...value ],
newCategory,
categoryTreeKeyValues
)
);
setShowCreateNewModal( false );
onInputChange( '' );
} }
/>
) }
</>
);
};

View File

@ -1,34 +0,0 @@
.woocommerce-create-new-category-modal {
min-width: 650px;
overflow: visible;
&__buttons {
margin-top: $gap-larger;
display: flex;
flex-direction: row;
gap: $gap-smaller;
justify-content: flex-end;
}
.category-field-dropdown {
&__menu {
padding: 0;
border: none;
}
}
}
.woocommerce-select-control__popover-menu {
margin-top: -$gap-small;
}
.woocommerce-select-control__popover-menu-container {
max-height: 300px;
overflow-y: scroll;
padding: 0 $gap-smaller 0 $gap-small;
> .category-field-dropdown__item:not(:first-child) {
.category-field-dropdown__item-content {
border-top: 1px solid $gray-200;
}
}
}

View File

@ -1,162 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Modal, TextControl } from '@wordpress/components';
import { useDebounce } from '@wordpress/compose';
import {
useState,
createElement,
createInterpolateElement,
} from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import {
__experimentalSelectTreeControl as SelectTree,
TreeItemType as Item,
} from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import {
EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME,
ProductCategory,
} from '@woocommerce/data';
/**
* Internal dependencies
*/
import { ProductCategoryNode, useCategorySearch } from './use-category-search';
import {
mapFromCategoriesToTreeItems,
mapFromCategoryToTreeItem,
} from './category-field';
import { TRACKS_SOURCE } from '../../constants';
type CreateCategoryModalProps = {
initialCategoryName?: string;
onCancel: () => void;
onCreate: ( newCategory: ProductCategory ) => void;
};
export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
initialCategoryName,
onCancel,
onCreate,
} ) => {
const {
categoriesSelectList,
isSearching,
searchCategories,
getFilteredItemsForSelectTree,
} = useCategorySearch();
const { createNotice } = useDispatch( 'core/notices' );
const [ isCreating, setIsCreating ] = useState( false );
const { createProductCategory, invalidateResolutionForStoreSelector } =
useDispatch( EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME );
const [ categoryName, setCategoryName ] = useState(
initialCategoryName || ''
);
const [ categoryParent, setCategoryParent ] =
useState< ProductCategoryNode | null >( null );
const [ categoryParentTypedValue, setCategoryParentTypedValue ] =
useState( '' );
const onSave = async () => {
recordEvent( 'product_category_add', {
source: TRACKS_SOURCE,
} );
setIsCreating( true );
try {
const newCategory: ProductCategory = await createProductCategory( {
name: categoryName,
parent: categoryParent ? categoryParent.id : undefined,
} );
invalidateResolutionForStoreSelector( 'getProductCategories' );
setIsCreating( false );
onCreate( newCategory );
} catch ( e ) {
createNotice(
'error',
__( 'Failed to create category.', 'woocommerce' )
);
setIsCreating( false );
onCancel();
}
};
const debouncedSearch = useDebounce( searchCategories, 250 );
return (
<Modal
title={ __( 'Create category', 'woocommerce' ) }
onRequestClose={ () => onCancel() }
className="woocommerce-create-new-category-modal"
>
<div className="woocommerce-create-new-category-modal__wrapper">
<TextControl
label={ __( 'Name', 'woocommerce' ) }
name="Tops"
value={ categoryName }
onChange={ setCategoryName }
/>
<SelectTree
label={ createInterpolateElement(
__( 'Parent category <optional/>', 'woocommerce' ),
{
optional: (
<span className="woocommerce-product-form__optional-input">
{ __( '(optional)', 'woocommerce' ) }
</span>
),
}
) }
id="parent-category-field"
isLoading={ isSearching }
items={ getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems( categoriesSelectList ),
categoryParentTypedValue,
[]
) }
shouldNotRecursivelySelect
selected={
categoryParent
? mapFromCategoryToTreeItem( categoryParent )
: undefined
}
onSelect={ ( item: Item ) =>
item &&
setCategoryParent( {
id: +item.value,
name: item.label,
parent: item.parent ? +item.parent : 0,
} )
}
onRemove={ () => setCategoryParent( null ) }
onInputChange={ ( value ) => {
debouncedSearch( value );
setCategoryParentTypedValue( value || '' );
} }
createValue={ categoryParentTypedValue }
/>
<div className="woocommerce-create-new-category-modal__buttons">
<Button
isSecondary
onClick={ () => onCancel() }
disabled={ isCreating }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<Button
isPrimary
disabled={ categoryName.length === 0 || isCreating }
isBusy={ isCreating }
onClick={ () => {
onSave();
} }
>
{ __( 'Save', 'woocommerce' ) }
</Button>
</div>
</div>
</Modal>
);
};

View File

@ -1,25 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useFormContext } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { CategoryField } from './category-field';
import { ProductCategoryNode } from './use-category-search';
export const DetailsCategoriesField = () => {
const { getInputProps } = useFormContext< Product >();
return (
<CategoryField
label={ __( 'Categories', 'woocommerce' ) }
placeholder={ __( 'Search or create category…', 'woocommerce' ) }
{ ...getInputProps< ProductCategoryNode[] >( 'categories' ) }
/>
);
};

View File

@ -1,2 +0,0 @@
export * from './details-categories-field';
export * from './category-field';

View File

@ -1,413 +0,0 @@
/**
* External dependencies
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useSelect, resolveSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import { useCategorySearch } from '../use-category-search';
import { mapFromCategoriesToTreeItems } from '../../details-categories-field/category-field';
jest.mock( '@wordpress/data', () => ( {
...jest.requireActual( '@wordpress/data' ),
useSelect: jest.fn(),
resolveSelect: jest.fn(),
} ) );
const mockCategoryList = [
{ id: 1, name: 'Clothing', parent: 0, count: 0 },
{ id: 2, name: 'Hoodies', parent: 1, count: 0 },
{ id: 4, name: 'Accessories', parent: 1, count: 0 },
{ id: 5, name: 'Belts', parent: 4, count: 0 },
{ id: 3, name: 'Rain gear', parent: 0, count: 0 },
{ id: 6, name: 'Furniture', parent: 0, count: 0 },
];
describe( 'useCategorySearch', () => {
const getProductCategoriesMock = jest
.fn()
.mockReturnValue( [ ...mockCategoryList ] );
const getProductCategoriesTotalCountMock = jest
.fn()
.mockReturnValue( mockCategoryList.length );
const getProductCategoriesResolveMock = jest.fn();
beforeEach( () => {
jest.clearAllMocks();
( useSelect as jest.Mock ).mockImplementation( ( callback ) => {
return callback( () => ( {
getProductCategories: getProductCategoriesMock,
getProductCategoriesTotalCount:
getProductCategoriesTotalCountMock,
} ) );
} );
( resolveSelect as jest.Mock ).mockImplementation( () => ( {
getProductCategories: getProductCategoriesResolveMock,
} ) );
} );
it( 'should retrieve an initial list of product categories and generate a tree', async () => {
getProductCategoriesMock.mockReturnValue( undefined );
getProductCategoriesTotalCountMock.mockReturnValue(
mockCategoryList.length
);
const { result, rerender, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
act( () => {
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
rerender();
} );
await waitForNextUpdate();
expect( result.current.categoriesSelectList.length ).toEqual(
mockCategoryList.length
);
expect( result.current.categories.length ).toEqual(
mockCategoryList.filter( ( c ) => c.parent === 0 ).length
);
} );
it( 'should return a correct tree for categories with each item containing a childrens property', async () => {
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
getProductCategoriesTotalCountMock.mockReturnValue(
mockCategoryList.length
);
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
const clothing = result.current.categories.find(
( cat ) => cat.data.name === 'Clothing'
);
expect( clothing?.children[ 0 ].data.name ).toEqual( 'Accessories' );
expect( clothing?.children[ 0 ].children[ 0 ].data.name ).toEqual(
'Belts'
);
} );
it( 'should sort items by count first and then alphabetical', async () => {
getProductCategoriesMock.mockReturnValue( [
...mockCategoryList,
{ id: 12, name: 'BB', parent: 0, count: 0 },
{ id: 13, name: 'AA', parent: 0, count: 0 },
{ id: 11, name: 'ZZZ', parent: 0, count: 20 },
] );
getProductCategoriesTotalCountMock.mockReturnValue(
mockCategoryList.length
);
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
expect( result.current.categoriesSelectList[ 0 ].name ).toEqual(
'ZZZ'
);
expect( result.current.categoriesSelectList[ 1 ].name ).toEqual( 'AA' );
expect( result.current.categoriesSelectList[ 2 ].name ).toEqual( 'BB' );
} );
it( 'should also sort children by count first and then alphabetical', async () => {
getProductCategoriesMock.mockReturnValue( [
...mockCategoryList,
{ id: 12, name: 'AB', parent: 1, count: 0 },
{ id: 13, name: 'AA', parent: 1, count: 0 },
{ id: 11, name: 'ZZZ', parent: 1, count: 20 },
] );
getProductCategoriesTotalCountMock.mockReturnValue(
mockCategoryList.length
);
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
const clothing = result.current.categories.find(
( cat ) => cat.data.name === 'Clothing'
);
expect( clothing?.children[ 0 ].data.name ).toEqual( 'ZZZ' );
expect( clothing?.children[ 1 ].data.name ).toEqual( 'AA' );
expect( clothing?.children[ 2 ].data.name ).toEqual( 'AB' );
} );
it( 'should order the select list by parent, child, nested child, parent', async () => {
getProductCategoriesMock.mockReturnValue( [
...mockCategoryList,
{ id: 13, name: 'AA', parent: 1, count: 0 },
{ id: 11, name: 'ZZ', parent: 1, count: 20 },
] );
getProductCategoriesTotalCountMock.mockReturnValue(
mockCategoryList.length
);
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
expect( result.current.categoriesSelectList[ 0 ].name ).toEqual(
'Clothing'
);
// child of clothing.
expect( result.current.categoriesSelectList[ 1 ].name ).toEqual( 'ZZ' );
expect( result.current.categoriesSelectList[ 2 ].name ).toEqual( 'AA' );
expect( result.current.categoriesSelectList[ 3 ].name ).toEqual(
'Accessories'
);
// child of accessories.
expect( result.current.categoriesSelectList[ 4 ].name ).toEqual(
'Belts'
);
// child of clothing.
expect( result.current.categoriesSelectList[ 5 ].name ).toEqual(
'Hoodies'
);
// top level.
expect( result.current.categoriesSelectList[ 6 ].name ).toEqual(
'Furniture'
);
} );
describe( 'getFilteredItemsForSelectTree', () => {
it( 'should filter items by label, matching input value, and if selected', async () => {
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
getProductCategoriesTotalCountMock.mockReturnValue(
mockCategoryList.length
);
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
const filteredItems = result.current.getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems(
result.current.categoriesSelectList
),
'Rain',
[]
);
expect( filteredItems.length ).toEqual( 1 );
expect( filteredItems[ 0 ].label ).toEqual( 'Rain gear' );
} );
it( 'should filter items by isOpen as well, keeping them if isOpen is true', async () => {
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
getProductCategoriesTotalCountMock.mockReturnValue(
mockCategoryList.length
);
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
act( () => {
result.current.searchCategories( 'Bel' );
} );
await waitForNextUpdate();
expect( result.current.categoriesSelectList.length ).toEqual( 6 );
const filteredItems = result.current.getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems(
result.current.categoriesSelectList
),
'Bel',
[]
);
expect( filteredItems.length ).toEqual( 3 );
expect( filteredItems[ 0 ].label ).toEqual( 'Clothing' );
expect( filteredItems[ 1 ].label ).toEqual( 'Accessories' );
expect( filteredItems[ 2 ].label ).toEqual( 'Belts' );
} );
} );
describe( 'searchCategories', () => {
it( 'should not use async when total categories is less then page size', async () => {
getProductCategoriesResolveMock.mockClear();
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
getProductCategoriesTotalCountMock.mockReturnValue(
mockCategoryList.length
);
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
act( () => {
result.current.searchCategories( 'Clo' );
} );
await waitForNextUpdate();
expect( getProductCategoriesResolveMock ).not.toHaveBeenCalled();
} );
it( 'should use async when total categories is more then page size', async () => {
getProductCategoriesResolveMock
.mockClear()
.mockResolvedValue(
mockCategoryList.filter( ( c ) => c.name === 'Clothing' )
);
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
act( () => {
result.current.searchCategories( 'Clo' );
} );
await waitForNextUpdate();
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
search: 'Clo',
per_page: 100,
} );
expect( result.current.categoriesSelectList.length ).toEqual( 1 );
} );
it( 'should update isSearching when async is enabled', async () => {
let finish: () => void = () => {};
getProductCategoriesResolveMock.mockClear().mockReturnValue(
new Promise( ( resolve ) => {
finish = () =>
resolve(
mockCategoryList.filter(
( c ) => c.name === 'Clothing'
)
);
} )
);
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
act( () => {
result.current.searchCategories( 'Clo' );
} );
expect( result.current.isSearching ).toBe( true );
act( () => {
finish();
} );
await waitForNextUpdate();
expect( result.current.isSearching ).toBe( false );
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
search: 'Clo',
per_page: 100,
} );
expect( result.current.categoriesSelectList.length ).toEqual( 1 );
} );
it( 'should set isSearching back to false if search failed and keep last results', async () => {
let finish: () => void = () => {};
getProductCategoriesResolveMock.mockClear().mockReturnValue(
new Promise( ( resolve, reject ) => {
finish = () => reject();
} )
);
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
act( () => {
result.current.searchCategories( 'Clo' );
} );
expect( result.current.isSearching ).toBe( true );
act( () => {
finish();
} );
await waitForNextUpdate();
expect( result.current.isSearching ).toBe( false );
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
search: 'Clo',
per_page: 100,
} );
expect( result.current.categoriesSelectList.length ).toEqual( 6 );
} );
it( 'should keep parent in the list if only child matches search value', async () => {
getProductCategoriesResolveMock
.mockClear()
.mockResolvedValue( [
mockCategoryList.find( ( c ) => c.name === 'Hoodies' ),
] );
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
act( () => {
result.current.searchCategories( 'Hood' );
} );
await waitForNextUpdate();
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
search: 'Hood',
per_page: 100,
} );
expect( result.current.categoriesSelectList.length ).toEqual( 2 );
expect( result.current.categoriesSelectList[ 0 ].name ).toEqual(
'Clothing'
);
expect( result.current.categoriesSelectList[ 1 ].name ).toEqual(
'Hoodies'
);
} );
it( 'should set parent isOpen to true if child matches search value', async () => {
getProductCategoriesResolveMock
.mockClear()
.mockResolvedValue( [
mockCategoryList.find( ( c ) => c.name === 'Hoodies' ),
] );
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
getProductCategoriesTotalCountMock.mockReturnValue( 200 );
const { result, waitForNextUpdate } = renderHook( () =>
useCategorySearch()
);
await waitForNextUpdate();
act( () => {
result.current.searchCategories( 'Hood' );
} );
await waitForNextUpdate();
expect( getProductCategoriesResolveMock ).toHaveBeenCalledWith( {
search: 'Hood',
per_page: 100,
} );
expect( result.current.categories[ 0 ].isOpen ).toEqual( true );
} );
} );
} );

View File

@ -1,286 +0,0 @@
/**
* External dependencies
*/
import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import { useSelect, resolveSelect } from '@wordpress/data';
import {
EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME,
WCDataSelector,
ProductCategory,
} from '@woocommerce/data';
import { escapeRegExp } from 'lodash';
import { TreeItemType } from '@woocommerce/components';
/**
* Internal dependencies
*/
import { CategoryTreeItem } from './category-field-item';
const PAGE_SIZE = 100;
const parentCategoryCache: Record< number, ProductCategory > = {};
/**
* Recursive function to set isOpen to true for all the childrens parents.
*/
function openParents(
treeList: Record< number, CategoryTreeItem >,
item: CategoryTreeItem
) {
if ( treeList[ item.parentID ] ) {
treeList[ item.parentID ].isOpen = true;
if ( treeList[ item.parentID ].parentID !== 0 ) {
openParents( treeList, treeList[ item.parentID ] );
}
}
}
export type ProductCategoryNode = Pick<
ProductCategory,
'id' | 'name' | 'parent'
>;
/**
* Sort function for category tree items, sorts by popularity and then alphabetically.
*/
export const sortCategoryTreeItems = (
menuItems: CategoryTreeItem[]
): CategoryTreeItem[] => {
return menuItems.sort( ( a, b ) => {
if ( a.data.count === b.data.count ) {
return a.data.name.localeCompare( b.data.name );
}
return b.data.count - a.data.count;
} );
};
/**
* Flattens the category tree into a single list, also sorts the children of any parent tree item.
*/
function flattenCategoryTreeAndSortChildren(
items: ProductCategory[] = [],
treeItems: CategoryTreeItem[]
) {
for ( const treeItem of treeItems ) {
items.push( treeItem.data );
if ( treeItem.children.length > 0 ) {
treeItem.children = sortCategoryTreeItems( treeItem.children );
flattenCategoryTreeAndSortChildren( items, treeItem.children );
}
}
return items;
}
/**
* Recursive function to turn a category list into a tree and retrieve any missing parents.
* It checks if any parents are missing, and then does a single request to retrieve those, running this function again after.
*/
export async function getCategoriesTreeWithMissingParents(
newCategories: ProductCategory[],
search: string
): Promise<
[
ProductCategory[],
CategoryTreeItem[],
Record< number, CategoryTreeItem >
]
> {
const items: Record< number, CategoryTreeItem > = {};
const missingParents: number[] = [];
for ( const cat of newCategories ) {
items[ cat.id ] = {
data: cat,
children: [],
parentID: cat.parent,
isOpen: false,
};
}
// Loops through each item and adds children to their parents by the use of parentID.
Object.keys( items ).forEach( ( key ) => {
const item = items[ parseInt( key, 10 ) ];
if ( item.parentID !== 0 ) {
// Check the parent cache incase the parent was missing and use that instead.
if (
! items[ item.parentID ] &&
parentCategoryCache[ item.parentID ]
) {
items[ item.parentID ] = {
data: parentCategoryCache[ item.parentID ],
children: [],
parentID: parentCategoryCache[ item.parentID ].parent,
isOpen: false,
};
}
if ( items[ item.parentID ] ) {
items[ item.parentID ].children.push( item );
parentCategoryCache[ item.parentID ] =
items[ item.parentID ].data;
// Open the parents if the child matches the search string.
const searchRegex = new RegExp( escapeRegExp( search ), 'i' );
if ( search.length > 0 && searchRegex.test( item.data.name ) ) {
openParents( items, item );
}
} else {
missingParents.push( item.parentID );
}
}
} );
// Retrieve the missing parent objects incase not all of them were included.
if ( missingParents.length > 0 ) {
return (
resolveSelect( EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME )
.getProductCategories( {
include: missingParents,
} )
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.then( ( parentCategories ) => {
return getCategoriesTreeWithMissingParents(
[
...( parentCategories as ProductCategory[] ),
...newCategories,
],
search
);
} )
);
}
const categoryTreeList = sortCategoryTreeItems(
Object.values( items ).filter( ( item ) => item.parentID === 0 )
);
const categoryCheckboxList = flattenCategoryTreeAndSortChildren(
[],
categoryTreeList
);
return Promise.resolve( [ categoryCheckboxList, categoryTreeList, items ] );
}
const productCategoryQueryObject = {
per_page: PAGE_SIZE,
};
/**
* A hook used to handle all the search logic for the category search component.
* This hook also handles the data structure and provides a tree like structure see: CategoryTreeItema.
*/
export const useCategorySearch = () => {
const lastSearchValue = useRef( '' );
const { initialCategories, totalCount } = useSelect(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
( select: WCDataSelector ) => {
const { getProductCategories, getProductCategoriesTotalCount } =
select( EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME );
return {
initialCategories: getProductCategories(
productCategoryQueryObject
),
totalCount: getProductCategoriesTotalCount(
productCategoryQueryObject
),
};
}
);
const [ isSearching, setIsSearching ] = useState( true );
const [ categoriesAndNewItem, setCategoriesAndNewItem ] = useState<
[
ProductCategory[],
CategoryTreeItem[],
Record< number, CategoryTreeItem >
]
>( [ [], [], {} ] );
const isAsync =
! initialCategories ||
( initialCategories.length > 0 && totalCount > PAGE_SIZE );
useEffect( () => {
if (
initialCategories &&
initialCategories.length > 0 &&
( categoriesAndNewItem[ 0 ].length === 0 ||
lastSearchValue.current.length === 0 )
) {
setIsSearching( true );
getCategoriesTreeWithMissingParents(
[ ...initialCategories ],
''
).then(
( categoryTree ) => {
setCategoriesAndNewItem( categoryTree );
setIsSearching( false );
},
() => {
setIsSearching( false );
}
);
}
}, [ initialCategories ] );
const searchCategories = useCallback(
async ( search?: string ): Promise< CategoryTreeItem[] > => {
lastSearchValue.current = search || '';
if ( ! isAsync && initialCategories.length > 0 ) {
return getCategoriesTreeWithMissingParents(
[ ...initialCategories ],
search || ''
).then( ( categoryData ) => {
setCategoriesAndNewItem( categoryData );
return categoryData[ 1 ];
} );
}
setIsSearching( true );
try {
const newCategories = await resolveSelect(
EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME
).getProductCategories( {
search,
per_page: PAGE_SIZE,
} );
const categoryTreeData =
await getCategoriesTreeWithMissingParents(
newCategories as ProductCategory[],
search || ''
);
setIsSearching( false );
setCategoriesAndNewItem( categoryTreeData );
return categoryTreeData[ 1 ];
} catch ( e ) {
setIsSearching( false );
return [];
}
},
[ initialCategories ]
);
const categoryTreeKeyValues = categoriesAndNewItem[ 2 ];
const getFilteredItemsForSelectTree = useCallback(
(
allItems: TreeItemType[],
inputValue: string,
selectedItems: TreeItemType[]
) => {
const searchRegex = new RegExp( escapeRegExp( inputValue ), 'i' );
return allItems.filter(
( item ) =>
selectedItems.indexOf( item ) < 0 &&
( searchRegex.test( item.label ) ||
( categoryTreeKeyValues[ +item.value ] &&
categoryTreeKeyValues[ +item.value ].isOpen ) )
);
},
[ categoriesAndNewItem ]
);
return {
searchCategories,
getFilteredItemsForSelectTree,
categoriesSelectList: categoriesAndNewItem[ 0 ],
categories: categoriesAndNewItem[ 1 ],
isSearching,
categoryTreeKeyValues,
};
};

View File

@ -31,37 +31,35 @@ export function EditorCanvas( {
const mouseMoveTypingRef = useMouseMoveTypingReset();
return (
<Iframe
head={
<>
<EditorStyles styles={ settings?.styles } />
<style>
{
// Forming a "block formatting context" to prevent margin collapsing.
// @see https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Block_formatting_context
`.is-root-container {
padding: 36px;
display: flow-root;
}
body { position: relative; }`
}
</style>
{ enableResizing && (
<style>
{
// Some themes will have `min-height: 100vh` for the root container,
// which isn't a requirement in auto resize mode.
`.is-root-container { min-height: 0 !important; }`
}
</style>
) }
</>
}
ref={ mouseMoveTypingRef }
name="editor-canvas"
className="edit-site-visual-editor__editor-canvas"
{ ...props }
>
{ children }
<>
<EditorStyles styles={ settings?.styles } />
<style>
{
// Forming a "block formatting context" to prevent margin collapsing.
// @see https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Block_formatting_context
`.is-root-container {
padding: 36px;
display: flow-root;
}
body { position: relative; }`
}
</style>
{ enableResizing && (
<style>
{
// Some themes will have `min-height: 100vh` for the root container,
// which isn't a requirement in auto resize mode.
`.is-root-container { min-height: 0 !important; }`
}
</style>
) }
{ children }
</>
</Iframe>
);
}

View File

@ -7,7 +7,6 @@ export { WooProductSectionItem as __experimentalWooProductSectionItem } from './
export { WooProductTabItem as __experimentalWooProductTabItem } from './woo-product-tab-item';
export { DetailsNameField as __experimentalDetailsNameField } from './details-name-field';
export { DetailsFeatureField as __experimentalDetailsFeatureField } from './details-feature-field';
export { DetailsCategoriesField as __experimentalDetailsCategoriesField } from './details-categories-field';
export { DetailsSummaryField as __experimentalDetailsSummaryField } from './details-summary-field';
export { DetailsDescriptionField as __experimentalDetailsDescriptionField } from './details-description-field';
export { WooProductMoreMenuItem as __experimentalWooProductMoreMenuItem } from './header';

View File

@ -0,0 +1,12 @@
.woocommerce-create-new-tag-modal {
min-width: 650px;
overflow: visible;
&__buttons {
margin-top: $gap-larger;
display: flex;
flex-direction: row;
gap: $gap-smaller;
justify-content: flex-end;
}
}

View File

@ -0,0 +1,93 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Modal, TextControl } from '@wordpress/components';
import { useState, createElement } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import { recordEvent } from '@woocommerce/tracks';
import {
EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME,
ProductTag,
} from '@woocommerce/data';
/**
* Internal dependencies
*/
import { TRACKS_SOURCE } from '../../constants';
type CreateTagModalProps = {
initialTagName?: string;
onCancel: () => void;
onCreate: ( newTag: ProductTag ) => void;
};
export const CreateTagModal: React.FC< CreateTagModalProps > = ( {
initialTagName,
onCancel,
onCreate,
} ) => {
const { createNotice } = useDispatch( 'core/notices' );
const [ isCreating, setIsCreating ] = useState( false );
const { createProductTag, invalidateResolutionForStoreSelector } =
useDispatch( EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME );
const [ tagName, setTagName ] = useState( initialTagName || '' );
const onSave = async () => {
recordEvent( 'product_tag_add', {
source: TRACKS_SOURCE,
} );
setIsCreating( true );
try {
const newTag: ProductTag = await createProductTag( {
name: tagName,
} );
invalidateResolutionForStoreSelector( 'getProductTags' );
setIsCreating( false );
onCreate( newTag );
} catch ( e ) {
createNotice(
'error',
__( 'Failed to create tag.', 'woocommerce' )
);
setIsCreating( false );
onCancel();
}
};
return (
<Modal
title={ __( 'Create tag', 'woocommerce' ) }
onRequestClose={ () => onCancel() }
className="woocommerce-create-new-tag-modal"
>
<div className="woocommerce-create-new-tag-modal__wrapper">
<TextControl
label={ __( 'Name', 'woocommerce' ) }
name="Tops"
value={ tagName }
onChange={ setTagName }
/>
<div className="woocommerce-create-new-tag-modal__buttons">
<Button
isSecondary
onClick={ () => onCancel() }
disabled={ isCreating }
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<Button
isPrimary
disabled={ tagName.length === 0 || isCreating }
isBusy={ isCreating }
onClick={ () => {
onSave();
} }
>
{ __( 'Save', 'woocommerce' ) }
</Button>
</div>
</div>
</Modal>
);
};

View File

@ -0,0 +1 @@
export * from './tag-field';

View File

@ -1,4 +1,4 @@
.woocommerce-category-field-dropdown {
.woocommerce-tag-field-dropdown {
.woocommerce-experimental-select-control__input {
height: auto;
}
@ -10,8 +10,8 @@
max-height: 300px;
overflow-y: scroll;
> .woocommerce-category-field-dropdown__item:not(:first-child) {
> .woocommerce-category-field-dropdown__item-content {
> .woocommerce-tag-field-dropdown__item:not(:first-child) {
> .woocommerce-tag-field-dropdown__item-content {
border-top: 1px solid $gray-200;
}
}
@ -19,13 +19,13 @@
&__item {
margin-bottom: 0;
.woocommerce-category-field-dropdown__item-content {
.woocommerce-tag-field-dropdown__item-content {
.components-base-control {
margin-top: 0;
}
}
&.is-new {
.category-field-dropdown__toggle {
.tag-field-dropdown__toggle {
margin-right: $gap-smaller;
}
}
@ -58,7 +58,8 @@
}
}
.woocommerce-category-field-dropdown__item.item_highlighted > .woocommerce-category-field-dropdown__item-content {
.woocommerce-tag-field-dropdown__item.item_highlighted
> .woocommerce-tag-field-dropdown__item-content {
font-weight: bold;
}

View File

@ -0,0 +1,176 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useState, createElement, Fragment } from '@wordpress/element';
import {
TreeItemType,
__experimentalSelectTreeControl as SelectTree,
} from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import {
EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME,
ProductTag,
} from '@woocommerce/data';
import { useDispatch } from '@wordpress/data';
import { useDebounce } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { useTagSearch, ProductTagNode } from './use-tag-search';
import { TRACKS_SOURCE } from '../../constants';
import { CreateTagModal } from './create-tag-modal';
type TagFieldProps = {
id: string;
label: string;
placeholder: string;
value?: ProductTagNode[];
onChange: ( value: ProductTagNode[] ) => void;
};
export function mapFromTagToTreeItem( val: ProductTagNode ): TreeItemType {
return {
value: String( val.id ),
label: val.name,
};
}
export function mapFromTreeItemToTag( val: TreeItemType ): ProductTagNode {
return {
id: +val.value,
name: val.label,
};
}
export function mapFromTagsToTreeItems(
tags: ProductTagNode[]
): TreeItemType[] {
return tags.map( mapFromTagToTreeItem );
}
export function mapFromTreeItemsToTags(
tags: TreeItemType[]
): ProductTagNode[] {
return tags.map( mapFromTreeItemToTag );
}
export const TagField: React.FC< TagFieldProps > = ( {
id,
label,
placeholder,
value = [],
onChange,
} ) => {
const { tagsSelectList, searchTags } = useTagSearch();
const [ searchValue, setSearchValue ] = useState( '' );
const [ isCreating, setIsCreating ] = useState( false );
const [ showCreateNewModal, setShowCreateNewModal ] = useState( false );
const [ newInputValue, setNewInputValue ] = useState<
undefined | string
>();
const { createProductTag, invalidateResolutionForStoreSelector } =
useDispatch( EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME );
const { createNotice } = useDispatch( 'core/notices' );
const onInputChange = ( searchString?: string ) => {
setSearchValue( searchString || '' );
searchTags( searchString || '' );
setNewInputValue( searchString );
};
const searchDelayed = useDebounce( onInputChange, 150 );
const onSave = async () => {
recordEvent( 'product_tag_add', {
source: TRACKS_SOURCE,
} );
setIsCreating( true );
try {
setNewInputValue( '' );
const newTag: ProductTag = await createProductTag( {
name: searchValue,
} );
invalidateResolutionForStoreSelector( 'getProductTags' );
setIsCreating( false );
onChange( [ ...value, newTag ] );
onInputChange( '' );
} catch ( e ) {
createNotice(
'error',
__( 'Failed to create tag.', 'woocommerce' )
);
setIsCreating( false );
}
};
return (
<>
<SelectTree
id={ id }
multiple
shouldNotRecursivelySelect
createValue={ searchValue }
label={ label }
isLoading={ isCreating }
onInputChange={ searchDelayed }
placeholder={ value.length === 0 ? placeholder : '' }
initialInputValue={ newInputValue }
onCreateNew={
searchValue.length === 0
? () => setShowCreateNewModal( true )
: onSave
}
shouldShowCreateButton={ ( typedValue ) =>
! typedValue ||
tagsSelectList.findIndex(
( item ) => item.name === typedValue
) === -1
}
items={ mapFromTagsToTreeItems( tagsSelectList ) }
selected={ mapFromTagsToTreeItems( value ) }
onSelect={ ( selectedItems ) => {
if ( Array.isArray( selectedItems ) ) {
const newItems: ProductTagNode[] =
mapFromTreeItemsToTags(
selectedItems.filter(
( { value: selectedItemValue } ) =>
! value.some(
( item ) =>
item.id === +selectedItemValue
)
)
);
onChange( [ ...value, ...newItems ] );
}
} }
onRemove={ ( removedItems ) => {
const newValues = Array.isArray( removedItems )
? value.filter(
( item ) =>
! removedItems.some(
( { value: removedValue } ) =>
item.id === +removedValue
)
)
: value.filter(
( item ) => item.id !== +removedItems.value
);
onChange( newValues );
} }
></SelectTree>
{ showCreateNewModal && (
<CreateTagModal
initialTagName={ searchValue }
onCancel={ () => setShowCreateNewModal( false ) }
onCreate={ ( newTag ) => {
onChange( [ ...value, newTag ] );
setShowCreateNewModal( false );
onInputChange( '' );
} }
/>
) }
</>
);
};

View File

@ -9,71 +9,66 @@ import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { CategoryField } from '../category-field';
import { ProductCategoryNode } from '../use-category-search';
import { TagField } from '../tag-field';
import { ProductTagNode } from '../use-tag-search';
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
jest.mock( '../use-category-search', () => {
const originalModule = jest.requireActual( '../use-category-search' );
jest.mock( '../use-tag-search', () => {
return {
getCategoriesTreeWithMissingParents:
originalModule.getCategoriesTreeWithMissingParents,
useCategorySearch: jest.fn().mockReturnValue( {
searchCategories: jest.fn(),
useTagSearch: jest.fn().mockReturnValue( {
searchTags: jest.fn(),
getFilteredItemsForSelectTree: jest.fn().mockReturnValue( [] ),
isSearching: false,
categoriesSelectList: [],
categoryTreeKeyValues: {},
tagsSelectList: [],
tagTreeKeyValues: {},
} ),
};
} );
describe( 'CategoryField', () => {
describe( 'TagField', () => {
beforeEach( () => {
jest.clearAllMocks();
} );
it( 'should render a dropdown select control', () => {
const { queryByText, queryByPlaceholderText } = render(
<Form initialValues={ { categories: [] } }>
<Form initialValues={ { tags: [] } }>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps< ProductCategoryNode[] >(
'categories'
) }
<TagField
id="tag-field"
label="Tags"
placeholder="Search or create tag…"
{ ...getInputProps< ProductTagNode[] >( 'tags' ) }
/>
) }
</Form>
);
queryByPlaceholderText( 'Search or create category…' )?.focus();
queryByPlaceholderText( 'Search or create tag…' )?.focus();
expect( queryByText( 'Create new' ) ).toBeInTheDocument();
} );
it( 'should pass in the selected categories as select control items', () => {
it( 'should pass in the selected tags as select control items', () => {
const { queryAllByText, queryByPlaceholderText } = render(
<Form
initialValues={ {
categories: [
tags: [
{ id: 2, name: 'Test' },
{ id: 5, name: 'Clothing' },
],
} }
>
{ ( { getInputProps }: FormContextType< Product > ) => (
<CategoryField
label="Categories"
placeholder="Search or create category…"
{ ...getInputProps< ProductCategoryNode[] >(
'categories'
) }
<TagField
id="another-tag-field"
label="Tags"
placeholder="Search or create tag…"
{ ...getInputProps< ProductTagNode[] >( 'tags' ) }
/>
) }
</Form>
);
queryByPlaceholderText( 'Search or create category…' )?.focus();
queryByPlaceholderText( 'Search or create tag…' )?.focus();
expect( queryAllByText( 'Test, Clothing' ) ).toHaveLength( 1 );
} );
} );

View File

@ -0,0 +1,40 @@
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';
import { resolveSelect } from '@wordpress/data';
import {
EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME,
ProductTag,
} from '@woocommerce/data';
export type ProductTagNode = Pick< ProductTag, 'id' | 'name' >;
/**
* A hook used to handle all the search logic for the tag search component.
*/
export const useTagSearch = () => {
const [ fetchedTags, setFetchedTags ] = useState< ProductTag[] >( [] );
const [ isSearching, setIsSearching ] = useState( true );
const fetchProductTags = ( search?: string ) => {
setIsSearching( true );
const query = search !== undefined ? { search } : '';
resolveSelect( EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME )
.getProductTags( query )
.then( ( tags ) => {
setFetchedTags( tags as ProductTag[] );
} )
.finally( () => {
setIsSearching( false );
} );
};
useEffect( fetchProductTags, [] );
return {
searchTags: fetchProductTags,
tagsSelectList: fetchedTags,
isSearching,
};
};

View File

@ -1,4 +1,5 @@
@import "./variations-actions-menu/styles.scss";
@import "../../icons/hidden-with-help-icon.scss";
$table-row-height: calc($grid-unit * 9);
@ -48,6 +49,18 @@ $table-row-height: calc($grid-unit * 9);
}
}
&__price {
text-align: right;
padding-right: $grid-unit-40;
}
&__regular-price--on-sale {
text-decoration: line-through;
color: $gray-600;
margin-left: 6px;
word-wrap: normal;
}
&__status-dot {
margin-right: $gap-smaller;
&.green {
@ -70,6 +83,7 @@ $table-row-height: calc($grid-unit * 9);
display: flex;
align-items: center;
justify-content: flex-end;
gap: $gap-smaller;
&--delete {
&.components-button.components-menu-item__button.is-link {

View File

@ -78,7 +78,7 @@ export function VariationActionsMenu( {
label={ __( 'Delete variation', 'woocommerce' ) }
variant="link"
onClick={ () => {
onDelete( selection.id );
onDelete( selection );
onClose();
} }
className="woocommerce-product-variations__actions--delete"

View File

@ -40,8 +40,6 @@ import { useEntityId } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import HiddenIcon from './hidden-icon';
import VisibleIcon from './visible-icon';
import { getProductStockStatus, getProductStockStatusClass } from '../../utils';
import {
DEFAULT_VARIATION_PER_PAGE_OPTION,
@ -51,10 +49,9 @@ import {
import { VariationActionsMenu } from './variation-actions-menu';
import { useSelection } from '../../hooks/use-selection';
import { VariationsActionsMenu } from './variations-actions-menu';
import HiddenWithHelpIcon from '../../icons/hidden-with-help-icon';
const NOT_VISIBLE_TEXT = __( 'Not visible to customers', 'woocommerce' );
const VISIBLE_TEXT = __( 'Visible to customers', 'woocommerce' );
const UPDATING_TEXT = __( 'Updating product variation', 'woocommerce' );
export function VariationsTable() {
const [ currentPage, setCurrentPage ] = useState( 1 );
@ -378,7 +375,22 @@ export function VariationsTable() {
}
) }
>
{ formatAmount( variation.price ) }
{ variation.on_sale && (
<span className="woocommerce-product-variations__sale-price">
{ formatAmount( variation.sale_price ) }
</span>
) }
<span
className={ classnames(
'woocommerce-product-variations__regular-price',
{
'woocommerce-product-variations__regular-price--on-sale':
variation.on_sale,
}
) }
>
{ formatAmount( variation.regular_price ) }
</span>
</div>
<div
className={ classnames(
@ -402,63 +414,14 @@ export function VariationsTable() {
<div className="woocommerce-product-variations__actions">
{ variation.status === 'private' && (
<Tooltip
// @ts-expect-error className is missing in TS, should remove this when it is included.
className="woocommerce-attribute-list-item__actions-tooltip"
position="top center"
text={ NOT_VISIBLE_TEXT }
>
<Button
className="components-button--hidden"
aria-label={
isUpdating[ variation.id ]
? UPDATING_TEXT
: NOT_VISIBLE_TEXT
}
aria-disabled={
isUpdating[ variation.id ]
}
onClick={ () =>
handleVariationChange(
variation.id,
{ status: 'publish' }
)
}
>
{ isUpdating[ variation.id ] ? (
<Spinner />
) : (
<HiddenIcon />
) }
</Button>
</Tooltip>
) }
{ variation.status === 'publish' && (
<Tooltip
position="top center"
text={ VISIBLE_TEXT }
>
<Button
className="components-button--visible"
aria-label={
isUpdating[ variation.id ]
? UPDATING_TEXT
: VISIBLE_TEXT
}
aria-disabled={
isUpdating[ variation.id ]
}
onClick={ () =>
handleVariationChange(
variation.id,
{ status: 'private' }
)
}
>
{ isUpdating[ variation.id ] ? (
<Spinner />
) : (
<VisibleIcon />
) }
</Button>
<div>
<HiddenWithHelpIcon />
</div>
</Tooltip>
) }
<VariationActionsMenu

View File

@ -0,0 +1,14 @@
.woocommerce-hidden-with-help-icon {
display: flex;
align-items: center;
position: relative;
&__hidden-icon {
color: $gray-600;
}
&__help-icon {
position: absolute;
right: -2px;
bottom: 0px;
}
}

View File

@ -0,0 +1,27 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import HelpIcon from './help-icon';
import HiddenIcon from './hidden-icon';
export default function HiddenWithHelpIcon( {
width = 24,
height = 24,
...props
}: React.HTMLProps< HTMLDivElement > ) {
return (
<div className="woocommerce-hidden-with-help-icon" { ...props }>
<HiddenIcon
className="woocommerce-hidden-with-help-icon__hidden-icon"
width={ width }
height={ height }
/>
<HelpIcon className="woocommerce-hidden-with-help-icon__help-icon" />
</div>
);
}

View File

@ -17,15 +17,11 @@
@import "components/radio-field/style.scss";
@import "components/notice/style.scss";
@import "components/iframe-editor/style.scss";
@import "components/details-categories-field/style.scss";
@import "components/details-categories-field/create-category-modal.scss";
@import "components/modal-editor/style.scss";
@import "components/feedback-bar/style.scss";
@import "components/product-mvp-feedback-modal/style.scss";
@import "components/edit-product-link-modal/style.scss";
@import "components/edit-product-link-modal/style.scss";
@import "components/details-categories-field/style.scss";
@import "components/details-categories-field/create-category-modal.scss";
@import "components/attribute-control/attribute-field.scss";
@import "components/attribute-control/edit-attribute-modal.scss";
@import "components/attribute-control/new-attribute-modal.scss";
@ -34,6 +30,8 @@
@import "components/attribute-list-item/attribute-list-item.scss";
@import "components/attribute-term-input-field/attribute-term-input-field.scss";
@import "components/variations-table/styles.scss";
@import "components/tags-field/create-tag-modal.scss";
@import "components/tags-field/style.scss";
/* Field Blocks */

View File

@ -1,5 +1,26 @@
# Changelog
## [0.4](https://github.com/woocommerce/woocommerce/releases/tag/0.4) - 2023-09-12
- Patch - Add Woo AI Personalization setting and check setting when generating descriptions with AI.
- Minor - Suggest product categories using AI
- Minor - [Woo AI] Add a Write with AI button for the short description field in product editor.
## [0.3](https://github.com/woocommerce/woocommerce/releases/tag/0.3) - 2023-08-18
- Patch - Fix Woo AI settings page fields persistence bug when disabling the feature.
- Patch - Woo AI - Fix store branding settings retrieval for use with description generation.
- Minor - Adding settings screen for AI centric settings.
- Minor - Generating short description after long description on product editor.
- Minor - [Woo AI] Add Store Branding data to product description generation prompt.
- Minor - Moving text completion hooks into @woocommerce/ai package for reuse.
- Minor - Updating AI endpoints for product editing features.
- Minor - Use additional product data (categories, tags, and attributes) when generating product descriptions.
- Minor - Update pnpm monorepo-wide to 8.6.5
- Minor - Update pnpm to 8.6.7
- Patch - Update `wp-env` to version 8.2.0.
- Minor - Upgrade TypeScript to 5.1.6
## [0.2](https://github.com/woocommerce/woocommerce/releases/tag/0.2) - 2023-06-28
- Minor - Adding error handling for a bad token request.

View File

@ -1,4 +0,0 @@
Significance: minor
Type: add
Adding settings screen for AI centric settings.

View File

@ -1,4 +0,0 @@
Significance: minor
Type: add
[Woo AI] Add Store Branding data to product description generation prompt.

View File

@ -1,4 +0,0 @@
Significance: minor
Type: add
Generating short description after long description on product editor.

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