Merge branch 'trunk' into feature/34548-multichannel-marketing-backend
# Conflicts: # plugins/woocommerce/includes/wc-update-functions.php
This commit is contained in:
commit
a8f3d7c2bf
|
@ -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 }}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
|
@ -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 }}
|
||||
|
|
|
@ -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 }` );
|
||||
}
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 }}"
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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];
|
||||
};
|
||||
|
|
@ -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 },
|
||||
|
|
|
@ -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 );
|
||||
|
|
|
@ -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:
|
||||
|
|
19
.syncpackrc
19
.syncpackrc
|
@ -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": [
|
||||
"**"
|
||||
]
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
/.github/ @woocommerce/atlas
|
|
@ -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**
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
Significance: patch
|
||||
Type: dev
|
||||
Comment: PHPCS violation fixes
|
||||
Comment: Dev dependency bump
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
Significance: patch
|
||||
Type: dev
|
||||
Comment: Dev dependency bump
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: update
|
||||
|
||||
Move classname down in SelectControl Menu so it is on the actual Menu element.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: add
|
||||
|
||||
Add async filtering support to the `__experimentalSelectControl` component
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: add
|
||||
|
||||
Add experimental open menu when user focus the select control input element
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: dev
|
||||
|
||||
Allow the user to select multiple images in the Media Library
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Fix pagination label text from uppercase to normal and font styles
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Add support for custom suffix prop on SelectControl.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: dev
|
||||
|
||||
Move file picker by clicking card into the MediaUploader component
|
|
@ -0,0 +1,5 @@
|
|||
Significance: patch
|
||||
Type: dev
|
||||
Comment: Dev dependency bump
|
||||
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: dev
|
||||
|
||||
Migrate search component to TS
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: enhancement
|
||||
|
||||
Align the field height across the whole form
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: enhancement
|
||||
|
||||
Fade the value selection field in the Attributes modal when no attribute is added
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: fix
|
||||
|
||||
Set editor mode on initialization to prevent initial text editor focus
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Include react-dates styles (no longer in WP 6.1+).
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Close DateTimePickerControl's dropdown when blurring from input.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Fixed DatePicker to work in WordPress 6.1 when currentDate is set to a moment instance.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: tweak
|
||||
|
||||
Updated image gallery toolbar position and tooltips.
|
|
@ -0,0 +1,4 @@
|
|||
Significance: patch
|
||||
Type: fix
|
||||
|
||||
Set initial values prop from reset form function as optional
|
|
@ -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",
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
/*rtl:begin:ignore*/
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
/*rtl:end:ignore*/
|
|
@ -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;
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -1 +1,2 @@
|
|||
export * from './select-control';
|
||||
export { default as useAsyncFilter } from './hooks/use-async-filter';
|
||||
|
|
|
@ -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 */
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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( {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
} );
|
||||
} );
|
|
@ -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 );
|
||||
} );
|
||||
} );
|
|
@ -80,6 +80,7 @@
|
|||
width: 320px;
|
||||
border: 1px solid $gray-400;
|
||||
background-color: $studio-white;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.woocommerce-calendar__input-error .components-popover__content {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 |
|
||||
|
|
|
@ -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>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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';
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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;
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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';
|
||||
};
|
|
@ -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,
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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( '-' );
|
||||
}
|
|
@ -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 ),
|
||||
} );
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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 ),
|
||||
};
|
||||
},
|
||||
};
|
|
@ -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;
|
|
@ -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
Loading…
Reference in New Issue