Merge branch 'trunk' into add/sync_of_deleted_orders

This commit is contained in:
Nestor Soriano 2023-05-26 09:14:39 +02:00
commit 34a8de217c
No known key found for this signature in database
GPG Key ID: 08110F3518C12CAD
876 changed files with 47686 additions and 7813 deletions

View File

@ -1,5 +1,5 @@
name: ✨ Enhancement Request name: ✨ Enhancement Request
description: If you have an idea to improve an existing feature in core or need something for development (such as a new hook) please let us know or submit a Pull Request! description: If you have an idea to improve existing functionality in core or need something for development (such as a new hook) please let us know or submit a Pull Request!
title: "[Enhancement]: " title: "[Enhancement]: "
labels: ["type: enhancement", "status: awaiting triage"] labels: ["type: enhancement", "status: awaiting triage"]
body: body:

View File

@ -1,5 +1,8 @@
blank_issues_enabled: true blank_issues_enabled: true
contact_links: contact_links:
- name: Feature Requests
url: https://woocommerce.com/feature-requests/woocommerce/
about: If you have an idea for a new feature that you wished existed in WooCommerce, take a look at our Feature Requests and vote, or open a new Feature Request yourself!
- name: 🔒 Security issue - name: 🔒 Security issue
url: https://hackerone.com/automattic/ url: https://hackerone.com/automattic/
about: For security reasons, please report all security issues via HackerOne. If the issue is valid, a bug bounty will be paid out to you. Please disclose responsibly and not via GitHub (which allows for exploiting issues in the wild before the patch is released). about: For security reasons, please report all security issues via HackerOne. If the issue is valid, a bug bounty will be paid out to you. Please disclose responsibly and not via GitHub (which allows for exploiting issues in the wild before the patch is released).

View File

@ -9,6 +9,8 @@
### Changes proposed in this Pull Request: ### 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. --> <!-- Describe the changes made to this Pull Request and the reason for such changes. -->
Closes # . Closes # .

View File

@ -9,6 +9,11 @@ inputs:
tests: tests:
description: Specific tests to run, separated by single whitespace. See https://playwright.dev/docs/test-cli description: Specific tests to run, separated by single whitespace. See https://playwright.dev/docs/test-cli
outputs:
result:
description: Whether the test passed or failed.
value: ${{ steps.run-api-tests.conclusion }}
runs: runs:
using: composite using: composite
steps: steps:

View File

@ -12,6 +12,11 @@ inputs:
description: The Playwright configuration file to use. description: The Playwright configuration file to use.
default: playwright.config.js default: playwright.config.js
outputs:
result:
description: Whether the test passed or failed.
value: ${{ steps.run-e2e-tests.conclusion }}
runs: runs:
using: composite using: composite
steps: steps:

View File

@ -0,0 +1,59 @@
name: Compose a Slack block for release tests
description: Create a Slack block that shows the API and E2E test results from one of the release tests, and upload it as an artifact.
permissions: {}
inputs:
test-name:
required: true
api-result:
required: true
type: choice
default: skipped
options:
- success
- failure
- cancelled
- skipped
e2e-result:
required: true
type: choice
default: skipped
options:
- success
- failure
- cancelled
- skipped
env-slug:
required: true
release-version:
required: true
runs:
using: composite
steps:
- name: Create context block as a JSON object
id: generate-json
uses: actions/github-script@v6
with:
script: |
const script = require('./.github/actions/tests/slack-summary-on-release/slack-blocks/scripts/create-result-block');
return script();
env:
API_RESULT: ${{ inputs.api-result }}
E2E_RESULT: ${{ inputs.e2e-result }}
ENV_SLUG: ${{ inputs.env-slug }}
TEST_NAME: ${{ inputs.test-name }}
RELEASE_VERSION: ${{ inputs.release-version }}
- name: Write JSON file
working-directory: /tmp
shell: bash
env:
CONTEXT_JSON: ${{ toJSON(steps.generate-json.outputs.result) }}
run: echo ${{ env.CONTEXT_JSON }} > "${{ inputs.test-name }}.json"
- name: Upload JSON file as artifact
uses: actions/upload-artifact@v3
with:
name: ${{ env.SLACK_BLOCKS_ARTIFACT }}
path: /tmp/${{ inputs.test-name }}.json

View File

@ -0,0 +1,31 @@
module.exports = () => {
const { API_RESULT, E2E_RESULT, ENV_SLUG, TEST_NAME, RELEASE_VERSION } =
process.env;
const { setElementText } = require( './utils' );
const apiLinkText = setElementText( {
testType: 'API',
result: API_RESULT,
envSlug: ENV_SLUG,
releaseVersion: RELEASE_VERSION,
} );
const e2eLinkText = setElementText( {
testType: 'E2E',
result: E2E_RESULT,
envSlug: ENV_SLUG,
releaseVersion: RELEASE_VERSION,
} );
const elementText = `*${ TEST_NAME }*\n ${ apiLinkText } ${ e2eLinkText }`;
const contextBlock = {
type: 'context',
elements: [
{
type: 'mrkdwn',
text: elementText,
},
],
};
return contextBlock;
};

View File

@ -0,0 +1,5 @@
const { setElementText } = require( './set-element-text' );
module.exports = {
setElementText,
};

View File

@ -0,0 +1,26 @@
const emojis = {
PASSED: ':workflow-passed:',
FAILED: ':workflow-failed:',
SKIPPED: ':workflow-skipped:',
CANCELLED: ':workflow-cancelled:',
UNKNOWN: ':grey_question:',
};
const selectEmoji = ( result ) => {
switch ( result ) {
case 'success':
return emojis.PASSED;
case 'failure':
return emojis.FAILED;
case 'skipped':
return emojis.SKIPPED;
case 'cancelled':
return emojis.CANCELLED;
default:
return emojis.UNKNOWN;
}
};
module.exports = {
selectEmoji,
};

View File

@ -0,0 +1,12 @@
const setElementText = ( { testType, result, envSlug, releaseVersion } ) => {
const { selectEmoji } = require( './select-emoji' );
const allureReportURL = `https://woocommerce.github.io/woocommerce-test-reports/release/${ releaseVersion }/${ envSlug }/${ testType.toLowerCase() }`;
const emoji = selectEmoji( result );
const textValue = `<${ allureReportURL }|${ testType.toUpperCase() } ${ emoji }>`;
return textValue;
};
module.exports = {
setElementText,
};

View File

@ -0,0 +1,27 @@
name: Combine all Slack blocks
description: Combine all Slack blocks to construct the payload for the Slack GitHub action
permissions: {}
inputs:
release-version:
required: true
blocks-dir:
require: true
outputs:
payload:
value: ${{ steps.payload.outputs.result }}
runs:
using: composite
steps:
- name: Construct payload from all blocks
id: payload
uses: actions/github-script@v6
env:
RELEASE_VERSION: ${{ inputs.release-version }}
BLOCKS_DIR: ${{ inputs.blocks-dir }}
with:
script: |
const script = require('./.github/actions/tests/slack-summary-on-release/slack-payload/scripts/construct-payload');
return script();

View File

@ -0,0 +1,36 @@
module.exports = () => {
const { RELEASE_VERSION, BLOCKS_DIR } = process.env;
const {
filterContextBlocks,
readContextBlocksFromJsonFiles,
} = require( './utils' );
const headerText = `Test summary for ${ RELEASE_VERSION }`;
const headerBlock = {
type: 'header',
text: {
type: 'plain_text',
text: headerText,
emoji: true,
},
};
const blocks_all = readContextBlocksFromJsonFiles( BLOCKS_DIR );
const blocks_wcUpdate = filterContextBlocks( blocks_all, 'WC Update' );
const blocks_wpVersions = filterContextBlocks( blocks_all, 'WP Latest' );
const blocks_phpVersions = filterContextBlocks( blocks_all, 'PHP' );
const blocks_plugins = filterContextBlocks( blocks_all, 'With' );
const blocksPayload = [ headerBlock ]
.concat( blocks_wcUpdate )
.concat( blocks_wpVersions )
.concat( blocks_phpVersions )
.concat( blocks_plugins );
const payload = {
text: headerText,
blocks: blocksPayload,
};
return payload;
};

View File

@ -0,0 +1,42 @@
const fs = require( 'fs' );
const path = require( 'path' );
const readContextBlocksFromJsonFiles = ( blocksDir ) => {
const jsonsDir = path.resolve( blocksDir );
const jsons = fs.readdirSync( jsonsDir );
let contextBlocks = [];
for ( const json of jsons ) {
const jsonPath = path.resolve( jsonsDir, json );
const contextBlock = require( jsonPath );
contextBlocks.push( contextBlock );
}
return contextBlocks;
};
const filterContextBlocks = ( blocks, testName ) => {
const divider = {
type: 'divider',
};
let filteredBlocks = [];
const matchingBlocks = blocks.filter( ( { elements } ) =>
elements[ 0 ].text.includes( testName )
);
matchingBlocks.forEach( ( block ) => {
filteredBlocks.push( block );
filteredBlocks.push( divider );
} );
return filteredBlocks;
};
module.exports = {
filterContextBlocks,
readContextBlocksFromJsonFiles,
};

View File

@ -0,0 +1,6 @@
const {
filterContextBlocks,
readContextBlocksFromJsonFiles,
} = require( './get-context-blocks' );
module.exports = { filterContextBlocks, readContextBlocksFromJsonFiles };

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 working-directory: plugins/woocommerce
run: pnpm run build:feature-config run: pnpm run build:feature-config
- name: Add PHP8 Compatibility. - id: parseMatrix
run: | name: Parse Matrix Variables
if [ "$(php -r "echo version_compare(PHP_VERSION,'8.0','>=');")" ]; then uses: actions/github-script@v6
cd plugins/woocommerce with:
curl -L https://github.com/woocommerce/phpunit/archive/add-compatibility-with-php8-to-phpunit-7.zip -o /tmp/phpunit-7.5-fork.zip script: |
unzip -d /tmp/phpunit-7.5-fork /tmp/phpunit-7.5-fork.zip const parseWPVersion = require( './.github/workflows/scripts/parse-wp-version' );
composer bin phpunit config --unset platform parseWPVersion( '${{ matrix.wp }}' ).then( ( version ) => {
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}}' core.setOutput( 'wpVersion', version );
composer bin phpunit require --dev -W phpunit/phpunit:@dev --ignore-platform-reqs } );
rm -rf ./vendor/phpunit/
composer dump-autoload
fi
- name: Init DB and WP - name: Prepare Testing Environment
working-directory: plugins/woocommerce env:
run: ./tests/bin/install.sh woo_test root root 127.0.0.1 ${{ matrix.wp }} WP_ENV_CORE: ${{ steps.parseMatrix.outputs.wpVersion }}
WP_ENV_PHP_VERSION: ${{ matrix.php }}
run: pnpm --filter=woocommerce env:test
- name: Run tests - name: Run Tests
working-directory: plugins/woocommerce env:
run: pnpm run test --color 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: jobs:
e2e-tests-run: e2e-tests-run:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login != 'github-actions[bot]' }}
name: Runs E2E tests. name: Runs E2E tests.
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
permissions: permissions:
@ -85,7 +84,6 @@ jobs:
api-tests-run: api-tests-run:
name: Runs API tests. name: Runs API tests.
if: github.event.pull_request.user.login != 'github-actions[bot]'
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
permissions: permissions:
contents: read 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: jobs:
test: test:
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.user.login != 'github-actions[bot]' }}
name: Code sniff (PHP 7.4, WP Latest) name: Code sniff (PHP 7.4, WP Latest)
timeout-minutes: 15 timeout-minutes: 15
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04

View File

@ -8,7 +8,7 @@ permissions: {}
jobs: jobs:
analyze: 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 name: Check pull request changes to highlight
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
permissions: permissions:

View File

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

View File

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

View File

@ -14,7 +14,7 @@ permissions: {}
jobs: jobs:
test: 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' || '' }} name: PHP ${{ matrix.php }} WP ${{ matrix.wp }} ${{ matrix.hpos && 'HPOS' || '' }}
timeout-minutes: 30 timeout-minutes: 30
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
@ -52,15 +52,24 @@ jobs:
with: with:
php-version: ${{ matrix.php }} php-version: ${{ matrix.php }}
- name: Tool versions - id: parseMatrix
run: | name: Parse Matrix Variables
php --version uses: actions/github-script@v6
composer --version 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 - name: Prepare Testing Environment
working-directory: plugins/woocommerce env:
run: ./tests/bin/install.sh woo_test root root 127.0.0.1 ${{ matrix.wp }} WP_ENV_CORE: ${{ steps.parseMatrix.outputs.wpVersion }}
WP_ENV_PHP_VERSION: ${{ matrix.php }}
run: pnpm --filter=woocommerce env:test
- name: Run tests - name: Run Tests
working-directory: plugins/woocommerce env:
run: pnpm run test --filter=woocommerce --color 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: with:
fetch-depth: 0 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 - name: Install prerequisites
run: | run: |
npm install -g pnpm pnpm install --filter monorepo-utils --ignore-scripts
pnpm install --filter monorepo-utils # 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' - name: 'Check whether today is the code freeze day'
id: check-freeze id: check-freeze
@ -71,42 +85,47 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: pnpm run utils code-freeze version-bump -o ${{ github.repository_owner }} -v ${{ steps.milestone.outputs.nextDevelopmentVersion }}.0-dev 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: notify-slack:
name: 'Sends code freeze notification to Slack' name: 'Sends code freeze notification to Slack'
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: code-freeze-prep needs: code-freeze-prep
if: ${{ inputs.skipSlackPing != true }} if: ${{ needs.code-freeze-prep.outputs.freeze == 'true' && inputs.skipSlackPing != true }}
steps: 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 - name: Slack
uses: archive/github-actions-slack@v2.0.0
id: notify id: notify
with: run: |
slack-bot-user-oauth-access-token: ${{ secrets.CODE_FREEZE_BOT_TOKEN }} pnpm utils slack "${{ secrets.CODE_FREEZE_BOT_TOKEN }}" "
slack-channel: ${{ inputs.slackChannelOverride || secrets.WOO_RELEASE_SLACK_CHANNEL }} :warning-8c: ${{ needs.code-freeze-prep.outputs.nextReleaseVersion }} Code Freeze :ice_cube:
slack-text: | 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>.
:warning-8c: ${{ needs.code-freeze-prep.outputs.nextReleaseVersion }} Code Freeze :ice_cube: " "${{ inputs.slackChannelOverride || secrets.WOO_RELEASE_SLACK_CHANNEL }}"
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 }}"
}
})

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

