Merge branch 'woocommerce:trunk' into trunk

This commit is contained in:
Fitim Vata 2023-05-25 09:46:16 +02:00 committed by GitHub
commit 00b96fb940
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
510 changed files with 38813 additions and 4156 deletions

View File

@ -9,6 +9,8 @@
### Changes proposed in this Pull Request:
<!-- If necessary, indicate if this PR is part of a bigger feature. Add a label with the format `focus: name of the feature [team:name of the team]`. -->
<!-- Describe the changes made to this Pull Request and the reason for such changes. -->
Closes # .
@ -25,4 +27,4 @@ Using the [WooCommerce Testing Instructions Guide](https://github.com/woocommerc
2.
3.
<!-- End testing instructions -->
<!-- End testing instructions -->

View File

@ -0,0 +1,10 @@
name: 'Changelog Auto Add'
on: workflow_dispatch
jobs:
hello-world:
name: 'Hello World'
runs-on: ubuntu-20.04
steps:
- name: Hello
run: echo "Hello World"

View File

@ -58,23 +58,24 @@ jobs:
working-directory: plugins/woocommerce
run: pnpm run build:feature-config
- name: Add PHP8 Compatibility.
run: |
if [ "$(php -r "echo version_compare(PHP_VERSION,'8.0','>=');")" ]; then
cd plugins/woocommerce
curl -L https://github.com/woocommerce/phpunit/archive/add-compatibility-with-php8-to-phpunit-7.zip -o /tmp/phpunit-7.5-fork.zip
unzip -d /tmp/phpunit-7.5-fork /tmp/phpunit-7.5-fork.zip
composer bin phpunit config --unset platform
composer bin phpunit config repositories.0 '{"type": "path", "url": "/tmp/phpunit-7.5-fork/phpunit-add-compatibility-with-php8-to-phpunit-7", "options": {"symlink": false}}'
composer bin phpunit require --dev -W phpunit/phpunit:@dev --ignore-platform-reqs
rm -rf ./vendor/phpunit/
composer dump-autoload
fi
- id: parseMatrix
name: Parse Matrix Variables
uses: actions/github-script@v6
with:
script: |
const parseWPVersion = require( './.github/workflows/scripts/parse-wp-version' );
parseWPVersion( '${{ matrix.wp }}' ).then( ( version ) => {
core.setOutput( 'wpVersion', version );
} );
- name: Init DB and WP
working-directory: plugins/woocommerce
run: ./tests/bin/install.sh woo_test root root 127.0.0.1 ${{ matrix.wp }}
- name: Prepare Testing Environment
env:
WP_ENV_CORE: ${{ steps.parseMatrix.outputs.wpVersion }}
WP_ENV_PHP_VERSION: ${{ matrix.php }}
run: pnpm --filter=woocommerce env:test
- name: Run tests
working-directory: plugins/woocommerce
run: pnpm run test --color
- name: Run Tests
env:
WP_ENV_CORE: ${{ steps.parseMatrix.outputs.wpVersion }}
WP_ENV_PHP_VERSION: ${{ matrix.php }}
run: pnpm --filter=woocommerce test:unit:env

View File

@ -13,7 +13,6 @@ permissions: {}
jobs:
e2e-tests-run:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login != 'github-actions[bot]' }}
name: Runs E2E tests.
runs-on: ubuntu-20.04
permissions:
@ -85,7 +84,6 @@ jobs:
api-tests-run:
name: Runs API tests.
if: github.event.pull_request.user.login != 'github-actions[bot]'
runs-on: ubuntu-20.04
permissions:
contents: read

View File

@ -1,61 +0,0 @@
name: Run code coverage on PR
on:
pull_request:
paths-ignore:
- '**/changelog/**'
workflow_dispatch:
defaults:
run:
shell: bash
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions: {}
jobs:
test:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login != 'github-actions[bot]' }}
name: Code coverage (PHP 7.4, WP Latest)
timeout-minutes: 30
runs-on: ubuntu-20.04
permissions:
contents: read
services:
database:
image: mysql:5.6
env:
MYSQL_ROOT_PASSWORD: root
ports:
- 3306:3306
options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 100
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
- name: Tool versions
run: |
php --version
composer --version
- name: Build Admin feature config
working-directory: plugins/woocommerce
run: pnpm run build:feature-config
- name: Init DB and WP
working-directory: plugins/woocommerce
run: bash tests/bin/install.sh woo_test root root 127.0.0.1 latest
- name: Run unit tests with code coverage. Allow to fail.
working-directory: plugins/woocommerce
run: |
RUN_CODE_COVERAGE=1 bash tests/bin/phpunit.sh
exit 0
- name: Send code coverage to Codecov.
run: |
bash <(curl -s https://codecov.io/bash)

View File

@ -16,7 +16,6 @@ permissions: {}
jobs:
test:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login != 'github-actions[bot]' }}
name: Code sniff (PHP 7.4, WP Latest)
timeout-minutes: 15
runs-on: ubuntu-20.04

View File

@ -8,7 +8,7 @@ permissions: {}
jobs:
analyze:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login != 'github-actions[bot]' }}
if: ${{ github.event.pull_request.user.login != 'github-actions[bot]' }}
name: Check pull request changes to highlight
runs-on: ubuntu-20.04
permissions:

View File

@ -11,7 +11,6 @@ permissions: {}
jobs:
changelogger_used:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login != 'github-actions[bot]' }}
name: Changelogger use
runs-on: ubuntu-20.04
permissions:

View File

@ -12,7 +12,6 @@ permissions: {}
jobs:
lint-test-js:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login != 'github-actions[bot]' }}
name: Lint and Test JS
runs-on: ubuntu-20.04
permissions:

View File

@ -14,7 +14,7 @@ permissions: {}
jobs:
test:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login != 'github-actions[bot]' }}
if: ${{ github.event.pull_request.user.login != 'github-actions[bot]' }}
name: PHP ${{ matrix.php }} WP ${{ matrix.wp }} ${{ matrix.hpos && 'HPOS' || '' }}
timeout-minutes: 30
runs-on: ubuntu-20.04
@ -52,15 +52,24 @@ jobs:
with:
php-version: ${{ matrix.php }}
- name: Tool versions
run: |
php --version
composer --version
- id: parseMatrix
name: Parse Matrix Variables
uses: actions/github-script@v6
with:
script: |
const parseWPVersion = require( './.github/workflows/scripts/parse-wp-version' );
parseWPVersion( '${{ matrix.wp }}' ).then( ( version ) => {
core.setOutput( 'wpVersion', version );
} );
- name: Init DB and WP
working-directory: plugins/woocommerce
run: ./tests/bin/install.sh woo_test root root 127.0.0.1 ${{ matrix.wp }}
- name: Prepare Testing Environment
env:
WP_ENV_CORE: ${{ steps.parseMatrix.outputs.wpVersion }}
WP_ENV_PHP_VERSION: ${{ matrix.php }}
run: pnpm --filter=woocommerce env:test
- name: Run tests
working-directory: plugins/woocommerce
run: pnpm run test --filter=woocommerce --color
- name: Run Tests
env:
WP_ENV_CORE: ${{ steps.parseMatrix.outputs.wpVersion }}
WP_ENV_PHP_VERSION: ${{ matrix.php }}
run: pnpm --filter=woocommerce test:unit:env

View File

@ -1,119 +0,0 @@
name: 'Release: Generate changelog'
on:
workflow_dispatch:
inputs:
releaseBranch:
description: 'The name of the release branch, in the format `release/x.y`'
required: true
releaseVersion:
description: 'The version of the release, in the format `x.y`'
required: true
env:
GIT_COMMITTER_NAME: 'WooCommerce Bot'
GIT_COMMITTER_EMAIL: 'no-reply@woocommerce.com'
GIT_AUTHOR_NAME: 'WooCommerce Bot'
GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com'
permissions: {}
jobs:
create-changelog-prs:
runs-on: ubuntu-20.04
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build: false
- name: 'Git fetch the release branch'
run: git fetch origin ${{ inputs.releaseBranch }}
- name: 'Checkout the release branch'
run: git checkout ${{ inputs.releaseBranch }}
- name: 'Create a new branch for the changelog update PR'
run: git checkout -b ${{ format( 'update/{0}-changelog', inputs.releaseVersion ) }}
- name: 'Generate the changelog file'
run: pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${{ inputs.releaseVersion }}
- name: Checkout pnpm-lock.yaml to prevent issues
run: git checkout pnpm-lock.yaml
- name: 'git rm deleted files'
run: git rm $(git ls-files --deleted)
- name: 'Commit deletion'
run: git commit -m "Delete changelog files from ${{ inputs.releaseVersion }} release"
- name: 'Remember the deletion commit hash'
id: rev-parse
run: echo "hash=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
- name: 'Insert NEXT_CHANGELOG contents into readme.txt'
run: php .github/workflows/scripts/release-changelog.php
- name: 'git add readme.txt'
run: git add plugins/woocommerce/readme.txt
- name: 'Commit readme'
run: git commit -m "Update the readme files for the ${{ inputs.releaseVersion }} release"
- name: 'Push update branch to origin'
run: git push origin ${{ format( 'update/{0}-changelog', inputs.releaseVersion ) }}
- name: 'Stash any other undesired changes'
run: git stash
- name: 'Checkout trunk'
run: git checkout trunk
- name: 'Create a branch for the changelog files deletion'
run: git checkout -b ${{ format( 'delete/{0}-changelog', inputs.releaseVersion ) }}
- name: 'Cherry-pick the deletion commit'
run: git cherry-pick ${{ steps.rev-parse.outputs.hash }}
- name: 'Push deletion branch to origin'
run: git push origin ${{ format( 'delete/{0}-changelog', inputs.releaseVersion ) }}
- name: 'Create release branch PR'
id: release-pr
uses: actions/github-script@v6
with:
script: |
const result = await github.rest.pulls.create( {
owner: "${{ github.repository_owner }}",
repo: "${{ github.event.repository.name }}",
head: "${{ format( 'update/{0}-changelog', inputs.releaseVersion ) }}",
base: "${{ inputs.releaseBranch }}",
title: "${{ format( 'Release: Prepare the changelog for {0}', inputs.releaseVersion ) }}",
body: "${{ format( 'This pull request was automatically generated during the code freeze to prepare the changelog for {0}', inputs.releaseVersion ) }}"
} );
return result.data.number;
- name: 'Create trunk PR'
id: trunk-pr
uses: actions/github-script@v6
with:
script: |
const result = await github.rest.pulls.create( {
owner: "${{ github.repository_owner }}",
repo: "${{ github.event.repository.name }}",
head: "${{ format( 'delete/{0}-changelog', inputs.releaseVersion ) }}",
base: "trunk",
title: "${{ format( 'Release: Remove {0} change files', inputs.releaseVersion ) }}",
body: "${{ format( 'This pull request was automatically generated during the code freeze to remove the changefiles from {0} that are compiled into the `{1}` branch via #{2}', inputs.releaseVersion, inputs.releaseBranch, steps.release-pr.outputs.result ) }}"
} );
return result.data.number;

View File

@ -41,10 +41,24 @@ jobs:
with:
fetch-depth: 0
- name: Setup PNPM
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
with:
version: '8.3.1'
- name: Setup Node
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Install prerequisites
run: |
npm install -g pnpm
pnpm install --filter monorepo-utils
pnpm install --filter monorepo-utils --ignore-scripts
# ignore scripts speeds up setup signficantly, but we still need to build monorepo utils
pnpm build
working-directory: tools/monorepo-utils
- name: 'Check whether today is the code freeze day'
id: check-freeze
@ -71,42 +85,47 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run utils code-freeze version-bump -o ${{ github.repository_owner }} -v ${{ steps.milestone.outputs.nextDevelopmentVersion }}.0-dev
- name: Generate changelog changes
id: changelog
if: steps.check-freeze.outputs.freeze == 'true'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run utils code-freeze changelog -o ${{ github.repository_owner }} -v ${{ steps.milestone.outputs.nextReleaseVersion }}
notify-slack:
name: 'Sends code freeze notification to Slack'
runs-on: ubuntu-20.04
needs: code-freeze-prep
if: ${{ needs.code-freeze-prep.outputs.freeze == 'true' && inputs.skipSlackPing != true }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Setup PNPM
uses: pnpm/action-setup@c3b53f6a16e57305370b4ae5a540c2077a1d50dd
with:
version: '8.3.1'
- name: Setup Node
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Install prerequisites
run: |
pnpm install --filter monorepo-utils --ignore-scripts
# ignore scripts speeds up setup signficantly, but we still need to build monorepo utils
pnpm build
working-directory: tools/monorepo-utils
- name: Slack
uses: archive/github-actions-slack@v2.0.0
id: notify
with:
slack-bot-user-oauth-access-token: ${{ secrets.CODE_FREEZE_BOT_TOKEN }}
slack-channel: ${{ inputs.slackChannelOverride || secrets.WOO_RELEASE_SLACK_CHANNEL }}
slack-text: |
:warning-8c: ${{ needs.code-freeze-prep.outputs.nextReleaseVersion }} Code Freeze :ice_cube:
The automation to cut the release branch for ${{ needs.code-freeze-prep.outputs.nextReleaseVersion }} has run. Any PRs that were not already merged will be a part of ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.nextDevelopmentVersion }} by default. If you have something that needs to make ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.nextReleaseVersion }} that hasn't yet been merged, please see the <${{ secrets.FG_LINK }}/code-freeze-for-woocommerce-core-release/|fieldguide page for the code freeze>.
trigger-changelog-action:
name: 'Trigger changelog action'
runs-on: ubuntu-20.04
permissions:
actions: write
needs: code-freeze-prep
if: needs.code-freeze-prep.outputs.freeze == 'true'
steps:
- name: 'Trigger changelog action'
uses: actions/github-script@v6
with:
script: |
github.rest.actions.createWorkflowDispatch({
owner: context.repo.owner,
repo: context.repo.repo,
workflow_id: 'release-changelog.yml',
ref: 'trunk',
inputs: {
releaseVersion: "${{ needs.code-freeze-prep.outputs.nextReleaseVersion }}",
releaseBranch: "${{ needs.code-freeze-prep.outputs.nextReleaseBranch }}"
}
})
run: |
pnpm utils slack "${{ secrets.CODE_FREEZE_BOT_TOKEN }}" "
:warning-8c: ${{ needs.code-freeze-prep.outputs.nextReleaseVersion }} Code Freeze :ice_cube:
The automation to cut the release branch for ${{ needs.code-freeze-prep.outputs.nextReleaseVersion }} has run. Any PRs that were not already merged will be a part of ${{ needs.code-freeze-prep.outputs.nextDevelopmentVersion }} by default. If you have something that needs to make ${{ needs.code-freeze-prep.outputs.nextReleaseVersion }} that hasn't yet been merged, please see the <${{ secrets.FG_LINK }}/code-freeze-for-woocommerce-core-release/|fieldguide page for the code freeze>.
" "${{ inputs.slackChannelOverride || secrets.WOO_RELEASE_SLACK_CHANNEL }}"

