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
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]: "
labels: ["type: enhancement", "status: awaiting triage"]
body:

View File

@ -1,5 +1,8 @@
blank_issues_enabled: true
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
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).

View File

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

View File

@ -9,6 +9,11 @@ inputs:
tests:
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:
using: composite
steps:

View File

@ -12,6 +12,11 @@ inputs:
description: The Playwright configuration file to use.
default: playwright.config.js
outputs:
result:
description: Whether the test passed or failed.
value: ${{ steps.run-e2e-tests.conclusion }}
runs:
using: composite
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
run: pnpm run build:feature-config
- name: Add PHP8 Compatibility.
run: |
if [ "$(php -r "echo version_compare(PHP_VERSION,'8.0','>=');")" ]; then
cd plugins/woocommerce
curl -L https://github.com/woocommerce/phpunit/archive/add-compatibility-with-php8-to-phpunit-7.zip -o /tmp/phpunit-7.5-fork.zip
unzip -d /tmp/phpunit-7.5-fork /tmp/phpunit-7.5-fork.zip
composer bin phpunit config --unset platform
composer bin phpunit config repositories.0 '{"type": "path", "url": "/tmp/phpunit-7.5-fork/phpunit-add-compatibility-with-php8-to-phpunit-7", "options": {"symlink": false}}'
composer bin phpunit require --dev -W phpunit/phpunit:@dev --ignore-platform-reqs
rm -rf ./vendor/phpunit/
composer dump-autoload
fi
- id: parseMatrix
name: Parse Matrix Variables
uses: actions/github-script@v6
with:
script: |
const parseWPVersion = require( './.github/workflows/scripts/parse-wp-version' );
parseWPVersion( '${{ matrix.wp }}' ).then( ( version ) => {
core.setOutput( 'wpVersion', version );
} );
- name: Init DB and WP
working-directory: plugins/woocommerce
run: ./tests/bin/install.sh woo_test root root 127.0.0.1 ${{ matrix.wp }}
- name: Prepare Testing Environment
env:
WP_ENV_CORE: ${{ steps.parseMatrix.outputs.wpVersion }}
WP_ENV_PHP_VERSION: ${{ matrix.php }}
run: pnpm --filter=woocommerce env:test
- name: Run tests
working-directory: plugins/woocommerce
run: pnpm run test --color
- name: Run Tests
env:
WP_ENV_CORE: ${{ steps.parseMatrix.outputs.wpVersion }}
WP_ENV_PHP_VERSION: ${{ matrix.php }}
run: pnpm --filter=woocommerce test:unit:env

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ permissions: {}
env:
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 }})
SLACK_BLOCKS_ARTIFACT: slack-blocks
jobs:
get-tag:
name: Get WooCommerce release tag
@ -122,12 +122,26 @@ jobs:
-f test_type="e2e" \
--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:
name: API on WP Latest
runs-on: ubuntu-20.04
needs: [get-tag, e2e-update-wc]
permissions:
contents: read
outputs:
result: ${{ steps.run-api-composite-action.outputs.result }}
env:
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
@ -178,6 +192,18 @@ jobs:
-f test_type="api" \
--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:
name: E2E on WP Latest
runs-on: ubuntu-20.04
@ -268,6 +294,19 @@ jobs:
-f test_type="e2e" \
--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:
name: Get WP L-1 & L-2 version numbers
needs: [get-tag]
@ -328,6 +367,8 @@ jobs:
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build-filters: woocommerce
- name: Launch WP Env
working-directory: plugins/woocommerce
@ -360,7 +401,7 @@ jobs:
uses: ./.github/actions/tests/run-api-tests
with:
report-name: ${{ env.API_WP_LATEST_X_ARTIFACT }}
tests: hello
tests: hello.test.js
env:
ALLURE_RESULTS_DIR: ${{ env.API_ALLURE_RESULTS_DIR }}
ALLURE_REPORT_DIR: ${{ env.API_ALLURE_REPORT_DIR }}
@ -435,6 +476,22 @@ jobs:
-f test_type="e2e" \
--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:
name: Test against PHP ${{ matrix.php_version }}
runs-on: ubuntu-20.04
@ -456,6 +513,8 @@ jobs:
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build-filters: woocommerce
- name: Launch WP Env
working-directory: plugins/woocommerce
@ -482,7 +541,7 @@ jobs:
uses: ./.github/actions/tests/run-api-tests
with:
report-name: ${{ env.API_ARTIFACT }}
tests: hello
tests: hello.test.js
env:
ALLURE_RESULTS_DIR: ${{ env.API_ALLURE_RESULTS_DIR }}
ALLURE_REPORT_DIR: ${{ env.API_ALLURE_REPORT_DIR }}
@ -557,6 +616,22 @@ jobs:
-f test_type="e2e" \
--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:
name: With ${{ matrix.plugin }}
runs-on: ubuntu-20.04
@ -582,18 +657,20 @@ jobs:
repo: WC_SUBSCRIPTIONS_REPO
private: true
env_description: 'woocommerce-subscriptions'
- plugin: 'WordPress SEO' # Yoast SEO in the UI, but the slug is wordpress-seo
repo: 'Yoast/wordpress-seo'
env_description: 'wordpress-seo'
- plugin: 'Contact Form 7'
repo: 'takayukister/contact-form-7'
env_description: 'contact-form-7'
- plugin: 'Gutenberg'
repo: 'WordPress/gutenberg'
env_description: 'gutenberg'
- plugin: 'Gutenberg - Nightly'
repo: 'bph/gutenberg'
env_description: 'gutenberg-nightly'
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build-filters: woocommerce
- name: Launch WP Env
working-directory: plugins/woocommerce
@ -656,3 +733,54 @@ jobs:
-f env_description="${{ matrix.env_description }}" \
-f test_type="e2e" \
--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/babel-plugin-import-jsx-pragma",
"@wordpress/babel-preset-default",
"@wordpress/env",
"@wordpress/stylelint-config",
"@wordpress/prettier-config",
"@wordpress/scripts",
@ -116,6 +115,15 @@
],
"isIgnored": true
},
{
"dependencies": [
"@wordpress/env"
],
"packages": [
"**"
],
"pinVersion": "^7.0.0"
},
{
"dependencies": [
"@wordpress/**"

View File

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

View File

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

View File

@ -1,5 +1,131 @@
== 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 =
**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 - 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 - 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)
* 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)
@ -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 - 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)
* 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 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)
@ -3371,7 +3497,7 @@
* Fix - Add protection around func_get_args_call for backwards compatibility. #28677
* 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
* 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 - Use the coenjacobs/mozart package to renamespace vendor packages. #28147
* 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 - 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 - 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 - Apply sale price until end of closing sale date. #22189
* Fix - Allow empty schema again when registering a custom field for the API. #22204
@ -6612,7 +6738,7 @@
* Removed internal scroll from log viewer.
* Add reply-to to admin emails.
* 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().
* Move location of billing email field to work with password managers.
* Option to restrict selling locations by country.
@ -8122,7 +8248,7 @@
* 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 - 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 - 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/