@ -135,7 +135,7 @@ function get_latest_version_with_release() {
} }
/** /**
* Function to retreive the sha1 reference for a given branch name. * Function to retrieve the sha1 reference for a given branch name.
* *
* @param string $branch The name of the branch. * @param string $branch The name of the branch.
* @return string Returns the name of the branch, or a falsey value on error. * @return string Returns the name of the branch, or a falsey value on error.

View File

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

View File

@ -14,7 +14,7 @@ permissions: {}
env: env:
E2E_WP_LATEST_ARTIFACT: E2E test on release smoke test site with WP Latest (run ${{ github.run_number }}) E2E_WP_LATEST_ARTIFACT: E2E test on release smoke test site with WP Latest (run ${{ github.run_number }})
E2E_UPDATE_WC_ARTIFACT: WooCommerce version update test on release smoke test site (run ${{ github.run_number }}) E2E_UPDATE_WC_ARTIFACT: WooCommerce version update test on release smoke test site (run ${{ github.run_number }})
SLACK_BLOCKS_ARTIFACT: slack-blocks
jobs: jobs:
get-tag: get-tag:
name: Get WooCommerce release tag name: Get WooCommerce release tag
@ -122,12 +122,26 @@ jobs:
-f test_type="e2e" \ -f test_type="e2e" \
--repo woocommerce/woocommerce-test-reports --repo woocommerce/woocommerce-test-reports
- name: Create Slack block
if: |
success() || (
failure() && steps.run-e2e-composite-action.outputs.result == 'failure'
)
uses: ./.github/actions/tests/slack-summary-on-release/slack-blocks
with:
test-name: WC Update test
e2e-result: ${{ steps.run-e2e-composite-action.outputs.result }}
env-slug: wp-latest
release-version: ${{ needs.get-tag.outputs.tag }}
api-wp-latest: api-wp-latest:
name: API on WP Latest name: API on WP Latest
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
needs: [get-tag, e2e-update-wc] needs: [get-tag, e2e-update-wc]
permissions: permissions:
contents: read contents: read
outputs:
result: ${{ steps.run-api-composite-action.outputs.result }}
env: env:
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-report ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-report
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-results ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-results
@ -178,6 +192,18 @@ jobs:
-f test_type="api" \ -f test_type="api" \
--repo woocommerce/woocommerce-test-reports --repo woocommerce/woocommerce-test-reports
- name: Create Slack block
if: |
success() || (
failure() && steps.run-api-composite-action.outputs.result == 'failure'
)
uses: ./.github/actions/tests/slack-summary-on-release/slack-blocks
with:
test-name: WP Latest
api-result: ${{ steps.run-api-composite-action.outputs.result }}
env-slug: wp-latest
release-version: ${{ needs.get-tag.outputs.tag }}
e2e-wp-latest: e2e-wp-latest:
name: E2E on WP Latest name: E2E on WP Latest
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
@ -268,6 +294,19 @@ jobs:
-f test_type="e2e" \ -f test_type="e2e" \
--repo woocommerce/woocommerce-test-reports --repo woocommerce/woocommerce-test-reports
- name: Create Slack block
if: |
success() || (
failure() && steps.run-e2e-composite-action.outputs.result == 'failure'
)
uses: ./.github/actions/tests/slack-summary-on-release/slack-blocks
with:
test-name: WP Latest
api-result: ${{ needs.api-wp-latest.outputs.result }}
e2e-result: ${{ steps.run-e2e-composite-action.outputs.result }}
env-slug: wp-latest
release-version: ${{ needs.get-tag.outputs.tag }}
get-wp-versions: get-wp-versions:
name: Get WP L-1 & L-2 version numbers name: Get WP L-1 & L-2 version numbers
needs: [get-tag] needs: [get-tag]
@ -328,6 +367,8 @@ jobs:
- name: Setup WooCommerce Monorepo - name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo uses: ./.github/actions/setup-woocommerce-monorepo
with:
build-filters: woocommerce
- name: Launch WP Env - name: Launch WP Env
working-directory: plugins/woocommerce working-directory: plugins/woocommerce
@ -360,7 +401,7 @@ jobs:
uses: ./.github/actions/tests/run-api-tests uses: ./.github/actions/tests/run-api-tests
with: with:
report-name: ${{ env.API_WP_LATEST_X_ARTIFACT }} report-name: ${{ env.API_WP_LATEST_X_ARTIFACT }}
tests: hello tests: hello.test.js
env: env:
ALLURE_RESULTS_DIR: ${{ env.API_ALLURE_RESULTS_DIR }} ALLURE_RESULTS_DIR: ${{ env.API_ALLURE_RESULTS_DIR }}
ALLURE_REPORT_DIR: ${{ env.API_ALLURE_REPORT_DIR }} ALLURE_REPORT_DIR: ${{ env.API_ALLURE_REPORT_DIR }}
@ -435,6 +476,22 @@ jobs:
-f test_type="e2e" \ -f test_type="e2e" \
--repo woocommerce/woocommerce-test-reports --repo woocommerce/woocommerce-test-reports
- name: Create Slack block
if: |
success() || (
failure() && (
steps.run-api-composite-action.outputs.result == 'failure' ||
steps.run-e2e-composite-action.outputs.result == 'failure'
)
)
uses: ./.github/actions/tests/slack-summary-on-release/slack-blocks
with:
test-name: ${{ matrix.version.description }} (${{ matrix.version.number }})
api-result: ${{ steps.run-api-composite-action.outputs.result }}
e2e-result: ${{ steps.run-e2e-composite-action.outputs.result }}
env-slug: ${{ matrix.version.env_description }}
release-version: ${{ needs.get-wp-versions.outputs.tag }}
test-php-versions: test-php-versions:
name: Test against PHP ${{ matrix.php_version }} name: Test against PHP ${{ matrix.php_version }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
@ -456,6 +513,8 @@ jobs:
- name: Setup WooCommerce Monorepo - name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo uses: ./.github/actions/setup-woocommerce-monorepo
with:
build-filters: woocommerce
- name: Launch WP Env - name: Launch WP Env
working-directory: plugins/woocommerce working-directory: plugins/woocommerce
@ -482,7 +541,7 @@ jobs:
uses: ./.github/actions/tests/run-api-tests uses: ./.github/actions/tests/run-api-tests
with: with:
report-name: ${{ env.API_ARTIFACT }} report-name: ${{ env.API_ARTIFACT }}
tests: hello tests: hello.test.js
env: env:
ALLURE_RESULTS_DIR: ${{ env.API_ALLURE_RESULTS_DIR }} ALLURE_RESULTS_DIR: ${{ env.API_ALLURE_RESULTS_DIR }}
ALLURE_REPORT_DIR: ${{ env.API_ALLURE_REPORT_DIR }} ALLURE_REPORT_DIR: ${{ env.API_ALLURE_REPORT_DIR }}
@ -557,6 +616,22 @@ jobs:
-f test_type="e2e" \ -f test_type="e2e" \
--repo woocommerce/woocommerce-test-reports --repo woocommerce/woocommerce-test-reports
- name: Create Slack block
if: |
success() || (
failure() && (
steps.run-api-composite-action.outputs.result == 'failure' ||
steps.run-e2e-composite-action.outputs.result == 'failure'
)
)
uses: ./.github/actions/tests/slack-summary-on-release/slack-blocks
with:
test-name: PHP ${{ matrix.php_version }}
api-result: ${{ steps.run-api-composite-action.outputs.result }}
e2e-result: ${{ steps.run-e2e-composite-action.outputs.result }}
env-slug: php-${{ matrix.php_version }}
release-version: ${{ needs.get-tag.outputs.tag }}
test-plugins: test-plugins:
name: With ${{ matrix.plugin }} name: With ${{ matrix.plugin }}
runs-on: ubuntu-20.04 runs-on: ubuntu-20.04
@ -582,18 +657,20 @@ jobs:
repo: WC_SUBSCRIPTIONS_REPO repo: WC_SUBSCRIPTIONS_REPO
private: true private: true
env_description: 'woocommerce-subscriptions' env_description: 'woocommerce-subscriptions'
- plugin: 'WordPress SEO' # Yoast SEO in the UI, but the slug is wordpress-seo - plugin: 'Gutenberg'
repo: 'Yoast/wordpress-seo' repo: 'WordPress/gutenberg'
env_description: 'wordpress-seo' env_description: 'gutenberg'
- plugin: 'Contact Form 7' - plugin: 'Gutenberg - Nightly'
repo: 'takayukister/contact-form-7' repo: 'bph/gutenberg'
env_description: 'contact-form-7' env_description: 'gutenberg-nightly'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup WooCommerce Monorepo - name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo uses: ./.github/actions/setup-woocommerce-monorepo
with:
build-filters: woocommerce
- name: Launch WP Env - name: Launch WP Env
working-directory: plugins/woocommerce working-directory: plugins/woocommerce
@ -656,3 +733,54 @@ jobs:
-f env_description="${{ matrix.env_description }}" \ -f env_description="${{ matrix.env_description }}" \
-f test_type="e2e" \ -f test_type="e2e" \
--repo woocommerce/woocommerce-test-reports --repo woocommerce/woocommerce-test-reports
- name: Create Slack block
if: |
success() || (
failure() && steps.run-e2e-composite-action.outputs.result == 'failure' )
uses: ./.github/actions/tests/slack-summary-on-release/slack-blocks
with:
test-name: With ${{ matrix.plugin }}
e2e-result: ${{ steps.run-e2e-composite-action.outputs.result }}
env-slug: ${{ matrix.env_description }}
release-version: ${{ needs.get-tag.outputs.tag }}
post-slack-summary:
name: Post Slack summary
runs-on: ubuntu-20.04
permissions:
contents: read
if: |
success() || (
failure() && contains( needs.*.result, 'failure' )
)
needs:
- e2e-wp-latest
- get-tag
- test-php-versions
- test-plugins
- test-wp-versions
steps:
- uses: actions/checkout@v3
- name: Download all slack blocks
id: download-slack-blocks
uses: actions/download-artifact@v3
with:
name: ${{ env.SLACK_BLOCKS_ARTIFACT }}
path: /tmp/slack-payload
- name: Construct payload from all blocks
id: run-payload-action
uses: ./.github/actions/tests/slack-summary-on-release/slack-payload
with:
release-version: ${{ needs.get-tag.outputs.tag }}
blocks-dir: ${{ steps.download-slack-blocks.outputs.download-path }}
- name: Send Slack message
uses: slackapi/slack-github-action@v1.23.0
with:
channel-id: ${{ secrets.RELEASE_TEST_SLACK_CHANNEL }}
payload: ${{ steps.run-payload-action.outputs.payload }}
env:
SLACK_BOT_TOKEN: ${{ secrets.E2E_SLACK_TOKEN }}

View File

@ -78,7 +78,6 @@
"@wordpress/eslint-plugin", "@wordpress/eslint-plugin",
"@wordpress/babel-plugin-import-jsx-pragma", "@wordpress/babel-plugin-import-jsx-pragma",
"@wordpress/babel-preset-default", "@wordpress/babel-preset-default",
"@wordpress/env",
"@wordpress/stylelint-config", "@wordpress/stylelint-config",
"@wordpress/prettier-config", "@wordpress/prettier-config",
"@wordpress/scripts", "@wordpress/scripts",
@ -116,6 +115,15 @@
], ],
"isIgnored": true "isIgnored": true
}, },
{
"dependencies": [
"@wordpress/env"
],
"packages": [
"**"
],
"pinVersion": "^7.0.0"
},
{ {
"dependencies": [ "dependencies": [
"@wordpress/**" "@wordpress/**"

View File

@ -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. 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. 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. - [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. - [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. - [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. Once you've installed all of the prerequisites, you can run the following commands to get everything working.

View File

@ -1,5 +1,131 @@
== Changelog == == Changelog ==
= 7.7.0 2023-05-10 =
**WooCommerce**
* Fix - Removing auto-draft as wc post type to resolve publish time bug. [#38099](https://github.com/woocommerce/woocommerce/pull/38099)
* Fix - Handle updating customer when user_registered is 0000-00-00 00:00:00. [#37907](https://github.com/woocommerce/woocommerce/pull/37907)
* Fix - Sync up date_column_name default for orders table, between stats and table data. [#37927](https://github.com/woocommerce/woocommerce/pull/37927)
* Fix - Fix regression in supporting nested date query arguments in HPOS. [#37827](https://github.com/woocommerce/woocommerce/pull/37827)
* Fix - Fix disabled "Save attributes" button [#37790](https://github.com/woocommerce/woocommerce/pull/37790)
* Fix - Accessibility update for product_categories shortcode. [#37445](https://github.com/woocommerce/woocommerce/pull/37445)
* Fix - Add sort order to migration script for consistency. [#37545](https://github.com/woocommerce/woocommerce/pull/37545)
* Fix - Add support for end_at ID to allow partial verification. [#37446](https://github.com/woocommerce/woocommerce/pull/37446)
* Fix - Avoid over-aggressive escaping of the payment gateway title in the context of the checkout thank you page. [#37481](https://github.com/woocommerce/woocommerce/pull/37481)
* Fix - Corrects imported ContainerInterface. It was not replaced because of the leading backslash. [#37334](https://github.com/woocommerce/woocommerce/pull/37334)
* Fix - Delete shipping zone count transient on woocommerce_shipping_zone_method_added and woocommerce_after_shipping_zone_object_save [#37693](https://github.com/woocommerce/woocommerce/pull/37693)
* Fix - Delete tax lookup and order stats database records when an order is deleted while orders table is authoritative [#36601](https://github.com/woocommerce/woocommerce/pull/36601)
* Fix - fixed bug where adjust_download_permissions was being scheduled on variable products without downloadable variations [#34828](https://github.com/woocommerce/woocommerce/pull/34828)
* Fix - Fixed the attributes table styling in TT2 tabs content area [#37639](https://github.com/woocommerce/woocommerce/pull/37639)
* Fix - Fix ellipsis menu overlaps on small screen [#37583](https://github.com/woocommerce/woocommerce/pull/37583)
* Fix - Fixes a failing e2e test for product variations [#37246](https://github.com/woocommerce/woocommerce/pull/37246)
* Fix - Fix global button aria-disabled style [#37461](https://github.com/woocommerce/woocommerce/pull/37461)
* Fix - Fix incorrect variable name in api-core-tests [#37388](https://github.com/woocommerce/woocommerce/pull/37388)
* Fix - Fix missing result prop in wcadmin_install_plugin_error track [#37466](https://github.com/woocommerce/woocommerce/pull/37466)
* Fix - Fix special characters not rendered in admin titles [#37546](https://github.com/woocommerce/woocommerce/pull/37546)
* Fix - Fix table alias issue in order field queries. [#37560](https://github.com/woocommerce/woocommerce/pull/37560)
* Fix - Fix the type for order items in the schema definition of REST API v1 and V2 [#35940](https://github.com/woocommerce/woocommerce/pull/35940)
* Fix - Lock the product block editor template root [#37685](https://github.com/woocommerce/woocommerce/pull/37685)
* Fix - Make migration more strict by removing IGNORE [#37595](https://github.com/woocommerce/woocommerce/pull/37595)
* Fix - Minor fixup for getting order ids in verify db command. [#37576](https://github.com/woocommerce/woocommerce/pull/37576)
* Fix - Refetch data for "Installed extensions" card after installing a recommended marketing channel. [#37300](https://github.com/woocommerce/woocommerce/pull/37300)
* Fix - Remove double checking for woocommerce_tax_display_cart filter. [#37617](https://github.com/woocommerce/woocommerce/pull/37617)
* Fix - Remove unique constraint from order_key, since orders can be created with empty order keys, which then conflict with the constraint. [#37594](https://github.com/woocommerce/woocommerce/pull/37594)
* Fix - Removing modification to rest_namespace on post type and replacing with middleware. [#37621](https://github.com/woocommerce/woocommerce/pull/37621)
* Fix - Replace information_schema queries in favor of create table parsing to remove foreign key constraints during updates [#37299](https://github.com/woocommerce/woocommerce/pull/37299)
* Fix - Restores comments (reviews) to the product editor. [#37457](https://github.com/woocommerce/woocommerce/pull/37457)
* Fix - Revert changes to use window.fetch in legacy cart JS [#37463](https://github.com/woocommerce/woocommerce/pull/37463)
* Fix - Synchronized SSR from template to REST API. [#37425](https://github.com/woocommerce/woocommerce/pull/37425)
* Fix - Updates an e2e variable name to be more descriptive [#37448](https://github.com/woocommerce/woocommerce/pull/37448)
* Fix - update select all to checkbox in menu editor [#37562](https://github.com/woocommerce/woocommerce/pull/37562)
* Fix - Use first meta value for HPOS migration when there are duplicates for flat column. [#37676](https://github.com/woocommerce/woocommerce/pull/37676)
* Fix - Hide stock status field if stock management is enabled. [#37890](https://github.com/woocommerce/woocommerce/pull/37890)
* Add - Add plugin installer version independent of WP cron. [#37753](https://github.com/woocommerce/woocommerce/pull/37753)
* Add - Add a category for product editor blocks [#37347](https://github.com/woocommerce/woocommerce/pull/37347)
* Add - Add country query param to payment gateway data sources [#37443](https://github.com/woocommerce/woocommerce/pull/37443)
* Add - Add image configuration to the product block template [#37340](https://github.com/woocommerce/woocommerce/pull/37340)
* Add - Add images block to product editor template [#37455](https://github.com/woocommerce/woocommerce/pull/37455)
* Add - add import webp support [#37307](https://github.com/woocommerce/woocommerce/pull/37307)
* Add - Adding charge sales tax field to product block editor template. [#37582](https://github.com/woocommerce/woocommerce/pull/37582)
* Add - Adding checkbox, conditional and inventory email blocks to product blocks editor. [#37646](https://github.com/woocommerce/woocommerce/pull/37646)
* Add - Adding inventory section and sku blocks to product block editor. [#37623](https://github.com/woocommerce/woocommerce/pull/37623)
* Add - Add method delete_meta_data_value to WC_Data objects [#37667](https://github.com/woocommerce/woocommerce/pull/37667)
* Add - Add methods to OrderUtil to get the names of order database tables [#37624](https://github.com/woocommerce/woocommerce/pull/37624)
* Add - Add pricing section to the pricing tab [#37513](https://github.com/woocommerce/woocommerce/pull/37513)
* Add - Add product editor blocks to assets build folder [#37318](https://github.com/woocommerce/woocommerce/pull/37318)
* Add - Add product schedule sale pricing block to blocks template definition [#37567](https://github.com/woocommerce/woocommerce/pull/37567)
* Add - Add product shipping fee block to blocks template definition [#37642](https://github.com/woocommerce/woocommerce/pull/37642)
* Add - Add summary block [#37302](https://github.com/woocommerce/woocommerce/pull/37302)
* Add - Add tax class to product editor template [#37529](https://github.com/woocommerce/woocommerce/pull/37529)
* Add - Add tracks events to attributes tab [#37622](https://github.com/woocommerce/woocommerce/pull/37622)
* Add - Add tracks events to variations tab [#37607](https://github.com/woocommerce/woocommerce/pull/37607)
* Add - Add Woo Payments feature slotfill on homepage [#37768](https://github.com/woocommerce/woocommerce/pull/37768)
* Add - Allows the WP, WC & PHP version to be specified in .wp-env.json for e2e and api tests [#37587](https://github.com/woocommerce/woocommerce/pull/37587)
* Add - Change variations dropdown menu visibility [#37558](https://github.com/woocommerce/woocommerce/pull/37558)
* Add - Register product track inventory block [#37585](https://github.com/woocommerce/woocommerce/pull/37585)
* Add - Register woocommerce/product-shipping-dimensions-fields block [#37683](https://github.com/woocommerce/woocommerce/pull/37683)
* Update - Update WooCommerce Blocks to 10.0.2 [#37818](https://github.com/woocommerce/woocommerce/pull/37818)
* Update - Update WC Blocks to include changes from 9.9.0, 10.0.0 and 10.0.1 [#37662](https://github.com/woocommerce/woocommerce/pull/37662)
* Update - Add default priority for countries that are not in the payment recommendation map [#37590](https://github.com/woocommerce/woocommerce/pull/37590)
* Update - Add Payoneer, zipco payment gateways and update Klarna available countries [#37329](https://github.com/woocommerce/woocommerce/pull/37329)
* Update - Filter out marketing channels in "Installed extensions" and "Discover more marketing tools" cards. [#37126](https://github.com/woocommerce/woocommerce/pull/37126)
* Update - FlexSlider always uses Web Animations API for "slide" animations. [#36987](https://github.com/woocommerce/woocommerce/pull/36987)
* Update - Make Multichannel Marketing the default new UI for Marketing page; remove classic Marketing page and unused code. [#37430](https://github.com/woocommerce/woocommerce/pull/37430)
* Update - Migrate steps location task to TS [#37257](https://github.com/woocommerce/woocommerce/pull/37257)
* Update - Refactoring product editor more menu items, and using in block editor slot fills. [#37255](https://github.com/woocommerce/woocommerce/pull/37255)
* Update - Register product editor blocks server-side [#37339](https://github.com/woocommerce/woocommerce/pull/37339)
* Update - Remove multichannel marketing info from WC Tracker [#37438](https://github.com/woocommerce/woocommerce/pull/37438)
* Update - Remove theme step from onboarding wizard [#37671](https://github.com/woocommerce/woocommerce/pull/37671)
* Update - Replacing multiple components on the block product page with a single hook. [#37283](https://github.com/woocommerce/woocommerce/pull/37283)
* Update - Show different error message when deleting an attribute used in variations [#37527](https://github.com/woocommerce/woocommerce/pull/37527)
* Update - Show tooltip in Save attributes button instead of using title attribute [#37345](https://github.com/woocommerce/woocommerce/pull/37345)
* Update - Support min_php_version and min_wp_version for the free extensions feed [#37694](https://github.com/woocommerce/woocommerce/pull/37694)
* Update - Update payment gateway recommendation priority [#37442](https://github.com/woocommerce/woocommerce/pull/37442)
* Update - Update textdomain in woocommerce-blocks *.json files to `woocommerce` [#37234](https://github.com/woocommerce/woocommerce/pull/37234)
* Dev - Fix recent failures in "Smoke test release" workflow. [#37783](https://github.com/woocommerce/woocommerce/pull/37783)
* Dev - Add composer scripts for linting with phpcs-changed [#37465](https://github.com/woocommerce/woocommerce/pull/37465)
* Dev - Add tracks event to gather onboarding heuristics [#37767](https://github.com/woocommerce/woocommerce/pull/37767)
* Dev - Bump required PHP version to 7.3 and PHPUnit version to 9 [#37366](https://github.com/woocommerce/woocommerce/pull/37366)
* Dev - Code refactor on marketing components. [#37444](https://github.com/woocommerce/woocommerce/pull/37444)
* Dev - Dev - Add customer object parameter to taxable address filter [#37426](https://github.com/woocommerce/woocommerce/pull/37426)
* Dev - Dev - Allow to filter wc_help_tip [#37485](https://github.com/woocommerce/woocommerce/pull/37485)
* Dev - Fix WP latest-2 version retrieval in the "Smoke test release" workflow. [#37675](https://github.com/woocommerce/woocommerce/pull/37675)
* Dev - Item controls for attribute creation are always visible [#37620](https://github.com/woocommerce/woocommerce/pull/37620)
* Dev - Migrate woocommerce-payments task to TS and remove connect.js from task fills [#37308](https://github.com/woocommerce/woocommerce/pull/37308)
* Dev - Move additional CES-related components to @woocommerce/customer-effort-score. [#37316](https://github.com/woocommerce/woocommerce/pull/37316)
* Dev - Move components to @woocommerce/product-editor [#37131](https://github.com/woocommerce/woocommerce/pull/37131)
* Dev - New empty state for variations - no variations have been created yet [#37411](https://github.com/woocommerce/woocommerce/pull/37411)
* Dev - Reduce flakiness on E2E test setup. [#37410](https://github.com/woocommerce/woocommerce/pull/37410)
* Dev - Rename the default placeholder in the new attribute form header to New attribute [#37645](https://github.com/woocommerce/woocommerce/pull/37645)
* Dev - Replaced `example.org` in tests with WP_TESTS_DOMAIN for consistency with WordPress Core. [#37742](https://github.com/woocommerce/woocommerce/pull/37742)
* Dev - Reset variable product tour after running e2e tests. [#37680](https://github.com/woocommerce/woocommerce/pull/37680)
* Dev - Run E2E tests on PR merge to trunk. [#37033](https://github.com/woocommerce/woocommerce/pull/37033)
* Dev - Set quantity value when stock tracking is enabled [#37304](https://github.com/woocommerce/woocommerce/pull/37304)
* Dev - Simplify boolean expression before && in Marketing page. [#37452](https://github.com/woocommerce/woocommerce/pull/37452)
* Dev - Smoke test WooCommerce with plugins installed on releases. [#37361](https://github.com/woocommerce/woocommerce/pull/37361)
* Dev - Split can create product, attributes and variations, edit variations and delete variations into smaller tests to avoid timing out [#37733](https://github.com/woocommerce/woocommerce/pull/37733)
* Dev - Update webpack config to use @woocommerce/internal-style-build's parser config [#37195](https://github.com/woocommerce/woocommerce/pull/37195)
* Tweak - Update plugin listing description [#38074](https://github.com/woocommerce/woocommerce/pull/38074)
* Tweak - Changed label for button to add a new global attribute value from the product screen. [#37414](https://github.com/woocommerce/woocommerce/pull/37414)
* Tweak - Default to sorting orders by date (desc) when HPOS is active. [#37565](https://github.com/woocommerce/woocommerce/pull/37565)
* Tweak - Exclude empty attributes from the attribute count when tracking product updates. [#37718](https://github.com/woocommerce/woocommerce/pull/37718)
* Tweak - Fix typo in a function comment. [#37746](https://github.com/woocommerce/woocommerce/pull/37746)
* Tweak - Fix typo in Stats controller [#37407](https://github.com/woocommerce/woocommerce/pull/37407)
* Tweak - Fix typos in comments in REST API customers controller [#37405](https://github.com/woocommerce/woocommerce/pull/37405)
* Tweak - Remove the multichannel marketing feature flag from database since it's the default option now. [#37454](https://github.com/woocommerce/woocommerce/pull/37454)
* Tweak - Remove timeouts in e2e tests for variable products and analytics. [#37335](https://github.com/woocommerce/woocommerce/pull/37335)
* Tweak - Update mobile app image resolution [#37506](https://github.com/woocommerce/woocommerce/pull/37506)
* Tweak - Update style of product attributes tab empty state. [#37429](https://github.com/woocommerce/woocommerce/pull/37429)
* Tweak - Update to the merchant variable product e2e test [#37714](https://github.com/woocommerce/woocommerce/pull/37714)
* Performance - Improve search count query performance by avoiding LEFT JOIN in favor of subquery. [#36688](https://github.com/woocommerce/woocommerce/pull/36688)
* Enhancement - Added a button to download SSR to a file. [#38110](https://github.com/woocommerce/woocommerce/pull/38110)
* Enhancement - Added a woocommerce_disable_api_access_log filter to disable last access logging for rest api. [#37332](https://github.com/woocommerce/woocommerce/pull/37332)
* Enhancement - Fix rounding difference on refunds with per-line taxes [#34641](https://github.com/woocommerce/woocommerce/pull/34641)
* Enhancement - Show info message when on variations tab and no attributes have been assigned to product. [#37352](https://github.com/woocommerce/woocommerce/pull/37352)
* Enhancement - Show tour when product type is changed to variable. [#37413](https://github.com/woocommerce/woocommerce/pull/37413)
= 7.6.1 2023-04-26 = = 7.6.1 2023-04-26 =
**WooCommerce** **WooCommerce**
@ -54,7 +180,7 @@
* Fix - Prevent possible warning arising from use of woocommerce_wp_* family of functions. [#37026](https://github.com/woocommerce/woocommerce/pull/37026) * Fix - Prevent possible warning arising from use of woocommerce_wp_* family of functions. [#37026](https://github.com/woocommerce/woocommerce/pull/37026)
* Fix - Record values for toggled checkboxes/features in settings [#37242](https://github.com/woocommerce/woocommerce/pull/37242) * Fix - Record values for toggled checkboxes/features in settings [#37242](https://github.com/woocommerce/woocommerce/pull/37242)
* Fix - Restore the sort order when orders are cached. [#36650](https://github.com/woocommerce/woocommerce/pull/36650) * Fix - Restore the sort order when orders are cached. [#36650](https://github.com/woocommerce/woocommerce/pull/36650)
* Fix - Treat order as seperate resource when validating for webhook since it's not necessarily a CPT anymore. [#36650](https://github.com/woocommerce/woocommerce/pull/36650) * Fix - Treat order as separate resource when validating for webhook since it's not necessarily a CPT anymore. [#36650](https://github.com/woocommerce/woocommerce/pull/36650)
* Fix - Update Customers report with latest user data after editing user. [#37237](https://github.com/woocommerce/woocommerce/pull/37237) * Fix - Update Customers report with latest user data after editing user. [#37237](https://github.com/woocommerce/woocommerce/pull/37237)
* Add - Add "Create a new campaign" modal in Campaigns card in Multichannel Marketing page. [#37044](https://github.com/woocommerce/woocommerce/pull/37044) * Add - Add "Create a new campaign" modal in Campaigns card in Multichannel Marketing page. [#37044](https://github.com/woocommerce/woocommerce/pull/37044)
* Add - Add a cache for orders, to use when custom order tables are enabled [#35014](https://github.com/woocommerce/woocommerce/pull/35014) * Add - Add a cache for orders, to use when custom order tables are enabled [#35014](https://github.com/woocommerce/woocommerce/pull/35014)
@ -432,7 +558,7 @@
* Tweak - Resolve an error in the product tracking code by testing to see if the `post_type` query var is set before checking its value. [#34501](https://github.com/woocommerce/woocommerce/pull/34501) * Tweak - Resolve an error in the product tracking code by testing to see if the `post_type` query var is set before checking its value. [#34501](https://github.com/woocommerce/woocommerce/pull/34501)
* Tweak - Simplify wording within the customer emails for on-hold orders. [#31886](https://github.com/woocommerce/woocommerce/pull/31886) * Tweak - Simplify wording within the customer emails for on-hold orders. [#31886](https://github.com/woocommerce/woocommerce/pull/31886)
* Tweak - WooCommerce has now been tested up to WordPress 6.1.x. [#35985](https://github.com/woocommerce/woocommerce/pull/35985) * Tweak - WooCommerce has now been tested up to WordPress 6.1.x. [#35985](https://github.com/woocommerce/woocommerce/pull/35985)
* Performance - Split CALC_FOUND_ROW query into seperate count query for better performance. [#35468](https://github.com/woocommerce/woocommerce/pull/35468) * Performance - Split CALC_FOUND_ROW query into separate count query for better performance. [#35468](https://github.com/woocommerce/woocommerce/pull/35468)
* Enhancement - Add a bottom padding to the whole form [#35721](https://github.com/woocommerce/woocommerce/pull/35721) * Enhancement - Add a bottom padding to the whole form [#35721](https://github.com/woocommerce/woocommerce/pull/35721)
* Enhancement - Add a confirmation modal when the user tries to navigate away with unsaved changes [#35625](https://github.com/woocommerce/woocommerce/pull/35625) * Enhancement - Add a confirmation modal when the user tries to navigate away with unsaved changes [#35625](https://github.com/woocommerce/woocommerce/pull/35625)
* Enhancement - Add a default placeholder title for newly added attributes and always show remove button for attributes [#35904](https://github.com/woocommerce/woocommerce/pull/35904) * Enhancement - Add a default placeholder title for newly added attributes and always show remove button for attributes [#35904](https://github.com/woocommerce/woocommerce/pull/35904)
@ -3371,7 +3497,7 @@
* Fix - Add protection around func_get_args_call for backwards compatibility. #28677 * Fix - Add protection around func_get_args_call for backwards compatibility. #28677
* Fix - Restore stock_status in REST API V3 response. #28731 * Fix - Restore stock_status in REST API V3 response. #28731
* Fix - Revert some of the changes related to perf enhancements (27735) as it was breaking stock_status filter. #28745 * Fix - Revert some of the changes related to perf enhancements (27735) as it was breaking stock_status filter. #28745
* Dev - Hook for intializing price slider in frontend. #28014 * Dev - Hook for initializing price slider in frontend. #28014
* Dev - Add support for running a custom initialization script for tests. #28041 * Dev - Add support for running a custom initialization script for tests. #28041
* Dev - Use the coenjacobs/mozart package to renamespace vendor packages. #28147 * Dev - Use the coenjacobs/mozart package to renamespace vendor packages. #28147
* Dev - Documentation for `wc_get_container`. #28269 * Dev - Documentation for `wc_get_container`. #28269
@ -5290,7 +5416,7 @@
* Fix - Fix edge case where `get_plugins` would not have the custom WooCommerce plugin headers if `get_plugins` was called early. #21669 * Fix - Fix edge case where `get_plugins` would not have the custom WooCommerce plugin headers if `get_plugins` was called early. #21669
* Fix - Prevent PHP warning when deprecated user meta starts with uppercase. #21943 * Fix - Prevent PHP warning when deprecated user meta starts with uppercase. #21943
* Fix - Fixed support for multiple query parameters translated to meta queries via REST API requests. #22108 * Fix - Fixed support for multiple query parameters translated to meta queries via REST API requests. #22108
* Fix - Prevent PHP errors when trying to access non-existant report tabs. #22183 * Fix - Prevent PHP errors when trying to access non-existent report tabs. #22183
* Fix - Filter by attributes dropdown placeholder text should not be wrapped in quotes. #22185 * Fix - Filter by attributes dropdown placeholder text should not be wrapped in quotes. #22185
* Fix - Apply sale price until end of closing sale date. #22189 * Fix - Apply sale price until end of closing sale date. #22189
* Fix - Allow empty schema again when registering a custom field for the API. #22204 * Fix - Allow empty schema again when registering a custom field for the API. #22204
@ -6612,7 +6738,7 @@
* Removed internal scroll from log viewer. * Removed internal scroll from log viewer.
* Add reply-to to admin emails. * Add reply-to to admin emails.
* Improved the zone setup flow. * Improved the zone setup flow.
* Made wc_get_wildcard_postcodes return the orignal postcode plus * since wildcards should match empty strings too. * Made wc_get_wildcard_postcodes return the original postcode plus * since wildcards should match empty strings too.
* Use all paid statuses in $customer->get_total_spent(). * Use all paid statuses in $customer->get_total_spent().
* Move location of billing email field to work with password managers. * Move location of billing email field to work with password managers.
* Option to restrict selling locations by country. * Option to restrict selling locations by country.
@ -8122,7 +8248,7 @@
* Tweak - Flat rate shipping support for percentage factor of additional costs. * Tweak - Flat rate shipping support for percentage factor of additional costs.
* Tweak - local delivery _ pattern matching for postcodes. e.g. NG1___ would match NG1 1AA but not NG10 1AA. * Tweak - local delivery _ pattern matching for postcodes. e.g. NG1___ would match NG1 1AA but not NG10 1AA.
* Tweak - Improved layered nav OR count logic * Tweak - Improved layered nav OR count logic
* Tweak - Make shipping methods check if taxable, so when customer is VAT excempt taxes are not included in price. * Tweak - Make shipping methods check if taxable, so when customer is VAT exempt taxes are not included in price.
* Tweak - Coupon in admin bar new menu #3974 * Tweak - Coupon in admin bar new menu #3974
* Tweak - Shortcode tag filters + updated menu names to make white labelling easier. * Tweak - Shortcode tag filters + updated menu names to make white labelling easier.
* Tweak - Removed placeholder polyfill. Use this plugin to replace functionality if required: https://wordpress.org/plugins/html5-placeholder-polyfill/ * Tweak - Removed placeholder polyfill. Use this plugin to replace functionality if required: https://wordpress.org/plugins/html5-placeholder-polyfill/

View File

@ -30,7 +30,7 @@
"create-extension": "node ./tools/create-extension/index.js", "create-extension": "node ./tools/create-extension/index.js",
"cherry-pick": "node ./tools/cherry-pick/bin/run", "cherry-pick": "node ./tools/cherry-pick/bin/run",
"sync-dependencies": "pnpm exec syncpack -- fix-mismatches", "sync-dependencies": "pnpm exec syncpack -- fix-mismatches",
"utils": "./tools/monorepo-utils/bin/run" "utils": "node ./tools/monorepo-utils/dist/index.js"
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.20.2", "@babel/preset-env": "^7.20.2",

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: add
Add single selection mode to SelectTree

View File

@ -0,0 +1,4 @@
Significance: patch
Type: dev
Migrate select control component to TS

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Correct spelling errors

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

@ -0,0 +1,4 @@
Significance: minor
Type: update
Show comma separated list in ready only mode of select tree control

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add allowDragging option to ImageGallery to support disabling drag and drop of images.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
TreeControl: Fix a bug where items with children were not selected in single mode and fix a bug where navigation between items with tab was not working properly.

View File

@ -94,7 +94,7 @@ Name | Type | Default | Description
`legendPosition` | One of: 'bottom', 'side', 'top', 'hidden' | `null` | Position the legend must be displayed in. If it's not defined, it's calculated depending on the viewport width and the mode `legendPosition` | One of: 'bottom', 'side', 'top', 'hidden' | `null` | Position the legend must be displayed in. If it's not defined, it's calculated depending on the viewport width and the mode
`legendTotals` | Object | `null` | Values to overwrite the legend totals. If not defined, the sum of all line values will be used `legendTotals` | Object | `null` | Values to overwrite the legend totals. If not defined, the sum of all line values will be used
`screenReaderFormat` | One of type: string, func | `'%B %-d, %Y'` | A datetime formatting string or overriding function to format the screen reader labels `screenReaderFormat` | One of type: string, func | `'%B %-d, %Y'` | A datetime formatting string or overriding function to format the screen reader labels
`showHeaderControls` | Boolean | `true` | Wether header UI controls must be displayed `showHeaderControls` | Boolean | `true` | Whether header UI controls must be displayed
`title` | String | `null` | A title describing this chart `title` | String | `null` | A title describing this chart
`tooltipLabelFormat` | One of type: string, func | `'%B %-d, %Y'` | A datetime formatting string or overriding function to format the tooltip label `tooltipLabelFormat` | One of type: string, func | `'%B %-d, %Y'` | A datetime formatting string or overriding function to format the tooltip label
`tooltipValueFormat` | One of type: string, func | `','` | A number formatting string or function to format the value displayed in the tooltips `tooltipValueFormat` | One of type: string, func | `','` | A number formatting string or function to format the value displayed in the tooltips

View File

@ -577,7 +577,7 @@ Chart.propTypes = {
PropTypes.func, PropTypes.func,
] ), ] ),
/** /**
* Wether header UI controls must be displayed. * Whether header UI controls must be displayed.
*/ */
showHeaderControls: PropTypes.bool, showHeaderControls: PropTypes.bool,
/** /**

View File

@ -1,6 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { Ref } from 'react';
import { format as formatDate } from '@wordpress/date'; import { format as formatDate } from '@wordpress/date';
import { import {
createElement, createElement,
@ -9,6 +10,7 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
forwardRef,
} from '@wordpress/element'; } from '@wordpress/element';
import { Icon, calendar } from '@wordpress/icons'; import { Icon, calendar } from '@wordpress/icons';
import moment, { Moment } from 'moment'; import moment, { Moment } from 'moment';
@ -48,306 +50,329 @@ export type DateTimePickerControlProps = {
placeholder?: string; placeholder?: string;
help?: string | null; help?: string | null;
onChangeDebounceWait?: number; onChangeDebounceWait?: number;
} & Omit< React.HTMLAttributes< HTMLDivElement >, 'onChange' >; } & Omit< React.HTMLAttributes< HTMLInputElement >, 'onChange' >;
export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( { export const DateTimePickerControl = forwardRef(
currentDate, function ForwardedDateTimePickerControl(
isDateOnlyPicker = false, {
is12HourPicker = true, currentDate,
timeForDateOnly = 'start-of-day', isDateOnlyPicker = false,
dateTimeFormat, is12HourPicker = true,
disabled = false, timeForDateOnly = 'start-of-day',
onChange, dateTimeFormat,
onBlur, disabled = false,
label, onChange,
placeholder, onBlur,
help, label,
className = '', placeholder,
onChangeDebounceWait = 500, help,
}: DateTimePickerControlProps ) => { className = '',
const instanceId = useInstanceId( DateTimePickerControl ); onChangeDebounceWait = 500,
const id = `inspector-date-time-picker-control-${ instanceId }`; ...props
const inputControl = useRef< InputControl >(); }: DateTimePickerControlProps,
ref: Ref< HTMLInputElement >
) {
const id = useInstanceId(
DateTimePickerControl,
'inspector-date-time-picker-control',
props.id
) as string;
const inputControl = useRef< InputControl >();
const displayFormat = useMemo( () => { const displayFormat = useMemo( () => {
if ( dateTimeFormat ) { if ( dateTimeFormat ) {
return 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' );
} }
return updatedMomentDate; if ( isDateOnlyPicker ) {
}, return defaultDateFormat;
[ isDateOnlyPicker, timeForDateOnly ] }
);
function hasFocusLeftInputAndDropdownContent( if ( is12HourPicker ) {
event: React.FocusEvent< HTMLInputElement > return default12HourDateTimeFormat;
): boolean { }
return ! event.relatedTarget?.closest(
'.components-dropdown__content' 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 `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 `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 `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 { .woocommerce-experimental-select-control__items-wrapper {
display: flex; display: flex;
flex-grow: 1; flex-grow: 1;
flex-wrap: wrap;
align-items: center; align-items: center;
padding: 2px $gap-smaller; padding: 2px $gap-smaller;

View File

@ -75,8 +75,8 @@ export const ComboBox = ( {
<input <input
{ ...inputProps } { ...inputProps }
ref={ ( node ) => { ref={ ( node ) => {
inputRef.current = node;
if ( typeof inputProps.ref === 'function' ) { if ( typeof inputProps.ref === 'function' ) {
inputRef.current = node;
( (
inputProps.ref as unknown as ( inputProps.ref as unknown as (
node: HTMLInputElement | null node: HTMLInputElement | null

View File

@ -12,6 +12,18 @@
border-color: var( --wp-admin-theme-color ); border-color: var( --wp-admin-theme-color );
} }
&.is-read-only.is-multiple.has-selected-items {
.woocommerce-experimental-select-control__combo-box-wrapper {
cursor: default;
}
.woocommerce-experimental-select-control__input {
opacity: 0;
width: 0;
height: 0;
}
}
&__label { &__label {
display: inline-block; display: inline-block;
margin-bottom: $gap-smaller; margin-bottom: $gap-smaller;

View File

@ -9,13 +9,14 @@ import {
useMultipleSelection, useMultipleSelection,
GetInputPropsOptions, GetInputPropsOptions,
} from 'downshift'; } from 'downshift';
import { useInstanceId } from '@wordpress/compose';
import { import {
useState, useState,
useEffect, useEffect,
createElement, createElement,
Fragment, Fragment,
} from '@wordpress/element'; } from '@wordpress/element';
import { search } from '@wordpress/icons'; import { chevronDown } from '@wordpress/icons';
/** /**
* Internal dependencies * Internal dependencies
@ -57,6 +58,7 @@ export type SelectControlProps< ItemType > = {
) => void; ) => void;
onRemove?: ( item: ItemType ) => void; onRemove?: ( item: ItemType ) => void;
onSelect?: ( selected: ItemType ) => void; onSelect?: ( selected: ItemType ) => void;
onKeyDown?: ( e: KeyboardEvent ) => void;
onFocus?: ( data: { inputValue: string } ) => void; onFocus?: ( data: { inputValue: string } ) => void;
stateReducer?: ( stateReducer?: (
state: UseComboboxState< ItemType | null >, state: UseComboboxState< ItemType | null >,
@ -69,6 +71,8 @@ export type SelectControlProps< ItemType > = {
inputProps?: GetInputPropsOptions; inputProps?: GetInputPropsOptions;
suffix?: JSX.Element | null; suffix?: JSX.Element | null;
showToggleButton?: boolean; showToggleButton?: boolean;
readOnlyWhenClosed?: boolean;
/** /**
* This is a feature already implemented in downshift@7.0.0 through the * 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 * reducer. In order for us to use it this prop is added temporarily until
@ -117,18 +121,24 @@ function SelectControl< ItemType = DefaultItemType >( {
onRemove = () => null, onRemove = () => null,
onSelect = () => null, onSelect = () => null,
onFocus = () => null, onFocus = () => null,
onKeyDown = () => null,
stateReducer = ( state, actionAndChanges ) => actionAndChanges.changes, stateReducer = ( state, actionAndChanges ) => actionAndChanges.changes,
placeholder, placeholder,
selected, selected,
className, className,
disabled, disabled,
inputProps = {}, inputProps = {},
suffix = <SuffixIcon icon={ search } />, suffix = <SuffixIcon icon={ chevronDown } />,
showToggleButton = false, showToggleButton = false,
readOnlyWhenClosed = true,
__experimentalOpenMenuOnFocus = false, __experimentalOpenMenuOnFocus = false,
}: SelectControlProps< ItemType > ) { }: SelectControlProps< ItemType > ) {
const [ isFocused, setIsFocused ] = useState( false ); const [ isFocused, setIsFocused ] = useState( false );
const [ inputValue, setInputValue ] = useState( '' ); const [ inputValue, setInputValue ] = useState( '' );
const instanceId = useInstanceId(
SelectControl,
'woocommerce-experimental-select-control'
);
let selectedItems = selected === null ? [] : selected; let selectedItems = selected === null ? [] : selected;
selectedItems = Array.isArray( selectedItems ) selectedItems = Array.isArray( selectedItems )
@ -230,15 +240,24 @@ function SelectControl< ItemType = DefaultItemType >( {
}, },
} ); } );
const isEventOutside = ( event: React.FocusEvent ) => {
return ! document
.querySelector( '.' + instanceId )
?.contains( event.relatedTarget );
};
const onRemoveItem = ( item: ItemType ) => { const onRemoveItem = ( item: ItemType ) => {
selectItem( null ); selectItem( null );
removeSelectedItem( item ); removeSelectedItem( item );
onRemove( item ); onRemove( item );
}; };
const isReadOnly = readOnlyWhenClosed && ! isOpen && ! isFocused;
const selectedItemTags = multiple ? ( const selectedItemTags = multiple ? (
<SelectedItems <SelectedItems
items={ selectedItems } items={ selectedItems }
isReadOnly={ isReadOnly }
getItemLabel={ getItemLabel } getItemLabel={ getItemLabel }
getItemValue={ getItemValue } getItemValue={ getItemValue }
getSelectedItemProps={ getSelectedItemProps } getSelectedItemProps={ getSelectedItemProps }
@ -251,8 +270,12 @@ function SelectControl< ItemType = DefaultItemType >( {
className={ classnames( className={ classnames(
'woocommerce-experimental-select-control', 'woocommerce-experimental-select-control',
className, className,
instanceId,
{ {
'is-read-only': isReadOnly,
'is-focused': isFocused, 'is-focused': isFocused,
'is-multiple': multiple,
'has-selected-items': selectedItems.length,
} }
) } ) }
> >
@ -282,7 +305,12 @@ function SelectControl< ItemType = DefaultItemType >( {
openMenu(); openMenu();
} }
}, },
onBlur: () => setIsFocused( false ), onBlur: ( event: React.FocusEvent ) => {
if ( isEventOutside( event ) ) {
setIsFocused( false );
}
},
onKeyDown,
placeholder, placeholder,
disabled, disabled,
...inputProps, ...inputProps,

View File

@ -1,3 +1,13 @@
.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 { .woocommerce-experimental-select-control__selected-item {
margin-right: $gap-smallest; margin-right: $gap-smallest;
margin-top: 2px; margin-top: 2px;

View File

@ -1,7 +1,8 @@
/** /**
* External dependencies * External dependencies
*/ */
import { createElement, Fragment } from '@wordpress/element'; import classnames from 'classnames';
import { createElement } from '@wordpress/element';
/** /**
* Internal dependencies * Internal dependencies
@ -10,6 +11,7 @@ import Tag from '../tag';
import { getItemLabelType, getItemValueType } from './types'; import { getItemLabelType, getItemValueType } from './types';
type SelectedItemsProps< ItemType > = { type SelectedItemsProps< ItemType > = {
isReadOnly: boolean;
items: ItemType[]; items: ItemType[];
getItemLabel: getItemLabelType< ItemType >; getItemLabel: getItemLabelType< ItemType >;
getItemValue: getItemValueType< ItemType >; getItemValue: getItemValueType< ItemType >;
@ -22,14 +24,34 @@ type SelectedItemsProps< ItemType > = {
}; };
export const SelectedItems = < ItemType, >( { export const SelectedItems = < ItemType, >( {
isReadOnly,
items, items,
getItemLabel, getItemLabel,
getItemValue, getItemValue,
getSelectedItemProps, getSelectedItemProps,
onRemove, onRemove,
}: SelectedItemsProps< ItemType > ) => { }: SelectedItemsProps< ItemType > ) => {
const classes = classnames(
'woocommerce-experimental-select-control__selected-items',
{
'is-read-only': isReadOnly,
}
);
if ( isReadOnly ) {
return (
<div className={ classes }>
{ items
.map( ( item ) => {
return getItemLabel( item );
} )
.join( ', ' ) }
</div>
);
}
return ( return (
<> <div className={ classes }>
{ items.map( ( item, index ) => { { items.map( ( item, index ) => {
return ( return (
// Disable reason: We prevent the default action to keep the input focused on click. // Disable reason: We prevent the default action to keep the input focused on click.
@ -42,6 +64,9 @@ export const SelectedItems = < ItemType, >( {
selectedItem: item, selectedItem: item,
index, index,
} ) } } ) }
onMouseDown={ ( event ) => {
event.preventDefault();
} }
onClick={ ( event ) => { onClick={ ( event ) => {
event.preventDefault(); event.preventDefault();
} } } }
@ -56,6 +81,6 @@ export const SelectedItems = < ItemType, >( {
</div> </div>
); );
} ) } } ) }
</> </div>
); );
}; };

View File

@ -22,6 +22,7 @@ import {
} from '../experimental-tree-control'; } from '../experimental-tree-control';
type MenuProps = { type MenuProps = {
isEventOutside: ( event: React.FocusEvent ) => boolean;
isOpen: boolean; isOpen: boolean;
isLoading?: boolean; isLoading?: boolean;
position?: Popover.Position; position?: Popover.Position;
@ -32,6 +33,7 @@ type MenuProps = {
} & Omit< TreeControlProps, 'items' >; } & Omit< TreeControlProps, 'items' >;
export const SelectTreeMenu = ( { export const SelectTreeMenu = ( {
isEventOutside,
isLoading, isLoading,
isOpen, isOpen,
className, className,
@ -103,8 +105,10 @@ export const SelectTreeMenu = ( {
) } ) }
position={ position } position={ position }
animate={ false } animate={ false }
onFocusOutside={ () => { onFocusOutside={ ( event ) => {
onClose(); if ( isEventOutside( event ) ) {
onClose();
}
} } } }
> >
{ isOpen && ( { isOpen && (

View File

@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/** /**
* External dependencies * External dependencies
*/ */
import { createElement, useState } from '@wordpress/element'; import { chevronDown } from '@wordpress/icons';
import classNames from 'classnames'; import classNames from 'classnames';
import { search } from '@wordpress/icons'; import { createElement, useState } from '@wordpress/element';
import { useInstanceId } from '@wordpress/compose'; import { useInstanceId } from '@wordpress/compose';
import { BaseControl, TextControl } from '@wordpress/components';
/** /**
* Internal dependencies * Internal dependencies
@ -19,8 +19,7 @@ import { SelectTreeMenu } from './select-tree-menu';
interface SelectTreeProps extends TreeControlProps { interface SelectTreeProps extends TreeControlProps {
id: string; id: string;
selected?: Item[]; selected?: Item | Item[];
getSelectedItemProps?: any;
treeRef?: React.ForwardedRef< HTMLOListElement >; treeRef?: React.ForwardedRef< HTMLOListElement >;
suffix?: JSX.Element | null; suffix?: JSX.Element | null;
isLoading?: boolean; isLoading?: boolean;
@ -30,9 +29,8 @@ interface SelectTreeProps extends TreeControlProps {
export const SelectTree = function SelectTree( { export const SelectTree = function SelectTree( {
items, items,
getSelectedItemProps,
treeRef: ref, treeRef: ref,
suffix = <SuffixIcon icon={ search } />, suffix = <SuffixIcon icon={ chevronDown } />,
placeholder, placeholder,
isLoading, isLoading,
onInputChange, onInputChange,
@ -40,114 +38,171 @@ export const SelectTree = function SelectTree( {
...props ...props
}: SelectTreeProps ) { }: SelectTreeProps ) {
const linkedTree = useLinkedTree( items ); const linkedTree = useLinkedTree( items );
const selectTreeInstanceId = useInstanceId(
SelectTree,
'woocommerce-experimental-select-tree-control__dropdown'
);
const menuInstanceId = useInstanceId( const menuInstanceId = useInstanceId(
SelectTree, SelectTree,
'woocommerce-select-tree-control__menu' 'woocommerce-select-tree-control__menu'
); );
const isEventOutside = ( event: React.FocusEvent ) => {
return ! document
.querySelector( '.' + selectTreeInstanceId )
?.contains( event.relatedTarget );
};
const recalculateInputValue = () => {
if ( onInputChange ) {
if ( ! props.multiple && props.selected ) {
onInputChange( ( props.selected as Item ).label );
} else {
onInputChange( '' );
}
}
};
const focusOnInput = () => {
(
document.querySelector( `#${ props.id }-input` ) as HTMLInputElement
)?.focus();
};
const [ isFocused, setIsFocused ] = useState( false ); const [ isFocused, setIsFocused ] = useState( false );
const [ isOpen, setIsOpen ] = useState( false ); const [ isOpen, setIsOpen ] = useState( false );
const isReadOnly = ! isOpen && ! isFocused;
const inputProps: React.InputHTMLAttributes< HTMLInputElement > = {
className: 'woocommerce-experimental-select-control__input',
id: `${ props.id }-input`,
'aria-autocomplete': 'list',
'aria-controls': `${ props.id }-menu`,
autoComplete: 'off',
onFocus: () => {
if ( ! isOpen ) {
setIsOpen( true );
}
setIsFocused( true );
},
onBlur: ( event ) => {
if ( isOpen && isEventOutside( event ) ) {
setIsOpen( false );
recalculateInputValue();
}
setIsFocused( false );
},
onKeyDown: ( event ) => {
setIsOpen( true );
if ( event.key === 'ArrowDown' ) {
event.preventDefault();
// focus on the first element from the Popover
(
document.querySelector(
`.${ menuInstanceId } input, .${ menuInstanceId } button`
) as HTMLInputElement | HTMLButtonElement
)?.focus();
}
if ( event.key === 'Tab' ) {
setIsOpen( false );
recalculateInputValue();
}
},
onChange: ( event ) =>
onInputChange && onInputChange( event.target.value ),
placeholder,
};
return ( return (
<div <div
className="woocommerce-experimental-select-tree-control__dropdown" className={ `woocommerce-experimental-select-tree-control__dropdown ${ selectTreeInstanceId }` }
tabIndex={ -1 } tabIndex={ -1 }
> >
<div <div
className={ classNames( className={ classNames(
'woocommerce-experimental-select-control', 'woocommerce-experimental-select-control',
{ {
'is-read-only': isReadOnly,
'is-focused': isFocused, 'is-focused': isFocused,
'is-multiple': props.multiple,
'has-selected-items':
Array.isArray( props.selected ) &&
props.selected.length,
} }
) } ) }
> >
<label <BaseControl label={ props.label } id={ `${ props.id }-input` }>
htmlFor={ `${ props.id }-input` } { props.multiple ? (
id={ `${ props.id }-label` } <ComboBox
className="woocommerce-experimental-select-control__label" comboBoxProps={ {
> className:
{ props.label } 'woocommerce-experimental-select-control__combo-box-wrapper',
</label> role: 'combobox',
<ComboBox 'aria-expanded': isOpen,
comboBoxProps={ { 'aria-haspopup': 'tree',
className: 'aria-owns': `${ props.id }-menu`,
'woocommerce-experimental-select-control__combo-box-wrapper', } }
role: 'combobox', inputProps={ inputProps }
'aria-expanded': isOpen, suffix={ suffix }
'aria-haspopup': 'tree', >
'aria-labelledby': `${ props.id }-label`, <SelectedItems
'aria-owns': `${ props.id }-menu`, isReadOnly={ isReadOnly }
} } items={ ( props.selected as Item[] ) || [] }
inputProps={ { getItemLabel={ ( item ) => item?.label || '' }
className: getItemValue={ ( item ) => item?.value || '' }
'woocommerce-experimental-select-control__input', onRemove={ ( item ) => {
id: `${ props.id }-input`, if (
'aria-autocomplete': 'list', ! Array.isArray( item ) &&
'aria-controls': `${ props.id }-menu`, props.onRemove
autoComplete: 'off', ) {
onFocus: () => { props.onRemove( item );
if ( ! isOpen ) { }
setIsOpen( true ); } }
} getSelectedItemProps={ () => ( {} ) }
setIsFocused( true ); />
}, </ComboBox>
onBlur: ( event ) => { ) : (
// if blurring to an element inside the dropdown, don't close it <TextControl
if ( { ...inputProps }
isOpen && value={ props.createValue || '' }
! document onChange={ ( value ) => {
.querySelector( '.' + menuInstanceId ) if ( onInputChange ) onInputChange( value );
?.contains( event.relatedTarget ) const item = items.find(
) { ( i ) => i.label === value
setIsOpen( false ); );
} if ( props.onSelect && item ) {
setIsFocused( false ); props.onSelect( item );
}, }
onKeyDown: ( event ) => { if ( ! value && props.onRemove ) {
setIsOpen( true ); props.onRemove( props.selected as Item );
if ( event.key === 'ArrowDown' ) { }
event.preventDefault(); } }
// focus on the first element from the Popover />
( ) }
document.querySelector( </BaseControl>
`.${ menuInstanceId } input, .${ menuInstanceId } button`
) as HTMLInputElement | HTMLButtonElement
)?.focus();
}
if ( event.key === 'Tab' ) {
setIsOpen( false );
}
},
onChange: ( event ) =>
onInputChange &&
onInputChange( event.target.value ),
placeholder,
} }
suffix={ suffix }
>
<SelectedItems
items={ ( props.selected as Item[] ) || [] }
getItemLabel={ ( item ) => item?.label || '' }
getItemValue={ ( item ) => item?.value || '' }
onRemove={ ( item ) => {
if ( ! Array.isArray( item ) && props.onRemove ) {
props.onRemove( item );
setIsOpen( false );
}
} }
getSelectedItemProps={ () => ( {} ) }
/>
</ComboBox>
</div> </div>
<SelectTreeMenu <SelectTreeMenu
{ ...props } { ...props }
onSelect={ ( item ) => {
if ( ! props.multiple && onInputChange ) {
onInputChange( ( item as Item ).label );
setIsOpen( false );
setIsFocused( false );
focusOnInput();
}
if ( props.onSelect ) {
props.onSelect( item );
}
} }
id={ `${ props.id }-menu` } id={ `${ props.id }-menu` }
className={ menuInstanceId.toString() } className={ menuInstanceId.toString() }
ref={ ref } ref={ ref }
isEventOutside={ isEventOutside }
isOpen={ isOpen } isOpen={ isOpen }
items={ linkedTree } items={ linkedTree }
shouldShowCreateButton={ shouldShowCreateButton } shouldShowCreateButton={ shouldShowCreateButton }
onClose={ () => setIsOpen( false ) } onClose={ () => {
setIsOpen( false );
} }
/> />
</div> </div>
); );

View File

@ -162,6 +162,36 @@ export const SingleWithinModalUsingBodyDropdownPlacement: React.FC = () => {
); );
}; };
export const SingleSelectTree: React.FC = () => {
const [ value, setValue ] = React.useState( '' );
const [ selected, setSelected ] = React.useState< Item | undefined >();
const items = filterItems( listItems, value );
return (
<SelectTree
id="single-select-tree"
label="Single Select Tree"
items={ items }
selected={ selected }
shouldNotRecursivelySelect
shouldShowCreateButton={ ( typedValue ) =>
! value ||
listItems.findIndex( ( item ) => item.label === typedValue ) ===
-1
}
createValue={ value }
// eslint-disable-next-line no-alert
onCreateNew={ () => alert( 'create new called' ) }
onInputChange={ ( a ) => setValue( a || '' ) }
onSelect={ ( selectedItems ) => {
setSelected( selectedItems as Item );
} }
onRemove={ () => setSelected( undefined ) }
/>
);
};
export default { export default {
title: 'WooCommerce Admin/experimental/SelectTreeControl', title: 'WooCommerce Admin/experimental/SelectTreeControl',
component: SelectTree, component: SelectTree,

View File

@ -122,8 +122,6 @@ export function useSelection( {
if ( item.children.length && ! shouldNotRecursivelySelect ) { if ( item.children.length && ! shouldNotRecursivelySelect ) {
value.push( ...getDeepChildren( item ) ); value.push( ...getDeepChildren( item ) );
} }
} else if ( item.children?.length ) {
return;
} }
if ( checked ) { if ( checked ) {

View File

@ -45,10 +45,6 @@ $control-size: $gap-large;
margin: 0; margin: 0;
} }
.components-radio-control__input {
@include screen-reader-only();
}
.components-checkbox-control__label { .components-checkbox-control__label {
display: none; display: none;
} }
@ -86,4 +82,8 @@ $control-size: $gap-large;
min-width: $control-size; min-width: $control-size;
} }
} }
&__checkbox {
@include screen-reader-only();
}
} }

View File

@ -60,13 +60,11 @@ export const TreeItem = forwardRef( function ForwardedTreeItem(
/> />
) : ( ) : (
<input <input
type="radio" type="checkbox"
className="experimental-woocommerce-tree-item__checkbox"
checked={ selection.checkedStatus === 'checked' } checked={ selection.checkedStatus === 'checked' }
className="components-radio-control__input"
onChange={ ( event ) => onChange={ ( event ) =>
selection.onSelectChild( selection.onSelectChild( event.target.checked )
event.currentTarget.checked
)
} }
/> />
) } ) }

View File

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

View File

@ -65,8 +65,8 @@ The `config` prop has the following structure:
- `label`: String - A label above the filter selector. - `label`: String - A label above the filter selector.
- `staticParams`: Array - Url parameters to persist when selecting a new filter. - `staticParams`: Array - Url parameters to persist when selecting a new filter.
- `param`: String - The url paramter this filter will modify. - `param`: String - The url parameter this filter will modify.
- `defaultValue`: String - The default paramter value to use instead of 'all'. - `defaultValue`: String - The default parameter value to use instead of 'all'.
- `showFilters`: Function - Determine if the filter should be shown. Supply a function with the query object as an argument returning a boolean. - `showFilters`: Function - Determine if the filter should be shown. Supply a function with the query object as an argument returning a boolean.
- `filters`: Array - Array of filter objects. - `filters`: Array - Array of filter objects.

View File

@ -374,11 +374,11 @@ FilterPicker.propTypes = {
*/ */
staticParams: PropTypes.array.isRequired, staticParams: PropTypes.array.isRequired,
/** /**
* The url paramter this filter will modify. * The url parameter this filter will modify.
*/ */
param: PropTypes.string.isRequired, param: PropTypes.string.isRequired,
/** /**
* The default paramter value to use instead of 'all'. * The default parameter value to use instead of 'all'.
*/ */
defaultValue: PropTypes.string, defaultValue: PropTypes.string,
/** /**

View File

@ -16,6 +16,7 @@ import { MediaUploadComponentType } from './types';
export type ImageGalleryToolbarProps = { export type ImageGalleryToolbarProps = {
childIndex: number; childIndex: number;
allowDragging?: boolean;
moveItem: ( fromIndex: number, toIndex: number ) => void; moveItem: ( fromIndex: number, toIndex: number ) => void;
removeItem: ( removeIndex: number ) => void; removeItem: ( removeIndex: number ) => void;
replaceItem: ( replaceItem: (
@ -29,6 +30,7 @@ export type ImageGalleryToolbarProps = {
export const ImageGalleryToolbar: React.FC< ImageGalleryToolbarProps > = ( { export const ImageGalleryToolbar: React.FC< ImageGalleryToolbarProps > = ( {
childIndex, childIndex,
allowDragging = true,
moveItem, moveItem,
removeItem, removeItem,
replaceItem, replaceItem,
@ -60,12 +62,14 @@ export const ImageGalleryToolbar: React.FC< ImageGalleryToolbarProps > = ( {
> >
{ ! isCoverItem && ( { ! isCoverItem && (
<ToolbarGroup> <ToolbarGroup>
<ToolbarButton { allowDragging && (
icon={ () => ( <ToolbarButton
<SortableHandle itemIndex={ childIndex } /> icon={ () => (
) } <SortableHandle itemIndex={ childIndex } />
label={ __( 'Drag to reorder', 'woocommerce' ) } ) }
/> label={ __( 'Drag to reorder', 'woocommerce' ) }
/>
) }
<ToolbarButton <ToolbarButton
disabled={ childIndex < 2 } disabled={ childIndex < 2 }
onClick={ () => movePrevious() } onClick={ () => movePrevious() }

View File

@ -0,0 +1,52 @@
/**
* External dependencies
*/
import { createElement } from '@wordpress/element';
import { DragEventHandler } from 'react';
/**
* Internal dependencies
*/
import { Sortable } from '../sortable';
import { ImageGalleryChild } from './types';
export type ImageGalleryWrapperProps = {
children: JSX.Element[];
allowDragging?: boolean;
onDragStart?: DragEventHandler< HTMLDivElement >;
onDragEnd?: DragEventHandler< HTMLDivElement >;
onDragOver?: DragEventHandler< HTMLLIElement >;
updateOrderedChildren?: ( items: ImageGalleryChild[] ) => void;
};
export const ImageGalleryWrapper: React.FC< ImageGalleryWrapperProps > = ( {
children,
allowDragging = true,
onDragStart = () => null,
onDragEnd = () => null,
onDragOver = () => null,
updateOrderedChildren = () => null,
} ) => {
if ( allowDragging ) {
return (
<Sortable
isHorizontal
onOrderChange={ ( items ) => {
updateOrderedChildren( items );
} }
onDragStart={ ( event ) => {
onDragStart( event );
} }
onDragEnd={ ( event ) => {
onDragEnd( event );
} }
onDragOver={ onDragOver }
>
{ children }
</Sortable>
);
}
return (
<div className="woocommerce-image-gallery__wrapper">{ children }</div>
);
};