View File

@ -0,0 +1,89 @@
const https = require( 'http' );
/**
For convenience, this method will convert between a display-friendly version format and one used
internally by wp-env. We lean towards using WordPress.org ZIPs which requires us to reference
the full URL to the archive. For instance, instead of needing the action to fully define the
URL to the nightly build we can pass "nightly" to this function and retrieve it.
@param {string} wpVersion The display-friendly version. Supports ("master", "trunk", "nightly",
"latest", "X.X" for version lines, and "X.X.X" for specific versions)
@return {Promise.<string>} The wp-env "core" property".
**/
module.exports = async function parseWPVersion( wpVersion ) {
// Start with versions we can infer immediately.
switch ( wpVersion ) {
case 'master':
case 'trunk': {
return 'WordPress/WordPress#master';
}
case 'nightly': {
return 'https://wordpress.org/nightly-builds/wordpress-latest.zip';
}
case 'latest': {
return 'https://wordpress.org/latest.zip';
}
}
return new Promise( ( resolve, reject ) => {
// We're going to download the correct zip archive based on the version they're requesting.
const parsedVersion = wpVersion.match( /([0-9]+)\.([0-9]+)(?:\.([0-9]+))?/ );
if ( ! parsedVersion ) {
throw new Error( `Invalid 'wp-version': ${ wpVersion } must be 'trunk', 'nightly', 'latest', 'X.X', or 'X.X.X'.` );
}
// When they've provided a specific version we can just provide that.
if ( parsedVersion[ 3 ] !== undefined ) {
let zipVersion = `${ parsedVersion[ 1 ] }.${ parsedVersion[ 2 ] }`;
// .0 versions do not have a patch.
if ( parsedVersion[ 3 ] !== '0' ) {
zipVersion += `.${ parsedVersion[ 3 ] }`;
}
resolve( `https://wordpress.org/wordpress-${ zipVersion }.zip` );
}
const request = https.get(
'http://api.wordpress.org/core/stable-check/1.0/',
( response ) => {
// Listen for the response data.
let data = '';
response.on('data', (chunk) => {
data += chunk;
});
// Once we have the entire response we can process it.
response.on('end', () => {
// Parse the response and find the latest version of every minor release.
const latestVersions = {};
const rawVersions = JSON.parse( data );
for ( const v in rawVersions ) {
// Parse the version so we can find the latest.
const matches = v.match( /([0-9]+)\.([0-9]+)(?:\.([0-9]+))?/ );
const minor = `${ matches[1] }.${ matches[2] }`;
const patch = matches[ 3 ] === undefined ? 0 : parseInt( matches[ 3 ] );
// We will only be keeping the latest release of each minor.
if ( latestVersions[ minor ] === undefined || patch > latestVersions[ minor ] ) {
latestVersions[ minor ] = patch;
}
}
let zipVersion = `${ parsedVersion[ 1 ] }.${ parsedVersion[ 2 ] }`;
// .0 versions do not have a patch.
if ( latestVersions[ zipVersion ] !== 0 ) {
zipVersion += `.${ latestVersions[ zipVersion ]}`;
}
resolve( `https://wordpress.org/wordpress-${ zipVersion }.zip` );
});
},
);
request.on( 'error', ( error ) => {
reject( error );
} );
} );
}

View File

@ -48,7 +48,7 @@ jobs:
working-directory: plugins/woocommerce
env:
UPDATE_WC: nightly
run: pnpm exec playwright test --config=tests/e2e-pw/daily.playwright.config.js update-woocommerce.spec.js
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js update-woocommerce.spec.js
- name: Run API tests.
working-directory: plugins/woocommerce
@ -200,15 +200,17 @@ jobs:
- plugin: 'WooCommerce Subscriptions'
repo: WC_SUBSCRIPTIONS_REPO
private: true
- plugin: 'WordPress SEO' # Yoast SEO in the UI, but the slug is wordpress-seo
repo: 'Yoast/wordpress-seo'
- plugin: 'Contact Form 7'
repo: 'takayukister/contact-form-7'
- plugin: 'Gutenberg'
repo: 'WordPress/gutenberg'
- plugin: 'Gutenberg - Nightly'
repo: 'bph/gutenberg'
steps:
- uses: actions/checkout@v3
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build-filters: woocommerce
- name: Launch wp-env e2e environment
working-directory: plugins/woocommerce
@ -224,13 +226,13 @@ jobs:
PLUGIN_REPOSITORY: ${{ matrix.private && secrets[matrix.repo] || matrix.repo }}
PLUGIN_NAME: ${{ matrix.plugin }}
GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }}
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js upload-plugin.spec.js
run: pnpm test:e2e-pw upload-plugin.spec.js
- name: Run the rest of E2E tests
working-directory: plugins/woocommerce
env:
E2E_MAX_FAILURES: 15
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js
run: pnpm test:e2e-pw
- name: Generate E2E Test report.
if: success() || failure()
@ -321,36 +323,29 @@ jobs:
matrix:
include:
- plugin: 'WooCommerce Payments'
repo: 'automattic/woocommerce-payments'
slug: woocommerce-payments
- plugin: 'WooCommerce PayPal Payments'
repo: 'woocommerce/woocommerce-paypal-payments'
slug: woocommerce-paypal-payments
- plugin: 'WooCommerce Shipping & Tax'
repo: 'automattic/woocommerce-services'
- plugin: 'WordPress SEO' # Yoast SEO in the UI, but the slug is wordpress-seo
repo: 'Yoast/wordpress-seo'
- plugin: 'Contact Form 7'
repo: 'takayukister/contact-form-7'
slug: woocommerce-services
- plugin: 'WooCommerce Subscriptions'
slug: woocommerce-subscriptions
- plugin: 'Gutenberg'
slug: gutenberg
- plugin: 'Gutenberg - Nightly'
slug: gutenberg-nightly
steps:
- name: Download test report artifact
uses: actions/download-artifact@v3
with:
name: ${{ env.ARTIFACT }}
# TODO: Add step to post job summary
- name: Get slug
id: get-slug
uses: actions/github-script@v6
with:
result-encoding: string
script: return "${{ matrix.repo }}".split( '/' ).pop()
- name: Publish reports
run: |
gh workflow run publish-test-reports-daily-plugins.yml \
-f run_id=$RUN_ID \
-f artifact="${{ env.ARTIFACT }}" \
-f plugin="${{ matrix.plugin }}" \
-f slug="${{ steps.get-slug.outputs.result }}" \
-f slug="${{ matrix.slug }}" \
-f s3_root=public \
--repo woocommerce/woocommerce-test-reports

View File

@ -657,12 +657,12 @@ jobs:
repo: WC_SUBSCRIPTIONS_REPO
private: true
env_description: 'woocommerce-subscriptions'
- plugin: 'WordPress SEO' # Yoast SEO in the UI, but the slug is wordpress-seo
repo: 'Yoast/wordpress-seo'
env_description: 'wordpress-seo'
- plugin: 'Contact Form 7'
repo: 'takayukister/contact-form-7'
env_description: 'contact-form-7'
- plugin: 'Gutenberg'
repo: 'WordPress/gutenberg'
env_description: 'gutenberg'
- plugin: 'Gutenberg - Nightly'
repo: 'bph/gutenberg'
env_description: 'gutenberg-nightly'
steps:
- name: Checkout
uses: actions/checkout@v3

View File

@ -23,26 +23,26 @@ Here are some examples of the ways you can use Turborepo / pnpm commands:
```bash
# Lint and build all plugins, packages, and tools. Note the use of `-r` for lint,
# turbo does not run the lint at this time.
pnpm run -r lint && pnpm run build
pnpm run -r lint && pnpm run build
# Build WooCommerce Core and all of its dependencies
pnpm run --filter='woocommerce' build
pnpm run --filter='woocommerce' build
# Lint the @woocommerce/components package - note the different argument order, turbo scripts
# are not running lints at this point in time.
pnpm run -r --filter='@woocommerce/components' lint
pnpm run -r --filter='@woocommerce/components' lint
# Test all of the @woocommerce scoped packages
pnpm run --filter='@woocommerce/*' test
pnpm run --filter='@woocommerce/*' test
# Build all of the JavaScript packages
pnpm run --filter='./packages/js/*' build
pnpm run --filter='./packages/js/*' build
# Build everything except WooCommerce Core
pnpm run --filter='!woocommerce' build
pnpm run --filter='!woocommerce' build
# Build everything that has changed since the last commit
pnpm run --filter='[HEAD^1]' build
pnpm run --filter='[HEAD^1]' build
```
### Cache busting Turbo
@ -90,3 +90,25 @@ pnpm -- wp-env destroy
Each of the [plugins in our repository](plugins) support using this tool to spin up a development environment. Note that rather than having a single top-level environment, each plugin has its own. This is done in order to prevent conflicts between them.
Please check out [the official documentation](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/) if you would like to learn more about this tool.
## Troubleshooting
### Installing PHP in Unix (e.g. Ubuntu)
Many unix systems such as Ubuntu will have PHP already installed. Sometimes without the extra packages you need to run WordPress and this will cause you to run into troubles.
Use your package manager to add the extra PHP packages you'll need.
e.g. in Ubuntu you can run:
```
sudo apt update
sudo apt install php-bcmath \
php-curl \
php-imagick \
php-intl \
php-json \
php-mbstring \
php-mysql \
php-xml \
php-zip
```

View File

