Merge branch 'trunk' into feature/34548-multichannel-marketing-backend

# Conflicts:
#	plugins/woocommerce/includes/wc-update-functions.php
This commit is contained in:
Nima 2022-12-15 15:06:36 +00:00
commit a8f3d7c2bf
894 changed files with 27454 additions and 29752 deletions

View File

@ -2,69 +2,69 @@ name: Setup WooCommerce Monorepo
description: Handles the installation, building, and caching of the projects within the monorepo.
inputs:
install-filters:
description: The PNPM filter used to decide what projects to install. Supports multiline strings for multiple filters.
default: ""
build:
description: Indicates whether or not the action should build any projects.
default: "true"
build-filters:
description: The PNPM filter used to decide what projects to build. Supports multiline strings for multiple filters.
default: ""
php-version:
description: The version of PHP that the action should set up.
default: "7.4"
install-filters:
description: The PNPM filter used to decide what projects to install. Supports multiline strings for multiple filters.
default: ''
build:
description: Indicates whether or not the action should build any projects.
default: 'true'
build-filters:
description: The PNPM filter used to decide what projects to build. Supports multiline strings for multiple filters.
default: ''
php-version:
description: The version of PHP that the action should set up.
default: '7.4'
runs:
using: composite
steps:
- name: Parse Action Input
id: parse-input
shell: bash
run: |
echo "::set-output name=INSTALL_FILTERS::$(node ./.github/actions/setup-woocommerce-monorepo/scripts/parse-input-filter.js '${{ inputs.install-filters }}')"
echo "::set-output name=BUILD_FILTERS::$(node ./.github/actions/setup-woocommerce-monorepo/scripts/parse-input-filter.js '${{ inputs.build-filters }}')"
using: composite
steps:
- name: Parse Action Input
id: parse-input
shell: bash
run: |
echo "INSTALL_FILTERS=$(node ./.github/actions/setup-woocommerce-monorepo/scripts/parse-input-filter.js '${{ inputs.install-filters }}')" >> $GITHUB_OUTPUT
echo "BUILD_FILTERS=$(node ./.github/actions/setup-woocommerce-monorepo/scripts/parse-input-filter.js '${{ inputs.build-filters }}')" >> $GITHUB_OUTPUT
- name: Setup PNPM
uses: pnpm/action-setup@10693b3829bf86eb2572aef5f3571dcf5ca9287d
with:
version: "^7.13.3"
- name: Setup PNPM
uses: pnpm/action-setup@10693b3829bf86eb2572aef5f3571dcf5ca9287d
with:
version: '^7.13.3'
- name: Setup Node
uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Setup PHP
uses: shivammathur/setup-php@e04e1d97f0c0481c6e1ba40f8a538454fe5d7709
with:
php-version: ${{ inputs.php-version }}
coverage: none
tools: phpcs, sirbrillig/phpcs-changed
- name: Setup Node
uses: actions/setup-node@2fddd8803e2f5c9604345a0b591c3020ee971a93
with:
node-version-file: .nvmrc
cache: pnpm
registry-url: 'https://registry.npmjs.org'
- name: Cache Composer Dependencies
uses: actions/cache@fd5de65bc895cf536527842281bea11763fefd77
with:
path: ~/.cache/composer/files
key: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-
- name: Setup PHP
uses: shivammathur/setup-php@e04e1d97f0c0481c6e1ba40f8a538454fe5d7709
with:
php-version: ${{ inputs.php-version }}
coverage: none
tools: phpcs, sirbrillig/phpcs-changed
- name: Install Node and PHP Dependencies
shell: bash
run: |
pnpm -w install turbo
pnpm install ${{ steps.parse-input.outputs.INSTALL_FILTERS }}
- name: Cache Composer Dependencies
uses: actions/cache@fd5de65bc895cf536527842281bea11763fefd77
with:
path: ~/.cache/composer/files
key: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-${{ hashFiles('**/composer.lock') }}
restore-keys: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-
- name: Cache Build Output
uses: actions/cache@fd5de65bc895cf536527842281bea11763fefd77
with:
path: node_modules/.cache/turbo
key: ${{ runner.os }}-build-output-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
restore-keys: ${{ runner.os }}-build-output-
- name: Install Node and PHP Dependencies
shell: bash
run: |
pnpm -w install turbo
pnpm install ${{ steps.parse-input.outputs.INSTALL_FILTERS }}
- name: Build
if: ${{ inputs.build == 'true' }}
shell: bash
run: pnpm -w exec turbo run turbo:build ${{ steps.parse-input.outputs.BUILD_FILTERS }}
- name: Cache Build Output
uses: actions/cache@fd5de65bc895cf536527842281bea11763fefd77
with:
path: node_modules/.cache/turbo
key: ${{ runner.os }}-build-output-${{ hashFiles('node_modules/.cache/turbo/*-meta.json') }}
restore-keys: ${{ runner.os }}-build-output-
- name: Build
if: ${{ inputs.build == 'true' }}
shell: bash
run: pnpm -w exec turbo run turbo:build ${{ steps.parse-input.outputs.BUILD_FILTERS }}

View File

@ -25,8 +25,9 @@ jobs:
- name: Load docker images and start containers.
working-directory: plugins/woocommerce
env:
ENABLE_HPOS: 0
run: pnpm env:test --filter=woocommerce
ENABLE_HPOS: 0
WP_ENV_PHP_VERSION: 7.4
run: pnpm run env:test
- name: Download and install Chromium browser.
working-directory: plugins/woocommerce
@ -39,7 +40,7 @@ jobs:
TOTAL_STR=$(pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js --list | grep "Total:")
NO_PREFIX=${TOTAL_STR#*"Total: "}
COUNT=${NO_PREFIX%" tests in"*}
echo "::set-output name=E2E_GRAND_TOTAL::$COUNT"
echo "E2E_GRAND_TOTAL=$COUNT" >> $GITHUB_OUTPUT
- name: Run Playwright E2E tests.
timeout-minutes: 60
@ -90,7 +91,7 @@ jobs:
- name: Load docker images and start containers.
working-directory: plugins/woocommerce
env:
ENABLE_HPOS: 0
ENABLE_HPOS: 0
run: pnpm env:test --filter=woocommerce
- name: Run Playwright API tests.
@ -137,7 +138,7 @@ jobs:
- name: Load docker images and start containers.
working-directory: plugins/woocommerce
env:
ENABLE_HPOS: 0
ENABLE_HPOS: 0
run: |
pnpm env:dev --filter=woocommerce
pnpm env:performance-init --filter=woocommerce

View File

@ -0,0 +1,84 @@
name: Build Live Branch
on:
pull_request:
concurrency:
# Cancel concurrent jobs on pull_request but not push, by including the run_id in the concurrency group for the latter.
group: build-${{ github.event_name == 'push' && github.run_id || 'pr' }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.repository_owner == 'woocommerce'
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
- name: Get current version
id: version
uses: actions/github-script@v6.3.3
with:
script:
const { getVersion } = require( './.github/workflows/scripts/get-plugin-version' );
const version = await getVersion( 'woocommerce' );
core.setOutput( 'version', version );
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build: false
- name: Prepare plugin zips
id: prepare
env:
CURRENT_VERSION: ${{ steps.version.outputs.version }}
run: |
# Current version must compare greather than any previously used current version for this PR.
# Assume GH run IDs are monotonic.
VERSUFFIX="${GITHUB_RUN_ID}-g$(git rev-parse --short HEAD)"
CURRENT_VERSION="$CURRENT_VERSION-$VERSUFFIX"
sed -i -e 's/Version: .*$/Version: '"$CURRENT_VERSION"'/' "$GITHUB_WORKSPACE/plugins/woocommerce/woocommerce.php"
echo "$CURRENT_VERSION" > "$GITHUB_WORKSPACE/plugins/woocommerce/version.txt"
cd "$GITHUB_WORKSPACE/plugins/woocommerce"
bash bin/build-zip.sh
mkdir "$GITHUB_WORKSPACE/zips"
cp "$GITHUB_WORKSPACE/plugins/woocommerce/woocommerce.zip" "$GITHUB_WORKSPACE/zips/woocommerce.zip"
cd "$GITHUB_WORKSPACE/zips"
unzip woocommerce.zip
rm woocommerce.zip
mv woocommerce woocommerce-dev
zip -q -r "woocommerce-dev.zip" "woocommerce-dev/"
rm -fR "$GITHUB_WORKSPACE/zips/woocommerce-dev"
# Plugin data is passed as a JSON object.
PLUGIN_DATA="{}"
PLUGIN_DATA=$( jq -c --arg slug "woocommerce" --arg ver "$CURRENT_VERSION" '.[ $slug ] = { version: $ver }' <<<"$PLUGIN_DATA" )
echo "plugin-data=$PLUGIN_DATA" >> $GITHUB_OUTPUT
- name: Create plugins artifact
uses: actions/upload-artifact@v3
if: steps.prepare.outputs.plugin-data != '{}'
with:
name: plugins
path: zips
# Only need to retain for a day since the beta builder slurps it up to distribute.
retention-days: 1
- name: Inform Beta Download webhook
if: steps.prepare.outputs.plugin-data != '{}'
env:
SECRET: ${{ secrets.WOOBETA_SECRET }}
PLUGIN_DATA: ${{ steps.prepare.outputs.plugin-data }}
PR: ${{ github.event.number }}
run: |
curl -v --fail -L \
--url "https://betadownload.jetpack.me/gh-action.php?run_id=$GITHUB_RUN_ID&pr=$PR&commit=$GITHUB_SHA" \
--form-string "repo=$GITHUB_REPOSITORY" \
--form-string "branch=${GITHUB_REF#refs/heads/}" \
--form-string "plugins=$PLUGIN_DATA" \
--form-string "secret=$SECRET"

View File

@ -43,4 +43,4 @@ jobs:
run: |
HEAD_REF=$(git rev-parse HEAD)
git checkout $HEAD_REF
phpcs-changed --git --git-base ${{ github.base_ref }} ${{ steps.changed-files.outputs.all_changed_files }}
phpcs-changed -s --git --git-base ${{ github.base_ref }} ${{ steps.changed-files.outputs.all_changed_files }}

View File

@ -1,4 +1,4 @@
name: Highlight templates and hooks changes
name: Highlight templates changes
on: pull_request
jobs:
analyze:
@ -21,77 +21,35 @@ jobs:
HEAD_REF=$(git rev-parse HEAD)
version=$(pnpm run analyzer major-minor "$HEAD_REF" "plugins/woocommerce/woocommerce.php" | tail -n 1)
pnpm run analyzer "$HEAD_REF" $version -o "github"
- name: Print results
id: results
run: echo "::set-output name=results::${{ steps.run.outputs.templates }}${{ steps.run.outputs.wphooks }}${{ steps.run.outputs.schema }}${{ steps.run.outputs.database }}"
comment:
name: Add comment to highlight changes
needs: analyze
runs-on: ubuntu-20.04
steps:
- name: Find Comment
uses: peter-evans/find-comment@v2
id: find-comment
- name: Check results
uses: actions/github-script@v6
with:
issue-number: ${{ github.event.number }}
body-includes: New hook, template, or database changes in this PR
- name: Add comment
if: ${{ needs.analyze.outputs.results && (steps.find-comment.outputs.comment-id == '') }}
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '## New hook, template, or database changes in this PR${{ needs.analyze.outputs.results }}'
})
- name: Update comment
if: ${{ needs.analyze.outputs.results && steps.find-comment.outputs.comment-id }}
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.updateComment({
comment_id: ${{ steps.find-comment.outputs.comment-id }},
owner: context.repo.owner,
repo: context.repo.repo,
body: '## New hook, template, or database changes in this PR${{ needs.analyze.outputs.results }}'
})
- name: Delete comment
if: ${{ !needs.analyze.outputs.results && steps.find-comment.outputs.comment-id }}
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.deleteComment({
comment_id: ${{ steps.find-comment.outputs.comment-id }},
owner: context.repo.owner,
repo: context.repo.repo
})
- name: Add label
if: ${{ needs.analyze.outputs.results }}
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.addLabels({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
labels: ['release: highlight']
})
- name: Remove label
if: ${{ !needs.analyze.outputs.results }}
continue-on-error: true
uses: actions/github-script@v5
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
github.rest.issues.removeLabel({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
name: ['release: highlight']
})
script: |
const template = '${{ steps.run.outputs.templates }}';
if ( template === '' ) {
return;
}
const templateArr = template.split( '\n' );
const modTemplateArr = [];
let needsVersionBump = false;
templateArr.forEach( ( el ) => {
if ( el.match( /NOTICE/ ) ) {
modTemplateArr.pop();
return;
}
if ( el.match( /WARNING/ ) ) {
needsVersionBump = true;
}
modTemplateArr.push( el );
} );
const templateResult = modTemplateArr.join( '\n' );
if ( needsVersionBump ) {
core.setFailed( `Templates have changed but template versions were not bumped:\n${ templateResult }` );
}

View File

@ -10,7 +10,7 @@ jobs:
changelogger_used:
name: Changelogger use
runs-on: ubuntu-20.04
timeout-minutes: 5
timeout-minutes: 15
steps:
- uses: actions/checkout@v3
with:

View File

@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v3
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
@ -24,17 +24,17 @@ jobs:
- name: Get current date
id: date
run: echo "::set-output name=date::$(date +'%Y-%m-%d')"
run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT
- name: Set all package string
id: all_description
if: ${{ github.event.inputs.packages == '-a'}}
run: echo "::set-output name=str::all packages"
run: echo "str=all packages" >> $GITHUB_OUTPUT
- name: Set Specific packages string
id: specific_description
if: ${{ github.event.inputs.packages != '-a'}}
run: echo "::set-output name=str::${{ github.event.inputs.packages }}"
run: echo "str=${{ github.event.inputs.packages }}" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@v4

View File

@ -1,13 +1,13 @@
name: "Release: Generate changelog"
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
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'
@ -16,96 +16,96 @@ env:
GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com'
jobs:
create-changelog-prs:
runs-on: ubuntu-20.04
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 0
create-changelog-prs:
runs-on: ubuntu-20.04
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: 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: 'Git fetch the release branch'
run: git fetch origin ${{ inputs.releaseBranch }}
- name: "Generate the changelog file"
run: pnpm --filter=woocommerce run changelog write --add-pr-num -n -vvv --use-version ${{ inputs.releaseVersion }}
- 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 "::set-output name=hash::$(git rev-parse HEAD)"
- 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;
- 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: '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