View File

@ -3,7 +3,7 @@
display: block; display: block;
} }
.woocommerce-sortable { .woocommerce-sortable, &__wrapper {
display: grid; display: grid;
grid-template-columns: inherit; grid-template-columns: inherit;
grid-gap: $gap; grid-gap: $gap;

View File

@ -14,10 +14,11 @@ import { MediaItem, MediaUpload } from '@wordpress/media-utils';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { Sortable, moveIndex } from '../sortable'; import { moveIndex } from '../sortable';
import { ImageGalleryToolbar } from './index'; import { ImageGalleryToolbar } from './index';
import { ImageGalleryChild, MediaUploadComponentType } from './types'; import { ImageGalleryChild, MediaUploadComponentType } from './types';
import { removeItem, replaceItem } from './utils'; import { removeItem, replaceItem } from './utils';
import { ImageGalleryWrapper } from './image-gallery-wrapper';
export type ImageGalleryProps = { export type ImageGalleryProps = {
children: ImageGalleryChild | ImageGalleryChild[]; children: ImageGalleryChild | ImageGalleryChild[];
@ -30,6 +31,7 @@ export type ImageGalleryProps = {
replaceIndex: number; replaceIndex: number;
media: { id: number } & MediaItem; media: { id: number } & MediaItem;
} ) => void; } ) => void;
allowDragging?: boolean;
onSelectAsCover?: ( itemId: string | null ) => void; onSelectAsCover?: ( itemId: string | null ) => void;
onOrderChange?: ( items: ImageGalleryChild[] ) => void; onOrderChange?: ( items: ImageGalleryChild[] ) => void;
MediaUploadComponent?: MediaUploadComponentType; MediaUploadComponent?: MediaUploadComponentType;
@ -41,6 +43,7 @@ export type ImageGalleryProps = {
export const ImageGallery: React.FC< ImageGalleryProps > = ( { export const ImageGallery: React.FC< ImageGalleryProps > = ( {
children, children,
columns = 4, columns = 4,
allowDragging = true,
onSelectAsCover = () => null, onSelectAsCover = () => null,
onOrderChange = () => null, onOrderChange = () => null,
onRemove = () => null, onRemove = () => null,
@ -82,11 +85,9 @@ export const ImageGallery: React.FC< ImageGalleryProps > = ( {
gridTemplateColumns: 'min-content '.repeat( columns ), gridTemplateColumns: 'min-content '.repeat( columns ),
} } } }
> >
<Sortable <ImageGalleryWrapper
isHorizontal allowDragging={ allowDragging }
onOrderChange={ ( items ) => { updateOrderedChildren={ updateOrderedChildren }
updateOrderedChildren( items );
} }
onDragStart={ ( event ) => { onDragStart={ ( event ) => {
setIsDragging( true ); setIsDragging( true );
onDragStart( event ); onDragStart( event );
@ -137,6 +138,7 @@ export const ImageGallery: React.FC< ImageGalleryProps > = ( {
}, },
isToolbarVisible ? ( isToolbarVisible ? (
<ImageGalleryToolbar <ImageGalleryToolbar
allowDragging={ allowDragging }
childIndex={ childIndex } childIndex={ childIndex }
lastChild={ lastChild={
childIndex === orderedChildren.length - 1 childIndex === orderedChildren.length - 1
@ -190,7 +192,7 @@ export const ImageGallery: React.FC< ImageGalleryProps > = ( {
) : null ) : null
); );
} ) } } ) }
</Sortable> </ImageGalleryWrapper>
</div> </div>
); );
}; };