@ -12,7 +12,7 @@ To get up and running within the WooCommerce Monorepo, you will need to make sur
- [NVM](https://github.com/nvm-sh/nvm#installing-and-updating): While you can always install Node through other means, we recommend using NVM to ensure you're aligned with the version used by our development teams. Our repository contains [an `.nvmrc` file](.nvmrc) which helps ensure you are using the correct version of Node.
- [PNPM](https://pnpm.io/installation): Our repository utilizes PNPM to manage project dependencies and run various scripts involved in building and testing projects.
- [PHP 7.2+](https://www.php.net/manual/en/install.php): WooCommerce Core currently features a minimum PHP version of 7.2. It is also needed to run Composer and various project build scripts.
- [PHP 7.2+](https://www.php.net/manual/en/install.php): WooCommerce Core currently features a minimum PHP version of 7.2. It is also needed to run Composer and various project build scripts. See [troubleshooting](DEVELOPMENT.md#troubleshooting) for troubleshooting problems installing PHP.
- [Composer](https://getcomposer.org/doc/00-intro.md): We use Composer to manage all of the dependencies for PHP packages and plugins.
Once you've installed all of the prerequisites, you can run the following commands to get everything working.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Make DateTimePickerControl a ForwardedRef component"

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add onKeyDown and readOnlyWhenClosed options to experimentalSelectControl

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Wrap selected items in experimental select control

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Use BaseControl in the SelectTree label

View File

@ -1,6 +1,7 @@
/**
* External dependencies
*/
import { Ref } from 'react';
import { format as formatDate } from '@wordpress/date';
import {
createElement,
@ -9,6 +10,7 @@ import {
useEffect,
useMemo,
useRef,
forwardRef,
} from '@wordpress/element';
import { Icon, calendar } from '@wordpress/icons';
import moment, { Moment } from 'moment';
@ -48,306 +50,329 @@ export type DateTimePickerControlProps = {
placeholder?: string;
help?: string | null;
onChangeDebounceWait?: number;
} & Omit< React.HTMLAttributes< HTMLDivElement >, 'onChange' >;
} & Omit< React.HTMLAttributes< HTMLInputElement >, 'onChange' >;
export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( {
currentDate,
isDateOnlyPicker = false,
is12HourPicker = true,
timeForDateOnly = 'start-of-day',
dateTimeFormat,
disabled = false,
onChange,
onBlur,
label,
placeholder,
help,
className = '',
onChangeDebounceWait = 500,
}: DateTimePickerControlProps ) => {
const instanceId = useInstanceId( DateTimePickerControl );
const id = `inspector-date-time-picker-control-${ instanceId }`;
const inputControl = useRef< InputControl >();
export const DateTimePickerControl = forwardRef(
function ForwardedDateTimePickerControl(
{
currentDate,
isDateOnlyPicker = false,
is12HourPicker = true,
timeForDateOnly = 'start-of-day',
dateTimeFormat,
disabled = false,
onChange,
onBlur,
label,
placeholder,
help,
className = '',
onChangeDebounceWait = 500,
...props
}: DateTimePickerControlProps,
ref: Ref< HTMLInputElement >
) {
const id = useInstanceId(
DateTimePickerControl,
'inspector-date-time-picker-control',
props.id
) as string;
const inputControl = useRef< InputControl >();
const displayFormat = useMemo( () => {
if ( dateTimeFormat ) {
return dateTimeFormat;
}
if ( isDateOnlyPicker ) {
return defaultDateFormat;
}
if ( is12HourPicker ) {
return default12HourDateTimeFormat;
}
return default24HourDateTimeFormat;
}, [ dateTimeFormat, isDateOnlyPicker, is12HourPicker ] );
function parseAsISODateTime(
dateString?: string | null,
assumeLocalTime = false
): Moment {
return assumeLocalTime
? moment( dateString, moment.ISO_8601, true ).utc()
: moment.utc( dateString, moment.ISO_8601, true );
}
function parseAsLocalDateTime( dateString: string | null ): Moment {
// parse input date string as local time;
// be lenient of user input and try to match any format Moment can
return moment( dateString );
}
const maybeForceTime = useCallback(
( momentDate: Moment ) => {
if ( ! isDateOnlyPicker || ! momentDate.isValid() )
return momentDate;
// We want to set to the start/end of the local time, so
// we need to put our Moment instance into "local" mode
const updatedMomentDate = momentDate.clone().local();
if ( timeForDateOnly === 'start-of-day' ) {
updatedMomentDate.startOf( 'day' );
} else if ( timeForDateOnly === 'end-of-day' ) {
updatedMomentDate.endOf( 'day' );
const displayFormat = useMemo( () => {
if ( dateTimeFormat ) {
return dateTimeFormat;
}
return updatedMomentDate;
},
[ isDateOnlyPicker, timeForDateOnly ]
);
if ( isDateOnlyPicker ) {
return defaultDateFormat;
}
function hasFocusLeftInputAndDropdownContent(
event: React.FocusEvent< HTMLInputElement >
): boolean {
return ! event.relatedTarget?.closest(
'.components-dropdown__content'
if ( is12HourPicker ) {
return default12HourDateTimeFormat;
}
return default24HourDateTimeFormat;
}, [ dateTimeFormat, isDateOnlyPicker, is12HourPicker ] );
function parseAsISODateTime(
dateString?: string | null,
assumeLocalTime = false
): Moment {
return assumeLocalTime
? moment( dateString, moment.ISO_8601, true ).utc()
: moment.utc( dateString, moment.ISO_8601, true );
}
function parseAsLocalDateTime( dateString: string | null ): Moment {
// parse input date string as local time;
// be lenient of user input and try to match any format Moment can
return moment( dateString );
}
const maybeForceTime = useCallback(
( momentDate: Moment ) => {
if ( ! isDateOnlyPicker || ! momentDate.isValid() )
return momentDate;
// We want to set to the start/end of the local time, so
// we need to put our Moment instance into "local" mode
const updatedMomentDate = momentDate.clone().local();
if ( timeForDateOnly === 'start-of-day' ) {
updatedMomentDate.startOf( 'day' );
} else if ( timeForDateOnly === 'end-of-day' ) {
updatedMomentDate.endOf( 'day' );
}
return updatedMomentDate;
},
[ isDateOnlyPicker, timeForDateOnly ]
);
function hasFocusLeftInputAndDropdownContent(
event: React.FocusEvent< HTMLInputElement >
): boolean {
return ! event.relatedTarget?.closest(
'.components-dropdown__content'
);
}
const formatDateTimeForDisplay = useCallback(
( dateTime: Moment ) => {
return dateTime.isValid()
? // @ts-expect-error TODO - fix this type error with moment
formatDate( displayFormat, dateTime.local() )
: dateTime.creationData().input?.toString() || '';
},
[ displayFormat ]
);
function formatDateTimeAsISO( dateTime: Moment ): string {
return dateTime.isValid()
? dateTime.utc().toISOString()
: dateTime.creationData().input?.toString() || '';
}
const currentDateTime = parseAsISODateTime( currentDate );
const [ inputString, setInputString ] = useState(
currentDateTime.isValid()
? formatDateTimeForDisplay( maybeForceTime( currentDateTime ) )
: ''
);
const inputStringDateTime = useMemo( () => {
return maybeForceTime( parseAsLocalDateTime( inputString ) );
}, [ inputString, maybeForceTime ] );
// We keep a ref to the onChange prop so that we can be sure we are
// always using the more up-to-date value, even if it changes
// it while a debounced onChange handler is in progress
const onChangeRef = useRef<
DateTimePickerControlOnChangeHandler | undefined
>();
useEffect( () => {
onChangeRef.current = onChange;
}, [ onChange ] );
const setInputStringAndMaybeCallOnChange = useCallback(
( newInputString: string, isUserTypedInput: boolean ) => {
// InputControl doesn't fire an onChange if what the user has typed
// matches the current value of the input field. To get around this,
// we pull the value directly out of the input field. This fixes
// the issue where the user ends up typing the same value. Unless they
// are typing extra slow. Without this workaround, we miss the last
// character typed.
const lastTypedValue = inputControl.current.value;
const newDateTime = maybeForceTime(
isUserTypedInput
? parseAsLocalDateTime( lastTypedValue )
: parseAsISODateTime( newInputString, true )
);
const isDateTimeSame =
newDateTime.isSame( inputStringDateTime );
if ( isUserTypedInput ) {
setInputString( lastTypedValue );
} else if ( ! isDateTimeSame ) {
setInputString( formatDateTimeForDisplay( newDateTime ) );
}
if (
typeof onChangeRef.current === 'function' &&
! isDateTimeSame
) {
onChangeRef.current(
newDateTime.isValid()
? formatDateTimeAsISO( newDateTime )
: lastTypedValue,
newDateTime.isValid()
);
}
},
[ formatDateTimeForDisplay, inputStringDateTime, maybeForceTime ]
);
const debouncedSetInputStringAndMaybeCallOnChange = useDebounce(
setInputStringAndMaybeCallOnChange,
onChangeDebounceWait
);
function focusInputControl() {
if ( inputControl.current ) {
inputControl.current.focus();
}
}
const getUserInputOrUpdatedCurrentDate = useCallback( () => {
if ( currentDate !== undefined ) {
const newDateTime = maybeForceTime(
parseAsISODateTime( currentDate, false )
);
if ( ! newDateTime.isValid() ) {
// keep the invalid string, so the user can correct it
return currentDate;
}
if ( ! newDateTime.isSame( inputStringDateTime ) ) {
return formatDateTimeForDisplay( newDateTime );
}
// the new currentDate is the same date as the inputString,
// so keep exactly what the user typed in
return inputString;
}
// the component is uncontrolled (not using currentDate),
// so just return the input string
return inputString;
}, [
currentDate,
formatDateTimeForDisplay,
inputString,
maybeForceTime,
] );
// We keep a ref to the onBlur prop so that we can be sure we are
// always using the more up-to-date value, otherwise, we get in
// any infinite loop when calling onBlur
const onBlurRef = useRef< () => void >();
useEffect( () => {
onBlurRef.current = onBlur;
}, [ onBlur ] );
const callOnBlurIfDropdownIsNotOpening = useCallback( ( willOpen ) => {
if ( ! willOpen && typeof onBlurRef.current === 'function' ) {
// in case the component is blurred before a debounced
// change has been processed, immediately set the input string
// to the current value of the input field, so that
// it won't be set back to the pre-change value
setInputStringAndMaybeCallOnChange(
inputControl.current.value,
true
);
onBlurRef.current();
}
}, [] );
return (
<Dropdown
className={ classNames(
'woocommerce-date-time-picker-control',
className
) }
position="bottom left"
focusOnMount={ false }
// @ts-expect-error `onToggle` does exist.
onToggle={ callOnBlurIfDropdownIsNotOpening }
renderToggle={ ( { isOpen, onClose, onToggle } ) => (
<BaseControl id={ id } label={ label } help={ help }>
<InputControl
{ ...props }
id={ id }
ref={ ( element: HTMLInputElement ) => {
inputControl.current = element;
if ( typeof ref === 'function' ) {
ref( element );
}
} }
disabled={ disabled }
value={ getUserInputOrUpdatedCurrentDate() }
onChange={ ( newValue: string ) =>
debouncedSetInputStringAndMaybeCallOnChange(
newValue,
true
)
}
onBlur={ (
event: React.FocusEvent< HTMLInputElement >
) => {
if (
hasFocusLeftInputAndDropdownContent( event )
) {
// close the dropdown, which will also trigger
// the component's onBlur to be called
onClose();
}
} }
suffix={
<Icon
icon={ calendar }
className="calendar-icon woocommerce-date-time-picker-control__input-control__suffix"
onClick={ focusInputControl }
size={ 16 }
/>
}
placeholder={ placeholder }
describedBy={ sprintf(
/* translators: A datetime format */
__(
'Date input describing a selected date in format %s',
'woocommerce'
),
dateTimeFormat
) }
onFocus={ () => {
if ( isOpen ) {
return; // the dropdown is already open, do we don't need to do anything
}
onToggle(); // show the dropdown
} }
aria-expanded={ isOpen }
/>
</BaseControl>
) }
popoverProps={ {
className: 'woocommerce-date-time-picker-control__popover',
} }
renderContent={ () => {
const Picker = isDateOnlyPicker
? DatePicker
: WpDateTimePicker;
return (
<Picker
// @ts-expect-error null is valid for currentDate
currentDate={
inputStringDateTime.isValid()
? formatDateTimeAsISO( inputStringDateTime )
: null
}
onChange={ ( newDateTimeISOString: string ) =>
setInputStringAndMaybeCallOnChange(
newDateTimeISOString,
false
)
}
is12Hour={ is12HourPicker }
/>
);
} }
/>
);
}
const formatDateTimeForDisplay = useCallback(
( dateTime: Moment ) => {
return dateTime.isValid()
? // @ts-expect-error TODO - fix this type error with moment
formatDate( displayFormat, dateTime.local() )
: dateTime.creationData().input?.toString() || '';
},
[ displayFormat ]
);
function formatDateTimeAsISO( dateTime: Moment ): string {
return dateTime.isValid()
? dateTime.utc().toISOString()
: dateTime.creationData().input?.toString() || '';
}
const currentDateTime = parseAsISODateTime( currentDate );
const [ inputString, setInputString ] = useState(
currentDateTime.isValid()
? formatDateTimeForDisplay( maybeForceTime( currentDateTime ) )
: ''
);
const inputStringDateTime = useMemo( () => {
return maybeForceTime( parseAsLocalDateTime( inputString ) );
}, [ inputString, maybeForceTime ] );
// We keep a ref to the onChange prop so that we can be sure we are
// always using the more up-to-date value, even if it changes
// it while a debounced onChange handler is in progress
const onChangeRef = useRef<
DateTimePickerControlOnChangeHandler | undefined
>();
useEffect( () => {
onChangeRef.current = onChange;
}, [ onChange ] );
const setInputStringAndMaybeCallOnChange = useCallback(
( newInputString: string, isUserTypedInput: boolean ) => {
// InputControl doesn't fire an onChange if what the user has typed
// matches the current value of the input field. To get around this,
// we pull the value directly out of the input field. This fixes
// the issue where the user ends up typing the same value. Unless they
// are typing extra slow. Without this workaround, we miss the last
// character typed.
const lastTypedValue = inputControl.current.value;
const newDateTime = maybeForceTime(
isUserTypedInput
? parseAsLocalDateTime( lastTypedValue )
: parseAsISODateTime( newInputString, true )
);
const isDateTimeSame = newDateTime.isSame( inputStringDateTime );
if ( isUserTypedInput ) {
setInputString( lastTypedValue );
} else if ( ! isDateTimeSame ) {
setInputString( formatDateTimeForDisplay( newDateTime ) );
}
if (
typeof onChangeRef.current === 'function' &&
! isDateTimeSame
) {
onChangeRef.current(
newDateTime.isValid()
? formatDateTimeAsISO( newDateTime )
: lastTypedValue,
newDateTime.isValid()
);
}
},
[ formatDateTimeForDisplay, inputStringDateTime, maybeForceTime ]
);
const debouncedSetInputStringAndMaybeCallOnChange = useDebounce(
setInputStringAndMaybeCallOnChange,
onChangeDebounceWait
);
function focusInputControl() {
if ( inputControl.current ) {
inputControl.current.focus();
}
}
const getUserInputOrUpdatedCurrentDate = useCallback( () => {
if ( currentDate !== undefined ) {
const newDateTime = maybeForceTime(
parseAsISODateTime( currentDate, false )
);
if ( ! newDateTime.isValid() ) {
// keep the invalid string, so the user can correct it
return currentDate;
}
if ( ! newDateTime.isSame( inputStringDateTime ) ) {
return formatDateTimeForDisplay( newDateTime );
}
// the new currentDate is the same date as the inputString,
// so keep exactly what the user typed in
return inputString;
}
// the component is uncontrolled (not using currentDate),
// so just return the input string
return inputString;
}, [ currentDate, formatDateTimeForDisplay, inputString, maybeForceTime ] );
// We keep a ref to the onBlur prop so that we can be sure we are
// always using the more up-to-date value, otherwise, we get in
// any infinite loop when calling onBlur
const onBlurRef = useRef< () => void >();
useEffect( () => {
onBlurRef.current = onBlur;
}, [ onBlur ] );
const callOnBlurIfDropdownIsNotOpening = useCallback( ( willOpen ) => {
if ( ! willOpen && typeof onBlurRef.current === 'function' ) {
// in case the component is blurred before a debounced
// change has been processed, immediately set the input string
// to the current value of the input field, so that
// it won't be set back to the pre-change value
setInputStringAndMaybeCallOnChange(
inputControl.current.value,
true
);
onBlurRef.current();
}
}, [] );
return (
<Dropdown
className={ classNames(
'woocommerce-date-time-picker-control',
className
) }
position="bottom left"
focusOnMount={ false }
// @ts-expect-error `onToggle` does exist.
onToggle={ callOnBlurIfDropdownIsNotOpening }
renderToggle={ ( { isOpen, onClose, onToggle } ) => (
<BaseControl id={ id } label={ label } help={ help }>
<InputControl
id={ id }
ref={ inputControl }
disabled={ disabled }
value={ getUserInputOrUpdatedCurrentDate() }
onChange={ ( newValue: string ) =>
debouncedSetInputStringAndMaybeCallOnChange(
newValue,
true
)
}
onBlur={ (
event: React.FocusEvent< HTMLInputElement >
) => {
if (
hasFocusLeftInputAndDropdownContent( event )
) {
// close the dropdown, which will also trigger
// the component's onBlur to be called
onClose();
}
} }
suffix={
<Icon
icon={ calendar }
className="calendar-icon woocommerce-date-time-picker-control__input-control__suffix"
onClick={ focusInputControl }
size={ 16 }
/>
}
placeholder={ placeholder }
describedBy={ sprintf(
/* translators: A datetime format */
__(
'Date input describing a selected date in format %s',
'woocommerce'
),
dateTimeFormat
) }
onFocus={ () => {
if ( isOpen ) {
return; // the dropdown is already open, do we don't need to do anything
}
onToggle(); // show the dropdown
} }
aria-expanded={ isOpen }
/>
</BaseControl>
) }
popoverProps={ {
className: 'woocommerce-date-time-picker-control__popover',
} }
renderContent={ () => {
const Picker = isDateOnlyPicker ? DatePicker : WpDateTimePicker;
return (
<Picker
// @ts-expect-error null is valid for currentDate
currentDate={
inputStringDateTime.isValid()
? formatDateTimeAsISO( inputStringDateTime )
: null
}
onChange={ ( newDateTimeISOString: string ) =>
setInputStringAndMaybeCallOnChange(
newDateTimeISOString,
false
)
}
is12Hour={ is12HourPicker }
/>
);
} }
/>
);
};
);

View File

@ -105,4 +105,6 @@ Name | Type | Default | Description
`onInputChange` | Function | `() => null` | A callback that fires when the user input has changed
`onRemove` | Function | `() => null` | A callback that fires when a selected item has been removed
`onSelect` | Function | `() => null` | A callback that fires when an item has been selected
`selected` | Array or Item | `undefined` | An array of selected items or a single selected item
`selected` | Array or Item | `undefined` | An array of selected items or a single selected item\
`onKeyDown` | Function | `() => null` | A callback that fires when a key is pressed
`readOnlyWhenClosed` | Boolean | `false` | Whether the input should be read-only when the menu is closed

View File

@ -18,7 +18,6 @@
.woocommerce-experimental-select-control__items-wrapper {
display: flex;
flex-grow: 1;
flex-wrap: wrap;
align-items: center;
padding: 2px $gap-smaller;

View File

@ -58,6 +58,7 @@ export type SelectControlProps< ItemType > = {
) => void;
onRemove?: ( item: ItemType ) => void;
onSelect?: ( selected: ItemType ) => void;
onKeyDown?: ( e: KeyboardEvent ) => void;
onFocus?: ( data: { inputValue: string } ) => void;
stateReducer?: (
state: UseComboboxState< ItemType | null >,
@ -70,6 +71,8 @@ export type SelectControlProps< ItemType > = {
inputProps?: GetInputPropsOptions;
suffix?: JSX.Element | null;
showToggleButton?: boolean;
readOnlyWhenClosed?: boolean;
/**
* This is a feature already implemented in downshift@7.0.0 through the
* reducer. In order for us to use it this prop is added temporarily until
@ -118,6 +121,7 @@ function SelectControl< ItemType = DefaultItemType >( {
onRemove = () => null,
onSelect = () => null,
onFocus = () => null,
onKeyDown = () => null,
stateReducer = ( state, actionAndChanges ) => actionAndChanges.changes,
placeholder,
selected,
@ -126,6 +130,7 @@ function SelectControl< ItemType = DefaultItemType >( {
inputProps = {},
suffix = <SuffixIcon icon={ chevronDown } />,
showToggleButton = false,
readOnlyWhenClosed = true,
__experimentalOpenMenuOnFocus = false,
}: SelectControlProps< ItemType > ) {
const [ isFocused, setIsFocused ] = useState( false );
@ -247,7 +252,7 @@ function SelectControl< ItemType = DefaultItemType >( {
onRemove( item );
};
const isReadOnly = ! isOpen && ! isFocused;
const isReadOnly = readOnlyWhenClosed && ! isOpen && ! isFocused;
const selectedItemTags = multiple ? (
<SelectedItems
@ -305,6 +310,7 @@ function SelectControl< ItemType = DefaultItemType >( {
setIsFocused( false );
}
},
onKeyDown,
placeholder,
disabled,
...inputProps,

View File

@ -1,7 +1,11 @@
.woocommerce-experimental-select-control__selected-items.is-read-only {
font-size: 13px;
color: $gray-900;
font-family: var(--wp--preset--font-family--system-font);
.woocommerce-experimental-select-control__selected-items {
flex-wrap: wrap;
&.is-read-only {
font-size: 13px;
color: $gray-900;
font-family: var(--wp--preset--font-family--system-font);
}
}
.woocommerce-experimental-select-control__selected-item {

View File

@ -5,7 +5,7 @@ import { chevronDown } from '@wordpress/icons';
import classNames from 'classnames';
import { createElement, useState } from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose';
import { TextControl } from '@wordpress/components';
import { BaseControl, TextControl } from '@wordpress/components';
/**
* Internal dependencies
@ -130,61 +130,55 @@ export const SelectTree = function SelectTree( {
}
) }
>
<label
htmlFor={ `${ props.id }-input` }
id={ `${ props.id }-label` }
className="woocommerce-experimental-select-control__label"
>
{ props.label }
</label>
{ props.multiple ? (
<ComboBox
comboBoxProps={ {
className:
'woocommerce-experimental-select-control__combo-box-wrapper',
role: 'combobox',
'aria-expanded': isOpen,
'aria-haspopup': 'tree',
'aria-labelledby': `${ props.id }-label`,
'aria-owns': `${ props.id }-menu`,
} }
inputProps={ inputProps }
suffix={ suffix }
>
<SelectedItems
isReadOnly={ isReadOnly }
items={ ( props.selected as Item[] ) || [] }
getItemLabel={ ( item ) => item?.label || '' }
getItemValue={ ( item ) => item?.value || '' }
onRemove={ ( item ) => {
if (
! Array.isArray( item ) &&
props.onRemove
) {
props.onRemove( item );
<BaseControl label={ props.label } id={ `${ props.id }-input` }>
{ props.multiple ? (
<ComboBox
comboBoxProps={ {
className:
'woocommerce-experimental-select-control__combo-box-wrapper',
role: 'combobox',
'aria-expanded': isOpen,
'aria-haspopup': 'tree',
'aria-owns': `${ props.id }-menu`,
} }
inputProps={ inputProps }
suffix={ suffix }
>
<SelectedItems
isReadOnly={ isReadOnly }
items={ ( props.selected as Item[] ) || [] }
getItemLabel={ ( item ) => item?.label || '' }
getItemValue={ ( item ) => item?.value || '' }
onRemove={ ( item ) => {
if (
! Array.isArray( item ) &&
props.onRemove
) {
props.onRemove( item );
}
} }
getSelectedItemProps={ () => ( {} ) }
/>
</ComboBox>
) : (
<TextControl
{ ...inputProps }
value={ props.createValue || '' }
onChange={ ( value ) => {
if ( onInputChange ) onInputChange( value );
const item = items.find(
( i ) => i.label === value
);
if ( props.onSelect && item ) {
props.onSelect( item );
}
if ( ! value && props.onRemove ) {
props.onRemove( props.selected as Item );
}
} }
getSelectedItemProps={ () => ( {} ) }
/>
</ComboBox>
) : (
<TextControl
{ ...inputProps }
value={ props.createValue || '' }
onChange={ ( value ) => {
if ( onInputChange ) onInputChange( value );
const item = items.find(
( i ) => i.label === value
);
if ( props.onSelect && item ) {
props.onSelect( item );
}
if ( ! value && props.onRemove ) {
props.onRemove( props.selected as Item );
}
} }
/>
) }
) }
</BaseControl>
</div>
<SelectTreeMenu
{ ...props }

View File

@ -33,34 +33,36 @@ export const Tree = forwardRef( function ForwardedTree(
return (
<>
<ol
{ ...treeProps }
className={ classNames(
treeProps.className,
'experimental-woocommerce-tree',
`experimental-woocommerce-tree--level-${ level }`
) }
>
{ items.map( ( child, index ) => (
<TreeItem
{ ...treeItemProps }
isExpanded={ props.isExpanded }
key={ child.data.value }
item={ child }
index={ index }
// Button ref is not working, so need to use CSS directly
onLastItemLoop={ () => {
(
rootListRef.current
?.closest( 'ol[role="tree"]' )
?.parentElement?.querySelector(
'.experimental-woocommerce-tree__button'
) as HTMLButtonElement
)?.focus();
} }
/>
) ) }
</ol>
{ items.length || isCreateButtonVisible ? (
<ol
{ ...treeProps }
className={ classNames(
treeProps.className,
'experimental-woocommerce-tree',
`experimental-woocommerce-tree--level-${ level }`
) }
>
{ items.map( ( child, index ) => (
<TreeItem
{ ...treeItemProps }
isExpanded={ props.isExpanded }
key={ child.data.value }
item={ child }
index={ index }
// Button ref is not working, so need to use CSS directly
onLastItemLoop={ () => {
(
rootListRef.current
?.closest( 'ol[role="tree"]' )
?.parentElement?.querySelector(
'.experimental-woocommerce-tree__button'
) as HTMLButtonElement
)?.focus();
} }
/>
) ) }
</ol>
) : null }
{ isCreateButtonVisible && (
<Button
className="experimental-woocommerce-tree__button"

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Added types for resolveSelect where applicable.

View File

@ -14,6 +14,7 @@ import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer, { State } from './reducer';
import { WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -35,4 +36,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -13,6 +13,7 @@ import * as actions from './actions';
import reducer, { State } from './reducer';
import { WPDataSelectors } from '../types';
import controls from '../controls';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -33,4 +34,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -14,6 +14,7 @@ import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer, { State } from './reducer';
import { WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -35,4 +36,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -15,6 +15,7 @@ import reducer, { State } from './reducer';
import controls from '../controls';
import { WPDataActions, WPDataSelectors } from '../types';
import { getItemsType } from './selectors';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -40,4 +41,7 @@ declare module '@wordpress/data' {
key: typeof STORE_NAME
): DispatchFromMap< typeof actions & WPDataActions >;
function select( key: typeof STORE_NAME ): ItemsSelector;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< ItemsSelector >;
}

View File

@ -16,6 +16,7 @@ import reducer, { State } from './reducer';
import * as resolvers from './resolvers';
import initDispatchers from './dispatchers';
import { WPDataActions, WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
registerStore< State >( STORE_NAME, {
reducer: reducer as Reducer< State, AnyAction >,
@ -35,4 +36,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -15,6 +15,7 @@ import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer, { State } from './reducer';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -36,4 +37,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -14,6 +14,7 @@ import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer, { State } from './reducer';
import { WPDataActions, WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -35,4 +36,7 @@ declare module '@wordpress/data' {
key: typeof STORE_NAME
): DispatchFromMap< typeof actions & WPDataActions >;
function select( key: typeof STORE_NAME ): OnboardingSelector;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< OnboardingSelector >;
}

View File

@ -14,6 +14,7 @@ import * as resolvers from './resolvers';
import reducer, { State } from './reducer';
import { controls } from './controls';
import { WPDataActions, WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -34,4 +35,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -14,6 +14,7 @@ import * as selectors from './selectors';
import reducer from './reducer';
import { STORE_KEY } from './constants';
import { WPDataActions } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export const PAYMENT_GATEWAYS_STORE_NAME = STORE_KEY;
@ -33,4 +34,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_KEY
): SelectFromMap< typeof selectors > & WPDataActions;
function resolveSelect(
key: typeof STORE_KEY
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -13,6 +13,7 @@ import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer, { State } from './reducer';
import { WPDataActions, WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -33,4 +34,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -14,6 +14,7 @@ import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer, { State } from './reducer';
import { WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -34,4 +35,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -24,6 +24,7 @@ import {
ReportStatObjectInfer,
ReportStatQueryParams,
} from './types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -57,4 +58,7 @@ declare module '@wordpress/data' {
key: typeof STORE_NAME
): DispatchFromMap< typeof actions & WPDataActions >;
function select( key: typeof STORE_NAME ): ReportsSelect;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< ReportsSelect >;
}

View File

@ -16,6 +16,7 @@ import * as resolvers from './resolvers';
import controls from '../controls';
import reducer, { State } from './reducer';
import { WPDataActions, WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -39,4 +40,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< ReviewSelector >;
}

View File

@ -17,6 +17,7 @@ import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer, { State } from './reducer';
import { SettingsState } from './types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -37,4 +38,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

@ -0,0 +1,13 @@
/**
* Type helper that maps select() return types to their resolveSelect() return types.
* This works by mapping over each Selector, and returning a function that
* returns a Promise of the Selector's return type.
*/
export type PromiseifySelectors< Selectors > = {
[ SelectorFunction in keyof Selectors ]: Selectors[ SelectorFunction ] extends (
...args: infer SelectorArgs
) => infer SelectorReturnType
? ( ...args: SelectorArgs ) => Promise< SelectorReturnType >
: never;
};

View File

@ -5,6 +5,10 @@ import { setLocaleData } from '@wordpress/i18n';
import { registerStore } from '@wordpress/data';
import 'regenerator-runtime/runtime';
// Mock the config module to avoid errors like:
// Core Error: Could not find config value for key ${ key }. Please make sure that if you need it then it has a default value assigned in config/_shared.json.
jest.mock( '@automattic/calypso-config' );
// Due to the dependency @wordpress/compose which introduces the use of
// ResizeObserver this global mock is required for some tests to work.
global.ResizeObserver = require( 'resize-observer-polyfill' );

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Moved geolocation country matching functions to @woocommerce/onboarding

View File

@ -0,0 +1,4 @@
{
"rootDir": "./src",
"preset": "../node_modules/@woocommerce/internal-js-tests/jest-preset.js"
}

View File

@ -37,14 +37,19 @@
"@wordpress/components": "wp-6.0",
"@wordpress/element": "wp-6.0",
"@wordpress/i18n": "wp-6.0",
"string-similarity": "4.0.4",
"gridicons": "^3.4.0"
},
"devDependencies": {
"@babel/core": "^7.17.5",
"@testing-library/react": "^12.1.3",
"@types/string-similarity" : "4.0.0",
"@types/wordpress__components": "^19.10.3",
"@types/wordpress__data": "^6.0.0",
"@types/jest": "^27.4.1",
"@woocommerce/eslint-plugin": "workspace:*",
"@woocommerce/internal-style-build": "workspace:*",
"@woocommerce/internal-js-tests": "workspace:*",
"@wordpress/browserslist-config": "wp-6.0",
"css-loader": "^3.6.0",
"eslint": "^8.32.0",
@ -62,6 +67,7 @@
},
"scripts": {
"turbo:build": "pnpm run build:js && pnpm run build:css",
"turbo:test": "jest --config ./jest.config.json",
"prepare": "composer install",
"changelog": "composer exec -- changelogger",
"clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*",
@ -70,12 +76,15 @@
"build:js": "tsc --project tsconfig.json && tsc --project tsconfig-cjs.json",
"build:css": "webpack",
"start": "concurrently \"tsc --project tsconfig.json --watch\" \"tsc --project tsconfig-cjs.json --watch\" \"webpack --watch\"",
"test": "pnpm -w exec turbo run turbo:test --filter=$npm_package_name",
"prepack": "pnpm run clean && pnpm run build",
"lint:fix": "eslint src --fix"
"lint:fix": "eslint src --fix",
"test-staged": "jest --bail --config ./jest.config.json --findRelatedTests"
},
"lint-staged": {
"*.(t|j)s?(x)": [
"pnpm lint:fix"
"pnpm lint:fix",
"pnpm test-staged"
]
}
}

View File

@ -15,3 +15,4 @@ export { WooPaymentGatewayConfigure } from './components/WooPaymentGatewayConfig
export { WooOnboardingTaskListItem } from './components/WooOnboardingTaskListItem';
export { WooOnboardingTaskListHeader } from './components/WooOnboardingTaskListHeader';
export { WooOnboardingTask } from './components/WooOnboardingTask';
export * from './utils/countries';

View File

@ -0,0 +1,79 @@
/**
* External dependencies
*/
import stringSimilarity from 'string-similarity';
/**
* Internal dependencies
*/
import { getMappingRegion } from './location-mapping';
/**
* Country state option.
*/
export type CountryStateOption = {
key: string;
label: string;
};
export type Location = {
country_short?: string;
region?: string;
city?: string;
};
/**
* Returns a country option for the given location.
*/
export const findCountryOption = (
countryStateOptions: CountryStateOption[],
location: Location | undefined,
minimumSimilarity = 0.7
) => {
if ( ! location ) {
return null;
}
let match = null;
let matchSimilarity = minimumSimilarity;
// eslint-disable-next-line @wordpress/no-unused-vars-before-return -- don't want to put this inside the loop
const mappingRegion = getMappingRegion( location );
for ( const option of countryStateOptions ) {
// Country matches exactly.
if ( option.key === location.country_short ) {
return option;
}
// Countries have regions such as 'US:CA'.
const countryCode = option.key.split( ':' )[ 0 ];
if (
countryCode === location.country_short &&
option.label.includes( '—' )
) {
const wcRegion = option.label.split( '—' )[ 1 ].trim();
// Region matches exactly with mapping.
if ( mappingRegion === wcRegion ) {
return option;
}
// Find the region with the highest similarity.
const similarity = Math.max(
stringSimilarity.compareTwoStrings(
wcRegion,
location.region || ''
),
stringSimilarity.compareTwoStrings(
wcRegion,
location.city || ''
)
);
if ( similarity >= matchSimilarity ) {
match = option;
matchSimilarity = similarity;
}
}
}
return match;
};

View File

@ -0,0 +1,58 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { Location } from '.';
/**
* This file is used to map a location to a WC region label
* so that we can find the correct country option
* since WPCOM geolocation returns a different
* region or city name.
*/
// Key is the country code, value is an object with keys as region/city names and values as WC region labels.
const MAPPING: Record< string, Record< string, string > > = {
PH: {
'National Capital Region': __( 'Metro Manila', 'woocommerce' ),
},
IT: {
Rome: __( 'Roma', 'woocommerce' ),
},
};
/**
* Returns a WC mapping region name for the given country, region and city.
*/
export const getMappingRegion = ( {
country_short: countryCode,
region = '',
city = '',
}: Location ) => {
if ( ! countryCode ) {
return null;
}
const countryMapping = MAPPING[ countryCode ];
if ( ! countryMapping ) {
return null;
}
const regionMapping = countryMapping[ region ];
if ( regionMapping ) {
return regionMapping;
}
const cityMapping = countryMapping[ city ];
if ( cityMapping ) {
return cityMapping;
}
return null;
};
export default MAPPING;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,96 @@
/* eslint-disable camelcase */
/* eslint-disable no-undef */
/**
* Internal dependencies
*/
import { findCountryOption } from '../../';
import { countryStateOptions } from './country-options';
import { locations } from './locations';
describe( 'findCountryOption', () => {
it( 'should return null on undefined location', () => {
const location = undefined;
expect( findCountryOption( countryStateOptions, location ) ).toEqual(
null
);
} );
it( 'should return null when not found', () => {
const location = { country_short: 'US' };
expect( findCountryOption( countryStateOptions, location ) ).toBeNull();
} );
it.each( [
{
location: { country_short: 'TW' },
expected: {
key: 'TW',
label: 'Taiwan',
},
},
{
location: { country_short: 'US', region: 'California' },
expected: {
key: 'US:CA',
label: 'United States (US) — California',
},
},
{
location: { country_short: 'ES', region: 'Madrid, Comunidad de' },
expected: {
key: 'ES:M',
label: 'Spain — Madrid',
},
},
{
location: {
country_short: 'AR',
region: 'Ciudad Autonoma de Buenos Aires',
},
expected: {
key: 'AR:C',
label: 'Argentina — Ciudad Autónoma de Buenos Aires',
},
},
{
location: {
country_short: 'IT',
region: 'Lazio',
city: 'Rome',
},
expected: {
key: 'IT:RM',
label: 'Italy — Roma',
},
},
{
location: {
country_short: 'PH',
region: 'National Capital Region',
city: 'Makati',
},
expected: {
key: 'PH:00',
label: 'Philippines — Metro Manila',
},
},
] )(
'should return the country option for location $expected',
( { location, expected } ) => {
expect(
findCountryOption( countryStateOptions, location, 0.4 )
).toEqual( expected );
}
);
it( 'should return a country option for > 98% locations', () => {
let matchCount = 0;
locations.forEach( ( location ) => {
if ( findCountryOption( countryStateOptions, location, 0.4 ) ) {
matchCount++;
}
} );
expect( matchCount / locations.length ).toBeGreaterThan( 0.98 );
} );
} );

View File

@ -0,0 +1,41 @@
/* eslint-disable camelcase */
/**
* Internal dependencies
*/
import { getMappingRegion } from '../../location-mapping';
describe( 'getMappingRegion', () => {
it( 'should return null for an empty location', () => {
expect( getMappingRegion( {} ) ).toBeNull();
} );
it( 'should return null for a location that is not in the mapping', () => {
expect(
getMappingRegion( { country_short: 'US', region: 'California' } )
).toBeNull();
} );
it( 'should return null for a location with no region', () => {
expect( getMappingRegion( { country_short: 'PH' } ) ).toBeNull();
} );
it( 'should return the region for a location that is in the mapping with a region', () => {
expect(
getMappingRegion( {
country_short: 'PH',
region: 'National Capital Region',
} )
).toBe( 'Metro Manila' );
} );
it( 'should return the region for a location that is in the mapping with a city', () => {
expect(
getMappingRegion( {
country_short: 'IT',
region: 'Lazio',
city: 'Rome',
} )
).toBe( 'Roma' );
} );
} );

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +0,0 @@
<svg width="24" height="25" viewBox="0 0 24 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.545 14.4296L13.9351 20.0409C13.7898 20.1865 13.6172 20.3019 13.4272 20.3807C13.2373 20.4595 13.0336 20.5 12.828 20.5C12.6224 20.5 12.4187 20.4595 12.2288 20.3807C12.0388 20.3019 11.8662 20.1865 11.7209 20.0409L5 13.3261V5.5H12.8241L19.545 12.2226C19.8364 12.5159 20 12.9126 20 13.3261C20 13.7396 19.8364 14.1363 19.545 14.4296V14.4296Z" stroke="#1E1E1E" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="9" cy="9.5" r="1" fill="#1E1E1E"/>
</svg>

Before

Width:  |  Height:  |  Size: 585 B

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" view-box="0 0 24 24">
<path
fill-rule="evenodd"
d="M5 5.5h14a.5.5 0 01.5.5v1.5a.5.5 0 01-.5.5H5a.5.5 0 01-.5-.5V6a.5.5 0 01.5-.5zM4 9.232A2 2 0 013 7.5V6a2 2 0 012-2h14a2 2 0 012 2v1.5a2 2 0 01-1 1.732V18a2 2 0 01-2 2H6a2 2 0 01-2-2V9.232zm1.5.268V18a.5.5 0 00.5.5h12a.5.5 0 00.5-.5V9.5h-13z"
clip-rule="evenodd"
/>
</svg>

Before

Width:  |  Height:  |  Size: 371 B

View File

@ -1,14 +0,0 @@
<svg view-box="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19 5H5C4.72386 5 4.5 5.22386 4.5 5.5V19.5C4.5 19.7761 4.72386 20 5 20H19C19.2761 20 19.5 19.7761 19.5 19.5V5.5C19.5 5.22386 19.2761 5 19 5ZM5 3.5C3.89543 3.5 3 4.39543 3 5.5V19.5C3 20.6046 3.89543 21.5 5 21.5H19C20.1046 21.5 21 20.6046 21 19.5V5.5C21 4.39543 20.1046 3.5 19 3.5H5Z"
fill="#1E1E1E"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.4772 10.9623C15.7683 10.6792 16.2317 10.6792 16.5228 10.9623L20.5228 14.8511L19.4772 15.9266L16 12.546L12.5228 15.9266C12.2719 16.1706 11.8857 16.2086 11.5921 16.0183L8.59643 14.0766L4.44186 17.106L3.55811 15.894L8.12953 12.5607C8.38061 12.3776 8.71858 12.3683 8.97934 12.5373L11.906 14.4342L15.4772 10.9623Z"
fill="#1E1E1E"
/>
</svg>

Before

Width:  |  Height:  |  Size: 820 B

View File

@ -1,8 +0,0 @@
<svg view-box="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6 6H18C18.2761 6 18.5 6.22386 18.5 6.5V13.5H15.5H14C14 14.6046 13.1046 15.5 12 15.5C10.8954 15.5 10 14.6046 10 13.5H8.5H5.5V6.5C5.5 6.22386 5.72386 6 6 6ZM5.5 15V18.5C5.5 18.7761 5.72386 19 6 19H18C18.2761 19 18.5 18.7761 18.5 18.5V15H15.1632C14.6015 16.1825 13.3962 17 12 17C10.6038 17 9.39855 16.1825 8.83682 15H5.5ZM4 13.5V6.5C4 5.39543 4.89543 4.5 6 4.5H18C19.1046 4.5 20 5.39543 20 6.5V13.5V15V18.5C20 19.6046 19.1046 20.5 18 20.5H6C4.89543 20.5 4 19.6046 4 18.5V15V13.5Z"
fill="#1E1E1E"
/>
</svg>

Before

Width:  |  Height:  |  Size: 625 B

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" view-box="0 0 24 24">
<path
fill-rule="evenodd"
d="M16.83 6.342l.602.3.625-.25.443-.176v12.569l-.443-.178-.625-.25-.603.301-1.444.723-2.41-.804-.475-.158-.474.158-2.41.803-1.445-.722-.603-.3-.625.25-.443.177V6.215l.443.178.625.25.603-.301 1.444-.722 2.41.803.475.158.474-.158 2.41-.803 1.445.722zM20 4l-1.5.6-1 .4-2-1-3 1-3-1-2 1-1-.4L5 4v17l1.5-.6 1-.4 2 1 3-1 3 1 2-1 1 .4 1.5.6V4zm-3.5 6.25v-1.5h-8v1.5h8zm0 3v-1.5h-8v1.5h8zm-8 3v-1.5h8v1.5h-8z"
clip-rule="evenodd"
/>
</svg>

Before

Width:  |  Height:  |  Size: 527 B

View File

@ -1,8 +0,0 @@
<svg view-box="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M3.5 7.75C3.5 6.7835 4.2835 6 5.25 6H14.75H15.5V6.75V9H17.25H17.5607L17.7803 9.21967L20.7803 12.2197L21 12.4393V12.75V14.75C21 15.7165 20.2165 16.5 19.25 16.5H19.2377C19.2458 16.5822 19.25 16.6656 19.25 16.75C19.25 18.1307 18.1307 19.25 16.75 19.25C15.3693 19.25 14.25 18.1307 14.25 16.75C14.25 16.6656 14.2542 16.5822 14.2623 16.5H14H10.2377C10.2458 16.5822 10.25 16.6656 10.25 16.75C10.25 18.1307 9.13071 19.25 7.75 19.25C6.36929 19.25 5.25 18.1307 5.25 16.75C5.25 16.6656 5.25418 16.5822 5.26234 16.5H4.25H3.5V15.75V7.75ZM14 15V9.75V9V7.5H5.25C5.11193 7.5 5 7.61193 5 7.75V15H5.96464C6.41837 14.5372 7.05065 14.25 7.75 14.25C8.44935 14.25 9.08163 14.5372 9.53536 15H14ZM18.5354 15H19.25C19.3881 15 19.5 14.8881 19.5 14.75V13.0607L16.9393 10.5H15.5V14.5845C15.8677 14.3717 16.2946 14.25 16.75 14.25C17.4493 14.25 18.0816 14.5372 18.5354 15ZM6.7815 16.5C6.76094 16.5799 6.75 16.6637 6.75 16.75C6.75 17.3023 7.19772 17.75 7.75 17.75C8.30228 17.75 8.75 17.3023 8.75 16.75C8.75 16.6637 8.73906 16.5799 8.7185 16.5C8.60749 16.0687 8.21596 15.75 7.75 15.75C7.28404 15.75 6.89251 16.0687 6.7815 16.5ZM15.7815 16.5C15.7609 16.5799 15.75 16.6637 15.75 16.75C15.75 17.3023 16.1977 17.75 16.75 17.75C17.3023 17.75 17.75 17.3023 17.75 16.75C17.75 16.6637 17.7391 16.5799 17.7185 16.5C17.7144 16.4841 17.7099 16.4683 17.705 16.4526C17.5784 16.0456 17.1987 15.75 16.75 15.75C16.284 15.75 15.8925 16.0687 15.7815 16.5Z"
fill="#1E1E1E"
/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Fix validation behavior#37984

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Update shipping class block to match new designs#38044

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Only register blocks when user navigates to the product edit page#38200

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add error specific messages to product save functionality

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Make DateTimePickerControl a ForwardedRef

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Wait for editor changes to be debounced before closing modal

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Fix double scrollbars on product editor page

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
List price gets reset on blur when error is shown#38221

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Remove css unrelated to the product block editor.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Limit 'email me when stock reaches' field to numerical only#38244

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Adjust styling based on internal styling feedback

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Use SelectTree in the Parent Category field

View File

@ -45,7 +45,7 @@ export function Edit( { attributes }: { attributes: BlockAttributes } ) {
<span className="woocommerce-product-form__checkbox-tooltip-icon">
<Icon
icon={ help }
size={ 20 }
size={ 22 }
fill="#949494"
/>
</span>

View File

@ -3,6 +3,7 @@
.components-base-control__field {
display: flex;
align-items: center;
}
.components-checkbox-control__label {
@ -11,6 +12,8 @@
&-tooltip-icon {
margin: -2px 0 0 $gap-small;
display: flex;
align-items: center;
}
}

View File

@ -13,8 +13,8 @@ export { init as initRegularPrice } from './regular-price';
export { init as initSalePrice } from './sale-price';
export { init as initScheduleSale } from './schedule-sale';
export { init as initSection } from './section';
export { init as initShippingClass } from './shipping-class';
export { init as initShippingDimensions } from './shipping-dimensions';
export { init as initShippingFee } from './shipping-fee';
export { init as initSummary } from './summary';
export { init as initTab } from './tab';
export { init as initInventoryQuantity } from './inventory-quantity';

View File

@ -3,16 +3,16 @@
*/
import { __, sprintf } from '@wordpress/i18n';
import { Link } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import {
createElement,
Fragment,
createInterpolateElement,
} from '@wordpress/element';
import { getSetting } from '@woocommerce/settings';
import { BlockEditProps } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import { useInstanceId } from '@wordpress/compose';
import {
BaseControl,
// @ts-expect-error `__experimentalInputControl` does exist.
@ -23,54 +23,91 @@ import {
// eslint-disable-next-line @woocommerce/dependency-group
import { useEntityProp } from '@wordpress/core-data';
export function Edit() {
/**
* Internal dependencies
*/
import { useValidation } from '../../contexts/validation-context';
import { InventoryEmailBlockAttributes } from './types';
export function Edit( {
clientId,
}: BlockEditProps< InventoryEmailBlockAttributes > ) {
const blockProps = useBlockProps();
const notifyLowStockAmount = getSetting( 'notifyLowStockAmount', 2 );
const [ lowStockAmount, setLowStockAmount ] = useEntityProp(
const [ lowStockAmount, setLowStockAmount ] = useEntityProp< number >(
'postType',
'product',
'low_stock_amount'
);
const id = useInstanceId( BaseControl, 'low_stock_amount' ) as string;
const {
ref: lowStockAmountRef,
error: lowStockAmountValidationError,
validate: validateLowStockAmount,
} = useValidation< Product >(
`low_stock_amount-${ clientId }`,
async function stockQuantityValidator() {
if ( lowStockAmount && lowStockAmount < 0 ) {
return __(
'This field must be a positive number.',
'woocommerce'
);
}
},
[ lowStockAmount ]
);
return (
<>
<div { ...blockProps }>
<div className="wp-block-columns">
<div className="wp-block-column">
<BaseControl
id={ 'product_inventory_email' }
id={ id }
label={ __(
'Email me when stock reaches',
'woocommerce'
) }
help={ createInterpolateElement(
__(
'Make sure to enable notifications in <link>store settings.</link>',
'woocommerce'
),
{
link: (
<Link
href={ `${ getSetting(
'adminUrl'
) }admin.php?page=wc-settings&tab=products&section=inventory` }
target="_blank"
type="external"
></Link>
help={
lowStockAmountValidationError ||
createInterpolateElement(
__(
'Make sure to enable notifications in <link>store settings.</link>',
'woocommerce'
),
}
) }
{
link: (
<Link
href={ `${ getSetting(
'adminUrl'
) }admin.php?page=wc-settings&tab=products&section=inventory` }
target="_blank"
type="external"
></Link>
),
}
)
}
className={
lowStockAmountValidationError && 'has-error'
}
>
<InputControl
name={ 'woocommerce-product-name' }
id={ id }
ref={ lowStockAmountRef }
name={ 'low_stock_amount' }
placeholder={ sprintf(
// translators: Default quantity to notify merchants of low stock.
__( '%d (store default)', 'woocommerce' ),
notifyLowStockAmount
) }
onChange={ setLowStockAmount }
onBlur={ validateLowStockAmount }
value={ lowStockAmount }
type="number"
min={ 0 }
/>
</BaseControl>

View File

@ -1,22 +1,29 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { BlockConfiguration } from '@wordpress/blocks';
/**
* Internal dependencies
*/
import { initBlock } from '../../utils';
import metadata from './block.json';
import { initBlock } from '../../utils/init-blocks';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { InventoryEmailBlockAttributes } from './types';
const { name } = metadata;
const { name, ...metadata } =
blockConfiguration as BlockConfiguration< InventoryEmailBlockAttributes >;
export { metadata, name };
export const settings = {
export const settings: Partial<
BlockConfiguration< InventoryEmailBlockAttributes >
> = {
example: {},
edit: Edit,
};
export const init = () =>
initBlock( {
name,
metadata: metadata as never,
settings,
} );
export function init() {
return initBlock( { name, metadata, settings } );
}

View File

@ -0,0 +1,8 @@
/**
* External dependencies
*/
import { BlockAttributes } from '@wordpress/blocks';
export interface InventoryEmailBlockAttributes extends BlockAttributes {
name: string;
}

View File

@ -1,6 +1,7 @@
/**
* External dependencies
*/
import { Product } from '@woocommerce/data';
import { BlockEditProps } from '@wordpress/blocks';
import { useBlockProps } from '@wordpress/block-editor';
import { useInstanceId } from '@wordpress/compose';
@ -17,10 +18,11 @@ import {
* Internal dependencies
*/
import { TrackInventoryBlockAttributes } from './types';
import { useValidation } from '../../contexts/validation-context';
import { useValidation } from '../../hooks/use-validation';
export function Edit( {}: BlockEditProps< TrackInventoryBlockAttributes > ) {
export function Edit( {
clientId,
}: BlockEditProps< TrackInventoryBlockAttributes > ) {
const blockProps = useBlockProps();
const [ manageStock ] = useEntityProp< boolean >(
@ -40,16 +42,21 @@ export function Edit( {}: BlockEditProps< TrackInventoryBlockAttributes > ) {
'product_stock_quantity'
) as string;
const stockQuantityValidationError = useValidation(
'product/stock_quantity',
function stockQuantityValidator() {
const {
ref: stockQuantityRef,
error: stockQuantityValidationError,
validate: validateStockQuantity,
} = useValidation< Product >(
`stock_quantity-${ clientId }`,
async function stockQuantityValidator() {
if ( manageStock && stockQuantity && stockQuantity < 0 ) {
return __(
'Stock quantity must be a positive number.',
'woocommerce'
);
}
}
},
[ manageStock, stockQuantity ]
);
useEffect( () => {
@ -72,9 +79,11 @@ export function Edit( {}: BlockEditProps< TrackInventoryBlockAttributes > ) {
<InputControl
id={ stockQuantityId }
name="stock_quantity"
ref={ stockQuantityRef }
label={ __( 'Available quantity', 'woocommerce' ) }
value={ stockQuantity }
onChange={ setStockQuantity }
onBlur={ validateStockQuantity }
type="number"
min={ 0 }
/>

View File

@ -35,7 +35,7 @@ import { useEntityProp, useEntityId } from '@wordpress/core-data';
*/
import { AUTO_DRAFT_NAME } from '../../utils';
import { EditProductLinkModal } from '../../components/edit-product-link-modal';
import { useValidation } from '../../hooks/use-validation';
import { useValidation } from '../../contexts/validation-context';
export function Edit() {
const blockProps = useBlockProps();
@ -77,9 +77,13 @@ export function Edit() {
}
);
const nameValidationError = useValidation(
'product/name',
function nameValidator() {
const {
ref: nameRef,
error: nameValidationError,
validate: validateName,
} = useValidation< Product >(
'name',
async function nameValidator() {
if ( ! name || name === AUTO_DRAFT_NAME ) {
return __( 'This field is required.', 'woocommerce' );
}
@ -90,7 +94,8 @@ export function Edit() {
'woocommerce'
);
}
}
},
[ name ]
);
const setSkuIfEmpty = () => {
@ -153,6 +158,7 @@ export function Edit() {
>
<InputControl
id={ nameControlId }
ref={ nameRef }
name="name"
placeholder={ __(
'e.g. 12 oz Coffee Mug',
@ -160,7 +166,10 @@ export function Edit() {
) }
onChange={ setName }
value={ name && name !== AUTO_DRAFT_NAME ? name : '' }
onBlur={ setSkuIfEmpty }
onBlur={ () => {
setSkuIfEmpty();
validateName();
} }
/>
</BaseControl>

View File

@ -2,18 +2,13 @@
* External dependencies
*/
import { Link } from '@woocommerce/components';
import { CurrencyContext } from '@woocommerce/currency';
import { getNewPath } from '@woocommerce/navigation';
import { recordEvent } from '@woocommerce/tracks';
import { useBlockProps } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { useInstanceId } from '@wordpress/compose';
import { useEntityProp } from '@wordpress/core-data';
import {
createElement,
useContext,
createInterpolateElement,
} from '@wordpress/element';
import { createElement, createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
BaseControl,
@ -25,7 +20,6 @@ import {
* Internal dependencies
*/
import { useCurrencyInputProps } from '../../hooks/use-currency-input-props';
import { formatCurrencyDisplayValue } from '../../utils';
import { PricingBlockAttributes } from './types';
export function Edit( {
@ -38,12 +32,9 @@ export function Edit( {
'product',
name
);
const context = useContext( CurrencyContext );
const { getCurrencyConfig, formatAmount } = context;
const currencyConfig = getCurrencyConfig();
const inputProps = useCurrencyInputProps( {
value: price,
setValue: setPrice,
onChange: setPrice,
} );
const interpolatedHelp = help
@ -71,13 +62,7 @@ export function Edit( {
{ ...inputProps }
id={ priceId }
name={ name }
onChange={ setPrice }
label={ label || __( 'Price', 'woocommerce' ) }
value={ formatCurrencyDisplayValue(
String( price ),
currencyConfig,
formatAmount
) }
/>
</BaseControl>
</div>

View File

@ -3,18 +3,14 @@
*/
import classNames from 'classnames';
import { Link } from '@woocommerce/components';
import { CurrencyContext } from '@woocommerce/currency';
import { Product } from '@woocommerce/data';
import { getNewPath } from '@woocommerce/navigation';
import { recordEvent } from '@woocommerce/tracks';
import { useBlockProps } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { useInstanceId } from '@wordpress/compose';
import { useEntityProp } from '@wordpress/core-data';
import {
createElement,
useContext,
createInterpolateElement,
} from '@wordpress/element';
import { createElement, createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
BaseControl,
@ -25,13 +21,13 @@ import {
/**
* Internal dependencies
*/
import { useValidation } from '../../contexts/validation-context';
import { useCurrencyInputProps } from '../../hooks/use-currency-input-props';
import { formatCurrencyDisplayValue } from '../../utils';
import { SalePriceBlockAttributes } from './types';
import { useValidation } from '../../hooks/use-validation';
export function Edit( {
attributes,
clientId,
}: BlockEditProps< SalePriceBlockAttributes > ) {
const blockProps = useBlockProps();
const { label, help } = attributes;
@ -45,12 +41,9 @@ export function Edit( {
'product',
'sale_price'
);
const context = useContext( CurrencyContext );
const { getCurrencyConfig, formatAmount } = context;
const currencyConfig = getCurrencyConfig();
const inputProps = useCurrencyInputProps( {
value: regularPrice,
setValue: setRegularPrice,
onChange: setRegularPrice,
} );
const interpolatedHelp = help
@ -71,9 +64,13 @@ export function Edit( {
'wp-block-woocommerce-product-regular-price-field'
) as string;
const regularPriceValidationError = useValidation(
'product/regular_price',
function regularPriceValidator() {
const {
ref: regularPriceRef,
error: regularPriceValidationError,
validate: validateRegularPrice,
} = useValidation< Product >(
`regular_price-${ clientId }`,
async function regularPriceValidator() {
const listPrice = Number.parseFloat( regularPrice );
if ( listPrice ) {
if ( listPrice < 0 ) {
@ -92,7 +89,8 @@ export function Edit( {
);
}
}
}
},
[ regularPrice, salePrice ]
);
return (
@ -112,13 +110,9 @@ export function Edit( {
{ ...inputProps }
id={ regularPriceId }
name={ 'regular_price' }
ref={ regularPriceRef }
label={ label }
value={ formatCurrencyDisplayValue(
String( regularPrice ),
currencyConfig,
formatAmount
) }
onChange={ setRegularPrice }
onBlur={ validateRegularPrice }
/>
</BaseControl>
</div>

View File

@ -2,12 +2,12 @@
* External dependencies
*/
import classNames from 'classnames';
import { CurrencyContext } from '@woocommerce/currency';
import { Product } from '@woocommerce/data';
import { useBlockProps } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { useInstanceId } from '@wordpress/compose';
import { useEntityProp } from '@wordpress/core-data';
import { createElement, useContext } from '@wordpress/element';
import { createElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import {
BaseControl,
@ -18,13 +18,13 @@ import {
/**
* Internal dependencies
*/
import { useValidation } from '../../contexts/validation-context';
import { useCurrencyInputProps } from '../../hooks/use-currency-input-props';
import { formatCurrencyDisplayValue } from '../../utils';
import { SalePriceBlockAttributes } from './types';
import { useValidation } from '../../hooks/use-validation';
export function Edit( {
attributes,
clientId,
}: BlockEditProps< SalePriceBlockAttributes > ) {
const blockProps = useBlockProps();
const { label, help } = attributes;
@ -38,12 +38,9 @@ export function Edit( {
'product',
'sale_price'
);
const context = useContext( CurrencyContext );
const { getCurrencyConfig, formatAmount } = context;
const currencyConfig = getCurrencyConfig();
const inputProps = useCurrencyInputProps( {
value: salePrice,
setValue: setSalePrice,
onChange: setSalePrice,
} );
const salePriceId = useInstanceId(
@ -51,9 +48,13 @@ export function Edit( {
'wp-block-woocommerce-product-sale-price-field'
) as string;
const salePriceValidationError = useValidation(
'product/sale_price',
function salePriceValidator() {
const {
ref: salePriceRef,
error: salePriceValidationError,
validate: validateSalePrice,
} = useValidation< Product >(
`sale-price-${ clientId }`,
async function salePriceValidator() {
if ( salePrice ) {
if ( Number.parseFloat( salePrice ) < 0 ) {
return __(
@ -72,7 +73,8 @@ export function Edit( {
);
}
}
}
},
[ regularPrice, salePrice ]
);
return (
@ -90,13 +92,9 @@ export function Edit( {
{ ...inputProps }
id={ salePriceId }
name={ 'sale_price' }
onChange={ setSalePrice }
ref={ salePriceRef }
label={ label }
value={ formatCurrencyDisplayValue(
String( salePrice ),
currencyConfig,
formatAmount
) }
onBlur={ validateSalePrice }
/>
</BaseControl>
</div>

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { DateTimePickerControl } from '@woocommerce/components';
import { Product } from '@woocommerce/data';
import { recordEvent } from '@woocommerce/tracks';
import { useBlockProps } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
@ -19,9 +20,11 @@ import { getSettings } from '@wordpress/date';
* Internal dependencies
*/
import { ScheduleSalePricingBlockAttributes } from './types';
import { useValidation } from '../../hooks/use-validation';
import { useValidation } from '../../contexts/validation-context';
export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > ) {
export function Edit( {
clientId,
}: BlockEditProps< ScheduleSalePricingBlockAttributes > ) {
const blockProps = useBlockProps();
const dateTimeFormat = getSettings().formats.datetime;
@ -84,9 +87,13 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > )
const _dateOnSaleFrom = moment( dateOnSaleFromGmt, moment.ISO_8601, true );
const _dateOnSaleTo = moment( dateOnSaleToGmt, moment.ISO_8601, true );
const dateOnSaleFromGmtValidationError = useValidation(
'product/date_on_sale_from_gmt',
function dateOnSaleFromValidator() {
const {
ref: dateOnSaleFromGmtRef,
error: dateOnSaleFromGmtValidationError,
validate: validateDateOnSaleFromGmt,
} = useValidation< Product >(
`date_on_sale_from_gmt-${ clientId }`,
async function dateOnSaleFromValidator() {
if ( showScheduleSale && dateOnSaleFromGmt ) {
if ( ! _dateOnSaleFrom.isValid() ) {
return __( 'Please enter a valid date.', 'woocommerce' );
@ -99,12 +106,17 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > )
);
}
}
}
},
[ showScheduleSale, dateOnSaleFromGmt, _dateOnSaleFrom, _dateOnSaleTo ]
);
const dateOnSaleToGmtValidationError = useValidation(
'product/date_on_sale_to_gmt',
function dateOnSaleToValidator() {
const {
ref: dateOnSaleToGmtRef,
error: dateOnSaleToGmtValidationError,
validate: validateDateOnSaleToGmt,
} = useValidation< Product >(
`date_on_sale_to_gmt-${ clientId }`,
async function dateOnSaleToValidator() {
if ( showScheduleSale && dateOnSaleToGmt ) {
if ( ! _dateOnSaleTo.isValid() ) {
return __( 'Please enter a valid date.', 'woocommerce' );
@ -117,7 +129,8 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > )
);
}
}
}
},
[ showScheduleSale, dateOnSaleFromGmt, _dateOnSaleFrom, _dateOnSaleTo ]
);
return (
@ -133,6 +146,9 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > )
<div className="wp-block-columns">
<div className="wp-block-column">
<DateTimePickerControl
ref={
dateOnSaleFromGmtRef as React.Ref< HTMLInputElement >
}
label={ __( 'From', 'woocommerce' ) }
placeholder={ __(
'Sale start date and time (optional)',
@ -145,11 +161,15 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > )
dateOnSaleFromGmtValidationError && 'has-error'
}
help={ dateOnSaleFromGmtValidationError as string }
onBlur={ validateDateOnSaleFromGmt }
/>
</div>
<div className="wp-block-column">
<DateTimePickerControl
ref={
dateOnSaleToGmtRef as React.Ref< HTMLInputElement >
}
label={ __( 'To', 'woocommerce' ) }
placeholder={ __(
'Sale end date and time (optional)',
@ -164,6 +184,7 @@ export function Edit( {}: BlockEditProps< ScheduleSalePricingBlockAttributes > )
.toISOString()
)
}
onBlur={ validateDateOnSaleToGmt }
className={
dateOnSaleToGmtValidationError && 'has-error'
}

View File

@ -14,9 +14,6 @@
"description": {
"type": "string",
"__experimentalRole": "content"
},
"icon": {
"type": "object"
}
},
"supports": {

View File

@ -8,13 +8,11 @@ import { InnerBlocks, useBlockProps } from '@wordpress/block-editor';
/**
* Internal dependencies
*/
import { BlockIcon } from '../../components/block-icon';
import { SectionBlockAttributes } from './types';
import { sanitizeHTML } from '../../utils/sanitize-html';
export function Edit( {
attributes,
clientId,
}: BlockEditProps< SectionBlockAttributes > ) {
const blockProps = useBlockProps();
const { description, title } = attributes;
@ -22,7 +20,6 @@ export function Edit( {
return (
<div { ...blockProps }>
<h2 className="wp-block-woocommerce-product-section__title">
<BlockIcon clientId={ clientId } />
<span>{ title }</span>
</h2>
{ description && (

View File

@ -9,27 +9,18 @@
.wp-block-woocommerce-product-section__title {
margin-top: 0;
margin-bottom: 0;
font-size: 24px;
font-size: 20px;
font-weight: 500;
color: $gray-900;
display: inline-flex;
align-items: center;
.block-editor-block-icon {
margin-right: 14px;
> div {
display: flex;
align-items: center;
justify-content: center;
}
}
}
&__description {
margin-top: $gap-small;
font-size: 13px;
color: $gray-900;
color: $gray-700;
line-height: 1.5;
}
}

View File

@ -1,11 +1,11 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 2,
"name": "woocommerce/product-shipping-fee-fields",
"title": "Product shipping fee fields",
"name": "woocommerce/product-shipping-class-field",
"title": "Product shipping class field",
"category": "woocommerce",
"description": "The product shipping fee fields.",
"keywords": [ "products", "shipping", "fee" ],
"description": "The product shipping class field.",
"keywords": [ "products", "shipping", "class" ],
"textdomain": "default",
"attributes": {
"title": {

View File

@ -18,19 +18,15 @@ import {
Fragment,
createElement,
createInterpolateElement,
useEffect,
useState,
} from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import classNames from 'classnames';
import { useEntityProp } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { ShippingFeeBlockAttributes } from './types';
import { useValidation } from '../../hooks/use-validation';
import { RadioField } from '../../components/radio-field';
import { ShippingClassBlockAttributes } from './types';
import { AddNewShippingClassModal } from '../../components';
import { ADD_NEW_SHIPPING_CLASS_OPTION_VALUE } from '../../constants';
@ -38,9 +34,6 @@ type ServerErrorResponse = {
code: string;
};
const FOLLOW_CLASS_OPTION_VALUE = 'follow_class';
const FREE_SHIPPING_OPTION_VALUE = 'free_shipping';
export const DEFAULT_SHIPPING_CLASS_OPTIONS: SelectControl.Option[] = [
{ value: '', label: __( 'No shipping class', 'woocommerce' ) },
{
@ -58,17 +51,6 @@ function mapShippingClassToSelectOption(
} ) );
}
const options = [
{
label: __( 'Follow class', 'woocommerce' ),
value: FOLLOW_CLASS_OPTION_VALUE,
},
{
label: __( 'Free shipping', 'woocommerce' ),
value: FREE_SHIPPING_OPTION_VALUE,
},
];
function extractDefaultShippingClassFromProduct(
categories?: PartialProduct[ 'categories' ],
shippingClasses?: ProductShippingClass[]
@ -87,19 +69,12 @@ function extractDefaultShippingClassFromProduct(
}
}
export function Edit( {
attributes,
}: BlockEditProps< ShippingFeeBlockAttributes > ) {
const { title } = attributes;
export function Edit( {}: BlockEditProps< ShippingClassBlockAttributes > ) {
const [ showShippingClassModal, setShowShippingClassModal ] =
useState( false );
const blockProps = useBlockProps();
const [ option, setOption ] = useState< string >(
FREE_SHIPPING_OPTION_VALUE
);
const { createProductShippingClass, invalidateResolution } = useDispatch(
EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME
);
@ -149,114 +124,71 @@ export function Edit( {
};
}, [] );
const shippingClassControlId = useInstanceId( BaseControl ) as string;
const shippingClassValidationError = useValidation(
'product/shipping_class',
function shippingClassValidator() {
if ( option === FOLLOW_CLASS_OPTION_VALUE && ! shippingClass ) {
return __( 'The shipping class is required.', 'woocommerce' );
}
}
);
function handleOptionChange( value: string ) {
setOption( value );
if ( value === FOLLOW_CLASS_OPTION_VALUE ) {
const [ firstShippingClass ] = shippingClasses;
setShippingClass( firstShippingClass?.slug ?? '' );
} else {
setShippingClass( '' );
}
}
useEffect( () => {
if ( shippingClass ) {
setOption( FOLLOW_CLASS_OPTION_VALUE );
}
}, [ shippingClass ] );
const shippingClassControlId = useInstanceId(
BaseControl,
'wp-block-woocommerce-product-shipping-class-field'
) as string;
return (
<div { ...blockProps }>
<div className="wp-block-columns">
<div className="wp-block-column">
<RadioField
title={ title }
selected={ option }
options={ options }
onChange={ handleOptionChange }
<SelectControl
id={ shippingClassControlId }
name="shipping_class"
value={ shippingClass }
onChange={ ( value: string ) => {
if (
value === ADD_NEW_SHIPPING_CLASS_OPTION_VALUE
) {
setShowShippingClassModal( true );
return;
}
setShippingClass( value );
} }
label={ __( 'Shipping class', 'woocommerce' ) }
options={ [
...DEFAULT_SHIPPING_CLASS_OPTIONS,
...mapShippingClassToSelectOption(
shippingClasses ?? []
),
] }
help={ createInterpolateElement(
__(
'Manage shipping classes and rates in <Link>global settings</Link>.',
'woocommerce'
),
{
Link: (
<Link
href={ getNewPath(
{
tab: 'shipping',
section: 'classes',
},
'',
{},
'wc-settings'
) }
target="_blank"
type="external"
onClick={ () => {
recordEvent(
'product_shipping_global_settings_link_click'
);
} }
>
<Fragment />
</Link>
),
}
) }
/>
</div>
<div className="wp-block-column"></div>
</div>
{ option === FOLLOW_CLASS_OPTION_VALUE && (
<div className="wp-block-columns">
<div
className={ classNames( 'wp-block-column', {
'has-error': shippingClassValidationError,
} ) }
>
<SelectControl
id={ shippingClassControlId }
name="shipping_class"
value={ shippingClass }
onChange={ ( value: string ) => {
if (
value ===
ADD_NEW_SHIPPING_CLASS_OPTION_VALUE
) {
setShowShippingClassModal( true );
return;
}
setShippingClass( value );
} }
label={ __( 'Shipping class', 'woocommerce' ) }
options={ [
...DEFAULT_SHIPPING_CLASS_OPTIONS,
...mapShippingClassToSelectOption(
shippingClasses ?? []
),
] }
help={
shippingClassValidationError ||
createInterpolateElement(
__(
'Manage shipping classes and rates in <Link>global settings</Link>.',
'woocommerce'
),
{
Link: (
<Link
href={ getNewPath(
{
tab: 'shipping',
section: 'classes',
},
'',
{},
'wc-settings'
) }
target="_blank"
type="external"
onClick={ () => {
recordEvent(
'product_shipping_global_settings_link_click'
);
} }
>
<Fragment />
</Link>
),
}
)
}
/>
</div>
<div className="wp-block-column"></div>
</div>
) }
{ showShippingClassModal && (
<AddNewShippingClassModal
shippingClass={ extractDefaultShippingClassFromProduct(

View File

@ -10,15 +10,15 @@ import { BlockConfiguration } from '@wordpress/blocks';
import { initBlock } from '../../utils/init-blocks';
import blockConfiguration from './block.json';
import { Edit } from './edit';
import { ShippingFeeBlockAttributes } from './types';
import { ShippingClassBlockAttributes } from './types';
const { name, ...metadata } =
blockConfiguration as BlockConfiguration< ShippingFeeBlockAttributes >;
blockConfiguration as BlockConfiguration< ShippingClassBlockAttributes >;
export { metadata, name };
export const settings: Partial<
BlockConfiguration< ShippingFeeBlockAttributes >
BlockConfiguration< ShippingClassBlockAttributes >
> = {
example: {},
edit: Edit,

View File

@ -3,6 +3,6 @@
*/
import { BlockAttributes } from '@wordpress/blocks';
export interface ShippingFeeBlockAttributes extends BlockAttributes {
export interface ShippingClassBlockAttributes extends BlockAttributes {
title: string;
}

View File

@ -3,7 +3,11 @@
*/
import { useBlockProps } from '@wordpress/block-editor';
import { BlockEditProps } from '@wordpress/blocks';
import { OPTIONS_STORE_NAME, ProductDimensions } from '@woocommerce/data';
import {
OPTIONS_STORE_NAME,
Product,
ProductDimensions,
} from '@woocommerce/data';
import { useInstanceId } from '@wordpress/compose';
import { useEntityProp } from '@wordpress/core-data';
import { useSelect } from '@wordpress/data';
@ -29,9 +33,11 @@ import {
HighlightSides,
ShippingDimensionsImage,
} from '../../components/shipping-dimensions-image';
import { useValidation } from '../../hooks/use-validation';
import { useValidation } from '../../contexts/validation-context';
export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > ) {
export function Edit( {
clientId,
}: BlockEditProps< ShippingDimensionsBlockAttributes > ) {
const blockProps = useBlockProps();
const [ dimensions, setDimensions ] =
@ -79,12 +85,70 @@ export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > )
};
}
const {
ref: dimensionsWidthRef,
error: dimensionsWidthValidationError,
validate: validateDimensionsWidth,
} = useValidation< Product >(
`dimensions_width-${ clientId }`,
async function dimensionsWidthValidator() {
if ( dimensions?.width && +dimensions.width <= 0 ) {
return __( 'Width must be greater than zero.', 'woocommerce' );
}
},
[ dimensions?.width ]
);
const {
ref: dimensionsLengthRef,
error: dimensionsLengthValidationError,
validate: validateDimensionsLength,
} = useValidation< Product >(
`dimensions_length-${ clientId }`,
async function dimensionsLengthValidator() {
if ( dimensions?.length && +dimensions.length <= 0 ) {
return __( 'Length must be greater than zero.', 'woocommerce' );
}
},
[ dimensions?.length ]
);
const {
ref: dimensionsHeightRef,
error: dimensionsHeightValidationError,
validate: validateDimensionsHeight,
} = useValidation< Product >(
`dimensions_height-${ clientId }`,
async function dimensionsHeightValidator() {
if ( dimensions?.height && +dimensions.height <= 0 ) {
return __( 'Height must be greater than zero.', 'woocommerce' );
}
},
[ dimensions?.height ]
);
const {
ref: weightRef,
error: weightValidationError,
validate: validateWeight,
} = useValidation< Product >(
`weight-${ clientId }`,
async function weightValidator() {
if ( weight && +weight <= 0 ) {
return __( 'Weight must be greater than zero.', 'woocommerce' );
}
},
[ weight ]
);
const dimensionsWidthProps = {
...getDimensionsControlProps( 'width', 'A' ),
id: useInstanceId(
BaseControl,
`product_shipping_dimensions_width`
) as string,
ref: dimensionsWidthRef,
onBlur: validateDimensionsWidth,
};
const dimensionsLengthProps = {
...getDimensionsControlProps( 'length', 'B' ),
@ -92,6 +156,8 @@ export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > )
BaseControl,
`product_shipping_dimensions_length`
) as string,
ref: dimensionsLengthRef,
onBlur: validateDimensionsLength,
};
const dimensionsHeightProps = {
...getDimensionsControlProps( 'height', 'C' ),
@ -99,6 +165,8 @@ export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > )
BaseControl,
`product_shipping_dimensions_height`
) as string,
ref: dimensionsHeightRef,
onBlur: validateDimensionsHeight,
};
const weightProps = {
id: useInstanceId( BaseControl, `product_shipping_weight` ) as string,
@ -106,41 +174,10 @@ export function Edit( {}: BlockEditProps< ShippingDimensionsBlockAttributes > )
value: formatNumber( String( weight ) ),
onChange: ( value: string ) => setWeight( parseNumber( value ) ),
suffix: weightUnit,
ref: weightRef,
onBlur: validateWeight,
};
const dimensionsWidthValidationError = useValidation(
'product/dimensions/width',
function dimensionsWidthValidator() {
if ( dimensions?.width && +dimensions.width <= 0 ) {
return __( 'Width must be greater than zero.', 'woocommerce' );
}
}
);
const dimensionsLengthValidationError = useValidation(
'product/dimensions/length',
function dimensionsLengthValidator() {
if ( dimensions?.length && +dimensions.length <= 0 ) {
return __( 'Length must be greater than zero.', 'woocommerce' );
}
}
);
const dimensionsHeightValidationError = useValidation(
'product/dimensions/height',
function dimensionsHeightValidator() {
if ( dimensions?.height && +dimensions.height <= 0 ) {
return __( 'Height must be greater than zero.', 'woocommerce' );
}
}
);
const weightValidationError = useValidation(
'product/weight',
function weightValidator() {
if ( weight && +weight <= 0 ) {
return __( 'Weight must be greater than zero.', 'woocommerce' );
}
}
);
return (
<div { ...blockProps }>
<h4>{ __( 'Dimensions', 'woocommerce' ) }</h4>

View File

@ -41,6 +41,10 @@
margin-left: auto;
margin-right: auto;
padding-bottom: 128px;
h4 {
font-size: 16px;
}
}
.components-input-control {
@ -101,9 +105,3 @@
display: none; // use important or increase specificity.
}
}
.woocommerce-layout:has( .woocommerce-product-block-editor ) {
.woocommerce-layout__header {
display: none;
}
}

View File

@ -55,31 +55,41 @@ function getSelectedWithParents(
return selected;
}
function mapFromCategoryType(
categories: ProductCategoryNode[]
): TreeItemType[] {
return categories.map( ( val ) =>
val.parent
? {
value: String( val.id ),
label: val.name,
parent: String( val.parent ),
}
: {
value: String( val.id ),
label: val.name,
}
);
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,
};
}
function mapToCategoryType(
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( ( cat ) => ( {
id: +cat.value,
name: cat.label,
parent: cat.parent ? +cat.parent : 0,
} ) );
return categories.map( mapFromTreeItemToCategory );
}
export const CategoryField: React.FC< CategoryFieldProps > = ( {
@ -129,15 +139,15 @@ export const CategoryField: React.FC< CategoryFieldProps > = ( {
) === -1
}
items={ getFilteredItemsForSelectTree(
mapFromCategoryType( categoriesSelectList ),
mapFromCategoriesToTreeItems( categoriesSelectList ),
searchValue,
mapFromCategoryType( value )
mapFromCategoriesToTreeItems( value )
) }
selected={ mapFromCategoryType( value ) }
selected={ mapFromCategoriesToTreeItems( value ) }
onSelect={ ( selectedItems ) => {
if ( Array.isArray( selectedItems ) ) {
const newItems: ProductCategoryNode[] =
mapToCategoryType(
mapFromTreeItemsToCategories(
selectedItems.filter(
( { value: selectedItemValue } ) =>
! value.some(

View File

@ -1,5 +1,6 @@
.woocommerce-create-new-category-modal {
min-width: 650px;
overflow: visible;
&__buttons {
margin-top: $gap-larger;

View File

@ -2,7 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, Modal, Spinner, TextControl } from '@wordpress/components';
import { Button, Modal, TextControl } from '@wordpress/components';
import { useDebounce } from '@wordpress/compose';
import {
useState,
@ -11,8 +11,8 @@ import {
} from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
import {
__experimentalSelectControl as SelectControl,
__experimentalSelectControlMenu as Menu,
__experimentalSelectTreeControl as SelectTree,
TreeItemType as Item,
} from '@woocommerce/components';
import { recordEvent } from '@woocommerce/tracks';
import {
@ -24,7 +24,10 @@ import {
* Internal dependencies
*/
import { ProductCategoryNode, useCategorySearch } from './use-category-search';
import { CategoryFieldItem } from './category-field-item';
import {
mapFromCategoriesToTreeItems,
mapFromCategoryToTreeItem,
} from './category-field';
type CreateCategoryModalProps = {
initialCategoryName?: string;
@ -32,15 +35,6 @@ type CreateCategoryModalProps = {
onCreate: ( newCategory: ProductCategory ) => void;
};
function getCategoryItemLabel( item: ProductCategoryNode | null ): string {
return item?.name || '';
}
function getCategoryItemValue(
item: ProductCategoryNode | null
): string | number {
return item?.id || '';
}
export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
initialCategoryName,
onCancel,
@ -49,9 +43,8 @@ export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
const {
categoriesSelectList,
isSearching,
categoryTreeKeyValues,
searchCategories,
getFilteredItems,
getFilteredItemsForSelectTree,
} = useCategorySearch();
const { createNotice } = useDispatch( 'core/notices' );
const [ isCreating, setIsCreating ] = useState( false );
@ -63,6 +56,9 @@ export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
const [ categoryParent, setCategoryParent ] =
useState< ProductCategoryNode | null >( null );
const [ categoryParentTypedValue, setCategoryParentTypedValue ] =
useState( '' );
const onSave = async () => {
recordEvent( 'product_category_add', {
new_product_page: true,
@ -101,8 +97,7 @@ export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
value={ categoryName }
onChange={ setCategoryName }
/>
<SelectControl< ProductCategoryNode >
items={ categoriesSelectList }
<SelectTree
label={ createInterpolateElement(
__( 'Parent category <optional/>', 'woocommerce' ),
{
@ -113,78 +108,34 @@ export const CreateCategoryModal: React.FC< CreateCategoryModalProps > = ( {
),
}
) }
selected={ categoryParent }
onSelect={ ( item ) => item && setCategoryParent( item ) }
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={ debouncedSearch }
getFilteredItems={ getFilteredItems }
getItemLabel={ getCategoryItemLabel }
getItemValue={ getCategoryItemValue }
>
{ ( {
items,
isOpen,
getMenuProps,
highlightedIndex,
getItemProps,
} ) => {
return (
<Menu
isOpen={ isOpen }
getMenuProps={ getMenuProps }
className="woocommerce-category-field-dropdown__menu"
>
{ [
isSearching ? (
<div
key="loading-spinner"
className="woocommerce-category-field-dropdown__item"
>
<div className="woocommerce-category-field-dropdown__item-content">
<Spinner />
</div>
</div>
) : null,
...items
.filter(
( item ) =>
categoryTreeKeyValues[ item.id ]
?.parentID === 0
)
.map( ( item ) => {
return (
<CategoryFieldItem
key={ `${ item.id }` }
item={
categoryTreeKeyValues[
item.id
]
}
selectedIds={
categoryParent
? [
categoryParent.id,
]
: []
}
items={ items }
highlightedIndex={
highlightedIndex
}
getItemProps={
getItemProps
}
/>
);
} ),
].filter(
( item ): item is JSX.Element =>
item !== null
) }
</Menu>
);
onInputChange={ ( value ) => {
debouncedSearch( value );
setCategoryParentTypedValue( value || '' );
} }
</SelectControl>
createValue={ categoryParentTypedValue }
/>
<div className="woocommerce-create-new-category-modal__buttons">
<Button
isSecondary

View File

@ -8,6 +8,7 @@ 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' ),
@ -179,7 +180,7 @@ describe( 'useCategorySearch', () => {
);
} );
describe( 'getFilteredItems', () => {
describe( 'getFilteredItemsForSelectTree', () => {
it( 'should filter items by label, matching input value, and if selected', async () => {
getProductCategoriesMock.mockReturnValue( [ ...mockCategoryList ] );
@ -191,13 +192,15 @@ describe( 'useCategorySearch', () => {
);
await waitForNextUpdate();
const filteredItems = result.current.getFilteredItems(
result.current.categoriesSelectList,
const filteredItems = result.current.getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems(
result.current.categoriesSelectList
),
'Rain',
[]
);
expect( filteredItems.length ).toEqual( 1 );
expect( filteredItems[ 0 ].name ).toEqual( 'Rain gear' );
expect( filteredItems[ 0 ].label ).toEqual( 'Rain gear' );
} );
it( 'should filter items by isOpen as well, keeping them if isOpen is true', async () => {
@ -215,15 +218,17 @@ describe( 'useCategorySearch', () => {
} );
await waitForNextUpdate();
expect( result.current.categoriesSelectList.length ).toEqual( 6 );
const filteredItems = result.current.getFilteredItems(
result.current.categoriesSelectList,
const filteredItems = result.current.getFilteredItemsForSelectTree(
mapFromCategoriesToTreeItems(
result.current.categoriesSelectList
),
'Bel',
[]
);
expect( filteredItems.length ).toEqual( 3 );
expect( filteredItems[ 0 ].name ).toEqual( 'Clothing' );
expect( filteredItems[ 1 ].name ).toEqual( 'Accessories' );
expect( filteredItems[ 2 ].name ).toEqual( 'Belts' );
expect( filteredItems[ 0 ].label ).toEqual( 'Clothing' );
expect( filteredItems[ 1 ].label ).toEqual( 'Accessories' );
expect( filteredItems[ 2 ].label ).toEqual( 'Belts' );
} );
} );

View File

@ -257,32 +257,6 @@ export const useCategorySearch = () => {
const categoryTreeKeyValues = categoriesAndNewItem[ 2 ];
/**
* getFilteredItems callback for use in the SelectControl component.
*/
const getFilteredItems = useCallback(
(
allItems: ProductCategoryNode[],
inputValue: string,
selectedItems: ProductCategoryNode[]
) => {
const searchRegex = new RegExp( escapeRegExp( inputValue ), 'i' );
return allItems.filter(
( item ) =>
selectedItems.indexOf( item ) < 0 &&
( searchRegex.test( item.name ) ||
( categoryTreeKeyValues[ item.id ] &&
categoryTreeKeyValues[ item.id ].isOpen ) )
);
},
[ categoriesAndNewItem ]
);
/**
* this is the same as getFilteredItems but for the SelectTree component, where item id is a string.
* After all the occurrences of getFilteredItems are migrated to use SelectTree,
* this can become the standard version
*/
const getFilteredItemsForSelectTree = useCallback(
(
allItems: TreeItemType[],
@ -303,7 +277,6 @@ export const useCategorySearch = () => {
return {
searchCategories,
getFilteredItems,
getFilteredItemsForSelectTree,
categoriesSelectList: categoriesAndNewItem[ 0 ],
categories: categoriesAndNewItem[ 1 ],

View File

@ -36,9 +36,7 @@ import { FullscreenMode, InterfaceSkeleton } from '@wordpress/interface';
*/
import { Header } from '../header';
import { BlockEditor } from '../block-editor';
import { initBlocks } from './init-blocks';
initBlocks();
import { ValidationProvider } from '../../contexts/validation-context';
export type ProductEditorSettings = Partial<
EditorSettings & EditorBlockListSettings
@ -65,31 +63,33 @@ export function Editor( { product, settings }: EditorProps ) {
<ShortcutProvider>
<FullscreenMode isActive={ false } />
<SlotFillProvider>
<InterfaceSkeleton
header={
<Header
productName={ product.name }
onTabSelect={ setSelectedTab }
/>
}
content={
<>
<BlockEditor
settings={ settings }
product={ product }
context={ {
selectedTab,
postType: 'product',
postId: product.id,
} }
<ValidationProvider initialValue={ product }>
<InterfaceSkeleton
header={
<Header
productName={ product.name }
onTabSelect={ setSelectedTab }
/>
{ /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ }
<PluginArea scope="woocommerce-product-block-editor" />
</>
}
/>
}
content={
<>
<BlockEditor
settings={ settings }
product={ product }
context={ {
selectedTab,
postType: 'product',
postId: product.id,
} }
/>
{ /* @ts-expect-error 'scope' does exist. @types/wordpress__plugins is outdated. */ }
<PluginArea scope="woocommerce-product-block-editor" />
</>
}
/>
<Popover.Slot />
<Popover.Slot />
</ValidationProvider>
</SlotFillProvider>
</ShortcutProvider>
</EntityProvider>

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