View File

@ -30,7 +30,7 @@
"create-extension": "node ./tools/create-extension/index.js",
"cherry-pick": "node ./tools/cherry-pick/bin/run",
"sync-dependencies": "pnpm exec syncpack -- fix-mismatches",
"utils": "./tools/monorepo-utils/bin/run"
"utils": "node ./tools/monorepo-utils/dist/index.js"
},
"devDependencies": {
"@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
`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
`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
`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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,6 +12,18 @@
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 {
display: inline-block;
margin-bottom: $gap-smaller;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,8 +65,8 @@ The `config` prop has the following structure:
- `label`: String - A label above the filter selector.
- `staticParams`: Array - Url parameters to persist when selecting a new filter.
- `param`: String - The url paramter this filter will modify.
- `defaultValue`: String - The default paramter value to use instead of 'all'.
- `param`: String - The url parameter this filter will modify.
- `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.
- `filters`: Array - Array of filter objects.

View File

@ -374,11 +374,11 @@ FilterPicker.propTypes = {
*/
staticParams: PropTypes.array.isRequired,
/**
* The url paramter this filter will modify.
* The url parameter this filter will modify.
*/
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,
/**

View File

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

View File

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

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 = [
{ id: 1, name: 'Apricots', parent: 0 },
{ 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 { Icon, search } from '@wordpress/icons';
import classnames from 'classnames';
import PropTypes from 'prop-types';
import { isArray } from 'lodash';
import {
RefObject,
ChangeEvent,
FocusEvent,
KeyboardEvent,
InputHTMLAttributes,
} from 'react';
/**
* Internal dependencies
*/
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.
*/
class Control extends Component {
constructor( props ) {
class Control extends Component< Props, State > {
input: RefObject< HTMLInputElement >;
constructor( props: Props ) {
super( props );
this.state = {
isActive: false,
@ -31,13 +162,13 @@ class Control extends Component {
this.onKeyDown = this.onKeyDown.bind( this );
}
updateSearch( onSearch ) {
return ( event ) => {
updateSearch( onSearch: ( query: string ) => void ) {
return ( event: ChangeEvent< HTMLInputElement > ) => {
onSearch( event.target.value );
};
}
onFocus( onSearch ) {
onFocus( onSearch: ( query: string ) => void ) {
const {
isSearchable,
setExpanded,
@ -45,7 +176,7 @@ class Control extends Component {
updateSearchOptions,
} = this.props;
return ( event ) => {
return ( event: FocusEvent< HTMLInputElement > ) => {
this.setState( { isActive: true } );
if ( isSearchable && showAllOnFocus ) {
event.target.select();
@ -68,7 +199,7 @@ class Control extends Component {
this.setState( { isActive: false } );
}
onKeyDown( event ) {
onKeyDown( event: KeyboardEvent< HTMLInputElement > ) {
const {
decrementSelectedIndex,
incrementSelectedIndex,
@ -78,7 +209,12 @@ class Control extends Component {
setExpanded,
} = this.props;
if ( BACKSPACE === event.keyCode && ! query && selected.length ) {
if (
BACKSPACE === event.keyCode &&
! query &&
isArray( selected ) &&
selected.length
) {
onChange( [ ...selected.slice( 0, -1 ) ] );
}
@ -100,7 +236,7 @@ class Control extends Component {
renderButton() {
const { multiple, selected } = this.props;
if ( multiple || ! selected.length ) {
if ( multiple || ! isArray( selected ) || ! selected.length ) {
return null;
}
@ -151,7 +287,7 @@ class Control extends Component {
aria-describedby={
hasTags && inlineTags
? `search-inline-input-${ instanceId }`
: null
: undefined
}
disabled={ disabled }
aria-label={ this.props.ariaLabel ?? this.props.label }
@ -168,7 +304,8 @@ class Control extends Component {
query,
selected,
} = 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.
if ( ! multiple && ! isFocused && ! inlineTags ) {
@ -194,6 +331,8 @@ class Control extends Component {
isSearchable,
label,
query,
onChange,
showClearButton,
} = this.props;
const { isActive } = this.state;
@ -213,7 +352,7 @@ class Control extends Component {
empty: ! query || query.length === 0,
'is-active': isActive,
'has-tags': inlineTags && hasTags,
'with-value': this.getInputValue().length,
'with-value': this.getInputValue()?.length,
'has-error': !! help,
'is-disabled': disabled,
}
@ -221,8 +360,11 @@ class Control extends Component {
onClick={ ( event ) => {
// Don't focus the input if the click event is from the error message.
if (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - event.target.className is not in the type definition.
event.target.className !==
'components-base-control__help'
'components-base-control__help' &&
this.input.current
) {
this.input.current.focus();
}
@ -234,7 +376,13 @@ class Control extends Component {
icon={ search }
/>
) }
{ inlineTags && <Tags { ...this.props } /> }
{ inlineTags && (
<Tags
onChange={ onChange }
showClearButton={ showClearButton }
selected={ this.props.selected }
/>
) }
<div className="components-base-control__field">
{ !! 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;

View File

@ -4,26 +4,205 @@
import { __, _n, sprintf } from '@wordpress/i18n';
import classnames from 'classnames';
import { Component, createElement } from '@wordpress/element';
import { debounce, escapeRegExp, identity, noop } from 'lodash';
import PropTypes from 'prop-types';
import {
debounce,
escapeRegExp,
identity,
isArray,
isNumber,
noop,
} from 'lodash';
import { withFocusOutside, withSpokenMessages } from '@wordpress/components';
import { withInstanceId, compose } from '@wordpress/compose';
import { ChangeEvent, InputHTMLAttributes } from 'react';
/**
* Internal dependencies
*/
import { Option, Selected } from './types';
import List from './list';
import Tags from './tags';
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,
* allowing a user to select from an option from a filtered list.
*/
export class SelectControl extends Component {
constructor( props ) {
export class SelectControl extends Component< Props, State > {
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 );
const { selected, options, excludeSelectedOptions } = props;
@ -50,7 +229,7 @@ export class SelectControl extends Component {
this.setNewValue = this.setNewValue.bind( this );
}
bindNode( node ) {
bindNode( node: HTMLDivElement ) {
this.node = node;
}
@ -58,7 +237,12 @@ export class SelectControl extends Component {
const { multiple, excludeSelectedOptions } = this.props;
const newState = { ...initialState };
// Reset selectedIndex if single selection.
if ( ! multiple && selected.length && selected[ 0 ].key ) {
if (
! multiple &&
isArray( selected ) &&
selected.length &&
selected[ 0 ].key
) {
newState.selectedIndex = ! excludeSelectedOptions
? this.props.options.findIndex(
( i ) => i.key === selected[ 0 ].key
@ -101,9 +285,12 @@ export class SelectControl extends Component {
return selectedOption ? [ selectedOption ] : [];
}
selectOption( option ) {
selectOption( option: Option ) {
const { multiple, selected } = this.props;
const newSelected = multiple ? [ ...selected, option ] : [ option ];
const newSelected =
multiple && isArray( selected )
? [ ...selected, option ]
: [ option ];
this.reset( newSelected );
@ -129,25 +316,24 @@ export class SelectControl extends Component {
} );
}
setNewValue( newValue ) {
setNewValue( newValue: Option[] ) {
const { onChange, selected, multiple } = this.props;
const { query } = this.state;
// Trigger a change if the selected value is different and pass back
// an array or string depending on the original value.
if ( multiple || Array.isArray( selected ) ) {
onChange( newValue, query );
onChange!( newValue, query );
} else {
onChange( newValue.length > 0 ? newValue[ 0 ].key : '', query );
onChange!( newValue.length > 0 ? newValue[ 0 ].key : '', query );
}
}
decrementSelectedIndex() {
const { selectedIndex } = this.state;
const options = this.getOptions();
const nextSelectedIndex =
selectedIndex !== null
? ( selectedIndex === 0 ? options.length : selectedIndex ) - 1
: options.length - 1;
const nextSelectedIndex = isNumber( selectedIndex )
? ( selectedIndex === 0 ? options.length : selectedIndex ) - 1
: options.length - 1;
this.setState( { selectedIndex: nextSelectedIndex } );
}
@ -155,13 +341,14 @@ export class SelectControl extends Component {
incrementSelectedIndex() {
const { selectedIndex } = this.state;
const options = this.getOptions();
const nextSelectedIndex =
selectedIndex !== null ? ( selectedIndex + 1 ) % options.length : 0;
const nextSelectedIndex = isNumber( selectedIndex )
? ( selectedIndex + 1 ) % options.length
: 0;
this.setState( { selectedIndex: nextSelectedIndex } );
}
announce( searchOptions ) {
announce( searchOptions: Option[] ) {
const { debouncedSpeak } = this.props;
if ( ! debouncedSpeak ) {
return;
@ -169,6 +356,7 @@ export class SelectControl extends Component {
if ( !! searchOptions.length ) {
debouncedSpeak(
sprintf(
// translators: %d: number of results.
_n(
'%d result 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() {
const { isSearchable, options, excludeSelectedOptions } = this.props;
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;
if ( excludeSelectedOptions ) {
return shownOptions.filter(
return shownOptions?.filter(
( option ) => ! selectedKeys.includes( option.key )
);
}
return shownOptions;
}
getOptionsByQuery( options, query ) {
getOptionsByQuery( options: Option[], query: string | null ) {
const { getSearchExpression, maxResults, onFilter } = this.props;
const filtered = [];
// Create a regular expression to filter the options.
const expression = getSearchExpression(
const expression = getSearchExpression!(
escapeRegExp( query ? query.trim() : '' )
);
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 } );
}
search( query ) {
search( query: string | null ) {
const cacheSearchOptions = this.cacheSearchOptions || [];
const searchOptions =
query !== null && ! query.length && ! this.props.hideBeforeSearch
@ -252,11 +443,13 @@ export class SelectControl extends Component {
isFocused: true,
searchOptions,
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( {
isExpanded: Boolean( this.getOptions().length ),
isExpanded: Boolean( this.getOptions()?.length ),
} );
}
);
@ -264,11 +457,11 @@ export class SelectControl extends Component {
this.updateSearchOptions( query );
}
updateSearchOptions( query ) {
updateSearchOptions( query: string | null ) {
const { hideBeforeSearch, options, onSearch } = this.props;
const promise = ( this.activePromise = Promise.resolve(
onSearch( options, query )
onSearch!( options, query )
).then( ( promiseOptions ) => {
if ( promise !== this.activePromise ) {
// 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,
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( {
@ -300,7 +495,7 @@ export class SelectControl extends Component {
} ) );
}
onAutofillChange( event ) {
onAutofillChange( event: ChangeEvent< HTMLInputElement > ) {
const { options } = this.props;
const searchOptions = this.getOptionsByQuery(
options,
@ -327,13 +522,14 @@ export class SelectControl extends Component {
const { isExpanded, isFocused, selectedIndex } = this.state;
const hasMultiple = this.hasMultiple();
const { key: selectedKey = '' } = options[ selectedIndex ] || {};
const { key: selectedKey = '' } =
( isNumber( selectedIndex ) && options[ selectedIndex ] ) || {};
const listboxId = isExpanded
? `woocommerce-select-control__listbox-${ instanceId }`
: null;
: undefined;
const activeId = isExpanded
? `woocommerce-select-control__option-${ instanceId }-${ selectedKey }`
: null;
: undefined;
return (
<div
@ -354,13 +550,25 @@ export class SelectControl extends Component {
name={ autofill }
type="text"
className="woocommerce-select-control__autofill-input"
tabIndex="-1"
tabIndex={ -1 }
/>
) }
{ children }
<Control
{ ...this.props }
{ ...this.state }
help={ this.props.help }
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 }
className={ controlClassName }
disabled={ disabled }
@ -374,15 +582,20 @@ export class SelectControl extends Component {
updateSearchOptions={ this.updateSearchOptions }
decrementSelectedIndex={ this.decrementSelectedIndex }
incrementSelectedIndex={ this.incrementSelectedIndex }
showClearButton={ this.props.showClearButton }
/>
{ ! inlineTags && hasMultiple && (
<Tags { ...this.props } selected={ this.getSelected() } />
<Tags
onChange={ this.props.onChange! }
showClearButton={ this.props.showClearButton }
selected={ this.getSelected() }
/>
) }
{ isExpanded && (
<List
{ ...this.props }
{ ...this.state }
activeId={ activeId }
instanceId={ instanceId! }
selectedIndex={ selectedIndex }
staticList={ this.props.staticList! }
listboxId={ listboxId }
node={ this.node }
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(
withSpokenMessages,
withInstanceId,

View File

@ -2,18 +2,73 @@
* External dependencies
*/
import { Button } from '@wordpress/components';
import { RefObject } from 'react';
import classnames from 'classnames';
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 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.
*/
class List extends Component {
constructor() {
super( ...arguments );
class List extends Component< Props > {
optionRefs: { [ key: number ]: RefObject< HTMLButtonElement > };
listbox: RefObject< HTMLDivElement >;
constructor( props: Props ) {
super( props );
this.handleKeyDown = this.handleKeyDown.bind( this );
this.select = this.select.bind( this );
@ -21,7 +76,7 @@ class List extends Component {
this.listbox = createRef();
}
componentDidUpdate( prevProps ) {
componentDidUpdate( prevProps: Props ) {
const { options, selectedIndex } = this.props;
// Remove old option refs to avoid memory leaks.
@ -29,12 +84,15 @@ class List extends Component {
this.optionRefs = {};
}
if ( selectedIndex !== prevProps.selectedIndex ) {
if (
selectedIndex !== prevProps.selectedIndex &&
isNumber( selectedIndex )
) {
this.scrollToOption( selectedIndex );
}
}
getOptionRef( index ) {
getOptionRef( index: number ) {
if ( ! this.optionRefs.hasOwnProperty( index ) ) {
this.optionRefs[ index ] = createRef();
}
@ -42,7 +100,7 @@ class List extends Component {
return this.optionRefs[ index ];
}
select( option ) {
select( option: Option ) {
const { onSelect } = this.props;
if ( option.isDisabled ) {
@ -52,9 +110,13 @@ class List extends Component {
onSelect( option );
}
scrollToOption( index ) {
scrollToOption( index: number ) {
const listbox = this.listbox.current;
if ( ! listbox ) {
return;
}
if ( listbox.scrollHeight <= listbox.clientHeight ) {
return;
}
@ -64,6 +126,12 @@ class List extends Component {
}
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 elementBottom = option.offsetTop + option.offsetHeight;
if ( elementBottom > scrollBottom ) {
@ -73,7 +141,7 @@ class List extends Component {
}
}
handleKeyDown( event ) {
handleKeyDown( event: KeyboardEvent ) {
const {
decrementSelectedIndex,
incrementSelectedIndex,
@ -100,7 +168,7 @@ class List extends Component {
break;
case ENTER:
if ( options[ selectedIndex ] ) {
if ( isNumber( selectedIndex ) && options[ selectedIndex ] ) {
this.select( options[ selectedIndex ] );
}
event.preventDefault();
@ -118,7 +186,7 @@ class List extends Component {
return;
case TAB:
if ( options[ selectedIndex ] ) {
if ( isNumber( selectedIndex ) && options[ selectedIndex ] ) {
this.select( options[ selectedIndex ] );
}
setExpanded( false );
@ -128,8 +196,14 @@ class List extends Component {
}
}
toggleKeyEvents( isListening ) {
toggleKeyEvents( isListening: boolean ) {
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.
// It seems that react fires the simulated capturing events after the
// native browser event has already bubbled so we can't stopPropagation
@ -138,12 +212,16 @@ class List extends Component {
const handler = isListening
? 'addEventListener'
: 'removeEventListener';
node[ handler ]( 'keydown', this.handleKeyDown, true );
node[ handler ](
'keydown',
this.handleKeyDown as ( e: Event ) => void,
true
);
}
componentDidMount() {
const { selectedIndex } = this.props;
if ( selectedIndex > -1 ) {
if ( isNumber( selectedIndex ) && selectedIndex > -1 ) {
this.scrollToOption( selectedIndex );
}
this.toggleKeyEvents( true );
@ -169,7 +247,7 @@ class List extends Component {
id={ listboxId }
role="listbox"
className={ listboxClasses }
tabIndex="-1"
tabIndex={ -1 }
>
{ options.map( ( option, index ) => (
<Button
@ -186,7 +264,7 @@ class List extends Component {
}
) }
onClick={ () => this.select( option ) }
tabIndex="-1"
tabIndex={ -1 }
>
{ option.label }
</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;

View File

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

View File

@ -5,19 +5,36 @@ import { __, sprintf } from '@wordpress/i18n';
import { Button } from '@wordpress/components';
import { Icon, cancelCircleFilled } from '@wordpress/icons';
import { createElement, Component, Fragment } from '@wordpress/element';
import { findIndex } from 'lodash';
import PropTypes from 'prop-types';
import { findIndex, isArray } from 'lodash';
/**
* Internal dependencies
*/
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.
*/
class Tags extends Component {
constructor( props ) {
class Tags extends Component< Props > {
constructor( props: Props ) {
super( props );
this.removeAll = this.removeAll.bind( this );
this.removeResult = this.removeResult.bind( this );
@ -28,9 +45,13 @@ class Tags extends Component {
onChange( [] );
}
removeResult( key ) {
removeResult( key: string | undefined ) {
return () => {
const { selected, onChange } = this.props;
if ( ! isArray( selected ) ) {
return;
}
const i = findIndex( selected, { key } );
onChange( [
...selected.slice( 0, i ),
@ -41,7 +62,7 @@ class Tags extends Component {
render() {
const { selected, showClearButton } = this.props;
if ( ! selected.length ) {
if ( ! isArray( selected ) || ! selected.length ) {
return null;
}
@ -63,6 +84,7 @@ class Tags extends Component {
key={ item.key }
id={ item.key }
label={ item.label }
// @ts-expect-error key is a string or undefined here
remove={ this.removeResult }
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;

View File

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

View File

@ -158,7 +158,7 @@ export type TableCardProps = CommonTableProps & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
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;
/**

View File

@ -22,7 +22,7 @@
## Breaking changes
- 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
- Fix race condition in data package's options module. #7947
- 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 reducer, { State } from './reducer';
import { WPDataSelectors } from '../types';
import { PromiseifySelectors } from '../types/promiseify-selectors';
export * from './types';
export type { State };
@ -35,4 +36,7 @@ declare module '@wordpress/data' {
function select(
key: typeof STORE_NAME
): SelectFromMap< typeof selectors > & WPDataSelectors;
function resolveSelect(
key: typeof STORE_NAME
): PromiseifySelectors< SelectFromMap< typeof selectors > >;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,7 @@ export const pluginNames = {
'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-services': __( 'WooCommerce Shipping & Tax', 'woocommerce' ),
'woocommerce-services:shipping': __(

View File

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

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