@ -1,17 +1,17 @@
name: "Release: Code freeze"
name: 'Release: Code freeze'
on:
schedule:
- cron: '0 16 * * 4' # Run at 1600 UTC on Thursdays.
workflow_dispatch:
inputs:
timeOverride:
description: "Time Override: The time to use in checking whether the action should run (default: 'now')."
default: 'now'
skipSlackPing:
description: "Skip Slack Ping: If true, the Slack ping will be skipped (useful for testing)"
type: boolean
slackChannelOverride:
description: "Slack Channel Override: The channel ID to send the Slack ping about the freeze"
schedule:
- cron: '0 16 * * 4' # Run at 1600 UTC on Thursdays.
workflow_dispatch:
inputs:
timeOverride:
description: "Time Override: The time to use in checking whether the action should run (default: 'now')."
default: 'now'
skipSlackPing:
description: 'Skip Slack Ping: If true, the Slack ping will be skipped (useful for testing)'
type: boolean
slackChannelOverride:
description: 'Slack Channel Override: The channel ID to send the Slack ping about the freeze'
env:
TIME_OVERRIDE: ${{ inputs.timeOverride }}
@ -21,142 +21,149 @@ env:
GIT_AUTHOR_EMAIL: 'no-reply@woocommerce.com'
jobs:
verify-code-freeze:
name: "Verify that today is the day of the code freeze"
runs-on: ubuntu-20.04
outputs:
freeze: ${{ steps.check-freeze.outputs.freeze }}
steps:
- name: "Install PHP"
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
verify-code-freeze:
name: 'Verify that today is the day of the code freeze'
runs-on: ubuntu-20.04
outputs:
freeze: ${{ steps.check-freeze.outputs.freeze }}
steps:
- name: 'Install PHP'
uses: shivammathur/setup-php@v2
with:
php-version: '7.4'
- name: "Check whether today is the code freeze day"
id: check-freeze
shell: php {0}
run: |
<?php
$now = time();
if ( getenv( 'TIME_OVERRIDE' ) ) {
$now = strtotime( getenv( 'TIME_OVERRIDE' ) );
}
- name: 'Check whether today is the code freeze day'
id: check-freeze
shell: php {0}
run: |
<?php
$now = time();
if ( getenv( 'TIME_OVERRIDE' ) ) {
$now = strtotime( getenv( 'TIME_OVERRIDE' ) );
}
// Code freeze comes 26 days prior to release day.
$release_time = strtotime( '+26 days', $now );
$release_day_of_week = date( 'l', $release_time );
$release_day_of_month = (int) date( 'j', $release_time );
// Code freeze comes 26 days prior to release day.
$release_time = strtotime( '+26 days', $now );
$release_day_of_week = date( 'l', $release_time );
$release_day_of_month = (int) date( 'j', $release_time );
// If 26 days from now isn't the second Tuesday, then it's not code freeze day.
if ( 'Tuesday' !== $release_day_of_week || $release_day_of_month < 8 || $release_day_of_month > 14 ) {
echo '::set-output name=freeze::1';
} else {
echo '::set-output name=freeze::0';
}
// If 26 days from now isn't the second Tuesday, then it's not code freeze day.
if ( 'Tuesday' !== $release_day_of_week || $release_day_of_month < 8 || $release_day_of_month > 14 ) {
file_put_contents( getenv( 'GITHUB_OUTPUT' ), "freeze=1\n", FILE_APPEND );
} else {
file_put_contents( getenv( 'GITHUB_OUTPUT' ), "freeze=0\n", FILE_APPEND );
}
maybe-create-next-milestone-and-release-branch:
name: "Maybe create next milestone and release branch"
runs-on: ubuntu-20.04
needs: verify-code-freeze
if: needs.verify-code-freeze.outputs.freeze == 0
outputs:
branch: ${{ steps.freeze.outputs.branch }}
release_version: ${{ steps.freeze.outputs.release_version }}
next_version: ${{ steps.freeze.outputs.next_version }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 100
maybe-create-next-milestone-and-release-branch:
name: 'Maybe create next milestone and release branch'
runs-on: ubuntu-20.04
needs: verify-code-freeze
if: needs.verify-code-freeze.outputs.freeze == 0
outputs:
branch: ${{ steps.freeze.outputs.branch }}
release_version: ${{ steps.freeze.outputs.release_version }}
next_version: ${{ steps.freeze.outputs.next_version }}
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 100
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build: false
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
with:
build: false
- name: "Run the script to enforce the code freeze"
id: freeze
run: php .github/workflows/scripts/release-code-freeze.php
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_OUTPUTS: 1
- name: 'Run the script to enforce the code freeze'
id: freeze
run: php .github/workflows/scripts/release-code-freeze.php
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_OUTPUTS: 1
prep-trunk:
name: Preps trunk for next development cycle
runs-on: ubuntu-20.04
needs: maybe-create-next-milestone-and-release-branch
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 100
prep-trunk:
name: Preps trunk for next development cycle
runs-on: ubuntu-20.04
needs: maybe-create-next-milestone-and-release-branch
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
fetch-depth: 100
- name: fetch-trunk
run: git fetch origin trunk
- name: fetch-trunk
run: git fetch origin trunk
- name: checkout-trunk
run: git checkout trunk
- name: checkout-trunk
run: git checkout trunk
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
- name: Setup WooCommerce Monorepo
uses: ./.github/actions/setup-woocommerce-monorepo
- name: Create branch
run: git checkout -b prep/trunk-for-next-dev-cycle-${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}
- name: Create branch
run: git checkout -b prep/trunk-for-next-dev-cycle-${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}
- name: Bump versions
working-directory: ./tools/version-bump
run: pnpm run version bump woocommerce -v ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}.0-dev
- name: Bump versions
working-directory: ./tools/version-bump
run: pnpm run version bump woocommerce -v ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}.0-dev
- name: Checkout pnpm-lock.yaml to prevent issues
run: git checkout pnpm-lock.yaml
- name: Checkout pnpm-lock.yaml to prevent issues
run: git checkout pnpm-lock.yaml
- name: Commit changes
run: git commit -am "Prep trunk for ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }} cycle"
- name: Commit changes
run: git commit -am "Prep trunk for ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }} cycle"
- name: Push branch up
run: git push --no-verify origin prep/trunk-for-next-dev-cycle-${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}
- name: Push branch up
run: git push --no-verify origin prep/trunk-for-next-dev-cycle-${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}
- name: Create the PR
uses: actions/github-script@v6
with:
script: |
const body = "This PR updates the versions in trunk to ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }} for next development cycle."
- name: Create the PR
uses: actions/github-script@v6
with:
script: |
const body = "This PR updates the versions in trunk to ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }} for next development cycle."
const pr = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: "Prep trunk for ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }} cycle",
head: "prep/trunk-for-next-dev-cycle-${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}",
base: "trunk",
body: body
})
const pr = await github.rest.pulls.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: "Prep trunk for ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }} cycle",
head: "prep/trunk-for-next-dev-cycle-${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}",
base: "trunk",
body: body
})
notify-slack:
name: "Sends code freeze notification to Slack"
if: ${{ inputs.skipSlackPing != true }}
runs-on: ubuntu-20.04
needs: maybe-create-next-milestone-and-release-branch
steps:
- 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.maybe-create-next-milestone-and-release-branch.outputs.release_version }} Code Freeze :ice_cube:
The automation to cut the release branch for ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.release_version }} has run. Any PRs that were not already merged will be a part of ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }} by default. If you have something that needs to make ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.release_version }} that hasn't yet been merged, please see the <${{ secrets.FG_LINK }}/code-freeze-for-woocommerce-core-release/|fieldguide page for the code freeze>.
notify-slack:
name: 'Sends code freeze notification to Slack'
if: ${{ inputs.skipSlackPing != true }}
runs-on: ubuntu-20.04
needs: maybe-create-next-milestone-and-release-branch
steps:
- 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.maybe-create-next-milestone-and-release-branch.outputs.release_version }} Code Freeze :ice_cube:
trigger-changelog-action:
name: "Trigger changelog action"
runs-on: ubuntu-20.04
needs: maybe-create-next-milestone-and-release-branch
steps:
- run: |
curl \
-X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: token ${{ secrets.WC_BOT_TRIAGE_TOKEN }}" \
-d '{"ref":"refs/heads/trunk","inputs":{"releaseBranch":"${{ needs.maybe-create-next-milestone-and-release-branch.outputs.branch }}","releaseVersion":"${{ needs.maybe-create-next-milestone-and-release-branch.outputs.release_version }}"}}' \
https://api.github.com/repos/${{ github.repository }}/actions/workflows/release-changelog.yml/dispatches
The automation to cut the release branch for ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.release_version }} has run. Any PRs that were not already merged will be a part of ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }} by default. If you have something that needs to make ${{ needs.maybe-create-next-milestone-and-release-branch.outputs.release_version }} 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
needs: maybe-create-next-milestone-and-release-branch
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: "release/${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}",
releaseBranch: "${{ needs.maybe-create-next-milestone-and-release-branch.outputs.next_version }}"
}
})

View File

@ -0,0 +1,13 @@
const { readFile } = require( 'fs/promises' );
const { join } = require( 'path' );
exports.getVersion = async plugin => {
const filePath = join(
process.env.GITHUB_WORKSPACE,
`plugins/${ plugin }/${ plugin }.php`
);
const pluginFileContents = await readFile( filePath, 'utf8' );
const versionMatch = pluginFileContents.match( /Version: (\d+\.\d+\.\d+.*)\n/m );
return versionMatch && versionMatch[1];
};

View File

@ -92,7 +92,7 @@ const addSummaryHeadingAndTable = ( core ) => {
const apiTableRow = createAPITableRow();
const e2eTableRow = createE2ETableRow();
core.summary.addHeading( 'Smoke tests on trunk' ).addTable( [
core.summary.addHeading( 'Smoke tests on nightly build' ).addTable( [
[
{ data: 'Test :test_tube:', header: true },
{ data: 'Passed :white_check_mark:', header: true },

View File

@ -1,4 +1,5 @@
<?php
// phpcs:ignoreFile
/**
* Script to automatically enforce the release code freeze.
*
@ -12,6 +13,16 @@ if ( getenv( 'TIME_OVERRIDE' ) ) {
$now = strtotime( getenv( 'TIME_OVERRIDE' ) );
}
/**
* Set an output for the GitHub action.
*
* @param string $name The name of the output.
* @param string $value The value of the output.
*/
function set_output( $name, $value ) {
file_put_contents( getenv( 'GITHUB_OUTPUT' ), "{$name}={$value}" . PHP_EOL, FILE_APPEND );
}
// Code freeze comes 26 days prior to release day.
$release_time = strtotime( '+26 days', $now );
$release_day_of_week = date( 'l', $release_time );
@ -41,10 +52,11 @@ $milestone_to_create = "{$milestone_major_minor}.0";
if ( getenv( 'GITHUB_OUTPUTS' ) ) {
echo 'Including GitHub Outputs...' . PHP_EOL;
echo '::set-output name=next_version::' . $milestone_major_minor . PHP_EOL;
echo '::set-output name=release_version::' . $branch_major_minor . PHP_EOL;
echo '::set-output name=branch::' . $release_branch_to_create . PHP_EOL;
echo '::set-output name=milestone::' . $milestone_to_create . PHP_EOL;
set_output( 'next_version', $milestone_major_minor );
set_output( 'release_version', $branch_major_minor );
set_output( 'branch', $release_branch_to_create );
set_output( 'milestone', $milestone_to_create );
}
if ( getenv( 'DRY_RUN' ) ) {
@ -56,7 +68,7 @@ if ( getenv( 'DRY_RUN' ) ) {
if ( create_github_milestone( $milestone_to_create ) ) {
echo "Created milestone {$milestone_to_create}" . PHP_EOL;
} else if ( '422' === $github_api_response_code ) {
} elseif ( '422' === $github_api_response_code ) {
// The milestone already existed when GitHub returns a 422 status.
echo "Notice: Unable to create {$milestone_to_create} milestone. Maybe it already exists? Skipping..." . PHP_EOL;
} else {
@ -65,7 +77,7 @@ if ( create_github_milestone( $milestone_to_create ) ) {
if ( create_github_branch_from_branch( 'trunk', $release_branch_to_create ) ) {
echo "Created branch {$release_branch_to_create}" . PHP_EOL;
} else if ( '422' === $github_api_response_code ) {
} elseif ( '422' === $github_api_response_code ) {
// The release branch already existed when GitHub returns a 422 status.
echo "Notice: Unable to create {$release_branch_to_create} branch. Maybe it already exists? Skipping..." . PHP_EOL;
exit( 1 );

View File

@ -1,7 +1,7 @@
name: Smoke test daily
on:
# schedule:
# - cron: '25 3 * * *'
schedule:
- cron: '25 3 * * *'
workflow_dispatch:
env:
@ -16,17 +16,19 @@ concurrency:
jobs:
e2e-tests:
name: E2E tests on trunk
name: E2E tests on nightly build
runs-on: ubuntu-20.04
if: always()
env:
BASE_URL: ${{ secrets.SMOKE_TEST_URL }}
ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }}
ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }}
ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }}
ADMIN_USER_EMAIL: ${{ secrets.SMOKE_TEST_ADMIN_USER_EMAIL }}
CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }}
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-report
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/test-results/allure-results
BASE_URL: ${{ secrets.SMOKE_TEST_URL }}
CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }}
CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }}
DEFAULT_TIMEOUT_OVERRIDE: 120000
steps:
- uses: actions/checkout@v3
with:
@ -44,36 +46,24 @@ jobs:
- name: Run 'Update WooCommerce' test.
working-directory: plugins/woocommerce
id: e2e-update
env:
UPDATE_WC: true
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js update-woocommerce.spec.js
run: pnpm exec playwright test --config=tests/e2e-pw/daily.playwright.config.js update-woocommerce.spec.js
- name: Run the rest of E2E tests.
timeout-minutes: 60
working-directory: plugins/woocommerce
id: e2e
env:
E2E_MAX_FAILURES: 15
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js basic.spec.js
E2E_MAX_FAILURES: 25
run: pnpm exec playwright test --config=tests/e2e-pw/daily.playwright.config.js
- name: Generate Playwright E2E Test report.
id: generate_e2e_report
if: |
always() &&
(
steps.e2e-update.conclusion != 'cancelled' ||
steps.e2e-update.conclusion != 'skipped' ||
steps.e2e.conclusion != 'cancelled' ||
steps.e2e.conclusion != 'skipped'
)
if: success() || failure()
working-directory: plugins/woocommerce
run: pnpm exec allure generate --clean ${{ env.ALLURE_RESULTS_DIR }} --output ${{ env.ALLURE_REPORT_DIR }}
- name: Archive E2E test report
if: |
always() &&
steps.generate_e2e_report.conclusion == 'success'
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: ${{ env.E2E_ARTIFACT }}
@ -84,10 +74,10 @@ jobs:
retention-days: 5
api-tests:
name: API tests on trunk
name: API tests on nightly build
runs-on: ubuntu-20.04
needs: [e2e-tests]
if: always()
if: success() || failure()
env:
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-results
ALLURE_REPORT_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/api-core-tests/api-test-report/allure-report
@ -103,8 +93,6 @@ jobs:
build: false
- name: Run API tests.
if: always()
id: run_playwright_api_tests
working-directory: plugins/woocommerce
env:
BASE_URL: ${{ secrets.SMOKE_TEST_URL }}
@ -114,20 +102,12 @@ jobs:
run: pnpm exec playwright test --config=tests/api-core-tests/playwright.config.js hello.test.js
- name: Generate API Test report.
id: generate_api_report
if: |
always() &&
(
steps.run_playwright_api_tests.conclusion != 'cancelled' ||
steps.run_playwright_api_tests.conclusion != 'skipped'
)
if: success() || failure()
working-directory: plugins/woocommerce
run: pnpm exec allure generate --clean ${{ env.ALLURE_RESULTS_DIR }} --output ${{ env.ALLURE_REPORT_DIR }}
- name: Archive API test report
if: |
always() &&
steps.generate_api_report.conclusion == 'success'
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: ${{ env.API_ARTIFACT }}
@ -138,10 +118,10 @@ jobs:
retention-days: 5
k6-tests:
name: k6 tests on trunk
name: k6 tests on nightly build
runs-on: ubuntu-20.04
needs: [api-tests]
if: always()
if: success() || failure()
steps:
- uses: actions/checkout@v3
with:
@ -158,7 +138,6 @@ jobs:
run: pnpm exec playwright install chromium
- name: Update performance test site with E2E test
if: always()
working-directory: plugins/woocommerce
env:
BASE_URL: ${{ secrets.SMOKE_TEST_PERF_URL }}/
@ -173,12 +152,10 @@ jobs:
continue-on-error: true
- name: Install k6
if: always()
run: |
curl https://github.com/grafana/k6/releases/download/v0.33.0/k6-v0.33.0-linux-amd64.tar.gz -L | tar xvz --strip-components 1
- name: Run k6 smoke tests
if: always()
env:
URL: ${{ secrets.SMOKE_TEST_PERF_URL }}
HOST: ${{ secrets.SMOKE_TEST_PERF_HOST }}
@ -191,10 +168,8 @@ jobs:
./k6 run plugins/woocommerce/tests/performance/tests/gh-action-daily-ext-requests.js
test-plugins:
name: Smoke tests with ${{ matrix.plugin }} plugin installed
name: Smoke tests on trunk with ${{ matrix.plugin }} plugin installed
runs-on: ubuntu-20.04
needs: [k6-tests]
if: always()
env:
USE_WP_ENV: 1
ALLURE_RESULTS_DIR: ${{ github.workspace }}/plugins/woocommerce/tests/e2e-pw/allure-results
@ -233,7 +208,6 @@ jobs:
run: pnpm exec playwright install chromium
- name: Run 'Upload plugin' test
id: e2e-upload
working-directory: plugins/woocommerce
env:
PLUGIN_REPOSITORY: ${{ matrix.private && secrets[matrix.repo] || matrix.repo }}
@ -242,32 +216,21 @@ jobs:
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js upload-plugin.spec.js
- name: Run the rest of E2E tests
id: e2e
working-directory: plugins/woocommerce
env:
E2E_MAX_FAILURES: 15
run: pnpm exec playwright test --config=tests/e2e-pw/playwright.config.js basic.spec.js
run: pnpm exec playwright test --config=tests/e2e-pw/daily.playwright.config.js
- name: Generate E2E Test report.
id: report
if: |
always() &&
(
steps.e2e-upload.conclusion != 'cancelled' ||
steps.e2e-upload.conclusion != 'skipped' ||
steps.e2e.conclusion != 'cancelled' ||
steps.e2e.conclusion != 'skipped'
)
if: success() || failure()
working-directory: plugins/woocommerce
run: pnpm exec allure generate --clean ${{ env.ALLURE_RESULTS_DIR }} --output ${{ env.ALLURE_REPORT_DIR }}
- name: Archive E2E test report
if: |
always() &&
steps.report.conclusion == 'success'
if: success() || failure()
uses: actions/upload-artifact@v3
with:
name: Smoke tests with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
name: Smoke tests on trunk with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
path: |
${{ env.ALLURE_RESULTS_DIR }}
${{ env.ALLURE_REPORT_DIR }}
@ -275,16 +238,17 @@ jobs:
retention-days: 5
trunk-results:
name: Publish report on smoke tests on trunk
if: always() &&
name: Publish report on smoke tests on nightly build
if: |
( success() || failure() ) &&
! github.event.pull_request.head.repo.fork
runs-on: ubuntu-20.04
needs: [test-plugins]
needs: [test-plugins, k6-tests]
steps:
- name: Create dirs
run: |
mkdir -p repo
mkdir -p artifacts/api
mkdir -p artifacts/api
mkdir -p artifacts/e2e
mkdir -p output
@ -330,16 +294,16 @@ jobs:
--repo woocommerce/woocommerce-test-reports
plugins-results:
name: Publish report on smoke tests with plugins
name: Publish report on Smoke tests on trunk with plugins
if: |
always() &&
( success() || failure() ) &&
! github.event.pull_request.head.repo.fork
runs-on: ubuntu-20.04
needs: [test-plugins]
needs: [test-plugins, k6-tests]
env:
GITHUB_TOKEN: ${{ secrets.REPORTS_TOKEN }}
RUN_ID: ${{ github.run_id }}
ARTIFACT: Smoke tests with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
ARTIFACT: Smoke tests on trunk with ${{ matrix.plugin }} plugin installed (run ${{ github.run_number }})
strategy:
fail-fast: false
matrix:

View File

@ -1,6 +1,6 @@
{
"dev": true,
"filter": "^(?:react|react-dom|typescript)$",
"filter": "^(?:react|react-dom|typescript|@typescript-eslint|@types/react).*$",
"indent": "\t",
"overrides": true,
"peer": true,
@ -13,7 +13,22 @@
"dependencies": [
"@typescript-eslint/**"
],
"pinVersion": "latest",
"dependencyTypes": [
"devDependencies"
],
"pinVersion": "^5.43.0",
"packages": [
"**"
]
},
{
"dependencies": [
"@types/react"
],
"dependencyTypes": [
"devDependencies"
],
"pinVersion": "^17.0.2",
"packages": [
"**"
]

1
CODEOWNERS Normal file
View File

@ -0,0 +1 @@
/.github/ @woocommerce/atlas

View File

@ -1,5 +1,16 @@
== Changelog ==
= 7.1.1 2022-12-07 =
**WooCommerce**
* Patch - Move HPOS hook woocommerce_before_delete_order before deleting order. [#35517](https://github.com/woocommerce/woocommerce/pull/35517)
**WooCommerce Blocks 8.7.6**
* Fix - Mini Cart block: fix compatibility with Page Optimize and Product Bundles plugins. [#7794](https://github.com/woocommerce/woocommerce-blocks/pull/7794)
* Fix - Mini Cart block: Load wc-blocks-registry package at the page's load instead of lazy load it. [#7813](https://github.com/woocommerce/woocommerce-blocks/pull/7813)
= 7.1.0 2022-11-08 =
**WooCommerce**

View File

@ -49,7 +49,7 @@
"request": "^2.88.2",
"sass": "^1.49.9",
"sass-loader": "^10.2.1",
"syncpack": "^8.2.4",
"syncpack": "^8.3.9",
"turbo": "^1.4.5",
"typescript": "^4.8.3",
"url-loader": "^1.1.2",

View File

@ -1,6 +1,6 @@
# WooCommerce Packages
Currently we have a small set of public-facing packages that can be dowloaded from [npm](https://www.npmjs.com/org/woocommerce) and used in external applications.
Currently we have a set of public-facing packages that can be dowloaded from [npm](https://www.npmjs.com/org/woocommerce) and used in external applications. Here is a non-exhaustive list.
- `@woocommerce/components`: A library of components that can be used to create pages in the WooCommerce dashboard and reports pages.
- `@woocommerce/csv-export`: A set of functions to convert data into CSV values, and enable a browser download of the CSV data.
@ -12,12 +12,11 @@ Currently we have a small set of public-facing packages that can be dowloaded fr
## Working with existing packages
- You can make changes to packages files as normal, and running `pnpm start` will compile and watch both app files and packages.
- :warning: Make sure any dependencies you add to a package are also added to that package's `package.json`, not just the woocommerce-admin package.json
- :warning: Make sure you're not importing from any woocommerce-admin files outside of the package (you can import from other packages, just use the `import from @woocommerce/package` syntax).
- Add your change to the CHANGELOG for that package under the next version number, creating one if necessary (we use semantic versioning for packages, [see these guidelines](https://github.com/WordPress/gutenberg/blob/master/CONTRIBUTING.md#maintaining-changelogs)).
- :warning: Add any dependencies to a package using `pnpm add` from the package root.
- :warning: Make sure you're not importing from any other files outside of the package (you can import from other packages, just use the `import from @woocommerce/package` syntax).
- Don't change the version in `package.json`.
- Label your PR with the `Packages` label.
- Once merged, you can wait for the next package release roundup, or you can publish a release now (see below, "Publishing packages").
- See the [Package Release Tool](https://github.com/woocommerce/woocommerce/blob/f9e7a5a3fb11cdd4dc064c02e045cf429cb6a2b6/tools/package-release/README.md) for instructions on how to release packages.
---
@ -36,7 +35,7 @@ To create a new package, add a new folder to `/packages`, containing…
"author": "Automattic",
"license": "GPL-2.0-or-later",
"keywords": [ "wordpress", "woocommerce" ],
"homepage": "https://github.com/woocommerce/woocommerce/tree/main/packages/[_YOUR_PACKAGE_]/README.md",
"homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/[_YOUR_PACKAGE_]/README.md",
"repository": {
"type": "git",
"url": "https://github.com/woocommerce/woocommerce.git"
@ -61,25 +60,6 @@ To create a new package, add a new folder to `/packages`, containing…
- Package description
- Installation details
- Usage example
4. A `src` directory for the source of your module, which will be built by default using the `pnpm run build:packages` command. Note that you'll want an `index.js` file that exports the package contents, see other packages for examples.
4. A `src` directory for the source of your module, which will be built by default using the `pnpm run turbo:build` command. Note that you'll want an `index.js` file that exports the package contents, see other packages for examples.
5. Add the new package name to `packages/dependency-extraction-webpack-plugin/assets/packages.js` so that users of that plugin will also be able to use the new package without enqueuing it.
---
## Publishing packages
- Run `pnpm run publish-packages:check` to run pnpm publish with the `--dry-run` option
- Create a PR with a CHANGELOG for each updated package (or try to add to the CHANGELOG with any PR editing `packages/`)
- Run `pnpm run publish-packages:prod` to publish the package
- _OR_ Run `pnpm run publish-packages:dev` to publish "next" releases (installed as `pnpm i @woocommerce/package@next`). Only use `:dev` if you have a reason to.
- Both commands will run `build:packages` before the publishing task, just to catch any last updates.
### Publishing a single package
Sometimes, its helpful to release a singular package. This can be done directly through pnpm. Be sure versions and builds are correct.
- Bump the version in the package's package.json as well as its CHANGELOG file.
- `pnpm install && pnpm run build:packages` to build packages.
- `cd packages/<package-name>`
- `pnpm publish`
5. Add the new package name to `packages/js/dependency-extraction-webpack-plugin/assets/packages.js` so that users of that plugin will also be able to use the new package without enqueuing it.

View File

@ -1,5 +1,5 @@
Significance: patch
Type: dev
Comment: PHPCS violation fixes
Comment: Dev dependency bump

View File

@ -41,7 +41,7 @@
"@types/config": "0.0.41",
"@types/expect-puppeteer": "^4.4.7",
"@types/puppeteer": "^5.4.5",
"@typescript-eslint/eslint-plugin": "^5.14.0",
"@typescript-eslint/eslint-plugin": "^5.43.0",
"@woocommerce/api": "^0.2.0",
"@woocommerce/eslint-plugin": "workspace:*",
"eslint": "^8.12.0",

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Dev dependency bump

View File

@ -51,8 +51,8 @@
"@types/create-hmac": "1.1.0",
"@types/jest": "^27.4.1",
"@types/node": "13.13.5",
"@typescript-eslint/eslint-plugin": "^5.3.1",
"@typescript-eslint/parser": "^5.3.1",
"@typescript-eslint/eslint-plugin": "^5.43.0",
"@typescript-eslint/parser": "^5.43.0",
"@woocommerce/eslint-plugin": "workspace:*",
"axios-mock-adapter": "^1.20.0",
"eslint": "^8.2.0",

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Move classname down in SelectControl Menu so it is on the actual Menu element.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add async filtering support to the `__experimentalSelectControl` component

View File

@ -0,0 +1,4 @@
Significance: patch
Type: add
Add experimental open menu when user focus the select control input element

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Allow the user to select multiple images in the Media Library

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix pagination label text from uppercase to normal and font styles

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add support for custom suffix prop on SelectControl.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: dev
Move file picker by clicking card into the MediaUploader component

View File

@ -0,0 +1,5 @@
Significance: patch
Type: dev
Comment: Dev dependency bump

View File

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

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Align the field height across the whole form

View File

@ -0,0 +1,4 @@
Significance: patch
Type: enhancement
Fade the value selection field in the Attributes modal when no attribute is added

View File

@ -0,0 +1,4 @@
Significance: minor
Type: fix
Set editor mode on initialization to prevent initial text editor focus

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Include react-dates styles (no longer in WP 6.1+).

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Close DateTimePickerControl's dropdown when blurring from input.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fixed DatePicker to work in WordPress 6.1 when currentDate is set to a moment instance.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: tweak
Updated image gallery toolbar position and tooltips.

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Set initial values prop from reset form function as optional

View File

@ -111,10 +111,12 @@
"@testing-library/dom": "^8.11.3",
"@testing-library/jest-dom": "^5.16.2",
"@testing-library/react": "^12.1.3",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.4.1",
"@types/lodash": "^4.14.184",
"@types/react": "^17.0.0",
"@types/prop-types": "^15.7.4",
"@types/react": "^17.0.2",
"@types/testing-library__jest-dom": "^5.14.3",
"@types/wordpress__components": "^19.10.1",
"@types/wordpress__data": "^6.0.0",

View File

@ -0,0 +1 @@
/*rtl:begin:ignore*/

View File

@ -116,7 +116,11 @@ class DatePicker extends Component {
</H>
<div className="woocommerce-calendar__react-dates is-core-datepicker">
<WpDatePicker
currentDate={ date }
currentDate={
date instanceof moment
? date.toDate()
: date
}
onChange={ partial(
this.onDateChange,
onToggle

View File

@ -0,0 +1 @@
/*rtl:end:ignore*/

View File

@ -1,3 +1,14 @@
/**
* We don't convert react-dates styles to RTL because react-dates uses an isRTL flag instead.
*
* We have to include the RTL-ignore directives via imports because of the ordering of how
* @imports are included in the output (see https://github.com/MohammadYounes/rtlcss/issues/113).
**/
@import './calendar/begin-rtl-ignore.css';
@import '../node_modules/react-dates/lib/css/_datepicker.css';
@import './calendar/end-rtl-ignore.css';
.woocommerce-calendar {
width: 100%;
background-color: $gray-100;

View File

@ -69,8 +69,6 @@ export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( {
const id = `inspector-date-time-picker-control-${ instanceId }`;
const inputControl = useRef< InputControl >();
const [ inputString, setInputString ] = useState( '' );
const displayFormat = useMemo( () => {
if ( dateTimeFormat ) {
return dateTimeFormat;
@ -145,6 +143,14 @@ export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( {
: 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 ] );
@ -161,15 +167,23 @@ export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( {
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( newInputString )
? parseAsLocalDateTime( lastTypedValue )
: parseAsISODateTime( newInputString, true )
);
const isDateTimeSame = newDateTime.isSame( inputStringDateTime );
if ( isUserTypedInput ) {
setInputString( newInputString );
setInputString( lastTypedValue );
} else if ( ! isDateTimeSame ) {
setInputString( formatDateTimeForDisplay( newDateTime ) );
}
@ -179,7 +193,9 @@ export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( {
! isDateTimeSame
) {
onChangeRef.current(
formatDateTimeAsISO( newDateTime ),
newDateTime.isValid()
? formatDateTimeAsISO( newDateTime )
: lastTypedValue,
newDateTime.isValid()
);
}
@ -198,23 +214,52 @@ export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( {
}
}
function getUserInputOrUpdatedCurrentDate() {
const newDateTime = maybeForceTime(
parseAsISODateTime( currentDate, false )
);
const getUserInputOrUpdatedCurrentDate = useCallback( () => {
if ( currentDate !== undefined ) {
const newDateTime = maybeForceTime(
parseAsISODateTime( currentDate, false )
);
if (
! newDateTime.isValid() ||
newDateTime.isSame(
maybeForceTime( parseAsLocalDateTime( inputString ) )
)
) {
// keep the input string as the user entered it
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;
}
return formatDateTimeForDisplay( newDateTime );
}
// 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
@ -225,12 +270,8 @@ export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( {
position="bottom left"
focusOnMount={ false }
// @ts-expect-error `onToggle` does exist.
onToggle={ ( willOpen ) => {
if ( ! willOpen && typeof onBlur === 'function' ) {
onBlur();
}
} }
renderToggle={ ( { isOpen, onToggle } ) => (
onToggle={ callOnBlurIfDropdownIsNotOpening }
renderToggle={ ( { isOpen, onClose, onToggle } ) => (
<BaseControl id={ id } label={ label } help={ help }>
<InputControl
id={ id }
@ -249,7 +290,9 @@ export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( {
if (
hasFocusLeftInputAndDropdownContent( event )
) {
onToggle(); // hide the dropdown
// close the dropdown, which will also trigger
// the component's onBlur to be called
onClose();
}
} }
suffix={
@ -285,14 +328,14 @@ export const DateTimePickerControl: React.FC< DateTimePickerControlProps > = ( {
} }
renderContent={ () => {
const Picker = isDateOnlyPicker ? DatePicker : WpDateTimePicker;
const inputDateTime = parseAsLocalDateTime( inputString );
return (
<Picker
// @ts-expect-error null is valid for currentDate
currentDate={
inputDateTime.isValid()
? formatDateTimeAsISO( inputDateTime )
: undefined
inputStringDateTime.isValid()
? formatDateTimeAsISO( inputStringDateTime )
: null
}
onChange={ ( newDateTimeISOString: string ) =>
setInputStringAndMaybeCallOnChange(

View File

@ -3,7 +3,7 @@
*/
import React from 'react';
import { Button, Popover, SlotFillProvider } from '@wordpress/components';
import { createElement, useState } from '@wordpress/element';
import { createElement, useCallback, useState } from '@wordpress/element';
/**
* Internal dependencies
@ -37,42 +37,6 @@ CustomDateTimeFormat.args = {
dateTimeFormat: customFormat,
};
function ControlledContainer( { children, ...props } ) {
function nowWithZeroedSeconds() {
const now = new Date();
now.setSeconds( 0 );
now.setMilliseconds( 0 );
return now;
}
const [ controlledDate, setControlledDate ] = useState(
nowWithZeroedSeconds().toISOString()
);
return (
<div { ...props }>
<div>{ children( controlledDate, setControlledDate ) }</div>
<div>
<Button
onClick={ () =>
setControlledDate(
nowWithZeroedSeconds().toISOString()
)
}
>
Reset to now
</Button>
<div>
<div>
Controlled date:
<br /> <span>{ controlledDate }</span>
</div>
</div>
</div>
</div>
);
}
export const ReallyLongHelp = Template.bind( {} );
ReallyLongHelp.args = {
...Basic.args,
@ -97,13 +61,19 @@ function ControlledDecorator( Story, props ) {
nowWithZeroedSeconds().toISOString()
);
const onChange = useCallback( ( newDateTimeISOString ) => {
setControlledDate( newDateTimeISOString );
// eslint-disable-next-line no-console
console.log( 'onChange', newDateTimeISOString );
}, [] );
return (
<div>
<Story
args={ {
...props.args,
currentDate: controlledDate,
onChange: setControlledDate,
onChange,
} }
/>
<div>

View File

@ -5,10 +5,22 @@
background: $studio-white;
position: relative;
display: flex;
align-items: center;
margin-bottom: 4px;
min-height: 36px;
box-sizing: border-box;
&--disabled {
opacity: 0.5;
}
}
.woocommerce-experimental-select-control__items-wrapper {
display: flex;
flex-grow: 1;
flex-wrap: wrap;
align-items: center;
padding: $gap-smallest 36px $gap-smallest $gap-smaller;
margin-bottom: 4px;
padding: 2px $gap-smaller;
> * {
display: inline-flex;
@ -29,9 +41,6 @@
}
}
.woocommerce-experimental-select-control__combox-box-icon {
position: absolute;
right: 6px;
top: 50%;
transform: translateY( -50% );
.woocommerce-experimental-select-control__suffix {
align-self: stretch;
}

View File

@ -2,7 +2,7 @@
* External dependencies
*/
import { createElement, MouseEvent, useRef } from 'react';
import { Icon, search } from '@wordpress/icons';
import classNames from 'classnames';
/**
* Internal dependencies
@ -13,12 +13,14 @@ type ComboBoxProps = {
children?: JSX.Element | JSX.Element[] | null;
comboBoxProps: Props;
inputProps: Props;
suffix?: JSX.Element | null;
};
export const ComboBox = ( {
children,
comboBoxProps,
inputProps,
suffix,
}: ComboBoxProps ) => {
const inputRef = useRef< HTMLInputElement | null >( null );
@ -40,30 +42,39 @@ export const ComboBox = ( {
// Keyboard users are still able to tab to and interact with elements in the combobox.
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div
className="woocommerce-experimental-select-control__combo-box-wrapper"
className={ classNames(
'woocommerce-experimental-select-control__combo-box-wrapper',
{
'woocommerce-experimental-select-control__combo-box-wrapper--disabled':
inputProps.disabled,
}
) }
onMouseDown={ maybeFocusInput }
>
{ children }
<div
{ ...comboBoxProps }
className="woocommerce-experimental-select-control__combox-box"
>
<input
{ ...inputProps }
ref={ ( node ) => {
inputRef.current = node;
(
inputProps.ref as unknown as (
node: HTMLInputElement | null
) => void
)( node );
} }
/>
<div className="woocommerce-experimental-select-control__items-wrapper">
{ children }
<div
{ ...comboBoxProps }
className="woocommerce-experimental-select-control__combox-box"
>
<input
{ ...inputProps }
ref={ ( node ) => {
inputRef.current = node;
(
inputProps.ref as unknown as (
node: HTMLInputElement | null
) => void
)( node );
} }
/>
</div>
</div>
<Icon
className="woocommerce-experimental-select-control__combox-box-icon"
icon={ search }
/>
{ suffix && (
<div className="woocommerce-experimental-select-control__suffix">
{ suffix }
</div>
) }
</div>
);
};

View File

@ -0,0 +1,79 @@
/**
* External dependencies
*/
import { Spinner } from '@wordpress/components';
import { useDebounce } from '@wordpress/compose';
import { useCallback, useState, createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { SelectControlProps } from '../select-control';
import { SuffixIcon } from '../suffix-icon';
export const DEFAULT_DEBOUNCE_TIME = 250;
export default function useAsyncFilter< T >( {
filter,
onFilterStart,
onFilterEnd,
onFilterError,
debounceTime,
}: UseAsyncFilterInput< T > ): UseAsyncFilterOutput< T > {
const [ isFetching, setIsFetching ] = useState( false );
const handleInputChange = useCallback(
function handleInputChangeCallback( value?: string ) {
if ( typeof filter === 'function' ) {
if ( typeof onFilterStart === 'function' )
onFilterStart( value );
setIsFetching( true );
filter( value )
.then( ( filteredItems ) => {
if ( typeof onFilterEnd === 'function' )
onFilterEnd( filteredItems, value );
} )
.catch( ( error: Error ) => {
if ( typeof onFilterError === 'function' )
onFilterError( error, value );
} )
.finally( () => {
setIsFetching( false );
} );
}
},
[ filter, onFilterStart, onFilterEnd, onFilterError ]
);
return {
isFetching,
suffix:
isFetching === true ? (
<SuffixIcon icon={ <Spinner /> } />
) : undefined,
getFilteredItems: ( items ) => items,
onInputChange: useDebounce(
handleInputChange,
typeof debounceTime === 'number'
? debounceTime
: DEFAULT_DEBOUNCE_TIME
),
};
}
export type UseAsyncFilterInput< T > = {
filter( value?: string ): Promise< T[] >;
onFilterStart?( value?: string ): void;
onFilterEnd?( filteredItems: T[], value?: string ): void;
onFilterError?( error: Error, value?: string ): void;
debounceTime?: number;
};
export type UseAsyncFilterOutput< T > = Pick<
SelectControlProps< T >,
'suffix' | 'onInputChange' | 'getFilteredItems'
> & {
isFetching: boolean;
};

View File

@ -1 +1,2 @@
export * from './select-control';
export { default as useAsyncFilter } from './hooks/use-async-filter';

View File

@ -46,39 +46,41 @@ export const Menu = ( {
return (
<div
ref={ selectControlMenuRef }
className={ classnames(
'woocommerce-experimental-select-control__menu',
className
) }
className="woocommerce-experimental-select-control__menu"
>
<Popover
// @ts-expect-error this prop does exist, see: https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/popover/index.tsx#L180.
__unstableSlotName="woocommerce-select-control-menu"
focusOnMount={ false }
className={ classnames(
'woocommerce-experimental-select-control__popover-menu',
{
'is-open': isOpen,
'has-results': Children.count( children ) > 0,
}
) }
position="bottom center"
animate={ false }
>
<ul
{ ...getMenuProps() }
className="woocommerce-experimental-select-control__popover-menu-container"
style={ {
width: boundingRect?.width,
} }
onMouseUp={ ( e ) =>
// Fix to prevent select control dropdown from closing when selecting within the Popover.
e.stopPropagation()
}
<div>
<Popover
// @ts-expect-error this prop does exist, see: https://github.com/WordPress/gutenberg/blob/trunk/packages/components/src/popover/index.tsx#L180.
__unstableSlotName="woocommerce-select-control-menu"
focusOnMount={ false }
className={ classnames(
'woocommerce-experimental-select-control__popover-menu',
{
'is-open': isOpen,
'has-results': Children.count( children ) > 0,
}
) }
position="bottom right"
animate={ false }
>
{ isOpen && children }
</ul>
</Popover>
<ul
{ ...getMenuProps() }
className={ classnames(
'woocommerce-experimental-select-control__popover-menu-container',
className
) }
style={ {
width: boundingRect?.width,
} }
onMouseUp={ ( e ) =>
// Fix to prevent select control dropdown from closing when selecting within the Popover.
e.stopPropagation()
}
>
{ isOpen && children }
</ul>
</Popover>
</div>
</div>
);
/* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */

View File

@ -2,6 +2,7 @@
@import './menu.scss';
@import './menu-item.scss';
@import './selected-items.scss';
@import './suffix-icon.scss';
.woocommerce-experimental-select-control {
position: relative;

View File

@ -14,6 +14,7 @@ import {
createElement,
Fragment,
} from '@wordpress/element';
import { search } from '@wordpress/icons';
/**
* Internal dependencies
@ -28,16 +29,17 @@ import { SelectedItems } from './selected-items';
import { ComboBox } from './combo-box';
import { Menu } from './menu';
import { MenuItem } from './menu-item';
import { SuffixIcon } from './suffix-icon';
import {
defaultGetItemLabel,
defaultGetItemValue,
defaultGetFilteredItems,
} from './utils';
type SelectControlProps< ItemType > = {
export type SelectControlProps< ItemType > = {
children?: ChildrenType< ItemType >;
items: ItemType[];
label: string;
label: string | JSX.Element;
getItemLabel?: getItemLabelType< ItemType >;
getItemValue?: getItemValueType< ItemType >;
getFilteredItems?: (
@ -63,6 +65,16 @@ type SelectControlProps< ItemType > = {
selected: ItemType | ItemType[] | null;
className?: string;
disabled?: boolean;
suffix?: JSX.Element | null;
/**
* 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
* current downshift version get updated.
*
* @see https://www.downshift-js.com/use-multiple-selection#usage-with-combobox
* @default false
*/
__experimentalOpenMenuOnFocus?: boolean;
};
export const selectControlStateChangeTypes = useCombobox.stateChangeTypes;
@ -107,6 +119,8 @@ function SelectControl< ItemType = DefaultItemType >( {
selected,
className,
disabled,
suffix = <SuffixIcon icon={ search } />,
__experimentalOpenMenuOnFocus = false,
}: SelectControlProps< ItemType > ) {
const [ isFocused, setIsFocused ] = useState( false );
const [ inputValue, setInputValue ] = useState( '' );
@ -247,11 +261,15 @@ function SelectControl< ItemType = DefaultItemType >( {
onFocus: () => {
setIsFocused( true );
onFocus( { inputValue } );
if ( __experimentalOpenMenuOnFocus ) {
openMenu();
}
},
onBlur: () => setIsFocused( false ),
placeholder,
disabled,
} ) }
suffix={ suffix }
>
<>
{ children( {

View File

@ -8,18 +8,24 @@ import {
SlotFillProvider,
Spinner,
} from '@wordpress/components';
import React from 'react';
import React, { useCallback } from 'react';
import { createElement, useState } from '@wordpress/element';
import { tag } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { SelectedType, DefaultItemType, getItemLabelType } from '../types';
import { MenuItem } from '../menu-item';
import { SelectControl, selectControlStateChangeTypes } from '../';
import {
SelectControl,
selectControlStateChangeTypes,
useAsyncFilter,
} from '../';
import { Menu, MenuSlot } from '../menu';
import { SuffixIcon } from '../suffix-icon';
const sampleItems = [
const sampleItems: DefaultItemType[] = [
{ value: 'apple', label: 'Apple' },
{ value: 'pear', label: 'Pear' },
{ value: 'orange', label: 'Orange' },
@ -131,35 +137,121 @@ export const FuzzyMatching: React.FC = () => {
export const Async: React.FC = () => {
const [ selectedItem, setSelectedItem ] =
useState< SelectedType< DefaultItemType > >( null );
useState< DefaultItemType | null >( null );
const [ fetchedItems, setFetchedItems ] = useState< DefaultItemType[] >(
[]
);
const [ isFetching, setIsFetching ] = useState( false );
const fetchItems = ( value: string | undefined ) => {
setIsFetching( true );
setFetchedItems( [] );
setTimeout( () => {
const results = sampleItems.sort( () => 0.5 - Math.random() );
setFetchedItems( results );
setIsFetching( false );
}, 1500 );
};
const filter = useCallback(
( value = '' ) =>
new Promise< DefaultItemType[] >( ( resolve ) => {
setTimeout( () => {
const filteredItems = [ ...sampleItems ]
.sort( ( a, b ) => a.label.localeCompare( b.label ) )
.filter( ( { label } ) =>
label.toLowerCase().includes( value.toLowerCase() )
);
resolve( filteredItems );
}, 1500 );
} ),
[ selectedItem ]
);
const { isFetching, ...selectProps } = useAsyncFilter< DefaultItemType >( {
filter,
onFilterStart() {
setFetchedItems( [] );
},
onFilterEnd( filteredItems ) {
setFetchedItems( filteredItems );
},
} );
return (
<>
<SelectControl
<SelectControl< DefaultItemType >
{ ...selectProps }
label="Async"
getFilteredItems={ ( allItems ) => {
return allItems;
} }
items={ fetchedItems }
onInputChange={ fetchItems }
selected={ selectedItem }
onSelect={ ( item ) => setSelectedItem( item ) }
onRemove={ () => setSelectedItem( null ) }
placeholder="Start typing..."
onSelect={ setSelectedItem }
onRemove={ () => setSelectedItem( null ) }
>
{ ( {
items,
isOpen,
highlightedIndex,
getItemProps,
getMenuProps,
} ) => {
return (
<Menu isOpen={ isOpen } getMenuProps={ getMenuProps }>
{ isFetching ? (
<Spinner />
) : (
items.map( ( item, index: number ) => (
<MenuItem
key={ `${ item.value }${ index }` }
index={ index }
isActive={ highlightedIndex === index }
item={ item }
getItemProps={ getItemProps }
>
{ item.label }
</MenuItem>
) )
) }
</Menu>
);
} }
</SelectControl>
</>
);
};
export const AsyncWithoutListeningFilterEvents: React.FC = () => {
const [ selectedItem, setSelectedItem ] =
useState< DefaultItemType | null >( null );
const [ fetchedItems, setFetchedItems ] = useState< DefaultItemType[] >(
[]
);
const filter = useCallback(
async ( value = '' ) => {
setFetchedItems( [] );
return new Promise< DefaultItemType[] >( ( resolve ) => {
setTimeout( () => {
const filteredItems = [ ...sampleItems ]
.sort( ( a, b ) => a.label.localeCompare( b.label ) )
.filter( ( { label } ) =>
label.toLowerCase().includes( value.toLowerCase() )
);
resolve( filteredItems );
}, 1500 );
} ).then( ( filteredItems ) => {
setFetchedItems( filteredItems );
return filteredItems;
} );
},
[ selectedItem ]
);
const { isFetching, ...selectProps } = useAsyncFilter< DefaultItemType >( {
filter,
} );
return (
<>
<SelectControl< DefaultItemType >
{ ...selectProps }
label="Async"
items={ fetchedItems }
selected={ selectedItem }
placeholder="Start typing..."
onSelect={ setSelectedItem }
onRemove={ () => setSelectedItem( null ) }
>
{ ( {
items,
@ -410,6 +502,77 @@ export const SingleWithinModalUsingBodyDropdownPlacement: React.FC = () => {
);
};
export const DefaultSuffix: React.FC = () => {
const [ selected, setSelected ] = useState<
SelectedType< DefaultItemType >
>( sampleItems[ 1 ] );
return (
<SelectControl
items={ sampleItems }
label="Default suffix"
selected={ selected }
onSelect={ ( item ) => item && setSelected( item ) }
onRemove={ () => setSelected( null ) }
/>
);
};
export const CustomSuffixIcon: React.FC = () => {
const [ selected, setSelected ] = useState<
SelectedType< DefaultItemType >
>( sampleItems[ 1 ] );
return (
<SelectControl
items={ sampleItems }
label="Custom suffix icon"
selected={ selected }
onSelect={ ( item ) => item && setSelected( item ) }
onRemove={ () => setSelected( null ) }
suffix={ <SuffixIcon icon={ tag } /> }
/>
);
};
export const NoSuffix: React.FC = () => {
const [ selected, setSelected ] = useState<
SelectedType< DefaultItemType >
>( sampleItems[ 1 ] );
return (
<SelectControl
items={ sampleItems }
label="No suffix"
selected={ selected }
onSelect={ ( item ) => item && setSelected( item ) }
onRemove={ () => setSelected( null ) }
suffix={ null }
/>
);
};
export const CustomSuffix: React.FC = () => {
const [ selected, setSelected ] = useState<
SelectedType< DefaultItemType >
>( sampleItems[ 1 ] );
return (
<SelectControl
items={ sampleItems }
label="Custom suffix"
selected={ selected }
onSelect={ ( item ) => item && setSelected( item ) }
onRemove={ () => setSelected( null ) }
suffix={
<div style={ { background: 'red', height: '100%' } }>
Suffix!
</div>
}
/>
);
};
export default {
title: 'WooCommerce Admin/experimental/SelectControl',
component: SelectControl,

View File

@ -0,0 +1,10 @@
.woocommerce-experimental-select-control__suffix-icon {
display: flex;
align-items: center;
height: 100%;
padding-right: $gap-smaller;
.components-spinner {
margin: 0;
}
}

View File

@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { createElement } from 'react';
import { Icon } from '@wordpress/icons';
type SuffixIconProps = {
icon: JSX.Element;
};
export const SuffixIcon = ( { icon }: SuffixIconProps ) => {
return (
<div className="woocommerce-experimental-select-control__suffix-icon">
<Icon icon={ icon } size={ 24 } />
</div>
);
};

View File

@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { render } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { SelectControl } from '../';
describe( 'SelectControl', () => {
it( 'should render the default suffix if none is specified', () => {
const { container } = render(
<SelectControl label="Select" items={ [] } selected={ null } />
);
// We can't really determine if the correct suffix icon is being used
// without checking against the SVG path, which would be brittle;
// so, we just check if any suffix icon has been rendered
expect(
container.querySelector(
'.woocommerce-experimental-select-control__suffix-icon'
)
).toBeInTheDocument();
} );
it( 'should render a custom suffix if one is specified', () => {
const { getByText } = render(
<SelectControl
label="Select"
items={ [] }
selected={ null }
suffix={ <div>custom suffix</div> }
/>
);
expect( getByText( 'custom suffix' ) ).toBeInTheDocument();
} );
it( 'should render no suffix if null is specified', () => {
const { container } = render(
<SelectControl
label="Select"
items={ [] }
selected={ null }
suffix={ null }
/>
);
expect(
container.querySelector(
'.woocommerce-experimental-select-control__suffix'
)
).not.toBeInTheDocument();
} );
} );

View File

@ -0,0 +1,128 @@
/**
* External dependencies
*/
import { act, renderHook } from '@testing-library/react-hooks';
import { useDebounce } from '@wordpress/compose';
/**
* Internal dependencies
*/
import { useAsyncFilter } from '../';
jest.mock( '@wordpress/compose', () => ( {
...jest.requireActual( '@wordpress/compose' ),
useDebounce: jest.fn( ( cb: CallableFunction ) => cb ),
} ) );
describe( 'useAsyncFilter', () => {
const filter = jest.fn();
const onFilterStart = jest.fn();
const onFilterEnd = jest.fn();
const onFilterError = jest.fn();
afterEach( () => {
jest.clearAllMocks();
} );
it( 'should filter the items successfully', async () => {
const filteredItems: string[] = [];
filter.mockResolvedValue( filteredItems );
const { result } = renderHook( () =>
useAsyncFilter( {
filter,
} )
);
const inputValue = 'Apple';
await act( async () => {
if ( result.current.onInputChange )
result.current.onInputChange( inputValue, {} );
} );
expect( useDebounce ).toHaveBeenCalledWith(
expect.any( Function ),
250
);
expect( filter ).toHaveBeenCalledWith( inputValue );
} );
it( 'should trigger onFilterStart at the begining of the filtering', async () => {
const filteredItems: string[] = [];
onFilterStart.mockImplementation( ( value = '' ) => {
expect( filter ).not.toHaveBeenCalledWith( value );
} );
filter.mockImplementation( ( value = '' ) => {
expect( onFilterStart ).toHaveBeenCalledWith( value );
return Promise.resolve( filteredItems );
} );
const { result } = renderHook( () =>
useAsyncFilter( {
filter,
onFilterStart,
} )
);
const inputValue = 'Apple';
await act( async () => {
if ( result.current.onInputChange )
result.current.onInputChange( inputValue, {} );
} );
expect( filter ).toHaveBeenCalledWith( inputValue );
} );
it( 'should trigger onFilterEnd when filtering is fullfiled', async () => {
const filteredItems: string[] = [];
filter.mockResolvedValue( filteredItems );
const { result } = renderHook( () =>
useAsyncFilter( {
filter,
onFilterEnd,
onFilterError,
} )
);
const inputValue = 'Apple';
await act( async () => {
if ( result.current.onInputChange )
result.current.onInputChange( inputValue, {} );
} );
expect( onFilterEnd ).toHaveBeenCalledWith( filteredItems, inputValue );
expect( onFilterError ).not.toHaveBeenCalled();
} );
it( 'should trigger onFilterError when filtering is rejected', async () => {
const error = new Error();
filter.mockRejectedValue( error );
const { result } = renderHook( () =>
useAsyncFilter( {
filter,
onFilterEnd,
onFilterError,
} )
);
const inputValue = 'Apple';
await act( async () => {
if ( result.current.onInputChange )
result.current.onInputChange( inputValue, {} );
} );
expect( onFilterEnd ).not.toHaveBeenCalled();
expect( onFilterError ).toHaveBeenCalledWith( error, inputValue );
} );
} );

View File

@ -80,6 +80,7 @@
width: 320px;
border: 1px solid $gray-400;
background-color: $studio-white;
padding: 0;
}
.woocommerce-calendar__input-error .components-popover__content {

View File

@ -44,7 +44,7 @@ export type FormContext< Values extends Record< string, any > > = {
): InputProps< Values, Value >;
isValidForm: boolean;
resetForm: (
initialValues: Values,
initialValues?: Values,
touchedFields?: { [ P in keyof Values ]?: boolean | undefined },
errors?: FormErrors< Values >
) => void;

View File

@ -166,11 +166,11 @@ function FormComponent< Values extends Record< string, any > >(
validate( values );
}, [] );
const resetForm = (
newInitialValues = {} as Values,
newTouchedFields = {},
newErrors = {}
) => {
const resetForm: (
newInitialValues?: Values,
newTouchedFields?: { [ P in keyof Values ]?: boolean | undefined },
newErrors?: FormErrors< Values >
) => void = ( newInitialValues, newTouchedFields = {}, newErrors = {} ) => {
const newValues = newInitialValues ?? initialValues.current ?? {};
initialValues.current = newValues;
setValuesInternal( newValues );
@ -183,7 +183,7 @@ function FormComponent< Values extends Record< string, any > >(
} ) );
const isValidForm = async () => {
await validate( values );
validate( values );
return ! Object.keys( errors ).length;
};

View File

@ -0,0 +1,7 @@
.components-base-control {
.components-text-control__input,
.components-input-control .components-input-control__container .components-input-control__input,
.components-select-control .components-input-control__container .components-select-control__input {
min-height: 36px;
}
}

View File

@ -1,7 +1,8 @@
.woocommerce-image-gallery__toolbar {
position: absolute;
top: -50px;
top: -58px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
background-color: white;
}

View File

@ -64,7 +64,7 @@ export const ImageGalleryToolbar: React.FC< ImageGalleryToolbarProps > = ( {
icon={ () => (
<SortableHandle itemIndex={ childIndex } />
) }
label={ __( 'Drag', 'woocommerce' ) }
label={ __( 'Drag to reorder', 'woocommerce' ) }
/>
<ToolbarButton
disabled={ childIndex < 2 }
@ -85,7 +85,7 @@ export const ImageGalleryToolbar: React.FC< ImageGalleryToolbarProps > = ( {
<ToolbarButton
onClick={ () => setAsCoverImage( childIndex ) }
icon={ CoverImageIcon }
label={ __( 'Set as cover image', 'woocommerce' ) }
label={ __( 'Set as cover', 'woocommerce' ) }
/>
</ToolbarGroup>
) }
@ -106,7 +106,7 @@ export const ImageGalleryToolbar: React.FC< ImageGalleryToolbarProps > = ( {
<ToolbarButton
onClick={ () => removeItem( childIndex ) }
icon={ trash }
label={ __( 'Delete', 'woocommerce' ) }
label={ __( 'Remove', 'woocommerce' ) }
/>
</ToolbarGroup>
</Toolbar>

View File

@ -45,6 +45,7 @@ export { default as SelectControl } from './select-control';
export {
SelectControl as __experimentalSelectControl,
selectControlStateChangeTypes,
useAsyncFilter,
} from './experimental-select-control';
export {
MenuItem as __experimentalSelectControlMenuItem,

View File

@ -1,5 +1,4 @@
MediaUploader
===
# MediaUploader
This component adds an upload button and a dropzone for uploading media to a site.
@ -9,21 +8,21 @@ By default this will use the functionality from `@wordpress/media-utils` which p
```jsx
<MediaUploader
label={ 'Click the button below to upload' }
onSelect={ ( file ) => setImages( [ ...images, file ] ) }
onUpload={ ( files ) => setImages( [ ...images, ...files ] ) }
label={ 'Click the button below to upload' }
onSelect={ ( file ) => setImages( [ ...images, file ] ) }
onUpload={ ( files ) => setImages( [ ...images, ...files ] ) }
/>
```
### Props
Name | Type | Default | Description
--- | --- | --- | ---
`allowedMediaTypes` | String[] | `[ 'image ]` | Allowed media types
`buttonText` | String | `Choose images` | Text to use for button
`hasDropZone` | Boolean | `true` | Whether or not to allow the dropzone
`label` | String | `Drag images here or click to upload` | String to use for the text shown inside the component
`MediaUploadComponent` | JSX.Element | `MediaModal` | The component to use for the media uploader
`onError` | Function | `() => null` | Callback function to run when an error occurs
`onUpload` | Function | `() => null` | Callback function to run when an upload occurs aftering dragging and dropping files
`onUpload` | Function | `() => null` | Callback function to run when selecting media from the opened media modal
| Name | Type | Default | Description |
| ---------------------- | ----------- | ------------------------------------- | ----------------------------------------------------------------------------------- |
| `allowedMediaTypes` | String[] | `[ 'image ]` | Allowed media types |
| `buttonText` | String | `Choose images` | Text to use for button |
| `hasDropZone` | Boolean | `true` | Whether or not to allow the dropzone |
| `label` | String | `Drag images here or click to upload` | String to use for the text shown inside the component |
| `MediaUploadComponent` | JSX.Element | `MediaModal` | The component to use for the media uploader |
| `onError` | Function | `() => null` | Callback function to run when an error occurs |
| `onUpload` | Function | `() => null` | Callback function to run when an upload occurs aftering dragging and dropping files |
| `onSelect` | Function | `() => null` | Callback function to run when selecting media from the opened media modal |

View File

@ -2,7 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Button, DropZone } from '@wordpress/components';
import { Button, DropZone, FormFileUpload } from '@wordpress/components';
import { createElement } from 'react';
import {
MediaItem,
@ -24,14 +24,18 @@ type MediaUploaderProps = {
MediaUploadComponent?: < T extends boolean = false >(
props: MediaUpload.Props< T >
) => JSX.Element;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onSelect?: ( value: { id: number } & { [ k: string ]: any } ) => void;
multipleSelect?: boolean;
onSelect?: (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: ( { id: number } & { [ k: string ]: any } ) | MediaItem[]
) => void;
onError?: ( error: {
code: UploadMediaErrorCode;
message: string;
file: File;
} ) => void;
onUpload?: ( files: MediaItem[] ) => void;
onFileUploadChange?: ( files: MediaItem[] ) => void;
uploadMedia?: ( options: UploadMediaOptions ) => Promise< void >;
};
@ -42,37 +46,77 @@ export const MediaUploader = ( {
label = __( 'Drag images here or click to upload', 'woocommerce' ),
maxUploadFileSize = 10000000,
MediaUploadComponent = MediaUpload,
multipleSelect = false,
onError = () => null,
onFileUploadChange = () => null,
onUpload = () => null,
onSelect = () => null,
uploadMedia = wpUploadMedia,
}: MediaUploaderProps ) => {
const getFormFileUploadAcceptedFiles = () =>
allowedMediaTypes.map( ( type ) => `${ type }/*` );
return (
<div className="woocommerce-media-uploader">
<div className="woocommerce-media-uploader__label">{ label }</div>
<FormFileUpload
accept={ getFormFileUploadAcceptedFiles().toString() }
multiple={ true }
onChange={ ( { currentTarget } ) => {
uploadMedia( {
filesList: currentTarget.files as FileList,
onError,
onFileChange: onFileUploadChange,
maxUploadFileSize,
} );
} }
render={ ( { openFileDialog } ) => (
<div
className="woocommerce-form-file-upload"
onKeyPress={ () => {} }
tabIndex={ 0 }
role="button"
onClick={ (
event: React.MouseEvent< HTMLDivElement, MouseEvent >
) => {
const { target } = event;
if (
( target as HTMLButtonElement )?.type !== 'button'
) {
openFileDialog();
}
} }
onBlur={ () => {} }
>
<div className="woocommerce-media-uploader">
<div className="woocommerce-media-uploader__label">
{ label }
</div>
<MediaUploadComponent
onSelect={ onSelect }
allowedTypes={ allowedMediaTypes }
render={ ( { open } ) => (
<Button variant="secondary" onClick={ open }>
{ buttonText }
</Button>
) }
/>
<MediaUploadComponent
onSelect={ onSelect }
allowedTypes={ allowedMediaTypes }
multiple={ multipleSelect }
render={ ( { open } ) => (
<Button variant="secondary" onClick={ open }>
{ buttonText }
</Button>
) }
/>
{ hasDropZone && (
<DropZone
onFilesDrop={ ( files ) =>
uploadMedia( {
filesList: files,
onError,
onFileChange: onUpload,
maxUploadFileSize,
} )
}
/>
{ hasDropZone && (
<DropZone
onFilesDrop={ ( files ) =>
uploadMedia( {
filesList: files,
onError,
onFileChange: onUpload,
maxUploadFileSize,
} )
}
/>
) }
</div>
</div>
) }
</div>
/>
);
};

View File

@ -17,7 +17,7 @@ declare let Blob: {
new (): Blob;
};
export const MockMediaUpload = ( { onSelect, render } ) => {
const MockMediaUpload = ( { onSelect, render } ) => {
const [ isOpen, setOpen ] = useState( false );
return (
@ -28,7 +28,10 @@ export const MockMediaUpload = ( { onSelect, render } ) => {
{ isOpen && (
<Modal
title="Media Modal"
onRequestClose={ () => setOpen( false ) }
onRequestClose={ ( event ) => {
setOpen( false );
event.stopPropagation();
} }
>
<p>
Use the default built-in{ ' ' }
@ -39,12 +42,13 @@ export const MockMediaUpload = ( { onSelect, render } ) => {
return (
<button
key={ i }
onClick={ () => {
onClick={ ( event ) => {
onSelect( {
alt: 'Random',
url: `https://picsum.photos/200?i=${ i }`,
} );
setOpen( false );
event.stopPropagation();
} }
style={ {
marginRight: '16px',
@ -101,8 +105,19 @@ const readImage = ( file: Blob ) => {
};
const mockUploadMedia = async ( { filesList, onFileChange } ) => {
// The values sent by the FormFileUpload and the DropZone components are different.
// This is why we need to transform everything into an array.
const list = await Object.keys( filesList ).map(
( key ) => filesList[ key ]
);
const images = await Promise.all(
filesList.map( ( file ) => readImage( file ) )
list.map( ( file ) => {
if ( typeof file === 'object' ) {
return readImage( file );
}
return {};
} )
);
onFileChange( images );
};
@ -118,6 +133,9 @@ export const Basic: React.FC = () => {
MediaUploadComponent={ MockMediaUpload }
onSelect={ ( file ) => setImages( [ ...images, file ] ) }
onError={ () => null }
onFileUploadChange={ ( files ) =>
setImages( [ ...images, ...files ] )
}
onUpload={ ( files ) =>
setImages( [ ...images, ...files ] )
}
@ -139,6 +157,9 @@ export const DisabledDropZone: React.FC = () => {
hasDropZone={ false }
label={ 'Click the button below to upload' }
MediaUploadComponent={ MockMediaUpload }
onFileUploadChange={ ( files ) =>
setImages( [ ...images, ...files ] )
}
onSelect={ ( file ) => setImages( [ ...images, file ] ) }
onError={ () => null }
uploadMedia={ mockUploadMedia }

View File

@ -100,6 +100,12 @@
.components-base-control__label {
margin-right: math.div($spacing, 2);
}
label.components-input-control__label {
@include font-size(13, 18px);
font-weight: 400;
text-transform: none;
}
}
}

View File

@ -31,21 +31,38 @@ export const EditorWritingFlow = ( {
const isEmpty = ! blocks.length;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore This action is available in the block editor data store.
const { insertBlock, selectBlock } = useDispatch( blockEditorStore );
const { selectedBlockClientIds } = useSelect( ( select ) => {
const { insertBlock, selectBlock, __unstableSetEditorMode } =
useDispatch( blockEditorStore );
const { selectedBlockClientIds, editorMode } = useSelect( ( select ) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore This selector is available in the block editor data store.
const { getSelectedBlockClientIds } = select( blockEditorStore );
const { getSelectedBlockClientIds, __unstableGetEditorMode } =
select( blockEditorStore );
return {
editorMode: __unstableGetEditorMode(),
selectedBlockClientIds: getSelectedBlockClientIds(),
};
} );
// This is a workaround to prevent focusing the block on intialization.
// 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
const setEditorIsInitializing = ( isInitializing: boolean ) => {
if ( typeof __unstableSetEditorMode !== 'function' ) {
return;
}
__unstableSetEditorMode( isInitializing ? 'initialized' : 'edit' );
};
useEffect( () => {
if ( selectedBlockClientIds?.length || ! firstBlock ) {
return;
}
setEditorIsInitializing( true );
selectBlock( firstBlock.clientId );
}, [ firstBlock, selectedBlockClientIds ] );
@ -60,6 +77,13 @@ export const EditorWritingFlow = ( {
}
}, [ isEmpty ] );
const maybeSetEditMode = () => {
if ( editorMode === 'edit' ) {
return;
}
setEditorIsInitializing( false );
};
return (
/* Gutenberg handles the keyboard events when focusing the content editable area. */
/* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
@ -71,7 +95,12 @@ export const EditorWritingFlow = ( {
} }
>
<BlockTools>
<WritingFlow>
<WritingFlow
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore These are forwarded as props to the WritingFlow component.
onClick={ maybeSetEditMode }
onFocus={ maybeSetEditMode }
>
<ObserveTyping>
<BlockList />
</ObserveTyping>

View File

@ -1,171 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A product attributes completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'attributes',
className: 'woocommerce-search__product-result',
options( search ) {
const query = search
? {
search,
per_page: 10,
orderby: 'count',
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/products/attributes', query ),
} );
},
isDebounced: true,
getOptionIdentifier( attribute ) {
return attribute.id;
},
getOptionKeywords( attribute ) {
return [ attribute.name ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All attributes with names that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const nameOption = {
key: 'name',
label,
value: { id: query, name: query },
};
return [ nameOption ];
},
getOptionLabel( attribute, query ) {
const match = computeSuggestionMatch( attribute.name, query ) || {};
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ attribute.name }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( attribute ) {
const value = {
key: attribute.id,
label: attribute.name,
};
return value;
},
};

View File

@ -0,0 +1,92 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'attributes',
className: 'woocommerce-search__product-result',
options( search ) {
const query = search
? {
search,
per_page: 10,
orderby: 'count',
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/products/attributes', query ),
} );
},
isDebounced: true,
getOptionIdentifier( attribute ) {
return attribute.id;
},
getOptionKeywords( attribute ) {
return [ attribute.name ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All attributes with names that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const nameOption = {
key: 'name',
label,
value: { id: query, name: query },
};
return [ nameOption ];
},
getOptionLabel( attribute, query ) {
const match = computeSuggestionMatch( attribute.name, query );
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ attribute.name }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( attribute ) {
const value = {
key: attribute.id,
label: attribute.name,
};
return value;
},
};
export default completer;

View File

@ -1,171 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A product categories completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'categories',
className: 'woocommerce-search__product-result',
options( search ) {
const query = search
? {
search,
per_page: 10,
orderby: 'count',
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/products/categories', query ),
} );
},
isDebounced: true,
getOptionIdentifier( category ) {
return category.id;
},
getOptionKeywords( cat ) {
return [ cat.name ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All categories with titles that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const titleOption = {
key: 'title',
label,
value: { id: query, name: query },
};
return [ titleOption ];
},
getOptionLabel( cat, query ) {
const match = computeSuggestionMatch( cat.name, query ) || {};
// @todo Bring back ProductImage, but allow for product category image
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ cat.name }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( cat ) {
const value = {
key: cat.id,
label: cat.name,
};
return value;
},
};

View File

@ -0,0 +1,92 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'categories',
className: 'woocommerce-search__product-result',
options( search ) {
const query = search
? {
search,
per_page: 10,
orderby: 'count',
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/products/categories', query ),
} );
},
isDebounced: true,
getOptionIdentifier( category ) {
return category.id;
},
getOptionKeywords( cat ) {
return [ cat.name ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All categories with titles that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const titleOption = {
key: 'title',
label,
value: { id: query, name: query },
};
return [ titleOption ];
},
getOptionLabel( cat, query ) {
const match = computeSuggestionMatch( cat.name, query );
// @todo Bring back ProductImage, but allow for product category image
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ cat.name }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( cat ) {
const value = {
key: cat.id,
label: cat.name,
};
return value;
},
};
export default completer;

View File

@ -1,168 +0,0 @@
/**
* External dependencies
*/
import { decodeEntities } from '@wordpress/html-entities';
import { createElement, Fragment } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import Flag from '../../flag';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
// Cache countries to avoid repeated requests.
let allCountries = null;
/**
* A country completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'countries',
className: 'woocommerce-search__country-result',
isDebounced: true,
options() {
// Returned cached countries if we've already received them.
if ( allCountries ) {
return Promise.resolve( allCountries );
}
// Make the request for country data.
return apiFetch( { path: '/wc-analytics/data/countries' } ).then(
( result ) => {
// Cache the response.
allCountries = result;
return allCountries;
}
);
},
getOptionIdentifier( country ) {
return country.code;
},
getSearchExpression( query ) {
return '^' + query;
},
getOptionKeywords( country ) {
return [ country.code, decodeEntities( country.name ) ];
},
getOptionLabel( country, query ) {
const name = decodeEntities( country.name );
const match = computeSuggestionMatch( name, query ) || {};
return (
<Fragment>
<Flag
key="thumbnail"
className="woocommerce-search__result-thumbnail"
code={ country.code }
size={ 18 }
hideFromScreenReader
/>
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ name }
>
{ query ? (
<Fragment>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</Fragment>
) : (
name
) }
</span>
</Fragment>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( country ) {
const value = {
key: country.code,
label: decodeEntities( country.name ),
};
return value;
},
};

View File

@ -0,0 +1,108 @@
/**
* External dependencies
*/
import { decodeEntities } from '@wordpress/html-entities';
import { createElement, Fragment } from '@wordpress/element';
import apiFetch from '@wordpress/api-fetch';
import { Country } from '@woocommerce/data';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import Flag from '../../flag';
import { AutoCompleter } from './types';
// Cache countries to avoid repeated requests.
let allCountries: Country[] | null = null;
const isCountries = ( value: unknown ): value is Country[] => {
return (
Array.isArray( value ) &&
value.length > 0 &&
typeof value[ 0 ] === 'object' &&
typeof value[ 0 ].code === 'string' &&
typeof value[ 0 ].name === 'string'
);
};
const completer: AutoCompleter = {
name: 'countries',
className: 'woocommerce-search__country-result',
isDebounced: true,
options() {
// Returned cached countries if we've already received them.
if ( allCountries ) {
return Promise.resolve( allCountries );
}
// Make the request for country data.
return apiFetch( { path: '/wc-analytics/data/countries' } ).then(
( result ) => {
if ( isCountries( result ) ) {
// Cache the response.
allCountries = result;
return allCountries;
}
// If the response is not valid, return an empty array.
// eslint-disable-next-line no-console
console.warn( 'Invalid countries response', result );
return [];
}
);
},
getOptionIdentifier( country ) {
return country.code;
},
getSearchExpression( query ) {
return '^' + query;
},
getOptionKeywords( country ) {
return [ country.code, decodeEntities( country.name ) ];
},
getOptionLabel( country, query ) {
const name = decodeEntities( country.name );
const match = computeSuggestionMatch( name, query );
return (
<Fragment>
{ /* @ts-expect-error TODO: migrate Flag component to TS. */ }
<Flag
key="thumbnail"
className="woocommerce-search__result-thumbnail"
code={ country.code }
// @ts-expect-error TODO: migrate Flag component.
size={ 18 }
hideFromScreenReader
/>
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ name }
>
{ query ? (
<Fragment>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</Fragment>
) : (
name
) }
</span>
</Fragment>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( country ) {
const value = {
key: country.code,
label: decodeEntities( country.name ),
};
return value;
},
};
export default completer;

View File

@ -1,169 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A coupon completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'coupons',
className: 'woocommerce-search__coupon-result',
options( search ) {
const query = search
? {
search,
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/coupons', query ),
} );
},
isDebounced: true,
getOptionIdentifier( coupon ) {
return coupon.id;
},
getOptionKeywords( coupon ) {
return [ coupon.code ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All coupons with codes that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const codeOption = {
key: 'code',
label,
value: { id: query, code: query },
};
return [ codeOption ];
},
getOptionLabel( coupon, query ) {
const match = computeSuggestionMatch( coupon.code, query ) || {};
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ coupon.code }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( coupon ) {
const value = {
key: coupon.id,
label: coupon.code,
};
return value;
},
};

View File

@ -0,0 +1,90 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'coupons',
className: 'woocommerce-search__coupon-result',
options( search ) {
const query = search
? {
search,
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/coupons', query ),
} );
},
isDebounced: true,
getOptionIdentifier( coupon ) {
return coupon.id;
},
getOptionKeywords( coupon ) {
return [ coupon.code ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All coupons with codes that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const codeOption = {
key: 'code',
label,
value: { id: query, code: query },
};
return [ codeOption ];
},
getOptionLabel( coupon, query ) {
const match = computeSuggestionMatch( coupon.code, query );
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ coupon.code }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( coupon ) {
const value = {
key: coupon.id,
label: coupon.code,
};
return value;
},
};
export default completer;

View File

@ -1,169 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A customer completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'customers',
className: 'woocommerce-search__customers-result',
options( name ) {
const query = name
? {
search: name,
searchby: 'name',
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/customers', query ),
} );
},
isDebounced: true,
getOptionIdentifier( customer ) {
return customer.id;
},
getOptionKeywords( customer ) {
return [ customer.name ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All customers with names that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const nameOption = {
key: 'name',
label,
value: { id: query, name: query },
};
return [ nameOption ];
},
getOptionLabel( customer, query ) {
const match = computeSuggestionMatch( customer.name, query ) || {};
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ customer.name }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( customer ) {
return {
key: customer.id,
label: customer.name,
};
},
};

View File

@ -0,0 +1,90 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'customers',
className: 'woocommerce-search__customers-result',
options( name ) {
const query = name
? {
search: name,
searchby: 'name',
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/customers', query ),
} );
},
isDebounced: true,
getOptionIdentifier( customer ) {
return customer.id;
},
getOptionKeywords( customer ) {
return [ customer.name ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All customers with names that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const nameOption = {
key: 'name',
label,
value: { id: query, name: query },
};
return [ nameOption ];
},
getOptionLabel( customer, query ) {
const match = computeSuggestionMatch( customer.name, query );
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ customer.name }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( customer ) {
return {
key: customer.id,
label: customer.name,
};
},
};
export default completer;

View File

@ -1,138 +0,0 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A download IP address autocompleter.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'download-ips',
className: 'woocommerce-search__download-ip-result',
options( match ) {
const query = match
? {
match,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/data/download-ips', query ),
} );
},
isDebounced: true,
getOptionIdentifier( download ) {
return download.user_ip_address;
},
getOptionKeywords( download ) {
return [ download.user_ip_address ];
},
getOptionLabel( download, query ) {
const match =
computeSuggestionMatch( download.user_ip_address, query ) || {};
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ download.user_ip_address }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
);
},
getOptionCompletion( download ) {
return {
key: download.user_ip_address,
label: download.user_ip_address,
};
},
};

View File

@ -0,0 +1,58 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'download-ips',
className: 'woocommerce-search__download-ip-result',
options( match ) {
const query = match
? {
match,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/data/download-ips', query ),
} );
},
isDebounced: true,
getOptionIdentifier( download ) {
return download.user_ip_address;
},
getOptionKeywords( download ) {
return [ download.user_ip_address ];
},
getOptionLabel( download, query ) {
const match = computeSuggestionMatch( download.user_ip_address, query );
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ download.user_ip_address }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
);
},
getOptionCompletion( download ) {
return {
key: download.user_ip_address,
label: download.user_ip_address,
};
},
};
export default completer;

View File

@ -1,141 +0,0 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A customer email completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'emails',
className: 'woocommerce-search__emails-result',
options( search ) {
const query = search
? {
search,
searchby: 'email',
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/customers', query ),
} );
},
isDebounced: true,
getOptionIdentifier( customer ) {
return customer.id;
},
getOptionKeywords( customer ) {
return [ customer.email ];
},
getOptionLabel( customer, query ) {
const match = computeSuggestionMatch( customer.email, query ) || {};
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ customer.email }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( customer ) {
return {
key: customer.id,
label: customer.email,
};
},
};

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'emails',
className: 'woocommerce-search__emails-result',
options( search ) {
const query = search
? {
search,
searchby: 'email',
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/customers', query ),
} );
},
isDebounced: true,
getOptionIdentifier( customer ) {
return customer.id;
},
getOptionKeywords( customer ) {
return [ customer.email ];
},
getOptionLabel( customer, query ) {
const match = computeSuggestionMatch( customer.email, query );
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ customer.email }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( customer ) {
return {
key: customer.id,
label: customer.email,
};
},
};
export default completer;

View File

@ -14,3 +14,4 @@ export { default as taxes } from './taxes';
export { default as usernames } from './usernames';
export { default as variableProduct } from './variable-product';
export { default as variations } from './variations';
export * from './types';

View File

@ -1,138 +0,0 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A orders autocompleter.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'orders',
className: 'woocommerce-search__order-result',
options( search ) {
const query = search
? {
number: search,
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/orders', query ),
} );
},
isDebounced: true,
getOptionIdentifier( order ) {
return order.id;
},
getOptionKeywords( order ) {
return [ '#' + order.number ];
},
getOptionLabel( order, query ) {
const match = computeSuggestionMatch( '#' + order.number, query ) || {};
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ '#' + order.number }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
);
},
getOptionCompletion( order ) {
return {
key: order.id,
label: '#' + order.number,
};
},
};

View File

@ -0,0 +1,59 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'orders',
className: 'woocommerce-search__order-result',
options( search ) {
const query = search
? {
number: search,
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/orders', query ),
} );
},
isDebounced: true,
getOptionIdentifier( order ) {
return order.id;
},
getOptionKeywords( order ) {
return [ '#' + order.number ];
},
getOptionLabel( order, query ) {
const match = computeSuggestionMatch( '#' + order.number, query );
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ '#' + order.number }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
);
},
getOptionCompletion( order ) {
return {
key: order.id,
label: '#' + order.number,
};
},
};
export default completer;

View File

@ -1,180 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement, Fragment } from '@wordpress/element';
import interpolateComponents from '@automattic/interpolate-components';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import ProductImage from '../../product-image';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A products completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'products',
className: 'woocommerce-search__product-result',
options( search ) {
const query = search
? {
search,
per_page: 10,
orderby: 'popularity',
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/products', query ),
} );
},
isDebounced: true,
getOptionIdentifier( product ) {
return product.id;
},
getOptionKeywords( product ) {
return [ product.name, product.sku ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All products with titles that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const titleOption = {
key: 'title',
label,
value: { id: query, name: query },
};
return [ titleOption ];
},
getOptionLabel( product, query ) {
const match = computeSuggestionMatch( product.name, query ) || {};
return (
<Fragment>
<ProductImage
key="thumbnail"
className="woocommerce-search__result-thumbnail"
product={ product }
width={ 18 }
height={ 18 }
alt=""
/>
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ product.name }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
</Fragment>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( product ) {
const value = {
key: product.id,
label: product.name,
};
return value;
},
};

View File

@ -0,0 +1,103 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement, Fragment } from '@wordpress/element';
import interpolateComponents from '@automattic/interpolate-components';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import ProductImage from '../../product-image';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'products',
className: 'woocommerce-search__product-result',
options( search ) {
const query = search
? {
search,
per_page: 10,
orderby: 'popularity',
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/products', query ),
} );
},
isDebounced: true,
getOptionIdentifier( product ) {
return product.id;
},
getOptionKeywords( product ) {
return [ product.name, product.sku ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All products with titles that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const titleOption = {
key: 'title',
label,
value: { id: query, name: query },
};
return [ titleOption ];
},
getOptionLabel( product, query ) {
const match = computeSuggestionMatch( product.name, query );
return (
<Fragment>
{ /* @ts-expect-error TODO: migrate ProductImage component to TS. */ }
<ProductImage
key="thumbnail"
className="woocommerce-search__result-thumbnail"
product={ product }
width={ 18 }
height={ 18 }
alt=""
/>
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ product.name }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
</Fragment>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( product ) {
const value = {
key: product.id,
label: product.name,
};
return value;
},
};
export default completer;

View File

@ -1,169 +0,0 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch, getTaxCode } from './utils';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A tax completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'taxes',
className: 'woocommerce-search__tax-result',
options( search ) {
const query = search
? {
code: search,
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/taxes', query ),
} );
},
isDebounced: true,
getOptionIdentifier( tax ) {
return tax.id;
},
getOptionKeywords( tax ) {
return [ tax.id, getTaxCode( tax ) ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All taxes with codes that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const codeOption = {
key: 'code',
label,
value: { id: query, name: query },
};
return [ codeOption ];
},
getOptionLabel( tax, query ) {
const match = computeSuggestionMatch( getTaxCode( tax ), query ) || {};
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ tax.code }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( tax ) {
const value = {
key: tax.id,
label: getTaxCode( tax ),
};
return value;
},
};

View File

@ -0,0 +1,90 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import interpolateComponents from '@automattic/interpolate-components';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch, getTaxCode } from './utils';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'taxes',
className: 'woocommerce-search__tax-result',
options( search ) {
const query = search
? {
code: search,
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/taxes', query ),
} );
},
isDebounced: true,
getOptionIdentifier( tax ) {
return tax.id;
},
getOptionKeywords( tax ) {
return [ tax.id, getTaxCode( tax ) ];
},
getFreeTextOptions( query ) {
const label = (
<span key="name" className="woocommerce-search__result-name">
{ interpolateComponents( {
mixedString: __(
'All taxes with codes that include {{query /}}',
'woocommerce'
),
components: {
query: (
<strong className="components-form-token-field__suggestion-match">
{ query }
</strong>
),
},
} ) }
</span>
);
const codeOption = {
key: 'code',
label,
value: { id: query, name: query },
};
return [ codeOption ];
},
getOptionLabel( tax, query ) {
const match = computeSuggestionMatch( getTaxCode( tax ), query );
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ tax.code }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( tax ) {
const value = {
key: tax.id,
label: getTaxCode( tax ),
};
return value;
},
};
export default completer;

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { ReactNode, ReactElement } from 'react';
// Options may be of any type or shape.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CompleterOption = any;
export type FnGetOptions = (
query?: string
) => CompleterOption[] | Promise< CompleterOption[] >;
export type OptionCompletionValue = string | ReactElement | object;
export type OptionCompletion = {
/**
* The action declares what should be done with the value.
* There are currently two supported actions:
* 1. "insert-at-caret" - Insert the value into the text (the default completion action).
* 2. "replace" - Replace the current block with the block specified in the value property.
*/
action: 'insert-at-caret' | 'replace';
// The completion value.
value: OptionCompletionValue;
};
export type FnGetOptionCompletion = (
// The value of the completer option.
value: CompleterOption
) => OptionCompletion | OptionCompletionValue;
export type AutoCompleter = {
/* The name of the completer. Useful for identifying a specific completer to be overridden via extensibility hooks. */
name: string;
/* The raw options for completion. May be an array, a function that returns an array, or a function that returns a promise for an array. */
options: CompleterOption[] | FnGetOptions;
/* A function that returns a key to be used for the option. */
getOptionIdentifier: ( option: CompleterOption ) => string | number;
/* A function that returns the label for a given option. A label may be a string or a mixed array of strings, elements, and components. */
getOptionLabel: ( option: CompleterOption, query: string ) => ReactNode;
/* A function that takes an option and responds with how the option should be completed. By default, the result is a value to be inserted in the text. However, a completer may explicitly declare how a completion should be treated by returning an object with action and value properties. */
getOptionCompletion: FnGetOptionCompletion;
/* A function that returns the keywords for the specified option. */
getOptionKeywords: ( option: CompleterOption ) => string[];
/* A function that returns whether or not the specified option should be disabled. Disabled options cannot be selected. */
isOptionDisabled?: ( option: CompleterOption ) => boolean;
/* A function that takes a Range before and a Range after the autocomplete trigger and query text and returns a boolean indicating whether the completer should be considered for that context. */
allowContext?: ( before: string, after: string ) => boolean;
/* A function that returns options for the specified query. This is useful for filtering options based on the query. */
getFreeTextOptions?: ( query: string ) => [
{
key: string;
label: JSX.Element;
value: unknown;
}
];
/* A function to add regex expression to the filter the results, passed the search query. */
getSearchExpression?: ( query: string ) => string;
/* A class name to apply to the autocompletion popup menu. */
className?: string;
/* Whether to apply debouncing for the autocompleter. Set to true to enable debouncing. */
isDebounced?: boolean;
/* The input type for the search box control. */
inputType?: 'text' | 'search' | 'number' | 'email' | 'tel' | 'url';
};

View File

@ -1,141 +0,0 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A customer username completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'usernames',
className: 'woocommerce-search__usernames-result',
options( search ) {
const query = search
? {
search,
searchby: 'username',
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/customers', query ),
} );
},
isDebounced: true,
getOptionIdentifier( customer ) {
return customer.id;
},
getOptionKeywords( customer ) {
return [ customer.username ];
},
getOptionLabel( customer, query ) {
const match = computeSuggestionMatch( customer.username, query ) || {};
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ customer.username }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( customer ) {
return {
key: customer.id,
label: customer.username,
};
},
};

View File

@ -0,0 +1,62 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
name: 'usernames',
className: 'woocommerce-search__usernames-result',
options( search ) {
const query = search
? {
search,
searchby: 'username',
per_page: 10,
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/customers', query ),
} );
},
isDebounced: true,
getOptionIdentifier( customer ) {
return customer.id;
},
getOptionKeywords( customer ) {
return [ customer.username ];
},
getOptionLabel( customer, query ) {
const match = computeSuggestionMatch( customer.username, query );
return (
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ customer.username }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( customer ) {
return {
key: customer.id,
label: customer.username,
};
},
};
export default completer;

View File

@ -12,7 +12,7 @@ import { decodeEntities } from '@wordpress/html-entities';
* @param {string} query The search term to match in the string.
* @return {Object} A list in three parts: before, match, and after.
*/
export function computeSuggestionMatch( suggestion, query ) {
export function computeSuggestionMatch( suggestion: string, query: string ) {
if ( ! query ) {
return null;
}
@ -33,7 +33,14 @@ export function computeSuggestionMatch( suggestion, query ) {
};
}
export function getTaxCode( tax ) {
type Tax = Partial< {
country: string;
state: string;
name: string;
priority: number;
} >;
export function getTaxCode( tax: Tax ) {
return [
tax.country,
tax.state,
@ -41,6 +48,6 @@ export function getTaxCode( tax ) {
tax.priority,
]
.filter( Boolean )
.map( ( item ) => item.toString().toUpperCase().trim() )
.map( ( item ) => item?.toString().toUpperCase().trim() )
.join( '-' );
}

View File

@ -1,109 +0,0 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import productsAutocompleter from './product';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* A variable products completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
...productsAutocompleter,
name: 'products',
options( search ) {
const query = search
? {
search,
per_page: 10,
orderby: 'popularity',
type: 'variable',
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/products', query ),
} );
},
};

View File

@ -0,0 +1,31 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import productsAutocompleter from './product';
import { AutoCompleter } from './types';
const completer: AutoCompleter = {
...productsAutocompleter,
name: 'products',
options( search ) {
const query = search
? {
search,
per_page: 10,
orderby: 'popularity',
type: 'variable',
}
: {};
return apiFetch( {
path: addQueryArgs( '/wc-analytics/products', query ),
} );
},
};
export default completer;

View File

@ -1,204 +0,0 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement, Fragment } from '@wordpress/element';
import { getQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import ProductImage from '../../product-image';
/**
* A raw completer option.
*
* @typedef {*} CompleterOption
*/
/**
* @callback FnGetOptions
*
* @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them.
*/
/**
* @callback FnGetOptionKeywords
* @param {CompleterOption} option a completer option.
*
* @return {string[]} list of key words to search.
*/
/**
* @callback FnIsOptionDisabled
* @param {CompleterOption} option a completer option.
*
* @return {string[]} whether or not the given option is disabled.
*/
/**
* @callback FnGetOptionLabel
* @param {CompleterOption} option a completer option.
*
* @return {(string|Array.<(string|Node)>)} list of react components to render.
*/
/**
* @callback FnAllowContext
* @param {string} before the string before the auto complete trigger and query.
* @param {string} after the string after the autocomplete trigger and query.
*
* @return {boolean} true if the completer can handle.
*/
/**
* @typedef {Object} OptionCompletion
* @property {'insert-at-caret'|'replace'} action the intended placement of the completion.
* @property {OptionCompletionValue} value the completion value.
*/
/**
* A completion value.
*
* @typedef {(string|WPElement|Object)} OptionCompletionValue
*/
/**
* @callback FnGetOptionCompletion
* @param {CompleterOption} value the value of the completer option.
* @param {string} query the text value of the autocomplete query.
*
* @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an
* OptionCompletionValue is returned, the
* completion action defaults to `insert-at-caret`.
*/
/**
* @typedef {Object} WPCompleter
* @property {string} name a way to identify a completer, useful for selective overriding.
* @property {?string} className A class to apply to the popup menu.
* @property {string} triggerPrefix the prefix that will display the menu.
* @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them.
* @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option.
* @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled.
* @property {FnGetOptionLabel} getOptionLabel get the label for a given option.
* @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates.
* @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option.
*/
/**
* Create a variation name by concatenating each of the variation's
* attribute option strings.
*
* @param {Object} variation - variation returned by the api
* @param {Array} variation.attributes - attribute objects, with option property.
* @param {string} variation.name - name of variation.
* @return {string} - formatted variation name
*/
function getVariationName( { attributes, name } ) {
const separator =
window.wcSettings.variationTitleAttributesSeparator || ' - ';
if ( name.indexOf( separator ) > -1 ) {
return name;
}
const attributeList = attributes
.map( ( { option } ) => option )
.join( ', ' );
return attributeList ? name + separator + attributeList : name;
}
/**
* A variations completer.
* See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface
*
* @type {WPCompleter}
*/
export default {
name: 'variations',
className: 'woocommerce-search__product-result',
options( search ) {
const query = search
? {
search,
per_page: 30,
_fields: [
'attributes',
'description',
'id',
'name',
'sku',
],
}
: {};
const product = getQuery().products;
// Product was specified, search only its variations.
if ( product ) {
if ( product.includes( ',' ) ) {
// eslint-disable-next-line no-console
console.warn(
'Invalid product id supplied to Variations autocompleter'
);
}
return apiFetch( {
path: addQueryArgs(
`/wc-analytics/products/${ product }/variations`,
query
),
} );
}
// Product was not specified, search all variations.
return apiFetch( {
path: addQueryArgs( '/wc-analytics/variations', query ),
} );
},
isDebounced: true,
getOptionIdentifier( variation ) {
return variation.id;
},
getOptionKeywords( variation ) {
return [ getVariationName( variation ), variation.sku ];
},
getOptionLabel( variation, query ) {
const match =
computeSuggestionMatch( getVariationName( variation ), query ) ||
{};
return (
<Fragment>
<ProductImage
key="thumbnail"
className="woocommerce-search__result-thumbnail"
product={ variation }
width={ 18 }
height={ 18 }
alt=""
/>
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ variation.description }
>
{ match.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match.suggestionMatch }
</strong>
{ match.suggestionAfterMatch }
</span>
</Fragment>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( variation ) {
return {
key: variation.id,
label: getVariationName( variation ),
};
},
};

View File

@ -0,0 +1,133 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
import apiFetch from '@wordpress/api-fetch';
import { createElement, Fragment } from '@wordpress/element';
import { getQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { computeSuggestionMatch } from './utils';
import ProductImage from '../../product-image';
import { AutoCompleter } from './types';
/**
* Create a variation name by concatenating each of the variation's
* attribute option strings.
*
* @param {Object} variation - variation returned by the api
* @param {Array} variation.attributes - attribute objects, with option property.
* @param {string} variation.name - name of variation.
* @return {string} - formatted variation name
*/
function getVariationName( {
attributes,
name,
}: {
attributes: Array< { option: string } >;
name: string;
} ) {
const separator =
window.wcSettings.variationTitleAttributesSeparator || ' - ';
if ( name.indexOf( separator ) > -1 ) {
return name;
}
const attributeList = attributes
.map( ( { option } ) => option )
.join( ', ' );
return attributeList ? name + separator + attributeList : name;
}
const completer: AutoCompleter = {
name: 'variations',
className: 'woocommerce-search__product-result',
options( search ) {
const query = search
? {
search,
per_page: 30,
_fields: [
'attributes',
'description',
'id',
'name',
'sku',
],
}
: {};
const product = ( getQuery() as Record< string, string > ).products;
// Product was specified, search only its variations.
if ( product ) {
if ( product.includes( ',' ) ) {
// eslint-disable-next-line no-console
console.warn(
'Invalid product id supplied to Variations autocompleter'
);
}
return apiFetch( {
path: addQueryArgs(
`/wc-analytics/products/${ product }/variations`,
query
),
} );
}
// Product was not specified, search all variations.
return apiFetch( {
path: addQueryArgs( '/wc-analytics/variations', query ),
} );
},
isDebounced: true,
getOptionIdentifier( variation ) {
return variation.id;
},
getOptionKeywords( variation ) {
return [ getVariationName( variation ), variation.sku ];
},
getOptionLabel( variation, query ) {
const match = computeSuggestionMatch(
getVariationName( variation ),
query
);
return (
<Fragment>
{ /* @ts-expect-error TODO: migrate ProductImage component to TS. */ }
<ProductImage
key="thumbnail"
className="woocommerce-search__result-thumbnail"
product={ variation }
width={ 18 }
height={ 18 }
alt=""
/>
<span
key="name"
className="woocommerce-search__result-name"
aria-label={ variation.description }
>
{ match?.suggestionBeforeMatch }
<strong className="components-form-token-field__suggestion-match">
{ match?.suggestionMatch }
</strong>
{ match?.suggestionAfterMatch }
</span>
</Fragment>
);
},
// This is slightly different than gutenberg/Autocomplete, we don't support different methods
// of replace/insertion, so we can just return the value.
getOptionCompletion( variation ) {
return {
key: variation.id,
label: getVariationName( variation ),
};
},
};
export default completer;

View File

@ -24,14 +24,152 @@ import {
usernames,
variableProduct,
variations,
AutoCompleter,
OptionCompletionValue,
} from './autocompleters';
type Option = {
key: string | number;
label: React.ReactNode;
keywords: string[];
value: unknown;
};
type SearchType =
| 'attributes'
| 'categories'
| 'countries'
| 'coupons'
| 'customers'
| 'downloadIps'
| 'emails'
| 'orders'
| 'products'
| 'taxes'
| 'usernames'
| 'variableProducts'
| 'variations'
| 'custom';
type Props = {
type: SearchType;
allowFreeTextSearch?: boolean;
className?: string;
onChange?: ( value: Option | OptionCompletionValue[] ) => void;
autocompleter?: AutoCompleter;
placeholder?: string;
selected?:
| string
| Array< {
key: number | string;
label?: string;
} >;
inlineTags?: boolean;
showClearButton?: boolean;
staticResults?: boolean;
disabled?: boolean;
multiple?: boolean;
};
type State = {
options: unknown[];
};
/**
* A search box which autocompletes results while typing, allowing for the user to select an existing object
* (product, order, customer, etc). Currently only products are supported.
*/
export class Search extends Component {
constructor( props ) {
export class Search extends Component< Props, State > {
static propTypes = {
/**
* Render additional options in the autocompleter to allow free text entering depending on the type.
*/
allowFreeTextSearch: PropTypes.bool,
/**
* Class name applied to parent div.
*/
className: PropTypes.string,
/**
* Function called when selected results change, passed result list.
*/
onChange: PropTypes.func,
/**
* The object type to be used in searching.
*/
type: PropTypes.oneOf( [
'attributes',
'categories',
'countries',
'coupons',
'customers',
'downloadIps',
'emails',
'orders',
'products',
'taxes',
'usernames',
'variableProducts',
'variations',
'custom',
] ).isRequired,
/**
* The custom autocompleter to be used in searching when type is 'custom'
*/
autocompleter: PropTypes.object,
/**
* A placeholder for the search input.
*/
placeholder: PropTypes.string,
/**
* 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,
} )
),
] ),
/**
* Render tags inside input, otherwise render below input.
*/
inlineTags: PropTypes.bool,
/**
* Render a 'Clear' button next to the input box to remove its contents.
*/
showClearButton: PropTypes.bool,
/**
* Render results list positioned statically instead of absolutely.
*/
staticResults: PropTypes.bool,
/**
* Whether the control is disabled or not.
*/
disabled: PropTypes.bool,
/**
* Allow multiple option selections.
*/
multiple: PropTypes.bool,
};
static defaultProps = {
allowFreeTextSearch: false,
onChange: noop,
selected: [],
inlineTags: false,
showClearButton: false,
staticResults: false,
disabled: false,
multiple: true,
};
constructor( props: Props ) {
super( props );
this.state = {
options: [],
@ -80,13 +218,15 @@ export class Search extends Component {
}
return this.props.autocompleter;
default:
return {};
throw new Error(
`No autocompleter found for type: ${ this.props.type }`
);
}
}
getFormattedOptions( options, query ) {
getFormattedOptions( options: unknown[], query: string ) {
const autocompleter = this.getAutocompleter();
const formattedOptions = [];
const formattedOptions: Option[] = [];
options.forEach( ( option ) => {
const formattedOption = {
@ -103,7 +243,7 @@ export class Search extends Component {
return formattedOptions;
}
fetchOptions( previousOptions, query ) {
fetchOptions( previousOptions: unknown[], query: string ) {
if ( ! query ) {
return [];
}
@ -123,11 +263,14 @@ export class Search extends Component {
} );
}
updateSelected( selected ) {
const { onChange } = this.props;
updateSelected( selected: Option[] ) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { onChange = ( _option: unknown[] ) => {} } = this.props;
const autocompleter = this.getAutocompleter();
const formattedSelections = selected.map( ( option ) => {
const formattedSelections = selected.map<
Option | OptionCompletionValue
>( ( option ) => {
return option.value
? autocompleter.getOptionCompletion( option.value )
: option;
@ -136,17 +279,21 @@ export class Search extends Component {
onChange( formattedSelections );
}
appendFreeTextSearch( options, query ) {
appendFreeTextSearch( options: unknown[], query: string ) {
const { allowFreeTextSearch } = this.props;
if ( ! query || ! query.length ) {
return [];
}
if ( ! allowFreeTextSearch ) {
const autocompleter = this.getAutocompleter();
if (
! allowFreeTextSearch ||
typeof autocompleter.getFreeTextOptions !== 'function'
) {
return options;
}
const autocompleter = this.getAutocompleter();
return [ ...autocompleter.getFreeTextOptions( query ), ...options ];
}
@ -195,90 +342,4 @@ export class Search extends Component {
}
}
Search.propTypes = {
/**
* Render additional options in the autocompleter to allow free text entering depending on the type.
*/
allowFreeTextSearch: PropTypes.bool,
/**
* Class name applied to parent div.
*/
className: PropTypes.string,
/**
* Function called when selected results change, passed result list.
*/
onChange: PropTypes.func,
/**
* The object type to be used in searching.
*/
type: PropTypes.oneOf( [
'attributes',
'categories',
'countries',
'coupons',
'customers',
'downloadIps',
'emails',
'orders',
'products',
'taxes',
'usernames',
'variableProducts',
'variations',
'custom',
] ).isRequired,
/**
* The custom autocompleter to be used in searching when type is 'custom'
*/
autocompleter: PropTypes.object,
/**
* A placeholder for the search input.
*/
placeholder: PropTypes.string,
/**
* 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,
} )
),
] ),
/**
* Render tags inside input, otherwise render below input.
*/
inlineTags: PropTypes.bool,
/**
* Render a 'Clear' button next to the input box to remove its contents.
*/
showClearButton: PropTypes.bool,
/**
* Render results list positioned statically instead of absolutely.
*/
staticResults: PropTypes.bool,
/**
* Whether the control is disabled or not.
*/
disabled: PropTypes.bool,
};
Search.defaultProps = {
allowFreeTextSearch: false,
onChange: noop,
selected: [],
inlineTags: false,
showClearButton: false,
staticResults: false,
disabled: false,
multiple: true,
};
export default Search;

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