View File

@ -45,7 +45,7 @@ export const EditorWritingFlow = ( {
}; };
} ); } );
// This is a workaround to prevent focusing the block on intialization. // This is a workaround to prevent focusing the block on initialization.
// Changing to a mode other than "edit" ensures that no initial position // Changing to a mode other than "edit" ensures that no initial position
// is found and no element gets subsequently focused. // is found and no element gets subsequently focused.
// See https://github.com/WordPress/gutenberg/blob/411b6eee8376e31bf9db4c15c92a80524ae38e9b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js#L42 // See https://github.com/WordPress/gutenberg/blob/411b6eee8376e31bf9db4c15c92a80524ae38e9b/packages/block-editor/src/components/block-list/use-block-props/use-focus-first-element.js#L42

View File

@ -153,7 +153,7 @@ describe( 'buildTermsTree', () => {
] ); ] );
} ); } );
test( 'should return a tree of items, with orphan categories appended to the end, with children of thier own', () => { test( 'should return a tree of items, with orphan categories appended to the end, with children of their own', () => {
const filteredList = [ const filteredList = [
{ id: 1, name: 'Apricots', parent: 0 }, { id: 1, name: 'Apricots', parent: 0 },
{ id: 3, name: 'Elderberry', parent: 2 }, { id: 3, name: 'Elderberry', parent: 2 },

View File

@ -6,18 +6,149 @@ import { BACKSPACE, DOWN, UP } from '@wordpress/keycodes';
import { createElement, Component, createRef } from '@wordpress/element'; import { createElement, Component, createRef } from '@wordpress/element';
import { Icon, search } from '@wordpress/icons'; import { Icon, search } from '@wordpress/icons';
import classnames from 'classnames'; import classnames from 'classnames';
import PropTypes from 'prop-types'; import { isArray } from 'lodash';
import {
RefObject,
ChangeEvent,
FocusEvent,
KeyboardEvent,
InputHTMLAttributes,
} from 'react';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import Tags from './tags'; import Tags from './tags';
import { Selected, Option } from './types';
type Props = {
/**
* Bool to determine if tags should be rendered.
*/
hasTags?: boolean;
/**
* Help text to be appended beneath the input.
*/
help?: string | JSX.Element;
/**
* Render tags inside input, otherwise render below input.
*/
inlineTags?: boolean;
/**
* Allow the select options to be filtered by search input.
*/
isSearchable?: boolean;
/**
* ID of the main SelectControl instance.
*/
instanceId?: number;
/**
* A label to use for the main input.
*/
label?: string;
/**
* ID used for a11y in the listbox.
*/
listboxId?: string;
/**
* Function called when the input is blurred.
*/
onBlur?: () => void;
/**
* Function called when selected results change, passed result list.
*/
onChange: ( selected: Option[] ) => void;
/**
* Function called when input field is changed or focused.
*/
onSearch: ( query: string ) => void;
/**
* A placeholder for the search input.
*/
placeholder?: string;
/**
* Search query entered by user.
*/
query?: string | null;
/**
* An array of objects describing selected values. If the label of the selected
* value is omitted, the Tag of that value will not be rendered inside the
* search box.
*/
selected?: Selected;
/**
* Show all options on focusing, even if a query exists.
*/
showAllOnFocus?: boolean;
/**
* Control input autocomplete field, defaults: off.
*/
autoComplete?: string;
/**
* Function to execute when the control should be expanded or collapsed.
*/
setExpanded: ( expanded: boolean ) => void;
/**
* Function to execute when the search value changes.
*/
updateSearchOptions: ( query: string ) => void;
/**
* Function to execute when keyboard navigation should decrement the selected index.
*/
decrementSelectedIndex: () => void;
/**
* Function to execute when keyboard navigation should increment the selected index.
*/
incrementSelectedIndex: () => void;
/**
* Multi-select mode allows multiple options to be selected.
*/
multiple?: boolean;
/**
* Is the control currently focused.
*/
isFocused?: boolean;
/**
* ID for accessibility purposes. aria-activedescendant will be set to this value.
*/
activeId?: string;
/**
* Disable the control.
*/
disabled?: boolean;
/**
* Is the control currently expanded. This is for accessibility purposes.
*/
isExpanded?: boolean;
/**
* The type of input to use for the search field.
*/
searchInputType?: InputHTMLAttributes< HTMLInputElement >[ 'type' ];
/**
* The aria label for the search input.
*/
ariaLabel?: string;
/**
* Class name to be added to the input.
*/
className?: string;
/**
* Show the clear button.
*/
showClearButton?: boolean;
};
type State = {
isActive: boolean;
};
/** /**
* A search control to allow user input to filter the options. * A search control to allow user input to filter the options.
*/ */
class Control extends Component { class Control extends Component< Props, State > {
constructor( props ) { input: RefObject< HTMLInputElement >;
constructor( props: Props ) {
super( props ); super( props );
this.state = { this.state = {
isActive: false, isActive: false,
@ -31,13 +162,13 @@ class Control extends Component {
this.onKeyDown = this.onKeyDown.bind( this ); this.onKeyDown = this.onKeyDown.bind( this );
} }
updateSearch( onSearch ) { updateSearch( onSearch: ( query: string ) => void ) {
return ( event ) => { return ( event: ChangeEvent< HTMLInputElement > ) => {
onSearch( event.target.value ); onSearch( event.target.value );
}; };
} }
onFocus( onSearch ) { onFocus( onSearch: ( query: string ) => void ) {
const { const {
isSearchable, isSearchable,
setExpanded, setExpanded,
@ -45,7 +176,7 @@ class Control extends Component {
updateSearchOptions, updateSearchOptions,
} = this.props; } = this.props;
return ( event ) => { return ( event: FocusEvent< HTMLInputElement > ) => {
this.setState( { isActive: true } ); this.setState( { isActive: true } );
if ( isSearchable && showAllOnFocus ) { if ( isSearchable && showAllOnFocus ) {
event.target.select(); event.target.select();
@ -68,7 +199,7 @@ class Control extends Component {
this.setState( { isActive: false } ); this.setState( { isActive: false } );
} }
onKeyDown( event ) { onKeyDown( event: KeyboardEvent< HTMLInputElement > ) {
const { const {
decrementSelectedIndex, decrementSelectedIndex,
incrementSelectedIndex, incrementSelectedIndex,
@ -78,7 +209,12 @@ class Control extends Component {
setExpanded, setExpanded,
} = this.props; } = this.props;
if ( BACKSPACE === event.keyCode && ! query && selected.length ) { if (
BACKSPACE === event.keyCode &&
! query &&
isArray( selected ) &&
selected.length
) {
onChange( [ ...selected.slice( 0, -1 ) ] ); onChange( [ ...selected.slice( 0, -1 ) ] );
} }
@ -100,7 +236,7 @@ class Control extends Component {
renderButton() { renderButton() {
const { multiple, selected } = this.props; const { multiple, selected } = this.props;
if ( multiple || ! selected.length ) { if ( multiple || ! isArray( selected ) || ! selected.length ) {
return null; return null;
} }
@ -151,7 +287,7 @@ class Control extends Component {
aria-describedby={ aria-describedby={
hasTags && inlineTags hasTags && inlineTags
? `search-inline-input-${ instanceId }` ? `search-inline-input-${ instanceId }`
: null : undefined
} }
disabled={ disabled } disabled={ disabled }
aria-label={ this.props.ariaLabel ?? this.props.label } aria-label={ this.props.ariaLabel ?? this.props.label }
@ -168,7 +304,8 @@ class Control extends Component {
query, query,
selected, selected,
} = this.props; } = this.props;
const selectedValue = selected.length ? selected[ 0 ].label : ''; const selectedValue =
isArray( selected ) && selected.length ? selected[ 0 ].label : '';
// Show the selected value for simple select dropdowns. // Show the selected value for simple select dropdowns.
if ( ! multiple && ! isFocused && ! inlineTags ) { if ( ! multiple && ! isFocused && ! inlineTags ) {
@ -194,6 +331,8 @@ class Control extends Component {
isSearchable, isSearchable,
label, label,
query, query,
onChange,
showClearButton,
} = this.props; } = this.props;
const { isActive } = this.state; const { isActive } = this.state;
@ -213,7 +352,7 @@ class Control extends Component {
empty: ! query || query.length === 0, empty: ! query || query.length === 0,
'is-active': isActive, 'is-active': isActive,
'has-tags': inlineTags && hasTags, 'has-tags': inlineTags && hasTags,
'with-value': this.getInputValue().length, 'with-value': this.getInputValue()?.length,
'has-error': !! help, 'has-error': !! help,
'is-disabled': disabled, 'is-disabled': disabled,
} }
@ -221,8 +360,11 @@ class Control extends Component {
onClick={ ( event ) => { onClick={ ( event ) => {
// Don't focus the input if the click event is from the error message. // Don't focus the input if the click event is from the error message.
if ( if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - event.target.className is not in the type definition.
event.target.className !== event.target.className !==
'components-base-control__help' 'components-base-control__help' &&
this.input.current
) { ) {
this.input.current.focus(); this.input.current.focus();
} }
@ -234,7 +376,13 @@ class Control extends Component {
icon={ search } icon={ search }
/> />
) } ) }
{ inlineTags && <Tags { ...this.props } /> } { inlineTags && (
<Tags
onChange={ onChange }
showClearButton={ showClearButton }
selected={ this.props.selected }
/>
) }
<div className="components-base-control__field"> <div className="components-base-control__field">
{ !! label && ( { !! label && (
@ -272,75 +420,4 @@ class Control extends Component {
} }
} }
Control.propTypes = {
/**
* Bool to determine if tags should be rendered.
*/
hasTags: PropTypes.bool,
/**
* Help text to be appended beneath the input.
*/
help: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
/**
* Render tags inside input, otherwise render below input.
*/
inlineTags: PropTypes.bool,
/**
* Allow the select options to be filtered by search input.
*/
isSearchable: PropTypes.bool,
/**
* ID of the main SelectControl instance.
*/
instanceId: PropTypes.number,
/**
* A label to use for the main input.
*/
label: PropTypes.string,
/**
* ID used for a11y in the listbox.
*/
listboxId: PropTypes.string,
/**
* Function called when the input is blurred.
*/
onBlur: PropTypes.func,
/**
* Function called when selected results change, passed result list.
*/
onChange: PropTypes.func,
/**
* Function called when input field is changed or focused.
*/
onSearch: PropTypes.func,
/**
* A placeholder for the search input.
*/
placeholder: PropTypes.string,
/**
* Search query entered by user.
*/
query: PropTypes.string,
/**
* An array of objects describing selected values. If the label of the selected
* value is omitted, the Tag of that value will not be rendered inside the
* search box.
*/
selected: PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] )
.isRequired,
label: PropTypes.string,
} )
),
/**
* Show all options on focusing, even if a query exists.
*/
showAllOnFocus: PropTypes.bool,
/**
* Control input autocomplete field, defaults: off.
*/
autoComplete: PropTypes.string,
};
export default Control; export default Control;

View File

@ -4,26 +4,205 @@
import { __, _n, sprintf } from '@wordpress/i18n'; import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames'; import classnames from 'classnames';
import { Component, createElement } from '@wordpress/element'; import { Component, createElement } from '@wordpress/element';
import { debounce, escapeRegExp, identity, noop } from 'lodash'; import {
import PropTypes from 'prop-types'; debounce,
escapeRegExp,
identity,
isArray,
isNumber,
noop,
} from 'lodash';
import { withFocusOutside, withSpokenMessages } from '@wordpress/components'; import { withFocusOutside, withSpokenMessages } from '@wordpress/components';
import { withInstanceId, compose } from '@wordpress/compose'; import { withInstanceId, compose } from '@wordpress/compose';
import { ChangeEvent, InputHTMLAttributes } from 'react';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { Option, Selected } from './types';
import List from './list'; import List from './list';
import Tags from './tags'; import Tags from './tags';
import Control from './control'; import Control from './control';
const initialState = { isExpanded: false, isFocused: false, query: '' }; type Props = {
/**
* Name to use for the autofill field, not used if no string is passed.
*/
autofill?: string;
/**
* A renderable component (or string) which will be displayed before the `Control` of this component.
*/
children?: React.ReactNode;
/**
* Class name applied to parent div.
*/
className?: string;
/**
* Class name applied to control wrapper.
*/
controlClassName?: string;
/**
* Allow the select options to be disabled.
*/
disabled?: boolean;
/**
* Exclude already selected options from the options list.
*/
excludeSelectedOptions?: boolean;
/**
* Add or remove items to the list of options after filtering,
* passed the array of filtered options and should return an array of options.
*/
onFilter?: (
options: Array< Option >,
query: string | null
) => Array< Option >;
/**
* Function to add regex expression to the filter the results, passed the search query.
*/
getSearchExpression?: ( query: string ) => RegExp;
/**
* Help text to be appended beneath the input.
*/
help?: string | JSX.Element;
/**
* Render tags inside input, otherwise render below input.
*/
inlineTags?: boolean;
/**
* Allow the select options to be filtered by search input.
*/
isSearchable?: boolean;
/**
* A label to use for the main input.
*/
label?: string;
/**
* Function called when selected results change, passed result list.
*/
onChange?: ( selected: string | Option[], query?: string | null ) => void;
/**
* Function run after search query is updated, passed previousOptions and query,
* should return a promise with an array of updated options.
*/
onSearch?: (
previousOptions: Array< Option >,
query: string | null
) => Promise< Array< Option > >;
/**
* An array of objects for the options list. The option along with its key, label and
* value will be returned in the onChange event.
*/
options: Option[];
/**
* A placeholder for the search input.
*/
placeholder?: string;
/**
* Time in milliseconds to debounce the search function after typing.
*/
searchDebounceTime?: number;
/**
* An array of objects describing selected values or optionally a string for a single value.
* If the label of the selected value is omitted, the Tag of that value will not
* be rendered inside the search box.
*/
selected?: Selected;
/**
* A limit for the number of results shown in the options menu. Set to 0 for no limit.
*/
maxResults?: number;
/**
* Allow multiple option selections.
*/
multiple?: boolean;
/**
* Render a 'Clear' button next to the input box to remove its contents.
*/
showClearButton?: boolean;
/**
* The input type for the search box control.
*/
searchInputType?: InputHTMLAttributes< HTMLInputElement >[ 'type' ];
/**
* Only show list options after typing a search query.
*/
hideBeforeSearch?: boolean;
/**
* Show all options on focusing, even if a query exists.
*/
showAllOnFocus?: boolean;
/**
* Render results list positioned statically instead of absolutely.
*/
staticList?: boolean;
/**
* autocomplete prop for the Control input field.
*/
autoComplete?: string;
/**
* Instance ID for the component.
*/
instanceId?: number;
/**
* From withSpokenMessages
*/
debouncedSpeak?: ( message: string, assertive?: string ) => void;
/**
* aria-label for the search input.
*/
ariaLabel?: string;
/**
* On Blur callback.
*/
onBlur?: () => void;
};
type State = {
isExpanded: boolean;
isFocused: boolean;
query: string | null;
searchOptions: Option[];
selectedIndex?: number | null;
};
const initialState: State = {
isExpanded: false,
isFocused: false,
query: '',
searchOptions: [],
};
/** /**
* A search box which filters options while typing, * A search box which filters options while typing,
* allowing a user to select from an option from a filtered list. * allowing a user to select from an option from a filtered list.
*/ */
export class SelectControl extends Component { export class SelectControl extends Component< Props, State > {
constructor( props ) { static defaultProps: Partial< Props > = {
excludeSelectedOptions: true,
getSearchExpression: identity,
inlineTags: false,
isSearchable: false,
onChange: noop,
onFilter: identity,
onSearch: ( options: Option[] ) => Promise.resolve( options ),
maxResults: 0,
multiple: false,
searchDebounceTime: 0,
searchInputType: 'search',
selected: [],
showAllOnFocus: false,
showClearButton: false,
hideBeforeSearch: false,
staticList: false,
autoComplete: 'off',
};
node: HTMLDivElement | null = null;
activePromise: Promise< void | Option[] > | null = null;
cacheSearchOptions: Option[] = [];
constructor( props: Props ) {
super( props ); super( props );
const { selected, options, excludeSelectedOptions } = props; const { selected, options, excludeSelectedOptions } = props;
@ -50,7 +229,7 @@ export class SelectControl extends Component {
this.setNewValue = this.setNewValue.bind( this ); this.setNewValue = this.setNewValue.bind( this );
} }
bindNode( node ) { bindNode( node: HTMLDivElement ) {
this.node = node; this.node = node;
} }
@ -58,7 +237,12 @@ export class SelectControl extends Component {
const { multiple, excludeSelectedOptions } = this.props; const { multiple, excludeSelectedOptions } = this.props;
const newState = { ...initialState }; const newState = { ...initialState };
// Reset selectedIndex if single selection. // Reset selectedIndex if single selection.
if ( ! multiple && selected.length && selected[ 0 ].key ) { if (
! multiple &&
isArray( selected ) &&
selected.length &&
selected[ 0 ].key
) {
newState.selectedIndex = ! excludeSelectedOptions newState.selectedIndex = ! excludeSelectedOptions
? this.props.options.findIndex( ? this.props.options.findIndex(
( i ) => i.key === selected[ 0 ].key ( i ) => i.key === selected[ 0 ].key
@ -101,9 +285,12 @@ export class SelectControl extends Component {
return selectedOption ? [ selectedOption ] : []; return selectedOption ? [ selectedOption ] : [];
} }
selectOption( option ) { selectOption( option: Option ) {
const { multiple, selected } = this.props; const { multiple, selected } = this.props;
const newSelected = multiple ? [ ...selected, option ] : [ option ]; const newSelected =
multiple && isArray( selected )
? [ ...selected, option ]
: [ option ];
this.reset( newSelected ); this.reset( newSelected );
@ -129,25 +316,24 @@ export class SelectControl extends Component {
} ); } );
} }
setNewValue( newValue ) { setNewValue( newValue: Option[] ) {
const { onChange, selected, multiple } = this.props; const { onChange, selected, multiple } = this.props;
const { query } = this.state; const { query } = this.state;
// Trigger a change if the selected value is different and pass back // Trigger a change if the selected value is different and pass back
// an array or string depending on the original value. // an array or string depending on the original value.
if ( multiple || Array.isArray( selected ) ) { if ( multiple || Array.isArray( selected ) ) {
onChange( newValue, query ); onChange!( newValue, query );
} else { } else {
onChange( newValue.length > 0 ? newValue[ 0 ].key : '', query ); onChange!( newValue.length > 0 ? newValue[ 0 ].key : '', query );
} }
} }
decrementSelectedIndex() { decrementSelectedIndex() {
const { selectedIndex } = this.state; const { selectedIndex } = this.state;
const options = this.getOptions(); const options = this.getOptions();
const nextSelectedIndex = const nextSelectedIndex = isNumber( selectedIndex )
selectedIndex !== null ? ( selectedIndex === 0 ? options.length : selectedIndex ) - 1
? ( selectedIndex === 0 ? options.length : selectedIndex ) - 1 : options.length - 1;
: options.length - 1;
this.setState( { selectedIndex: nextSelectedIndex } ); this.setState( { selectedIndex: nextSelectedIndex } );
} }
@ -155,13 +341,14 @@ export class SelectControl extends Component {
incrementSelectedIndex() { incrementSelectedIndex() {
const { selectedIndex } = this.state; const { selectedIndex } = this.state;
const options = this.getOptions(); const options = this.getOptions();
const nextSelectedIndex = const nextSelectedIndex = isNumber( selectedIndex )
selectedIndex !== null ? ( selectedIndex + 1 ) % options.length : 0; ? ( selectedIndex + 1 ) % options.length
: 0;
this.setState( { selectedIndex: nextSelectedIndex } ); this.setState( { selectedIndex: nextSelectedIndex } );
} }
announce( searchOptions ) { announce( searchOptions: Option[] ) {
const { debouncedSpeak } = this.props; const { debouncedSpeak } = this.props;
if ( ! debouncedSpeak ) { if ( ! debouncedSpeak ) {
return; return;
@ -169,6 +356,7 @@ export class SelectControl extends Component {
if ( !! searchOptions.length ) { if ( !! searchOptions.length ) {
debouncedSpeak( debouncedSpeak(
sprintf( sprintf(
// translators: %d: number of results.
_n( _n(
'%d result found, use up and down arrow keys to navigate.', '%d result found, use up and down arrow keys to navigate.',
'%d results found, use up and down arrow keys to navigate.', '%d results found, use up and down arrow keys to navigate.',
@ -187,23 +375,26 @@ export class SelectControl extends Component {
getOptions() { getOptions() {
const { isSearchable, options, excludeSelectedOptions } = this.props; const { isSearchable, options, excludeSelectedOptions } = this.props;
const { searchOptions } = this.state; const { searchOptions } = this.state;
const selectedKeys = this.getSelected().map( ( option ) => option.key ); const selected = this.getSelected();
const selectedKeys = isArray( selected )
? selected.map( ( option ) => option.key )
: [];
const shownOptions = isSearchable ? searchOptions : options; const shownOptions = isSearchable ? searchOptions : options;
if ( excludeSelectedOptions ) { if ( excludeSelectedOptions ) {
return shownOptions.filter( return shownOptions?.filter(
( option ) => ! selectedKeys.includes( option.key ) ( option ) => ! selectedKeys.includes( option.key )
); );
} }
return shownOptions; return shownOptions;
} }
getOptionsByQuery( options, query ) { getOptionsByQuery( options: Option[], query: string | null ) {
const { getSearchExpression, maxResults, onFilter } = this.props; const { getSearchExpression, maxResults, onFilter } = this.props;
const filtered = []; const filtered = [];
// Create a regular expression to filter the options. // Create a regular expression to filter the options.
const expression = getSearchExpression( const expression = getSearchExpression!(
escapeRegExp( query ? query.trim() : '' ) escapeRegExp( query ? query.trim() : '' )
); );
const search = expression ? new RegExp( expression, 'i' ) : /^$/; const search = expression ? new RegExp( expression, 'i' ) : /^$/;
@ -232,14 +423,14 @@ export class SelectControl extends Component {
} }
} }
return onFilter( filtered, query ); return onFilter!( filtered, query );
} }
setExpanded( value ) { setExpanded( value: boolean ) {
this.setState( { isExpanded: value } ); this.setState( { isExpanded: value } );
} }
search( query ) { search( query: string | null ) {
const cacheSearchOptions = this.cacheSearchOptions || []; const cacheSearchOptions = this.cacheSearchOptions || [];
const searchOptions = const searchOptions =
query !== null && ! query.length && ! this.props.hideBeforeSearch query !== null && ! query.length && ! this.props.hideBeforeSearch
@ -252,11 +443,13 @@ export class SelectControl extends Component {
isFocused: true, isFocused: true,
searchOptions, searchOptions,
selectedIndex: selectedIndex:
query?.length > 0 ? null : this.state.selectedIndex, // Only reset selectedIndex if we're actually searching. query && query?.length > 0
? null
: this.state.selectedIndex, // Only reset selectedIndex if we're actually searching.
}, },
() => { () => {
this.setState( { this.setState( {
isExpanded: Boolean( this.getOptions().length ), isExpanded: Boolean( this.getOptions()?.length ),
} ); } );
} }
); );
@ -264,11 +457,11 @@ export class SelectControl extends Component {
this.updateSearchOptions( query ); this.updateSearchOptions( query );
} }
updateSearchOptions( query ) { updateSearchOptions( query: string | null ) {
const { hideBeforeSearch, options, onSearch } = this.props; const { hideBeforeSearch, options, onSearch } = this.props;
const promise = ( this.activePromise = Promise.resolve( const promise = ( this.activePromise = Promise.resolve(
onSearch( options, query ) onSearch!( options, query )
).then( ( promiseOptions ) => { ).then( ( promiseOptions ) => {
if ( promise !== this.activePromise ) { if ( promise !== this.activePromise ) {
// Another promise has become active since this one was asked to resolve, so do nothing, // Another promise has become active since this one was asked to resolve, so do nothing,
@ -288,7 +481,9 @@ export class SelectControl extends Component {
{ {
searchOptions, searchOptions,
selectedIndex: selectedIndex:
query?.length > 0 ? null : this.state.selectedIndex, // Only reset selectedIndex if we're actually searching. query && query?.length > 0
? null
: this.state.selectedIndex, // Only reset selectedIndex if we're actually searching.
}, },
() => { () => {
this.setState( { this.setState( {
@ -300,7 +495,7 @@ export class SelectControl extends Component {
} ) ); } ) );
} }
onAutofillChange( event ) { onAutofillChange( event: ChangeEvent< HTMLInputElement > ) {
const { options } = this.props; const { options } = this.props;
const searchOptions = this.getOptionsByQuery( const searchOptions = this.getOptionsByQuery(
options, options,
@ -327,13 +522,14 @@ export class SelectControl extends Component {
const { isExpanded, isFocused, selectedIndex } = this.state; const { isExpanded, isFocused, selectedIndex } = this.state;
const hasMultiple = this.hasMultiple(); const hasMultiple = this.hasMultiple();
const { key: selectedKey = '' } = options[ selectedIndex ] || {}; const { key: selectedKey = '' } =
( isNumber( selectedIndex ) && options[ selectedIndex ] ) || {};
const listboxId = isExpanded const listboxId = isExpanded
? `woocommerce-select-control__listbox-${ instanceId }` ? `woocommerce-select-control__listbox-${ instanceId }`
: null; : undefined;
const activeId = isExpanded const activeId = isExpanded
? `woocommerce-select-control__option-${ instanceId }-${ selectedKey }` ? `woocommerce-select-control__option-${ instanceId }-${ selectedKey }`
: null; : undefined;
return ( return (
<div <div
@ -354,13 +550,25 @@ export class SelectControl extends Component {
name={ autofill } name={ autofill }
type="text" type="text"
className="woocommerce-select-control__autofill-input" className="woocommerce-select-control__autofill-input"
tabIndex="-1" tabIndex={ -1 }
/> />
) } ) }
{ children } { children }
<Control <Control
{ ...this.props } help={ this.props.help }
{ ...this.state } label={ this.props.label }
inlineTags={ inlineTags }
isSearchable={ isSearchable }
isFocused={ isFocused }
instanceId={ instanceId }
searchInputType={ this.props.searchInputType }
query={ this.state.query }
placeholder={ this.props.placeholder }
autoComplete={ this.props.autoComplete }
multiple={ this.props.multiple }
ariaLabel={ this.props.ariaLabel }
onBlur={ this.props.onBlur }
showAllOnFocus={ this.props.showAllOnFocus }
activeId={ activeId } activeId={ activeId }
className={ controlClassName } className={ controlClassName }
disabled={ disabled } disabled={ disabled }
@ -374,15 +582,20 @@ export class SelectControl extends Component {
updateSearchOptions={ this.updateSearchOptions } updateSearchOptions={ this.updateSearchOptions }
decrementSelectedIndex={ this.decrementSelectedIndex } decrementSelectedIndex={ this.decrementSelectedIndex }
incrementSelectedIndex={ this.incrementSelectedIndex } incrementSelectedIndex={ this.incrementSelectedIndex }
showClearButton={ this.props.showClearButton }
/> />
{ ! inlineTags && hasMultiple && ( { ! inlineTags && hasMultiple && (
<Tags { ...this.props } selected={ this.getSelected() } /> <Tags
onChange={ this.props.onChange! }
showClearButton={ this.props.showClearButton }
selected={ this.getSelected() }
/>
) } ) }
{ isExpanded && ( { isExpanded && (
<List <List
{ ...this.props } instanceId={ instanceId! }
{ ...this.state } selectedIndex={ selectedIndex }
activeId={ activeId } staticList={ this.props.staticList! }
listboxId={ listboxId } listboxId={ listboxId }
node={ this.node } node={ this.node }
onSelect={ this.selectOption } onSelect={ this.selectOption }
@ -398,171 +611,6 @@ export class SelectControl extends Component {
} }
} }
SelectControl.propTypes = {
/**
* Name to use for the autofill field, not used if no string is passed.
*/
autofill: PropTypes.string,
/**
* A renderable component (or string) which will be displayed before the `Control` of this component.
*/
children: PropTypes.node,
/**
* Class name applied to parent div.
*/
className: PropTypes.string,
/**
* Class name applied to control wrapper.
*/
controlClassName: PropTypes.string,
/**
* Allow the select options to be disabled.
*/
disabled: PropTypes.bool,
/**
* Exclude already selected options from the options list.
*/
excludeSelectedOptions: PropTypes.bool,
/**
* Add or remove items to the list of options after filtering,
* passed the array of filtered options and should return an array of options.
*/
onFilter: PropTypes.func,
/**
* Function to add regex expression to the filter the results, passed the search query.
*/
getSearchExpression: PropTypes.func,
/**
* Help text to be appended beneath the input.
*/
help: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ),
/**
* Render tags inside input, otherwise render below input.
*/
inlineTags: PropTypes.bool,
/**
* Allow the select options to be filtered by search input.
*/
isSearchable: PropTypes.bool,
/**
* A label to use for the main input.
*/
label: PropTypes.string,
/**
* Function called when selected results change, passed result list.
*/
onChange: PropTypes.func,
/**
* Function run after search query is updated, passed previousOptions and query,
* should return a promise with an array of updated options.
*/
onSearch: PropTypes.func,
/**
* An array of objects for the options list. The option along with its key, label and
* value will be returned in the onChange event.
*/
options: PropTypes.arrayOf(
PropTypes.shape( {
isDisabled: PropTypes.bool,
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] )
.isRequired,
keywords: PropTypes.arrayOf(
PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] )
),
label: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.object,
] ),
value: PropTypes.any,
} )
).isRequired,
/**
* A placeholder for the search input.
*/
placeholder: PropTypes.string,
/**
* Time in milliseconds to debounce the search function after typing.
*/
searchDebounceTime: PropTypes.number,
/**
* An array of objects describing selected values or optionally a string for a single value.
* If the label of the selected value is omitted, the Tag of that value will not
* be rendered inside the search box.
*/
selected: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.oneOfType( [
PropTypes.number,
PropTypes.string,
] ).isRequired,
label: PropTypes.string,
} )
),
] ),
/**
* A limit for the number of results shown in the options menu. Set to 0 for no limit.
*/
maxResults: PropTypes.number,
/**
* Allow multiple option selections.
*/
multiple: PropTypes.bool,
/**
* Render a 'Clear' button next to the input box to remove its contents.
*/
showClearButton: PropTypes.bool,
/**
* The input type for the search box control.
*/
searchInputType: PropTypes.oneOf( [
'text',
'search',
'number',
'email',
'tel',
'url',
] ),
/**
* Only show list options after typing a search query.
*/
hideBeforeSearch: PropTypes.bool,
/**
* Show all options on focusing, even if a query exists.
*/
showAllOnFocus: PropTypes.bool,
/**
* Render results list positioned statically instead of absolutely.
*/
staticList: PropTypes.bool,
/**
* autocomplete prop for the Control input field.
*/
autoComplete: PropTypes.string,
};
SelectControl.defaultProps = {
autofill: null,
excludeSelectedOptions: true,
getSearchExpression: identity,
inlineTags: false,
isSearchable: false,
onChange: noop,
onFilter: identity,
onSearch: ( options ) => Promise.resolve( options ),
maxResults: 0,
multiple: false,
searchDebounceTime: 0,
searchInputType: 'search',
selected: [],
showAllOnFocus: false,
showClearButton: false,
hideBeforeSearch: false,
staticList: false,
autoComplete: 'off',
};
export default compose( export default compose(
withSpokenMessages, withSpokenMessages,
withInstanceId, withInstanceId,

View File

@ -2,18 +2,73 @@
* External dependencies * External dependencies
*/ */
import { Button } from '@wordpress/components'; import { Button } from '@wordpress/components';
import { RefObject } from 'react';
import classnames from 'classnames'; import classnames from 'classnames';
import { createElement, Component, createRef } from '@wordpress/element'; import { createElement, Component, createRef } from '@wordpress/element';
import { isEqual } from 'lodash'; import { isEqual, isNumber } from 'lodash';
import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, TAB } from '@wordpress/keycodes'; import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, TAB } from '@wordpress/keycodes';
import PropTypes from 'prop-types';
/**
* Internal dependencies
*/
import { Option } from './types';
type Props = {
/**
* ID of the main SelectControl instance.
*/
listboxId?: string;
/**
* ID used for a11y in the listbox.
*/
instanceId: number;
/**
* Parent node to bind keyboard events to.
*/
node: HTMLElement | null;
/**
* Function to execute when an option is selected.
*/
onSelect: ( option: Option ) => void;
/**
* Array of options to display.
*/
options: Array< Option >;
/**
* Integer for the currently selected item.
*/
selectedIndex: number | null | undefined;
/**
* Bool to determine if the list should be positioned absolutely or staticly.
*/
staticList: boolean;
/**
* Function to execute when keyboard navigation should decrement the selected index.
*/
decrementSelectedIndex: () => void;
/**
* Function to execute when keyboard navigation should increment the selected index.
*/
incrementSelectedIndex: () => void;
/**
* Function to execute when the search value changes.
*/
onSearch: ( option: string | null ) => void;
/**
* Function to execute when the list should be expanded or collapsed.
*/
setExpanded: ( expanded: boolean ) => void;
};
/** /**
* A list box that displays filtered options after search. * A list box that displays filtered options after search.
*/ */
class List extends Component { class List extends Component< Props > {
constructor() { optionRefs: { [ key: number ]: RefObject< HTMLButtonElement > };
super( ...arguments ); listbox: RefObject< HTMLDivElement >;
constructor( props: Props ) {
super( props );
this.handleKeyDown = this.handleKeyDown.bind( this ); this.handleKeyDown = this.handleKeyDown.bind( this );
this.select = this.select.bind( this ); this.select = this.select.bind( this );
@ -21,7 +76,7 @@ class List extends Component {
this.listbox = createRef(); this.listbox = createRef();
} }
componentDidUpdate( prevProps ) { componentDidUpdate( prevProps: Props ) {
const { options, selectedIndex } = this.props; const { options, selectedIndex } = this.props;
// Remove old option refs to avoid memory leaks. // Remove old option refs to avoid memory leaks.
@ -29,12 +84,15 @@ class List extends Component {
this.optionRefs = {}; this.optionRefs = {};
} }
if ( selectedIndex !== prevProps.selectedIndex ) { if (
selectedIndex !== prevProps.selectedIndex &&
isNumber( selectedIndex )
) {
this.scrollToOption( selectedIndex ); this.scrollToOption( selectedIndex );
} }
} }
getOptionRef( index ) { getOptionRef( index: number ) {
if ( ! this.optionRefs.hasOwnProperty( index ) ) { if ( ! this.optionRefs.hasOwnProperty( index ) ) {
this.optionRefs[ index ] = createRef(); this.optionRefs[ index ] = createRef();
} }
@ -42,7 +100,7 @@ class List extends Component {
return this.optionRefs[ index ]; return this.optionRefs[ index ];
} }
select( option ) { select( option: Option ) {
const { onSelect } = this.props; const { onSelect } = this.props;
if ( option.isDisabled ) { if ( option.isDisabled ) {
@ -52,9 +110,13 @@ class List extends Component {
onSelect( option ); onSelect( option );
} }
scrollToOption( index ) { scrollToOption( index: number ) {
const listbox = this.listbox.current; const listbox = this.listbox.current;
if ( ! listbox ) {
return;
}
if ( listbox.scrollHeight <= listbox.clientHeight ) { if ( listbox.scrollHeight <= listbox.clientHeight ) {
return; return;
} }
@ -64,6 +126,12 @@ class List extends Component {
} }
const option = this.optionRefs[ index ].current; const option = this.optionRefs[ index ].current;
if ( ! option ) {
// eslint-disable-next-line no-console
console.warn( 'Option not found, index:', index );
return;
}
const scrollBottom = listbox.clientHeight + listbox.scrollTop; const scrollBottom = listbox.clientHeight + listbox.scrollTop;
const elementBottom = option.offsetTop + option.offsetHeight; const elementBottom = option.offsetTop + option.offsetHeight;
if ( elementBottom > scrollBottom ) { if ( elementBottom > scrollBottom ) {
@ -73,7 +141,7 @@ class List extends Component {
} }
} }
handleKeyDown( event ) { handleKeyDown( event: KeyboardEvent ) {
const { const {
decrementSelectedIndex, decrementSelectedIndex,
incrementSelectedIndex, incrementSelectedIndex,
@ -100,7 +168,7 @@ class List extends Component {
break; break;
case ENTER: case ENTER:
if ( options[ selectedIndex ] ) { if ( isNumber( selectedIndex ) && options[ selectedIndex ] ) {
this.select( options[ selectedIndex ] ); this.select( options[ selectedIndex ] );
} }
event.preventDefault(); event.preventDefault();
@ -118,7 +186,7 @@ class List extends Component {
return; return;
case TAB: case TAB:
if ( options[ selectedIndex ] ) { if ( isNumber( selectedIndex ) && options[ selectedIndex ] ) {
this.select( options[ selectedIndex ] ); this.select( options[ selectedIndex ] );
} }
setExpanded( false ); setExpanded( false );
@ -128,8 +196,14 @@ class List extends Component {
} }
} }
toggleKeyEvents( isListening ) { toggleKeyEvents( isListening: boolean ) {
const { node } = this.props; const { node } = this.props;
if ( ! node ) {
// eslint-disable-next-line no-console
console.warn( 'No node to bind events to.' );
return;
}
// This exists because we must capture ENTER key presses before RichText. // This exists because we must capture ENTER key presses before RichText.
// It seems that react fires the simulated capturing events after the // It seems that react fires the simulated capturing events after the
// native browser event has already bubbled so we can't stopPropagation // native browser event has already bubbled so we can't stopPropagation
@ -138,12 +212,16 @@ class List extends Component {
const handler = isListening const handler = isListening
? 'addEventListener' ? 'addEventListener'
: 'removeEventListener'; : 'removeEventListener';
node[ handler ]( 'keydown', this.handleKeyDown, true ); node[ handler ](
'keydown',
this.handleKeyDown as ( e: Event ) => void,
true
);
} }
componentDidMount() { componentDidMount() {
const { selectedIndex } = this.props; const { selectedIndex } = this.props;
if ( selectedIndex > -1 ) { if ( isNumber( selectedIndex ) && selectedIndex > -1 ) {
this.scrollToOption( selectedIndex ); this.scrollToOption( selectedIndex );
} }
this.toggleKeyEvents( true ); this.toggleKeyEvents( true );
@ -169,7 +247,7 @@ class List extends Component {
id={ listboxId } id={ listboxId }
role="listbox" role="listbox"
className={ listboxClasses } className={ listboxClasses }
tabIndex="-1" tabIndex={ -1 }
> >
{ options.map( ( option, index ) => ( { options.map( ( option, index ) => (
<Button <Button
@ -186,7 +264,7 @@ class List extends Component {
} }
) } ) }
onClick={ () => this.select( option ) } onClick={ () => this.select( option ) }
tabIndex="-1" tabIndex={ -1 }
> >
{ option.label } { option.label }
</Button> </Button>
@ -196,50 +274,4 @@ class List extends Component {
} }
} }
List.propTypes = {
/**
* ID of the main SelectControl instance.
*/
instanceId: PropTypes.number,
/**
* ID used for a11y in the listbox.
*/
listboxId: PropTypes.string,
/**
* Parent node to bind keyboard events to.
*/
// eslint-disable-next-line no-undef
node: PropTypes.instanceOf( Element ).isRequired,
/**
* Function to execute when an option is selected.
*/
onSelect: PropTypes.func,
/**
* Array of options to display.
*/
options: PropTypes.arrayOf(
PropTypes.shape( {
isDisabled: PropTypes.bool,
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] )
.isRequired,
keywords: PropTypes.arrayOf(
PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] )
),
label: PropTypes.oneOfType( [
PropTypes.string,
PropTypes.object,
] ),
value: PropTypes.any,
} )
).isRequired,
/**
* Integer for the currently selected item.
*/
selectedIndex: PropTypes.number,
/**
* Bool to determine if the list should be positioned absolutely or staticly.
*/
staticList: PropTypes.bool,
};
export default List; export default List;

View File

@ -1,8 +1,12 @@
/** /**
* External dependencies * External dependencies
*/ */
import { SelectControl } from '@woocommerce/components'; import React from 'react';
import { useState } from '@wordpress/element'; import { createElement, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import SelectControl from '../';
const options = [ const options = [
{ {

View File

@ -5,19 +5,36 @@ import { __, sprintf } from '@wordpress/i18n';
import { Button } from '@wordpress/components'; import { Button } from '@wordpress/components';
import { Icon, cancelCircleFilled } from '@wordpress/icons'; import { Icon, cancelCircleFilled } from '@wordpress/icons';
import { createElement, Component, Fragment } from '@wordpress/element'; import { createElement, Component, Fragment } from '@wordpress/element';
import { findIndex } from 'lodash'; import { findIndex, isArray } from 'lodash';
import PropTypes from 'prop-types';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import Tag from '../tag'; import Tag from '../tag';
import { Option, Selected } from './types';
type Props = {
/**
* Function called when selected results change, passed result list.
*/
onChange: ( selected: Option[] ) => void;
/**
* An array of objects describing selected values. If the label of the selected
* value is omitted, the Tag of that value will not be rendered inside the
* search box.
*/
selected?: Selected;
/**
* Render a 'Clear' button next to the input box to remove its contents.
*/
showClearButton?: boolean;
};
/** /**
* A list of tags to display selected items. * A list of tags to display selected items.
*/ */
class Tags extends Component { class Tags extends Component< Props > {
constructor( props ) { constructor( props: Props ) {
super( props ); super( props );
this.removeAll = this.removeAll.bind( this ); this.removeAll = this.removeAll.bind( this );
this.removeResult = this.removeResult.bind( this ); this.removeResult = this.removeResult.bind( this );
@ -28,9 +45,13 @@ class Tags extends Component {
onChange( [] ); onChange( [] );
} }
removeResult( key ) { removeResult( key: string | undefined ) {
return () => { return () => {
const { selected, onChange } = this.props; const { selected, onChange } = this.props;
if ( ! isArray( selected ) ) {
return;
}
const i = findIndex( selected, { key } ); const i = findIndex( selected, { key } );
onChange( [ onChange( [
...selected.slice( 0, i ), ...selected.slice( 0, i ),
@ -41,7 +62,7 @@ class Tags extends Component {
render() { render() {
const { selected, showClearButton } = this.props; const { selected, showClearButton } = this.props;
if ( ! selected.length ) { if ( ! isArray( selected ) || ! selected.length ) {
return null; return null;
} }
@ -63,6 +84,7 @@ class Tags extends Component {
key={ item.key } key={ item.key }
id={ item.key } id={ item.key }
label={ item.label } label={ item.label }
// @ts-expect-error key is a string or undefined here
remove={ this.removeResult } remove={ this.removeResult }
screenReaderLabel={ screenReaderLabel } screenReaderLabel={ screenReaderLabel }
/> />
@ -89,31 +111,4 @@ class Tags extends Component {
} }
} }
Tags.propTypes = {
/**
* Function called when selected results change, passed result list.
*/
onChange: PropTypes.func,
/**
* Function to execute when an option is selected.
*/
onSelect: PropTypes.func,
/**
* An array of objects describing selected values. If the label of the selected
* value is omitted, the Tag of that value will not be rendered inside the
* search box.
*/
selected: PropTypes.arrayOf(
PropTypes.shape( {
key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] )
.isRequired,
label: PropTypes.string,
} )
),
/**
* Render a 'Clear' button next to the input box to remove its contents.
*/
showClearButton: PropTypes.bool,
};
export default Tags; export default Tags;

View File

@ -1,6 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import React from 'react';
import { render, waitFor } from '@testing-library/react'; import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createElement } from '@wordpress/element'; import { createElement } from '@wordpress/element';
@ -9,10 +10,11 @@ import { createElement } from '@wordpress/element';
* Internal dependencies * Internal dependencies
*/ */
import { SelectControl } from '../index'; import { SelectControl } from '../index';
import { Option } from '../types';
describe( 'SelectControl', () => { describe( 'SelectControl', () => {
const query = 'lorem'; const query = 'lorem';
const options = [ const options: Option[] = [
{ key: '1', label: 'lorem 1', value: { id: '1' } }, { key: '1', label: 'lorem 1', value: { id: '1' } },
{ key: '2', label: 'lorem 2', value: { id: '2' } }, { key: '2', label: 'lorem 2', value: { id: '2' } },
{ key: '3', label: 'bar', value: { id: '3' } }, { key: '3', label: 'bar', value: { id: '3' } },
@ -168,9 +170,9 @@ describe( 'SelectControl', () => {
} ); } );
it( 'changes the options on search', async () => { it( 'changes the options on search', async () => {
const queriedOptions = []; const queriedOptions: Option[] = [];
// eslint-disable-next-line @typescript-eslint/no-shadow // eslint-disable-next-line @typescript-eslint/no-shadow
const queryOptions = ( options, searchedQuery ) => { const queryOptions = async ( options: Option[], searchedQuery: string | null ) => {
if ( searchedQuery === 'test' ) { if ( searchedQuery === 'test' ) {
queriedOptions.push( { queriedOptions.push( {
key: 'test-option', key: 'test-option',
@ -209,7 +211,7 @@ describe( 'SelectControl', () => {
isSearchable isSearchable
showAllOnFocus showAllOnFocus
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
onChange={ onChangeMock } onChange={ onChangeMock }
/> />
@ -229,7 +231,7 @@ describe( 'SelectControl', () => {
isSearchable isSearchable
selected={ [ { ...options[ 0 ] } ] } selected={ [ { ...options[ 0 ] } ] }
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
onChange={ onChangeMock } onChange={ onChangeMock }
/> />
@ -258,7 +260,7 @@ describe( 'SelectControl', () => {
isSearchable isSearchable
selected={ options[ 0 ].key } selected={ options[ 0 ].key }
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
onChange={ onChangeMock } onChange={ onChangeMock }
/> />
@ -289,7 +291,7 @@ describe( 'SelectControl', () => {
showAllOnFocus showAllOnFocus
selected={ options[ 2 ].key } selected={ options[ 2 ].key }
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
onChange={ onChangeMock } onChange={ onChangeMock }
excludeSelectedOptions={ false } excludeSelectedOptions={ false }
@ -316,7 +318,7 @@ describe( 'SelectControl', () => {
showAllOnFocus showAllOnFocus
selected={ options[ 2 ].key } selected={ options[ 2 ].key }
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
onChange={ onChangeMock } onChange={ onChangeMock }
excludeSelectedOptions={ false } excludeSelectedOptions={ false }
@ -364,7 +366,7 @@ describe( 'SelectControl', () => {
isSearchable isSearchable
selected={ [ { ...options[ 0 ] } ] } selected={ [ { ...options[ 0 ] } ] }
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
onChange={ onChangeMock } onChange={ onChangeMock }
/> />
@ -383,7 +385,7 @@ describe( 'SelectControl', () => {
isSearchable isSearchable
selected={ options[ 0 ].key } selected={ options[ 0 ].key }
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
onChange={ onChangeMock } onChange={ onChangeMock }
/> />
@ -416,9 +418,8 @@ describe( 'SelectControl', () => {
const { getByRole } = render( const { getByRole } = render(
<SelectControl <SelectControl
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
selected={ null }
/> />
); );
@ -440,7 +441,7 @@ describe( 'SelectControl', () => {
const { getByRole } = render( const { getByRole } = render(
<SelectControl <SelectControl
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
selected={ options[ 1 ].key } selected={ options[ 1 ].key }
excludeSelectedOptions={ false } excludeSelectedOptions={ false }
@ -465,7 +466,7 @@ describe( 'SelectControl', () => {
const { getByRole } = render( const { getByRole } = render(
<SelectControl <SelectControl
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
selected={ options[ options.length - 1 ].key } selected={ options[ options.length - 1 ].key }
excludeSelectedOptions={ false } excludeSelectedOptions={ false }
@ -490,9 +491,8 @@ describe( 'SelectControl', () => {
const { getByRole } = render( const { getByRole } = render(
<SelectControl <SelectControl
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
selected={ null }
/> />
); );
@ -514,9 +514,8 @@ describe( 'SelectControl', () => {
const { getByRole, queryByRole } = render( const { getByRole, queryByRole } = render(
<SelectControl <SelectControl
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
selected={ null }
/> />
); );
@ -537,9 +536,8 @@ describe( 'SelectControl', () => {
const { getByRole } = render( const { getByRole } = render(
<SelectControl <SelectControl
options={ options } options={ options }
onSearch={ () => options } onSearch={ async () => options }
onFilter={ () => options } onFilter={ () => options }
selected={ null }
excludeSelectedOptions={ false } excludeSelectedOptions={ false }
onChange={ onChangeMock } onChange={ onChangeMock }
/> />

View File

@ -0,0 +1,9 @@
export type Option = {
key: string;
label: string;
isDisabled?: boolean;
keywords?: Array< string >;
value?: unknown;
};
export type Selected = string | Option[];

View File

@ -110,7 +110,7 @@ export const Sortable = ( {
// Items before the current item cause a one off error when // Items before the current item cause a one off error when
// removed from the old array and spliced into the new array. // removed from the old array and spliced into the new array.
// TODO: Issue with dragging into same position having to do with isBefore returning true intially. // TODO: Issue with dragging into same position having to do with isBefore returning true initially.
let targetIndex = dragIndex < index ? index : index + 1; let targetIndex = dragIndex < index ? index : index + 1;
if ( isBefore( event, isHorizontal ) ) { if ( isBefore( event, isHorizontal ) ) {
targetIndex--; targetIndex--;

View File

@ -61,7 +61,7 @@ Name | Type | Default | Description
`ids` | Array | `null` | A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ] `ids` | Array | `null` | A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ]
`isLoading` | Boolean | `false` | Defines if the table contents are loading. It will display `TablePlaceholder` component instead of `Table` if that's the case `isLoading` | Boolean | `false` | Defines if the table contents are loading. It will display `TablePlaceholder` component instead of `Table` if that's the case
`onQueryChange` | Function | `noop` | A function which returns a callback function to update the query string for a given `param` `onQueryChange` | Function | `noop` | A function which returns a callback function to update the query string for a given `param`
`onColumnsChange` | Function | `noop` | A function which returns a callback function which is called upon the user changing the visiblity of columns `onColumnsChange` | Function | `noop` | A function which returns a callback function which is called upon the user changing the visibility of columns
`onSearch` | Function | `noop` | A function which is called upon the user searching in the table header `onSearch` | Function | `noop` | A function which is called upon the user searching in the table header
`onSort` | Function | `undefined` | A function which is called upon the user changing the sorting of the table `onSort` | Function | `undefined` | A function which is called upon the user changing the sorting of the table
`downloadable` | Boolean | `false` | Whether the table must be downloadable. If true, the download button will appear `downloadable` | Boolean | `false` | Whether the table must be downloadable. If true, the download button will appear

View File

@ -158,7 +158,7 @@ export type TableCardProps = CommonTableProps & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
onQueryChange?: ( param: string ) => ( ...props: any ) => void; onQueryChange?: ( param: string ) => ( ...props: any ) => void;
/** /**
* A function which returns a callback function which is called upon the user changing the visiblity of columns. * A function which returns a callback function which is called upon the user changing the visibility of columns.
*/ */
onColumnsChange?: ( showCols: Array< string >, key?: string ) => void; onColumnsChange?: ( showCols: Array< string >, key?: string ) => void;
/** /**

View File

@ -22,7 +22,7 @@
## Breaking changes ## Breaking changes
- Fix the batch fetch logic for the options data store. #7587 - Fix the batch fetch logic for the options data store. #7587
- Add backwards compability for old function format. #7688 - Add backwards compatibility for old function format. #7688
- Add console warning for inbox note contents exceeding 320 characters and add dompurify dependency. #7869 - Add console warning for inbox note contents exceeding 320 characters and add dompurify dependency. #7869
- Fix race condition in data package's options module. #7947 - Fix race condition in data package's options module. #7947
- Remove dev dependency `@woocommerce/wc-admin-settings`. #8057 - Remove dev dependency `@woocommerce/wc-admin-settings`. #8057

View File

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

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Correct spelling errors

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Fix onboarding productTypes TS define type

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Update Payfast title

View File

@ -14,6 +14,7 @@ import * as actions from './actions';
import * as resolvers from './resolvers'; import * as resolvers from './resolvers';
import reducer, { State } from './reducer'; import reducer, { State } from './reducer';
import { WPDataSelectors } from '../types'; import { WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types'; export * from './types';
export type { State }; export type { State };
@ -35,4 +36,7 @@ declare module '@wordpress/data' {
function select( function select(
key: typeof STORE_NAME key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors; ): 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 reducer, { State } from './reducer';
import { WPDataSelectors } from '../types'; import { WPDataSelectors } from '../types';
import controls from '../controls'; import controls from '../controls';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types'; export * from './types';
export type { State }; export type { State };
@ -33,4 +34,7 @@ declare module '@wordpress/data' {
function select( function select(
key: typeof STORE_NAME key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors; ): 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 * as resolvers from './resolvers';
import reducer, { State } from './reducer'; import reducer, { State } from './reducer';
import { WPDataSelectors } from '../types'; import { WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types'; export * from './types';
export type { State }; export type { State };
@ -35,4 +36,7 @@ declare module '@wordpress/data' {
function select( function select(
key: typeof STORE_NAME key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors; ): 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 controls from '../controls';
import { WPDataActions, WPDataSelectors } from '../types'; import { WPDataActions, WPDataSelectors } from '../types';
import { getItemsType } from './selectors'; import { getItemsType } from './selectors';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types'; export * from './types';
export type { State }; export type { State };
@ -40,4 +41,7 @@ declare module '@wordpress/data' {
key: typeof STORE_NAME key: typeof STORE_NAME
): DispatchFromMap< typeof actions & WPDataActions >; ): DispatchFromMap< typeof actions & WPDataActions >;
function select( key: typeof STORE_NAME ): ItemsSelector; 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 * as resolvers from './resolvers';
import initDispatchers from './dispatchers'; import initDispatchers from './dispatchers';
import { WPDataActions, WPDataSelectors } from '../types'; import { WPDataActions, WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
registerStore< State >( STORE_NAME, { registerStore< State >( STORE_NAME, {
reducer: reducer as Reducer< State, AnyAction >, reducer: reducer as Reducer< State, AnyAction >,
@ -35,4 +36,7 @@ declare module '@wordpress/data' {
function select( function select(
key: typeof STORE_NAME key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors; ): 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 actions from './actions';
import * as resolvers from './resolvers'; import * as resolvers from './resolvers';
import reducer, { State } from './reducer'; import reducer, { State } from './reducer';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types'; export * from './types';
export type { State }; export type { State };
@ -36,4 +37,7 @@ declare module '@wordpress/data' {
function select( function select(
key: typeof STORE_NAME key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors; ): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
} }

View File

@ -16,7 +16,7 @@ import {
ProfileItems, ProfileItems,
TaskListType, TaskListType,
TaskType, TaskType,
OnboardingProductType, OnboardingProductTypes,
} from './types'; } from './types';
import { Plugin } from '../plugins/types'; import { Plugin } from '../plugins/types';
@ -267,9 +267,7 @@ export function actionTaskSuccess( task: Partial< TaskType > ) {
}; };
} }
export function getProductTypesSuccess( export function getProductTypesSuccess( productTypes: OnboardingProductTypes ) {
productTypes: OnboardingProductType[]
) {
return { return {
type: TYPES.GET_PRODUCT_TYPES_SUCCESS, type: TYPES.GET_PRODUCT_TYPES_SUCCESS,
productTypes, productTypes,

View File

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

View File

@ -35,7 +35,7 @@ export const defaultState: OnboardingState = {
}, },
emailPrefill: '', emailPrefill: '',
paymentMethods: [], paymentMethods: [],
productTypes: [], productTypes: {},
requesting: {}, requesting: {},
taskLists: {}, taskLists: {},
}; };

View File

@ -24,7 +24,7 @@ import {
import { DeprecatedTasks } from './deprecated-tasks'; import { DeprecatedTasks } from './deprecated-tasks';
import { import {
ExtensionList, ExtensionList,
OnboardingProductType, OnboardingProductTypes,
ProfileItems, ProfileItems,
TaskListType, TaskListType,
} from './types'; } from './types';
@ -126,7 +126,7 @@ export function* getFreeExtensions() {
export function* getProductTypes() { export function* getProductTypes() {
try { try {
const results: OnboardingProductType[] = yield apiFetch( { const results: OnboardingProductTypes = yield apiFetch( {
path: WC_ADMIN_NAMESPACE + '/onboarding/product-types', path: WC_ADMIN_NAMESPACE + '/onboarding/product-types',
method: 'GET', method: 'GET',
} ); } );

View File

@ -12,7 +12,6 @@ import {
OnboardingState, OnboardingState,
ExtensionList, ExtensionList,
ProfileItems, ProfileItems,
OnboardingProductType,
} from './types'; } from './types';
import { WPDataSelectors } from '../types'; import { WPDataSelectors } from '../types';
import { Plugin } from '../plugins/types'; import { Plugin } from '../plugins/types';
@ -92,10 +91,8 @@ export const getEmailPrefill = ( state: OnboardingState ): string => {
return state.emailPrefill || ''; return state.emailPrefill || '';
}; };
export const getProductTypes = ( export const getProductTypes = ( state: OnboardingState ) => {
state: OnboardingState return state.productTypes || {};
): OnboardingProductType[] => {
return state.productTypes || [];
}; };
export type OnboardingSelectors = { export type OnboardingSelectors = {

View File

@ -75,7 +75,7 @@ export type OnboardingState = {
profileItems: ProfileItems; profileItems: ProfileItems;
taskLists: Record< string, TaskListType >; taskLists: Record< string, TaskListType >;
paymentMethods: Plugin[]; paymentMethods: Plugin[];
productTypes: OnboardingProductType[]; productTypes: OnboardingProductTypes;
emailPrefill: string; emailPrefill: string;
// TODO clarify what the error record's type is // TODO clarify what the error record's type is
errors: Record< string, unknown >; errors: Record< string, unknown >;
@ -152,11 +152,21 @@ export type MethodFields = {
}; };
export type OnboardingProductType = { export type OnboardingProductType = {
default?: boolean;
label: string; label: string;
default?: boolean;
product?: number; product?: number;
id?: number;
title?: string;
yearly_price?: number;
description?: string;
more_url?: string;
slug?: string;
}; };
export type OnboardingProductTypes =
| Record< ProductTypeSlug, OnboardingProductType >
| Record< string, never >;
export type ExtensionList = { export type ExtensionList = {
key: string; key: string;
title: string; title: string;

View File

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

View File

@ -33,7 +33,7 @@ export const pluginNames = {
'woocommerce' 'woocommerce'
), ),
'woocommerce-gateway-stripe': __( 'WooCommerce Stripe', 'woocommerce' ), 'woocommerce-gateway-stripe': __( 'WooCommerce Stripe', 'woocommerce' ),
'woocommerce-payfast-gateway': __( 'WooCommerce PayFast', 'woocommerce' ), 'woocommerce-payfast-gateway': __( 'WooCommerce Payfast', 'woocommerce' ),
'woocommerce-payments': __( 'WooCommerce Payments', 'woocommerce' ), 'woocommerce-payments': __( 'WooCommerce Payments', 'woocommerce' ),
'woocommerce-services': __( 'WooCommerce Shipping & Tax', 'woocommerce' ), 'woocommerce-services': __( 'WooCommerce Shipping & Tax', 'woocommerce' ),
'woocommerce-services:shipping': __( 'woocommerce-services:shipping': __(

View File

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

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