diff --git a/.github/actions/setup-woocommerce-monorepo/action.yml b/.github/actions/setup-woocommerce-monorepo/action.yml index b31441bce31..037794d8631 100644 --- a/.github/actions/setup-woocommerce-monorepo/action.yml +++ b/.github/actions/setup-woocommerce-monorepo/action.yml @@ -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 }} diff --git a/.github/workflows/pr-build-and-e2e-tests.yml b/.github/workflows/pr-build-and-e2e-tests.yml index a5f7a7afe31..b4dbc95df24 100644 --- a/.github/workflows/pr-build-and-e2e-tests.yml +++ b/.github/workflows/pr-build-and-e2e-tests.yml @@ -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 diff --git a/.github/workflows/pr-build-live-branch.yml b/.github/workflows/pr-build-live-branch.yml new file mode 100644 index 00000000000..00706c67d74 --- /dev/null +++ b/.github/workflows/pr-build-live-branch.yml @@ -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" diff --git a/.github/workflows/pr-code-sniff.yml b/.github/workflows/pr-code-sniff.yml index 2eee0a02a3a..f18a5aae4e6 100644 --- a/.github/workflows/pr-code-sniff.yml +++ b/.github/workflows/pr-code-sniff.yml @@ -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 }} diff --git a/.github/workflows/pr-highlight-changes.yml b/.github/workflows/pr-highlight-changes.yml index c6a0186279e..2d6d86e6fce 100644 --- a/.github/workflows/pr-highlight-changes.yml +++ b/.github/workflows/pr-highlight-changes.yml @@ -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 }` ); + } diff --git a/.github/workflows/pr-lint-monorepo.yml b/.github/workflows/pr-lint-monorepo.yml index 9615dabfe65..afc92bc4eed 100644 --- a/.github/workflows/pr-lint-monorepo.yml +++ b/.github/workflows/pr-lint-monorepo.yml @@ -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: diff --git a/.github/workflows/prepare-package-release.yml b/.github/workflows/prepare-package-release.yml index 9ba5e668979..6a524e58613 100644 --- a/.github/workflows/prepare-package-release.yml +++ b/.github/workflows/prepare-package-release.yml @@ -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 diff --git a/.github/workflows/release-changelog.yml b/.github/workflows/release-changelog.yml index bd3514b0947..85f725a8a46 100644 --- a/.github/workflows/release-changelog.yml +++ b/.github/workflows/release-changelog.yml @@ -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; diff --git a/.github/workflows/release-code-freeze.yml b/.github/workflows/release-code-freeze.yml index 4173434e13f..2ad6197d74f 100644 --- a/.github/workflows/release-code-freeze.yml +++ b/.github/workflows/release-code-freeze.yml @@ -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: | - 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 }}" + } + }) diff --git a/.github/workflows/scripts/get-plugin-version.js b/.github/workflows/scripts/get-plugin-version.js new file mode 100644 index 00000000000..4b2f0b9c063 --- /dev/null +++ b/.github/workflows/scripts/get-plugin-version.js @@ -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]; +}; + diff --git a/.github/workflows/scripts/prepare-test-summary-daily.js b/.github/workflows/scripts/prepare-test-summary-daily.js index 784e42bf396..7c9ae2c90b1 100644 --- a/.github/workflows/scripts/prepare-test-summary-daily.js +++ b/.github/workflows/scripts/prepare-test-summary-daily.js @@ -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 }, diff --git a/.github/workflows/scripts/release-code-freeze.php b/.github/workflows/scripts/release-code-freeze.php index cc0ebe197c7..210e4f5767b 100644 --- a/.github/workflows/scripts/release-code-freeze.php +++ b/.github/workflows/scripts/release-code-freeze.php @@ -1,4 +1,5 @@ ` -- `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. diff --git a/packages/js/extend-cart-checkout-block/changelog/fix-changelogger-phpcs b/packages/js/admin-e2e-tests/changelog/dev-adjust-sync similarity index 50% rename from packages/js/extend-cart-checkout-block/changelog/fix-changelogger-phpcs rename to packages/js/admin-e2e-tests/changelog/dev-adjust-sync index 10fdefc7d22..f11d1e352f4 100644 --- a/packages/js/extend-cart-checkout-block/changelog/fix-changelogger-phpcs +++ b/packages/js/admin-e2e-tests/changelog/dev-adjust-sync @@ -1,5 +1,5 @@ Significance: patch Type: dev -Comment: PHPCS violation fixes +Comment: Dev dependency bump diff --git a/packages/js/admin-e2e-tests/package.json b/packages/js/admin-e2e-tests/package.json index 1db0d4e9689..93926703248 100644 --- a/packages/js/admin-e2e-tests/package.json +++ b/packages/js/admin-e2e-tests/package.json @@ -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", diff --git a/packages/js/api/changelog/dev-adjust-sync b/packages/js/api/changelog/dev-adjust-sync new file mode 100644 index 00000000000..f11d1e352f4 --- /dev/null +++ b/packages/js/api/changelog/dev-adjust-sync @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Dev dependency bump + + diff --git a/packages/js/api/package.json b/packages/js/api/package.json index 588449eeb8d..b88f65e0018 100644 --- a/packages/js/api/package.json +++ b/packages/js/api/package.json @@ -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", diff --git a/packages/js/components/changelog/add-34_create_new_category_field_modal b/packages/js/components/changelog/add-34_create_new_category_field_modal new file mode 100644 index 00000000000..4bfc4ae8b9b --- /dev/null +++ b/packages/js/components/changelog/add-34_create_new_category_field_modal @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Move classname down in SelectControl Menu so it is on the actual Menu element. diff --git a/packages/js/components/changelog/add-35076 b/packages/js/components/changelog/add-35076 new file mode 100644 index 00000000000..acbe196ea01 --- /dev/null +++ b/packages/js/components/changelog/add-35076 @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add async filtering support to the `__experimentalSelectControl` component diff --git a/packages/js/components/changelog/add-35173-category-field-improvements b/packages/js/components/changelog/add-35173-category-field-improvements new file mode 100644 index 00000000000..5eb682fd22d --- /dev/null +++ b/packages/js/components/changelog/add-35173-category-field-improvements @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add experimental open menu when user focus the select control input element diff --git a/packages/js/components/changelog/add-35181_allow_select_multiple_images b/packages/js/components/changelog/add-35181_allow_select_multiple_images new file mode 100644 index 00000000000..247d164a7f7 --- /dev/null +++ b/packages/js/components/changelog/add-35181_allow_select_multiple_images @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Allow the user to select multiple images in the Media Library diff --git a/packages/js/components/changelog/add-35788 b/packages/js/components/changelog/add-35788 new file mode 100644 index 00000000000..7d0c1f80dbc --- /dev/null +++ b/packages/js/components/changelog/add-35788 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix pagination label text from uppercase to normal and font styles diff --git a/packages/js/components/changelog/add-select-control-suffix b/packages/js/components/changelog/add-select-control-suffix new file mode 100644 index 00000000000..860761e71c0 --- /dev/null +++ b/packages/js/components/changelog/add-select-control-suffix @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add support for custom suffix prop on SelectControl. diff --git a/packages/js/components/changelog/dev-35714_move_file_picker_to_media_uploader b/packages/js/components/changelog/dev-35714_move_file_picker_to_media_uploader new file mode 100644 index 00000000000..62cf00b1a9d --- /dev/null +++ b/packages/js/components/changelog/dev-35714_move_file_picker_to_media_uploader @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Move file picker by clicking card into the MediaUploader component diff --git a/packages/js/components/changelog/dev-adjust-sync b/packages/js/components/changelog/dev-adjust-sync new file mode 100644 index 00000000000..f11d1e352f4 --- /dev/null +++ b/packages/js/components/changelog/dev-adjust-sync @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Dev dependency bump + + diff --git a/packages/js/components/changelog/dev-migrate-search-component-to-ts b/packages/js/components/changelog/dev-migrate-search-component-to-ts new file mode 100644 index 00000000000..ded2b14cc92 --- /dev/null +++ b/packages/js/components/changelog/dev-migrate-search-component-to-ts @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Migrate search component to TS diff --git a/packages/js/components/changelog/enhancement-35567 b/packages/js/components/changelog/enhancement-35567 new file mode 100644 index 00000000000..0d4f5a995a9 --- /dev/null +++ b/packages/js/components/changelog/enhancement-35567 @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Align the field height across the whole form diff --git a/packages/js/components/changelog/enhancement-35568 b/packages/js/components/changelog/enhancement-35568 new file mode 100644 index 00000000000..5c6dabeaa7e --- /dev/null +++ b/packages/js/components/changelog/enhancement-35568 @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Fade the value selection field in the Attributes modal when no attribute is added diff --git a/packages/js/components/changelog/fix-35697 b/packages/js/components/changelog/fix-35697 new file mode 100644 index 00000000000..8e9f36504c7 --- /dev/null +++ b/packages/js/components/changelog/fix-35697 @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Set editor mode on initialization to prevent initial text editor focus diff --git a/packages/js/components/changelog/fix-analytics-daterange-custom-wp61 b/packages/js/components/changelog/fix-analytics-daterange-custom-wp61 new file mode 100644 index 00000000000..f965ebdce29 --- /dev/null +++ b/packages/js/components/changelog/fix-analytics-daterange-custom-wp61 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Include react-dates styles (no longer in WP 6.1+). diff --git a/packages/js/components/changelog/fix-date-time-picker-blur-close b/packages/js/components/changelog/fix-date-time-picker-blur-close new file mode 100644 index 00000000000..28fa1e5dcd4 --- /dev/null +++ b/packages/js/components/changelog/fix-date-time-picker-blur-close @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Close DateTimePickerControl's dropdown when blurring from input. diff --git a/packages/js/components/changelog/fix-datepicker-moment b/packages/js/components/changelog/fix-datepicker-moment new file mode 100644 index 00000000000..8d2aafbd252 --- /dev/null +++ b/packages/js/components/changelog/fix-datepicker-moment @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fixed DatePicker to work in WordPress 6.1 when currentDate is set to a moment instance. diff --git a/packages/js/components/changelog/fix-product-images-toolbar-positioning b/packages/js/components/changelog/fix-product-images-toolbar-positioning new file mode 100644 index 00000000000..fc6df228ba7 --- /dev/null +++ b/packages/js/components/changelog/fix-product-images-toolbar-positioning @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Updated image gallery toolbar position and tooltips. diff --git a/packages/js/components/changelog/fix-unsaved-prompt b/packages/js/components/changelog/fix-unsaved-prompt new file mode 100644 index 00000000000..80d311cee86 --- /dev/null +++ b/packages/js/components/changelog/fix-unsaved-prompt @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Set initial values prop from reset form function as optional diff --git a/packages/js/components/package.json b/packages/js/components/package.json index 2a07a218727..221e7c2de54 100644 --- a/packages/js/components/package.json +++ b/packages/js/components/package.json @@ -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", diff --git a/packages/js/components/src/calendar/begin-rtl-ignore.css b/packages/js/components/src/calendar/begin-rtl-ignore.css new file mode 100644 index 00000000000..d5b20f59c9f --- /dev/null +++ b/packages/js/components/src/calendar/begin-rtl-ignore.css @@ -0,0 +1 @@ +/*rtl:begin:ignore*/ diff --git a/packages/js/components/src/calendar/date-picker.js b/packages/js/components/src/calendar/date-picker.js index 7fe681e7fa1..8fa3fee63f8 100644 --- a/packages/js/components/src/calendar/date-picker.js +++ b/packages/js/components/src/calendar/date-picker.js @@ -116,7 +116,11 @@ class DatePicker extends Component {
= ( { 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 ( = ( { 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 } ) => ( = ( { 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 ( setInputStringAndMaybeCallOnChange( diff --git a/packages/js/components/src/date-time-picker-control/stories/index.tsx b/packages/js/components/src/date-time-picker-control/stories/index.tsx index 4ce10f12129..e41e2413859 100644 --- a/packages/js/components/src/date-time-picker-control/stories/index.tsx +++ b/packages/js/components/src/date-time-picker-control/stories/index.tsx @@ -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 ( -
-
{ children( controlledDate, setControlledDate ) }
-
- -
-
- Controlled date: -
{ controlledDate } -
-
-
-
- ); -} - 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 (
diff --git a/packages/js/components/src/experimental-select-control/combo-box.scss b/packages/js/components/src/experimental-select-control/combo-box.scss index cf5268ed26c..481797ab2f0 100644 --- a/packages/js/components/src/experimental-select-control/combo-box.scss +++ b/packages/js/components/src/experimental-select-control/combo-box.scss @@ -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; } diff --git a/packages/js/components/src/experimental-select-control/combo-box.tsx b/packages/js/components/src/experimental-select-control/combo-box.tsx index 6226da4518d..1589c09f13a 100644 --- a/packages/js/components/src/experimental-select-control/combo-box.tsx +++ b/packages/js/components/src/experimental-select-control/combo-box.tsx @@ -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 */
- { children } -
- { - inputRef.current = node; - ( - inputProps.ref as unknown as ( - node: HTMLInputElement | null - ) => void - )( node ); - } } - /> +
+ { children } +
+ { + inputRef.current = node; + ( + inputProps.ref as unknown as ( + node: HTMLInputElement | null + ) => void + )( node ); + } } + /> +
- + { suffix && ( +
+ { suffix } +
+ ) }
); }; diff --git a/packages/js/components/src/experimental-select-control/hooks/use-async-filter.tsx b/packages/js/components/src/experimental-select-control/hooks/use-async-filter.tsx new file mode 100644 index 00000000000..8672acfa535 --- /dev/null +++ b/packages/js/components/src/experimental-select-control/hooks/use-async-filter.tsx @@ -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 ? ( + } /> + ) : 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; +}; diff --git a/packages/js/components/src/experimental-select-control/index.ts b/packages/js/components/src/experimental-select-control/index.ts index d30d0825353..d328ae7fccd 100644 --- a/packages/js/components/src/experimental-select-control/index.ts +++ b/packages/js/components/src/experimental-select-control/index.ts @@ -1 +1,2 @@ export * from './select-control'; +export { default as useAsyncFilter } from './hooks/use-async-filter'; diff --git a/packages/js/components/src/experimental-select-control/menu.tsx b/packages/js/components/src/experimental-select-control/menu.tsx index 60a8cbb6e4e..13680d9349d 100644 --- a/packages/js/components/src/experimental-select-control/menu.tsx +++ b/packages/js/components/src/experimental-select-control/menu.tsx @@ -46,39 +46,41 @@ export const Menu = ( { return (
- 0, - } - ) } - position="bottom center" - animate={ false } - > -
    - // Fix to prevent select control dropdown from closing when selecting within the Popover. - e.stopPropagation() - } +
    + 0, + } + ) } + position="bottom right" + animate={ false } > - { isOpen && children } -
-
+
    + // Fix to prevent select control dropdown from closing when selecting within the Popover. + e.stopPropagation() + } + > + { isOpen && children } +
+ +
); /* eslint-enable jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/click-events-have-key-events */ diff --git a/packages/js/components/src/experimental-select-control/select-control.scss b/packages/js/components/src/experimental-select-control/select-control.scss index 129331685ac..cf71dadd8cd 100644 --- a/packages/js/components/src/experimental-select-control/select-control.scss +++ b/packages/js/components/src/experimental-select-control/select-control.scss @@ -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; diff --git a/packages/js/components/src/experimental-select-control/select-control.tsx b/packages/js/components/src/experimental-select-control/select-control.tsx index 8545e652112..e811d1d3e4e 100644 --- a/packages/js/components/src/experimental-select-control/select-control.tsx +++ b/packages/js/components/src/experimental-select-control/select-control.tsx @@ -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 = , + __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( { diff --git a/packages/js/components/src/experimental-select-control/stories/index.tsx b/packages/js/components/src/experimental-select-control/stories/index.tsx index c13f9c88ebd..80902ed691c 100644 --- a/packages/js/components/src/experimental-select-control/stories/index.tsx +++ b/packages/js/components/src/experimental-select-control/stories/index.tsx @@ -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 ( <> - + { ...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 ( + + { isFetching ? ( + + ) : ( + items.map( ( item, index: number ) => ( + + { item.label } + + ) ) + ) } + + ); + } } + + + ); +}; + +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 ( + <> + + { ...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 ( + item && setSelected( item ) } + onRemove={ () => setSelected( null ) } + /> + ); +}; + +export const CustomSuffixIcon: React.FC = () => { + const [ selected, setSelected ] = useState< + SelectedType< DefaultItemType > + >( sampleItems[ 1 ] ); + + return ( + item && setSelected( item ) } + onRemove={ () => setSelected( null ) } + suffix={ } + /> + ); +}; + +export const NoSuffix: React.FC = () => { + const [ selected, setSelected ] = useState< + SelectedType< DefaultItemType > + >( sampleItems[ 1 ] ); + + return ( + item && setSelected( item ) } + onRemove={ () => setSelected( null ) } + suffix={ null } + /> + ); +}; + +export const CustomSuffix: React.FC = () => { + const [ selected, setSelected ] = useState< + SelectedType< DefaultItemType > + >( sampleItems[ 1 ] ); + + return ( + item && setSelected( item ) } + onRemove={ () => setSelected( null ) } + suffix={ +
+ Suffix! +
+ } + /> + ); +}; + export default { title: 'WooCommerce Admin/experimental/SelectControl', component: SelectControl, diff --git a/packages/js/components/src/experimental-select-control/suffix-icon.scss b/packages/js/components/src/experimental-select-control/suffix-icon.scss new file mode 100644 index 00000000000..f007e55d7ef --- /dev/null +++ b/packages/js/components/src/experimental-select-control/suffix-icon.scss @@ -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; + } +} diff --git a/packages/js/components/src/experimental-select-control/suffix-icon.tsx b/packages/js/components/src/experimental-select-control/suffix-icon.tsx new file mode 100644 index 00000000000..f1077fb08de --- /dev/null +++ b/packages/js/components/src/experimental-select-control/suffix-icon.tsx @@ -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 ( +
+ +
+ ); +}; diff --git a/packages/js/components/src/experimental-select-control/test/index.tsx b/packages/js/components/src/experimental-select-control/test/index.tsx new file mode 100644 index 00000000000..b93d64a9c07 --- /dev/null +++ b/packages/js/components/src/experimental-select-control/test/index.tsx @@ -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( + + ); + + // 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( + custom suffix
} + /> + ); + + expect( getByText( 'custom suffix' ) ).toBeInTheDocument(); + } ); + + it( 'should render no suffix if null is specified', () => { + const { container } = render( + + ); + + expect( + container.querySelector( + '.woocommerce-experimental-select-control__suffix' + ) + ).not.toBeInTheDocument(); + } ); +} ); diff --git a/packages/js/components/src/experimental-select-control/test/use-async-filter.spec.ts b/packages/js/components/src/experimental-select-control/test/use-async-filter.spec.ts new file mode 100644 index 00000000000..846f407ca3f --- /dev/null +++ b/packages/js/components/src/experimental-select-control/test/use-async-filter.spec.ts @@ -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 ); + } ); +} ); diff --git a/packages/js/components/src/filters/style.scss b/packages/js/components/src/filters/style.scss index 1c648799d76..cb98f3579aa 100644 --- a/packages/js/components/src/filters/style.scss +++ b/packages/js/components/src/filters/style.scss @@ -80,6 +80,7 @@ width: 320px; border: 1px solid $gray-400; background-color: $studio-white; + padding: 0; } .woocommerce-calendar__input-error .components-popover__content { diff --git a/packages/js/components/src/form/form-context.ts b/packages/js/components/src/form/form-context.ts index d7ca500810d..21c46f36e79 100644 --- a/packages/js/components/src/form/form-context.ts +++ b/packages/js/components/src/form/form-context.ts @@ -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; diff --git a/packages/js/components/src/form/form.tsx b/packages/js/components/src/form/form.tsx index 878c6ce6ef0..76cfde5d16d 100644 --- a/packages/js/components/src/form/form.tsx +++ b/packages/js/components/src/form/form.tsx @@ -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; }; diff --git a/packages/js/components/src/form/style.scss b/packages/js/components/src/form/style.scss new file mode 100644 index 00000000000..4f1ea0a8ad4 --- /dev/null +++ b/packages/js/components/src/form/style.scss @@ -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; + } +} diff --git a/packages/js/components/src/image-gallery/image-gallery-toolbar.scss b/packages/js/components/src/image-gallery/image-gallery-toolbar.scss index 55e1c16103f..fa9507fb86f 100644 --- a/packages/js/components/src/image-gallery/image-gallery-toolbar.scss +++ b/packages/js/components/src/image-gallery/image-gallery-toolbar.scss @@ -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; } diff --git a/packages/js/components/src/image-gallery/image-gallery-toolbar.tsx b/packages/js/components/src/image-gallery/image-gallery-toolbar.tsx index 1c2cbcc0f98..8a325f157a5 100644 --- a/packages/js/components/src/image-gallery/image-gallery-toolbar.tsx +++ b/packages/js/components/src/image-gallery/image-gallery-toolbar.tsx @@ -64,7 +64,7 @@ export const ImageGalleryToolbar: React.FC< ImageGalleryToolbarProps > = ( { icon={ () => ( ) } - label={ __( 'Drag', 'woocommerce' ) } + label={ __( 'Drag to reorder', 'woocommerce' ) } /> = ( { setAsCoverImage( childIndex ) } icon={ CoverImageIcon } - label={ __( 'Set as cover image', 'woocommerce' ) } + label={ __( 'Set as cover', 'woocommerce' ) } /> ) } @@ -106,7 +106,7 @@ export const ImageGalleryToolbar: React.FC< ImageGalleryToolbarProps > = ( { removeItem( childIndex ) } icon={ trash } - label={ __( 'Delete', 'woocommerce' ) } + label={ __( 'Remove', 'woocommerce' ) } /> diff --git a/packages/js/components/src/index.ts b/packages/js/components/src/index.ts index 38a03508a74..f19e35f5a5e 100644 --- a/packages/js/components/src/index.ts +++ b/packages/js/components/src/index.ts @@ -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, diff --git a/packages/js/components/src/media-uploader/README.md b/packages/js/components/src/media-uploader/README.md index c3fe0216ec4..16293deb8f8 100644 --- a/packages/js/components/src/media-uploader/README.md +++ b/packages/js/components/src/media-uploader/README.md @@ -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 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 \ No newline at end of file +| 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 | diff --git a/packages/js/components/src/media-uploader/media-uploader.tsx b/packages/js/components/src/media-uploader/media-uploader.tsx index c04534e50db..07bbaa8fd43 100644 --- a/packages/js/components/src/media-uploader/media-uploader.tsx +++ b/packages/js/components/src/media-uploader/media-uploader.tsx @@ -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 ( -
-
{ label }
+ { + uploadMedia( { + filesList: currentTarget.files as FileList, + onError, + onFileChange: onFileUploadChange, + maxUploadFileSize, + } ); + } } + render={ ( { openFileDialog } ) => ( +
{} } + tabIndex={ 0 } + role="button" + onClick={ ( + event: React.MouseEvent< HTMLDivElement, MouseEvent > + ) => { + const { target } = event; + if ( + ( target as HTMLButtonElement )?.type !== 'button' + ) { + openFileDialog(); + } + } } + onBlur={ () => {} } + > +
+
+ { label } +
- ( - - ) } - /> + ( + + ) } + /> - { hasDropZone && ( - - uploadMedia( { - filesList: files, - onError, - onFileChange: onUpload, - maxUploadFileSize, - } ) - } - /> + { hasDropZone && ( + + uploadMedia( { + filesList: files, + onError, + onFileChange: onUpload, + maxUploadFileSize, + } ) + } + /> + ) } +
+
) } -
+ /> ); }; diff --git a/packages/js/components/src/media-uploader/stories/index.tsx b/packages/js/components/src/media-uploader/stories/index.tsx index 05f7ae230bf..373a1aeb7f5 100644 --- a/packages/js/components/src/media-uploader/stories/index.tsx +++ b/packages/js/components/src/media-uploader/stories/index.tsx @@ -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 && ( setOpen( false ) } + onRequestClose={ ( event ) => { + setOpen( false ); + event.stopPropagation(); + } } >

Use the default built-in{ ' ' } @@ -39,12 +42,13 @@ export const MockMediaUpload = ( { onSelect, render } ) => { return (

'; +} + +register_activation_hook( __FILE__, '{{slugSnakeCase}}_activate' ); + +/** + * Activation hook. + * + * @since {{version}} + */ +function {{slugSnakeCase}}_activate() { + if ( ! class_exists( 'WooCommerce' ) ) { + add_action( 'admin_notices', '{{slugSnakeCase}}_missing_wc_notice' ); + return; + } +} + +if ( ! class_exists( '{{slugSnakeCase}}' ) ) : + /** + * The {{slugSnakeCase}} class. + */ + class {{slugSnakeCase}} { + /** + * This class instance. + * + * @var \{{slugSnakeCase}} single instance of this class. + */ + private static $instance; + + /** + * Constructor. + */ + public function __construct() { + if ( is_admin() ) { + new Setup(); + } + } + + /** + * Cloning is forbidden. + * + */ + public function __clone() { + wc_doing_it_wrong( __FUNCTION__, __( 'Cloning is forbidden.', '{{slugSnakeCase}}' ), $this->version ); + } + + /** + * Unserializing instances of this class is forbidden. + * + */ + public function __wakeup() { + wc_doing_it_wrong( __FUNCTION__, __( 'Unserializing instances of this class is forbidden.', '{{slugSnakeCase}}' ), $this->version ); + } + + /** + * Gets the main instance. + * + * Ensures only one instance can be loaded. + * + * @return \{{slugSnakeCase}} + */ + public static function instance() { + + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + } +endif; + +add_action( 'plugins_loaded', '{{slugSnakeCase}}_init', 10 ); + +/** + * Initialize the plugin. + * + * @since {{version}} + */ +function {{slugSnakeCase}}_init() { + load_plugin_textdomain( '{{slugSnakeCase}}', false, plugin_basename( dirname( __FILE__ ) ) . '/languages' ); + + if ( ! class_exists( 'WooCommerce' ) ) { + add_action( 'admin_notices', '{{slugSnakeCase}}_missing_wc_notice' ); + return; + } + + {{slugSnakeCase}}::instance(); + +} diff --git a/packages/js/create-woo-extension/.editorconfig.mustache b/packages/js/create-woo-extension/.editorconfig.mustache new file mode 100644 index 00000000000..d7dbce738b0 --- /dev/null +++ b/packages/js/create-woo-extension/.editorconfig.mustache @@ -0,0 +1,27 @@ +# This file is for unifying the coding style for different editors and IDEs +# editorconfig.org + +# WordPress Coding Standards +# https://make.wordpress.org/core/handbook/coding-standards/ + +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +tab_width = 4 +indent_style = tab +insert_final_newline = true +trim_trailing_whitespace = true + +[*.txt] +trim_trailing_whitespace = false + +[*.{md,json,yml}] +trim_trailing_whitespace = false +indent_style = space +indent_size = 2 + +[*.json] +indent_style = tab \ No newline at end of file diff --git a/packages/js/create-woo-extension/.eslintrc.js.mustache b/packages/js/create-woo-extension/.eslintrc.js.mustache new file mode 100644 index 00000000000..22ddc24c584 --- /dev/null +++ b/packages/js/create-woo-extension/.eslintrc.js.mustache @@ -0,0 +1,6 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + rules: { + 'react/react-in-jsx-scope': 'off', + }, +}; diff --git a/packages/js/create-woo-extension/.gitignore.mustache b/packages/js/create-woo-extension/.gitignore.mustache new file mode 100644 index 00000000000..14dfbf25c04 --- /dev/null +++ b/packages/js/create-woo-extension/.gitignore.mustache @@ -0,0 +1,62 @@ + +# Operating System files +.DS_Store +Thumbs.db + +# IDE files +.idea +.vscode/* +project.xml +project.properties +.project +.settings* +*.sublime-project +*.sublime-workspace +.sublimelinterrc + +# Sass +.sass-cache/ + +# Logs +logs/ + +# Environment files +wp-cli.local.yml +yarn-error.log +npm-debug.log +.pnpm-debug.log + +# Build files +*.sql +*.swp +*.zip + +# Built packages +build/ +build-module/ +build-style/ +build-types/ +dist/ + +# Project files +node_modules/ +vendor/ + +# TypeScript files +tsconfig.tsbuildinfo + +# Node Package Dependencies +package-lock.json + +# wp-env config +.wp-env.override.json + +# Unit tests +tmp/ + +# Composer +vendor/ +bin/composer/**/vendor/ +lib/vendor/ +contributors.md +contributors.html diff --git a/packages/js/create-woo-extension/.prettierrc.json.mustache b/packages/js/create-woo-extension/.prettierrc.json.mustache new file mode 100644 index 00000000000..235376574a9 --- /dev/null +++ b/packages/js/create-woo-extension/.prettierrc.json.mustache @@ -0,0 +1 @@ +"@wordpress/prettier-config" diff --git a/packages/js/create-woo-extension/.wp-env.json.mustache b/packages/js/create-woo-extension/.wp-env.json.mustache new file mode 100644 index 00000000000..7b3c8ba0ba1 --- /dev/null +++ b/packages/js/create-woo-extension/.wp-env.json.mustache @@ -0,0 +1,13 @@ +{ + "phpVersion": null, + "core": null, + "plugins": [ + "https://downloads.wordpress.org/plugin/woocommerce.7.1.0.zip", + "." + ], + "config": { + "JETPACK_AUTOLOAD_DEV": true, + "WP_DEBUG": true, + "SCRIPT_DEBUG": true + } +} diff --git a/packages/js/create-woo-extension/README.md b/packages/js/create-woo-extension/README.md new file mode 100644 index 00000000000..effa91ec729 --- /dev/null +++ b/packages/js/create-woo-extension/README.md @@ -0,0 +1,19 @@ +# @woocommerce/create-woo-extension + +This is a template to be used with `@wordpress/create-block` to create a WooCommerce Extension starting point. + +## Installation + +``` +npx @wordpress/create-block -t @woocommerce/create-woo-extension +``` + +When this has completed, go to your WordPress plugins page and activate the plugin. + +## Development + +Install from a local directory. + +``` +npx @wordpress/create-block -t ./path/to/woocommerce/packages/js/create-woo-extension +``` diff --git a/packages/js/create-woo-extension/README.md.mustache b/packages/js/create-woo-extension/README.md.mustache new file mode 100644 index 00000000000..ff8f47f94dd --- /dev/null +++ b/packages/js/create-woo-extension/README.md.mustache @@ -0,0 +1,20 @@ +# Woo Plugin Setup + +A boilerplate for modern WooCommerce development. This project adds a React page to WooCommerce Admin and a corresponding navigation item. + +## Getting Started + +### Prerequisites + +- [NPM](https://www.npmjs.com/) +- [wp-env](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/) + +### Installation and Build + +``` +npm install +npm build +wp-env start +``` + +Visit the added page at http://localhost:8888/wp-admin/admin.php?page=wc-admin&path=%2Fexample. diff --git a/packages/js/create-woo-extension/changelog/.gitkeep b/packages/js/create-woo-extension/changelog/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/js/create-woo-extension/changelog/add-dev-env b/packages/js/create-woo-extension/changelog/add-dev-env new file mode 100644 index 00000000000..a2f0ae8ef75 --- /dev/null +++ b/packages/js/create-woo-extension/changelog/add-dev-env @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Initial PR + + diff --git a/packages/js/create-woo-extension/changelog/add-wc-validation b/packages/js/create-woo-extension/changelog/add-wc-validation new file mode 100644 index 00000000000..4e8303e4307 --- /dev/null +++ b/packages/js/create-woo-extension/changelog/add-wc-validation @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Add WC validation diff --git a/packages/js/create-woo-extension/composer.json b/packages/js/create-woo-extension/composer.json new file mode 100644 index 00000000000..c552cf99b2b --- /dev/null +++ b/packages/js/create-woo-extension/composer.json @@ -0,0 +1,32 @@ +{ + "name": "woocommerce/extend-cart-checkout-block", + "description": "A template to be used with `@wordpress/create-block` to create a WooCommerce extension.", + "type": "library", + "license": "GPL-3.0-or-later", + "minimum-stability": "dev", + "require-dev": { + "automattic/jetpack-changelogger": "3.1.3" + }, + "config": { + "platform": { + "php": "7.2" + } + }, + "extra": { + "changelogger": { + "formatter": { + "filename": "../../../tools/changelogger/class-package-formatter.php" + }, + "types": { + "fix": "Fixes an existing bug", + "add": "Adds functionality", + "update": "Update existing functionality", + "dev": "Development related task", + "tweak": "A minor adjustment to the codebase", + "performance": "Address performance issues", + "enhancement": "Improve existing functionality" + }, + "changelog": "CHANGELOG.md" + } + } +} diff --git a/packages/js/create-woo-extension/composer.json.mustache b/packages/js/create-woo-extension/composer.json.mustache new file mode 100644 index 00000000000..e1f0f009641 --- /dev/null +++ b/packages/js/create-woo-extension/composer.json.mustache @@ -0,0 +1,18 @@ +{ + "name": "{{namespace}}/{{slug}}", + "description": "WooCommerce plugin", + "autoload": { + "psr-4": { + "{{slugPascalCase}}\\": "includes/" + } + }, + "require-dev": { + "phpunit/phpunit": "^9.0", + "automattic/jetpack-autoloader": "^2" + }, + "config": { + "allow-plugins": { + "automattic/jetpack-autoloader": true + } + } +} diff --git a/packages/js/create-woo-extension/composer.lock b/packages/js/create-woo-extension/composer.lock new file mode 100644 index 00000000000..ace5b309e5e --- /dev/null +++ b/packages/js/create-woo-extension/composer.lock @@ -0,0 +1,483 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "af64b929c80c204120d9eccb66330d6c", + "packages": [], + "packages-dev": [ + { + "name": "automattic/jetpack-changelogger", + "version": "v3.1.3", + "source": { + "type": "git", + "url": "https://github.com/Automattic/jetpack-changelogger.git", + "reference": "cdd256d8ba6369f82d9377de7e9e2598e3e16ae0" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Automattic/jetpack-changelogger/zipball/cdd256d8ba6369f82d9377de7e9e2598e3e16ae0", + "reference": "cdd256d8ba6369f82d9377de7e9e2598e3e16ae0", + "shasum": "" + }, + "require": { + "php": ">=5.6", + "symfony/console": "^3.4 || ^5.2", + "symfony/process": "^3.4 || ^5.2", + "wikimedia/at-ease": "^1.2 || ^2.0" + }, + "require-dev": { + "wikimedia/testing-access-wrapper": "^1.0 || ^2.0", + "yoast/phpunit-polyfills": "1.0.3" + }, + "bin": [ + "bin/changelogger" + ], + "type": "project", + "extra": { + "autotagger": true, + "branch-alias": { + "dev-trunk": "3.1.x-dev" + }, + "mirror-repo": "Automattic/jetpack-changelogger", + "version-constants": { + "::VERSION": "src/Application.php" + }, + "changelogger": { + "link-template": "https://github.com/Automattic/jetpack-changelogger/compare/${old}...${new}" + } + }, + "autoload": { + "psr-4": { + "Automattic\\Jetpack\\Changelog\\": "lib", + "Automattic\\Jetpack\\Changelogger\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "description": "Jetpack Changelogger tool. Allows for managing changelogs by dropping change files into a changelog directory with each PR.", + "support": { + "source": "https://github.com/Automattic/jetpack-changelogger/tree/v3.1.3" + }, + "time": "2022-06-21T07:31:56+00:00" + }, + { + "name": "psr/log", + "version": "1.1.4", + "source": { + "type": "git", + "url": "https://github.com/php-fig/log.git", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", + "reference": "d49695b909c3b7628b6289db5479a1c204601f11", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "Psr/Log/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, + "time": "2021-05-03T11:20:27+00:00" + }, + { + "name": "symfony/console", + "version": "3.4.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/console.git", + "reference": "a10b1da6fc93080c180bba7219b5ff5b7518fe81" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/console/zipball/a10b1da6fc93080c180bba7219b5ff5b7518fe81", + "reference": "a10b1da6fc93080c180bba7219b5ff5b7518fe81", + "shasum": "" + }, + "require": { + "php": "^5.5.9|>=7.0.8", + "symfony/debug": "~2.8|~3.0|~4.0", + "symfony/polyfill-mbstring": "~1.0" + }, + "conflict": { + "symfony/dependency-injection": "<3.4", + "symfony/process": "<3.3" + }, + "provide": { + "psr/log-implementation": "1.0" + }, + "require-dev": { + "psr/log": "~1.0", + "symfony/config": "~3.3|~4.0", + "symfony/dependency-injection": "~3.4|~4.0", + "symfony/event-dispatcher": "~2.8|~3.0|~4.0", + "symfony/lock": "~3.4|~4.0", + "symfony/process": "~3.3|~4.0" + }, + "suggest": { + "psr/log": "For using the console logger", + "symfony/event-dispatcher": "", + "symfony/lock": "", + "symfony/process": "" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Console\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Console Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/console/tree/3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-24T10:57:07+00:00" + }, + { + "name": "symfony/debug", + "version": "4.4.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/debug.git", + "reference": "1a692492190773c5310bc7877cb590c04c2f05be" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/debug/zipball/1a692492190773c5310bc7877cb590c04c2f05be", + "reference": "1a692492190773c5310bc7877cb590c04c2f05be", + "shasum": "" + }, + "require": { + "php": ">=7.1.3", + "psr/log": "^1|^2|^3" + }, + "conflict": { + "symfony/http-kernel": "<3.4" + }, + "require-dev": { + "symfony/http-kernel": "^3.4|^4.0|^5.0" + }, + "default-branch": true, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Debug\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides tools to ease debugging PHP code", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/debug/tree/v4.4.44" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "abandoned": "symfony/error-handler", + "time": "2022-07-28T16:29:46+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "dev-main", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "reference": "8ad114f6b39e2c98a8b0e3bd907732c207c2b534", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "default-branch": true, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.27-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.27.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2022-11-03T14:55:06+00:00" + }, + { + "name": "symfony/process", + "version": "3.4.x-dev", + "source": { + "type": "git", + "url": "https://github.com/symfony/process.git", + "reference": "b8648cf1d5af12a44a51d07ef9bf980921f15fca" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/process/zipball/b8648cf1d5af12a44a51d07ef9bf980921f15fca", + "reference": "b8648cf1d5af12a44a51d07ef9bf980921f15fca", + "shasum": "" + }, + "require": { + "php": "^5.5.9|>=7.0.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Process\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony Process Component", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/process/tree/3.4" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-10-24T10:57:07+00:00" + }, + { + "name": "wikimedia/at-ease", + "version": "v2.0.0", + "source": { + "type": "git", + "url": "https://github.com/wikimedia/at-ease.git", + "reference": "013ac61929797839c80a111a3f1a4710d8248e7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/wikimedia/at-ease/zipball/013ac61929797839c80a111a3f1a4710d8248e7a", + "reference": "013ac61929797839c80a111a3f1a4710d8248e7a", + "shasum": "" + }, + "require": { + "php": ">=5.6.99" + }, + "require-dev": { + "jakub-onderka/php-console-highlighter": "0.3.2", + "jakub-onderka/php-parallel-lint": "1.0.0", + "mediawiki/mediawiki-codesniffer": "22.0.0", + "mediawiki/minus-x": "0.3.1", + "ockcyp/covers-validator": "0.5.1 || 0.6.1", + "phpunit/phpunit": "4.8.36 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/Wikimedia/Functions.php" + ], + "psr-4": { + "Wikimedia\\AtEase\\": "src/Wikimedia/AtEase/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "GPL-2.0-or-later" + ], + "authors": [ + { + "name": "Tim Starling", + "email": "tstarling@wikimedia.org" + }, + { + "name": "MediaWiki developers", + "email": "wikitech-l@lists.wikimedia.org" + } + ], + "description": "Safe replacement to @ for suppressing warnings.", + "homepage": "https://www.mediawiki.org/wiki/at-ease", + "support": { + "source": "https://github.com/wikimedia/at-ease/tree/master" + }, + "time": "2018-10-10T15:39:06+00:00" + } + ], + "aliases": [], + "minimum-stability": "dev", + "stability-flags": [], + "prefer-stable": false, + "prefer-lowest": false, + "platform": [], + "platform-dev": [], + "platform-overrides": { + "php": "7.2" + }, + "plugin-api-version": "2.3.0" +} diff --git a/packages/js/create-woo-extension/includes/Admin/Setup.php.mustache b/packages/js/create-woo-extension/includes/Admin/Setup.php.mustache new file mode 100644 index 00000000000..43e5c6723e6 --- /dev/null +++ b/packages/js/create-woo-extension/includes/Admin/Setup.php.mustache @@ -0,0 +1,81 @@ + array(), + 'version' => filemtime( $script_path ), + ); + $script_url = plugins_url( $script_path, MAIN_PLUGIN_FILE ); + + wp_register_script( + '{{slug}}', + $script_url, + $script_asset['dependencies'], + $script_asset['version'], + true + ); + + wp_register_style( + '{{slug}}', + plugins_url( '/build/index.css', MAIN_PLUGIN_FILE ), + // Add any dependencies styles may have, such as wp-components. + array(), + filemtime( dirname( MAIN_PLUGIN_FILE ) . '/build/index.css' ) + ); + + wp_enqueue_script( '{{slug}}' ); + wp_enqueue_style( '{{slug}}' ); + } + + /** + * Register page in wc-admin. + * + * @since 1.0.0 + */ + public function register_page() { + + if ( ! function_exists( 'wc_admin_register_page' ) ) { + return; + } + + wc_admin_register_page( + array( + 'id' => '{{slugSnakeCase}}-example-page', + 'title' => __( '{{title}}', '{{slugSnakeCase}}' ), + 'parent' => 'woocommerce', + 'path' => '/{{slug}}', + ) + ); + } +} diff --git a/packages/js/create-woo-extension/index.js b/packages/js/create-woo-extension/index.js new file mode 100644 index 00000000000..b4af0d6a82e --- /dev/null +++ b/packages/js/create-woo-extension/index.js @@ -0,0 +1,17 @@ +module.exports = { + templatesPath: __dirname, + defaultValues: { + npmDependencies: [ '@wordpress/hooks', '@wordpress/i18n' ], + npmDevDependencies: [ + '@woocommerce/dependency-extraction-webpack-plugin', + '@woocommerce/eslint-plugin', + '@wordpress/prettier-config', + '@wordpress/scripts', + ], + namespace: 'extension', + license: 'GPL-3.0+', + }, + customScripts: { + postinstall: 'composer install', + }, +}; diff --git a/packages/js/create-woo-extension/languages/woo-plugin-setup.pot.mustache b/packages/js/create-woo-extension/languages/woo-plugin-setup.pot.mustache new file mode 100644 index 00000000000..6b65dab9437 --- /dev/null +++ b/packages/js/create-woo-extension/languages/woo-plugin-setup.pot.mustache @@ -0,0 +1,45 @@ +# Copyright (C) 2022 WooCommerce +# This file is distributed under the GNU General Public License v3.0. +msgid "" +msgstr "" +"Project-Id-Version: Woo Plugin Setup 1.0.0\n" +"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/{{slug}}\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"POT-Creation-Date: 2022-11-27T21:35:18+00:00\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"X-Generator: WP-CLI 2.7.1\n" +"X-Domain: {{slug}}\n" + +#. Plugin Name of the plugin +msgid "Woo Plugin Setup" +msgstr "" + +#. Plugin URI of the plugin +msgid "https://github.com/psealock/woo-plugin-setup" +msgstr "" + +#. Description of the plugin +msgid "This is an example of an extension template to follow." +msgstr "" + +#. Author of the plugin +msgid "WooCommerce" +msgstr "" + +#. Author URI of the plugin +msgid "https://woocommerce.com" +msgstr "" + +#: build/index.js:1 +#: src/index.js:13 +msgid "My Example Extension" +msgstr "" + +#: build/index.js:1 +#: src/index.js:20 +msgid "My Example Page" +msgstr "" diff --git a/packages/js/create-woo-extension/package.json b/packages/js/create-woo-extension/package.json new file mode 100644 index 00000000000..c6d9efe834c --- /dev/null +++ b/packages/js/create-woo-extension/package.json @@ -0,0 +1,25 @@ +{ + "name": "@woocommerce/create-woo-extension", + "version": "1.0.0", + "description": "A template to be used with `@wordpress/create-block` to create a WooCommerce extension.", + "main": "index.js", + "engines": { + "node": "^16.13.1", + "pnpm": "^7.13.3" + }, + "scripts": { + "postinstall": "composer install", + "changelog": "composer exec -- changelogger" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/woocommerce/woocommerce.git" + }, + "keywords": [], + "author": "", + "license": "GPL-3.0+", + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/create-woo-extensionv#readme" +} diff --git a/packages/js/create-woo-extension/src/index.js.mustache b/packages/js/create-woo-extension/src/index.js.mustache new file mode 100644 index 00000000000..f4ea611bfc8 --- /dev/null +++ b/packages/js/create-woo-extension/src/index.js.mustache @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { addFilter } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import './index.scss'; + +const MyExamplePage = () => ( +

{ __( 'My Example Extension', '{{textdomain}}' ) }

+); + +addFilter( 'woocommerce_admin_pages_list', '{{slug}}', ( pages ) => { + pages.push( { + container: MyExamplePage, + path: '/{{slug}}', + breadcrumbs: [ __( '{{title}}', '{{textdomain}}' ) ], + navArgs: { + id: '{{slugSnakeCase}}', + }, + } ); + + return pages; +} ); diff --git a/packages/js/create-woo-extension/src/index.scss.mustache b/packages/js/create-woo-extension/src/index.scss.mustache new file mode 100644 index 00000000000..b0130a56356 --- /dev/null +++ b/packages/js/create-woo-extension/src/index.scss.mustache @@ -0,0 +1,3 @@ +h1 { + background-color: gold; +} diff --git a/packages/js/create-woo-extension/tests/Test.php.mustache b/packages/js/create-woo-extension/tests/Test.php.mustache new file mode 100644 index 00000000000..2a23d935674 --- /dev/null +++ b/packages/js/create-woo-extension/tests/Test.php.mustache @@ -0,0 +1,15 @@ +assertEquals( 1, 1 ); + } +} diff --git a/packages/js/create-woo-extension/webpack.config.js.mustache b/packages/js/create-woo-extension/webpack.config.js.mustache new file mode 100644 index 00000000000..7c211bc7ca0 --- /dev/null +++ b/packages/js/create-woo-extension/webpack.config.js.mustache @@ -0,0 +1,13 @@ +const defaultConfig = require( '@wordpress/scripts/config/webpack.config' ); +const WooCommerceDependencyExtractionWebpackPlugin = require( '@woocommerce/dependency-extraction-webpack-plugin' ); + +module.exports = { + ...defaultConfig, + plugins: [ + ...defaultConfig.plugins.filter( + ( plugin ) => + plugin.constructor.name !== 'DependencyExtractionWebpackPlugin' + ), + new WooCommerceDependencyExtractionWebpackPlugin(), + ], +}; diff --git a/packages/js/customer-effort-score/changelog/add-35124-ces-add-question b/packages/js/customer-effort-score/changelog/add-35124-ces-add-question new file mode 100644 index 00000000000..e89521d9d87 --- /dev/null +++ b/packages/js/customer-effort-score/changelog/add-35124-ces-add-question @@ -0,0 +1,4 @@ +Significance: major +Type: update + +Updating to accept two questions to display in CES modal. diff --git a/packages/js/customer-effort-score/changelog/add-35126_ces_exit_prompt b/packages/js/customer-effort-score/changelog/add-35126_ces_exit_prompt new file mode 100644 index 00000000000..9b4ba7be2ad --- /dev/null +++ b/packages/js/customer-effort-score/changelog/add-35126_ces_exit_prompt @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add description and noticeLabel props to customer feedback components. diff --git a/packages/js/customer-effort-score/changelog/add-35126_ces_exit_prompt_settings b/packages/js/customer-effort-score/changelog/add-35126_ces_exit_prompt_settings new file mode 100644 index 00000000000..d9909507b53 --- /dev/null +++ b/packages/js/customer-effort-score/changelog/add-35126_ces_exit_prompt_settings @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update CustomerEffortScore tracks to add callback for when Modal is dismissed. diff --git a/packages/js/customer-effort-score/changelog/add-35129_ces_prompt_new_product_mvp b/packages/js/customer-effort-score/changelog/add-35129_ces_prompt_new_product_mvp new file mode 100644 index 00000000000..3830a5fbc10 --- /dev/null +++ b/packages/js/customer-effort-score/changelog/add-35129_ces_prompt_new_product_mvp @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix styling issue with new Wordpress version. diff --git a/packages/js/customer-effort-score/changelog/add-35129_product_mvp_ces b/packages/js/customer-effort-score/changelog/add-35129_product_mvp_ces new file mode 100644 index 00000000000..51ee5464ecc --- /dev/null +++ b/packages/js/customer-effort-score/changelog/add-35129_product_mvp_ces @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Update text for options to match questions, and provide custom options prop. diff --git a/packages/js/customer-effort-score/src/customer-effort-score.tsx b/packages/js/customer-effort-score/src/customer-effort-score.tsx index 958da108eae..4a885f400b5 100644 --- a/packages/js/customer-effort-score/src/customer-effort-score.tsx +++ b/packages/js/customer-effort-score/src/customer-effort-score.tsx @@ -14,11 +14,20 @@ import { CustomerFeedbackModal } from './customer-feedback-modal'; const noop = () => {}; type CustomerEffortScoreProps = { - recordScoreCallback: ( score: number, comments: string ) => void; - label: string; + recordScoreCallback: ( + score: number, + secondScore: number, + comments: string + ) => void; + title: string; + description?: string; + noticeLabel?: string; + firstQuestion: string; + secondQuestion: string; onNoticeShownCallback?: () => void; onNoticeDismissedCallback?: () => void; onModalShownCallback?: () => void; + onModalDismissedCallback?: () => void; icon?: React.ReactElement | null; }; @@ -30,18 +39,28 @@ type CustomerEffortScoreProps = { * * @param {Object} props Component props. * @param {Function} props.recordScoreCallback Function to call when the score should be recorded. - * @param {string} props.label The label displayed in the modal. + * @param {string} props.title The title displayed in the modal. + * @param {string} props.description The description displayed in the modal. + * @param {string} props.noticeLabel The notice label displayed in the notice. + * @param {string} props.firstQuestion The first survey question. + * @param {string} props.secondQuestion The second survey question. * @param {Function} props.onNoticeShownCallback Function to call when the notice is shown. * @param {Function} props.onNoticeDismissedCallback Function to call when the notice is dismissed. * @param {Function} props.onModalShownCallback Function to call when the modal is shown. + * @param {Function} props.onModalDismissedCallback Function to call when modal is dismissed. * @param {Object} props.icon Icon (React component) to be shown on the notice. */ const CustomerEffortScore: React.VFC< CustomerEffortScoreProps > = ( { recordScoreCallback, - label, + title, + description, + noticeLabel, + firstQuestion, + secondQuestion, onNoticeShownCallback = noop, onNoticeDismissedCallback = noop, onModalShownCallback = noop, + onModalDismissedCallback = noop, icon, } ) => { const [ shouldCreateNotice, setShouldCreateNotice ] = useState( true ); @@ -53,7 +72,7 @@ const CustomerEffortScore: React.VFC< CustomerEffortScoreProps > = ( { return; } - createNotice( 'success', label, { + createNotice( 'success', noticeLabel || title, { actions: [ { label: __( 'Give feedback', 'woocommerce' ), @@ -83,8 +102,12 @@ const CustomerEffortScore: React.VFC< CustomerEffortScoreProps > = ( { return ( ); }; @@ -95,9 +118,9 @@ CustomerEffortScore.propTypes = { */ recordScoreCallback: PropTypes.func.isRequired, /** - * The label displayed in the modal. + * The title displayed in the modal. */ - label: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, /** * The function to call when the notice is shown. */ diff --git a/packages/js/customer-effort-score/src/customer-feedback-modal/index.tsx b/packages/js/customer-effort-score/src/customer-feedback-modal/index.tsx index f167740a90a..0a005f85d81 100644 --- a/packages/js/customer-effort-score/src/customer-feedback-modal/index.tsx +++ b/packages/js/customer-effort-score/src/customer-feedback-modal/index.tsx @@ -25,45 +25,69 @@ import { __ } from '@wordpress/i18n'; * * @param {Object} props Component props. * @param {Function} props.recordScoreCallback Function to call when the results are sent. - * @param {string} props.label Question to ask the customer. + * @param {string} props.title Title displayed in the modal. + * @param {string} props.description Description displayed in the modal. + * @param {string} props.firstQuestion The first survey question. + * @param {string} props.secondQuestion The second survey question. * @param {string} props.defaultScore Default score. * @param {Function} props.onCloseModal Callback for when user closes modal by clicking cancel. + * @param {Function} props.customOptions List of custom score options, contains label and value. */ function CustomerFeedbackModal( { recordScoreCallback, - label, + title, + description, + firstQuestion, + secondQuestion, defaultScore = NaN, onCloseModal, + customOptions, }: { - recordScoreCallback: ( score: number, comments: string ) => void; - label: string; + recordScoreCallback: ( + score: number, + secondScore: number, + comments: string + ) => void; + title: string; + description?: string; + firstQuestion: string; + secondQuestion: string; defaultScore?: number; onCloseModal?: () => void; + customOptions?: { label: string; value: string }[]; } ): JSX.Element | null { - const options = [ - { - label: __( 'Very difficult', 'woocommerce' ), - value: '1', - }, - { - label: __( 'Somewhat difficult', 'woocommerce' ), - value: '2', - }, - { - label: __( 'Neutral', 'woocommerce' ), - value: '3', - }, - { - label: __( 'Somewhat easy', 'woocommerce' ), - value: '4', - }, - { - label: __( 'Very easy', 'woocommerce' ), - value: '5', - }, - ]; + const options = + customOptions && customOptions.length > 0 + ? customOptions + : [ + { + label: __( 'Strongly disagree', 'woocommerce' ), + value: '1', + }, + { + label: __( 'Disagree', 'woocommerce' ), + value: '2', + }, + { + label: __( 'Neutral', 'woocommerce' ), + value: '3', + }, + { + label: __( 'Agree', 'woocommerce' ), + value: '4', + }, + { + label: __( 'Strongly Agree', 'woocommerce' ), + value: '5', + }, + ]; - const [ score, setScore ] = useState( defaultScore || NaN ); + const [ firstQuestionScore, setFirstQuestionScore ] = useState( + defaultScore || NaN + ); + const [ secondQuestionScore, setSecondQuestionScore ] = useState( + defaultScore || NaN + ); const [ comments, setComments ] = useState( '' ); const [ showNoScoreMessage, setShowNoScoreMessage ] = useState( false ); const [ isOpen, setOpen ] = useState( true ); @@ -75,19 +99,31 @@ function CustomerFeedbackModal( { } }; - const onRadioControlChange = ( value: string ) => { + const onRadioControlChange = ( + value: string, + setter: ( val: number ) => void + ) => { const valueAsInt = parseInt( value, 10 ); - setScore( valueAsInt ); + setter( valueAsInt ); setShowNoScoreMessage( ! Number.isInteger( valueAsInt ) ); }; const sendScore = () => { - if ( ! Number.isInteger( score ) ) { + if ( + ! ( + Number.isInteger( firstQuestionScore ) && + Number.isInteger( secondQuestionScore ) + ) + ) { setShowNoScoreMessage( true ); return; } setOpen( false ); - recordScoreCallback( score, comments ); + recordScoreCallback( + firstQuestionScore, + secondQuestionScore, + comments + ); }; if ( ! isOpen ) { @@ -97,10 +133,25 @@ function CustomerFeedbackModal( { return ( + + { description || + __( + 'Your feedback will help create a better experience for thousands of merchants like you. Please tell us to what extent you agree or disagree with the statements below.', + 'woocommerce' + ) } + + - { label } + { firstQuestion }
+ onRadioControlChange( + value as string, + setFirstQuestionScore + ) + } />
- { ( score === 1 || score === 2 ) && ( + + { secondQuestion } + + +
+ + onRadioControlChange( + value as string, + setSecondQuestionScore + ) + } + /> +
+ + { [ firstQuestionScore, secondQuestionScore ].some( + ( score ) => score === 1 || score === 2 + ) && (
setComments( value ) } rows={ 5 } /> @@ -153,7 +241,7 @@ function CustomerFeedbackModal( { { __( 'Cancel', 'woocommerce' ) }
@@ -162,7 +250,9 @@ function CustomerFeedbackModal( { CustomerFeedbackModal.propTypes = { recordScoreCallback: PropTypes.func.isRequired, - label: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + firstQuestion: PropTypes.string.isRequired, + secondQuestion: PropTypes.string.isRequired, defaultScore: PropTypes.number, onCloseModal: PropTypes.func, }; diff --git a/packages/js/customer-effort-score/src/customer-feedback-modal/test/index.tsx b/packages/js/customer-effort-score/src/customer-feedback-modal/test/index.tsx index 40829821a4e..b4136fd3fe6 100644 --- a/packages/js/customer-effort-score/src/customer-feedback-modal/test/index.tsx +++ b/packages/js/customer-effort-score/src/customer-feedback-modal/test/index.tsx @@ -16,7 +16,9 @@ describe( 'CustomerFeedbackModal', () => { render( ); @@ -33,13 +35,15 @@ describe( 'CustomerFeedbackModal', () => { render( ); await screen.findByRole( 'dialog' ); // Wait for the modal to render. - fireEvent.click( screen.getByRole( 'button', { name: /send/i } ) ); // Press send button. + fireEvent.click( screen.getByRole( 'button', { name: /share/i } ) ); // Press send button. // Wait for error message. await screen.findByRole( 'alert' ); @@ -51,7 +55,9 @@ describe( 'CustomerFeedbackModal', () => { render( ); @@ -59,17 +65,21 @@ describe( 'CustomerFeedbackModal', () => { await screen.findByRole( 'dialog' ); expect( - screen.queryByLabelText( 'Comments (optional)' ) + screen.queryByLabelText( + 'How is that screen useful to you? What features would you add or change?' + ) ).not.toBeInTheDocument(); } ); - it.each( [ 'Very difficult', 'Somewhat difficult' ] )( + it.each( [ 'Strongly disagree', 'Disagree' ] )( 'should toggle the comments field when %s is selected', async ( labelText ) => { render( ); @@ -77,18 +87,22 @@ describe( 'CustomerFeedbackModal', () => { await screen.findByRole( 'dialog' ); // Select the option. - fireEvent.click( screen.getByLabelText( labelText ) ); + fireEvent.click( screen.getAllByLabelText( labelText )[ 0 ] ); // Wait for comments field to show. - await screen.findByLabelText( 'Comments (optional)' ); + await screen.findByLabelText( + 'How is that screen useful to you? What features would you add or change?' + ); // Select neutral score. - fireEvent.click( screen.getByLabelText( 'Neutral' ) ); + fireEvent.click( screen.getAllByLabelText( 'Neutral' )[ 0 ] ); // Wait for comments field to hide. await waitFor( () => { expect( - screen.queryByLabelText( 'Comments (optional)' ) + screen.queryByLabelText( + 'How is that screen useful to you? What features would you add or change?' + ) ).not.toBeInTheDocument(); } ); } diff --git a/packages/js/customer-effort-score/src/style.scss b/packages/js/customer-effort-score/src/style.scss index 6c7397a20f8..adabb8fdba9 100644 --- a/packages/js/customer-effort-score/src/style.scss +++ b/packages/js/customer-effort-score/src/style.scss @@ -1,7 +1,7 @@ @import 'customer-feedback-simple/customer-feedback-simple.scss'; .woocommerce-customer-effort-score__selection { - margin: 1em 0; + margin: 1em 0 1.5em 0; .components-base-control__field { display: flex; @@ -11,6 +11,13 @@ @include breakpoint( '>600px' ) { flex-direction: row; + justify-content: center; + } + + .components-h-stack { + @include breakpoint( '>600px' ) { + flex-direction: row; + } } } @@ -84,10 +91,14 @@ } .woocommerce-customer-effort-score__comments { + margin-bottom: 1.5em; + label { display: block; color: inherit; font-weight: bold; + text-transform: none; + font-size: 14px; } textarea { @@ -102,3 +113,8 @@ margin-left: 1em; } } + +.woocommerce-customer-effort-score .woocommerce-customer-effort-score__intro { + max-width: 550px; + margin: 0 0 1.5em 0; +} diff --git a/packages/js/customer-effort-score/src/test/index.tsx b/packages/js/customer-effort-score/src/test/index.tsx index 59f54f2afce..e8bd5e774cc 100644 --- a/packages/js/customer-effort-score/src/test/index.tsx +++ b/packages/js/customer-effort-score/src/test/index.tsx @@ -35,7 +35,9 @@ describe( 'CustomerEffortScore', () => { render( @@ -45,7 +47,7 @@ describe( 'CustomerEffortScore', () => { // Notice status. expect.any( String ), // Notice message. - 'label', + 'title', // Notice options. expect.objectContaining( { icon, @@ -63,7 +65,9 @@ describe( 'CustomerEffortScore', () => { const { rerender } = render( ); @@ -71,7 +75,9 @@ describe( 'CustomerEffortScore', () => { rerender( ); @@ -82,7 +88,9 @@ describe( 'CustomerEffortScore', () => { render( ); @@ -121,7 +129,9 @@ describe( 'CustomerEffortScore', () => { render( ); diff --git a/packages/js/data/changelog/add-35175 b/packages/js/data/changelog/add-35175 new file mode 100644 index 00000000000..e5dbd235dee --- /dev/null +++ b/packages/js/data/changelog/add-35175 @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Add missing reviews property to product data diff --git a/packages/js/data/changelog/add-35771 b/packages/js/data/changelog/add-35771 new file mode 100644 index 00000000000..28a5bdaefb9 --- /dev/null +++ b/packages/js/data/changelog/add-35771 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add product variations data store diff --git a/packages/js/data/changelog/add-35772 b/packages/js/data/changelog/add-35772 new file mode 100644 index 00000000000..289817caff8 --- /dev/null +++ b/packages/js/data/changelog/add-35772 @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Update attributes type for product variations data store diff --git a/packages/js/data/changelog/add-in-app-tour-track-events b/packages/js/data/changelog/add-in-app-tour-track-events new file mode 100644 index 00000000000..f9d60be1200 --- /dev/null +++ b/packages/js/data/changelog/add-in-app-tour-track-events @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add an attribute to an onboarding task to indicate whether to track a task view. diff --git a/packages/js/data/changelog/dev-adjust-sync b/packages/js/data/changelog/dev-adjust-sync new file mode 100644 index 00000000000..395bc6d8d2d --- /dev/null +++ b/packages/js/data/changelog/dev-adjust-sync @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Dev dependency bump diff --git a/packages/js/data/changelog/fix-34929 b/packages/js/data/changelog/fix-34929 new file mode 100644 index 00000000000..c01aa5a1f0b --- /dev/null +++ b/packages/js/data/changelog/fix-34929 @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add is_read query option for notes data store diff --git a/packages/js/data/package.json b/packages/js/data/package.json index 5dfa2234cd3..cd803090447 100644 --- a/packages/js/data/package.json +++ b/packages/js/data/package.json @@ -57,7 +57,7 @@ "@types/lodash": "^4.14.182", "@types/md5": "^2.3.2", "@types/qs": "^6.9.7", - "@types/react": "^17.0.0", + "@types/react": "^17.0.2", "@types/wordpress__compose": "^4.0.1", "@types/wordpress__core-data": "^2.4.5", "@types/wordpress__data": "^6.0.0", diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts index be23c55eff3..f386b083731 100644 --- a/packages/js/data/src/index.ts +++ b/packages/js/data/src/index.ts @@ -24,6 +24,7 @@ export { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones'; export { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags'; export { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories'; export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms'; +export { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations'; export { PaymentGateway } from './payment-gateways/types'; // Export hooks @@ -76,6 +77,7 @@ export * from './countries/types'; export * from './onboarding/types'; export * from './plugins/types'; export * from './products/types'; +export { ProductVariation } from './product-variations/types'; export { QueryProductAttribute, ProductAttributeSelectors, @@ -115,6 +117,7 @@ import type { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones'; import type { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags'; import type { EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME } from './product-categories'; import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms'; +import type { EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME } from './product-variations'; export type WCDataStoreName = | typeof REVIEWS_STORE_NAME @@ -136,7 +139,8 @@ export type WCDataStoreName = | typeof EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME | typeof EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME - | typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME; + | typeof EXPERIMENTAL_PRODUCT_CATEGORIES_STORE_NAME + | typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME; /** * Internal dependencies @@ -154,6 +158,7 @@ import { ShippingZonesSelectors } from './shipping-zones/types'; import { ProductTagSelectors } from './product-tags/types'; import { ProductCategorySelectors } from './product-categories/types'; import { ProductAttributeTermsSelectors } from './product-attribute-terms/types'; +import { ProductVariationSelectors } from './product-variations/types'; // As we add types to all the package selectors we can fill out these unknown types with real ones. See one // of the already typed selectors for an example of how you can do this. @@ -193,6 +198,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME ? ProductCategorySelectors : T extends typeof EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME ? ProductAttributeTermsSelectors + : T extends typeof EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME + ? ProductVariationSelectors : T extends typeof ORDERS_STORE_NAME ? OrdersSelectors : T extends typeof EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME @@ -209,6 +216,7 @@ export { ActionDispatchers as ProductAttributesActions } from './product-attribu export { ActionDispatchers as ProductTagsActions } from './product-tags/types'; export { ActionDispatchers as ProductCategoryActions } from './product-categories/types'; export { ActionDispatchers as ProductAttributeTermsActions } from './product-attribute-terms/types'; +export { ActionDispatchers as ProductVariationsActions } from './product-variations/types'; export { ActionDispatchers as ProductsStoreActions } from './products/actions'; export { ActionDispatchers as ProductShippingClassesActions } from './product-shipping-classes/types'; export { ActionDispatchers as ShippingZonesActions } from './shipping-zones/types'; diff --git a/packages/js/data/src/notes/types.ts b/packages/js/data/src/notes/types.ts index 4fd8c183f7a..8494970fcbd 100644 --- a/packages/js/data/src/notes/types.ts +++ b/packages/js/data/src/notes/types.ts @@ -52,6 +52,7 @@ export type Note = { // [Notes.php](https://github.com/woocommerce/woocommerce/blob/af97aaf41067bcd0b7ff12df9b6169f97c326c0f/plugins/woocommerce/src/Admin/API/Notes.php#L629-L699) export type NoteQuery = Partial< { context: string; + is_read: boolean; order: 'asc' | 'desc'; orderby: 'note_id' | 'date' | 'type' | 'title' | 'status'; page: number; diff --git a/packages/js/data/src/onboarding/types.ts b/packages/js/data/src/onboarding/types.ts index 24060daa19d..7cbf182201c 100644 --- a/packages/js/data/src/onboarding/types.ts +++ b/packages/js/data/src/onboarding/types.ts @@ -25,6 +25,7 @@ export type TaskType = { isActioned: boolean; eventPrefix: string; level: number; + recordViewEvent: boolean; additionalData?: { woocommerceTaxCountries?: string[]; taxJarActivated?: boolean; diff --git a/packages/js/data/src/product-variations/constants.ts b/packages/js/data/src/product-variations/constants.ts new file mode 100644 index 00000000000..45f88e4ca41 --- /dev/null +++ b/packages/js/data/src/product-variations/constants.ts @@ -0,0 +1,4 @@ +export const STORE_NAME = 'wc/admin/products/variations'; + +export const WC_PRODUCT_VARIATIONS_NAMESPACE = + '/wc/v3/products/{product_id}/variations'; diff --git a/packages/js/data/src/product-variations/index.ts b/packages/js/data/src/product-variations/index.ts new file mode 100644 index 00000000000..4a6a444c202 --- /dev/null +++ b/packages/js/data/src/product-variations/index.ts @@ -0,0 +1,14 @@ +/** + * Internal dependencies + */ +import { STORE_NAME, WC_PRODUCT_VARIATIONS_NAMESPACE } from './constants'; +import { createCrudDataStore } from '../crud'; + +createCrudDataStore( { + storeName: STORE_NAME, + resourceName: 'ProductVariation', + pluralResourceName: 'ProductVariations', + namespace: WC_PRODUCT_VARIATIONS_NAMESPACE, +} ); + +export const EXPERIMENTAL_PRODUCT_VARIATIONS_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/product-variations/types.ts b/packages/js/data/src/product-variations/types.ts new file mode 100644 index 00000000000..2369bce1d8c --- /dev/null +++ b/packages/js/data/src/product-variations/types.ts @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { DispatchFromMap } from '@automattic/data-stores'; + +/** + * Internal dependencies + */ +import { CrudActions, CrudSelectors } from '../crud/types'; +import { Product, ProductQuery, ReadOnlyProperties } from '../products/types'; + +export type ProductVariationAttribute = { + id: number; + name: string; + option: string; +}; + +export type ProductVariation = Omit< + Product, + 'name' | 'slug' | 'attributes' +> & { + attributes: ProductVariationAttribute[]; +}; + +type Query = Omit< ProductQuery, 'name' >; + +type MutableProperties = Partial< + Omit< ProductVariation, ReadOnlyProperties > +>; + +type ProductVariationActions = CrudActions< + 'ProductVariation', + ProductVariation, + MutableProperties +>; + +export type ProductVariationSelectors = CrudSelectors< + 'ProductVariation', + 'ProductVariations', + ProductVariation, + Query, + MutableProperties +>; + +export type ActionDispatchers = DispatchFromMap< ProductVariationActions >; diff --git a/packages/js/data/src/products/types.ts b/packages/js/data/src/products/types.ts index 961261a771f..7b3f6154634 100644 --- a/packages/js/data/src/products/types.ts +++ b/packages/js/data/src/products/types.ts @@ -45,79 +45,80 @@ export type Product< Status = ProductStatus, Type = ProductType > = Omit< Schema.Post, 'status' | 'categories' > & { - id: number; - name: string; - slug: string; - permalink: string; + attributes: ProductAttribute[]; + average_rating: string; + backordered: boolean; + backorders: 'no' | 'notify' | 'yes'; + backorders_allowed: boolean; + button_text: string; + categories: Pick< ProductCategory, 'id' | 'name' | 'slug' >[]; date_created: string; date_created_gmt: string; date_modified: string; date_modified_gmt: string; - type: Type; - status: Status; - featured: boolean; - description: string; - short_description: string; - sku: string; date_on_sale_from_gmt: string | null; date_on_sale_to_gmt: string | null; - virtual: boolean; + description: string; + dimensions: ProductDimensions; + download_expiry: number; + download_limit: number; downloadable: boolean; downloads: ProductDownload[]; - download_limit: number; - download_expiry: number; external_url: string; - button_text: string; - tax_status: 'taxable' | 'shipping' | 'none'; - tax_class: 'standard' | 'reduced-rate' | 'zero-rate' | undefined; - manage_stock: boolean; - stock_quantity: number; + featured: boolean; + id: number; low_stock_amount: number; - stock_status: 'instock' | 'outofstock' | 'onbackorder'; - backorders: 'no' | 'notify' | 'yes'; + manage_stock: boolean; + name: string; + on_sale: boolean; + permalink: string; price: string; price_html: string; - regular_price: string; - sale_price: string; - on_sale: boolean; purchasable: boolean; - total_sales: number; - backorders_allowed: boolean; - backordered: boolean; - shipping_required: boolean; - shipping_taxable: boolean; - shipping_class: string; - shipping_class_id: number; - average_rating: string; + regular_price: string; rating_count: number; related_ids: number[]; + reviews_allowed: boolean; + sale_price: string; + shipping_class: string; + shipping_class_id: number; + shipping_required: boolean; + shipping_taxable: boolean; + short_description: string; + slug: string; + sku: string; + status: Status; + stock_quantity: number; + stock_status: 'instock' | 'outofstock' | 'onbackorder'; + tax_class: 'standard' | 'reduced-rate' | 'zero-rate' | undefined; + tax_status: 'taxable' | 'shipping' | 'none'; + total_sales: number; + type: Type; variations: number[]; - attributes: ProductAttribute[]; - dimensions: ProductDimensions; + virtual: boolean; weight: string; - categories: Pick< ProductCategory, 'id' | 'name' | 'slug' >[]; }; export const productReadOnlyProperties = [ - 'id', - 'permalink', + 'average_rating', + 'backordered', + 'backorders_allowed', 'date_created', 'date_created_gmt', 'date_modified', 'date_modified_gmt', + 'id', + 'on_sale', + 'permalink', 'price', 'price_html', - 'on_sale', 'purchasable', - 'total_sales', - 'backorders_allowed', - 'backordered', - 'shipping_required', - 'shipping_taxable', - 'shipping_class_id', - 'average_rating', 'rating_count', 'related_ids', + 'shipping_class_id', + 'shipping_required', + 'shipping_taxable', + 'total_sales', 'variations', ] as const; diff --git a/packages/js/e2e-utils/package.json b/packages/js/e2e-utils/package.json index e95728acd7c..d643cdc4553 100644 --- a/packages/js/e2e-utils/package.json +++ b/packages/js/e2e-utils/package.json @@ -30,8 +30,8 @@ "@babel/plugin-transform-runtime": "^7.16.4", "@babel/polyfill": "7.12.1", "@babel/preset-env": "7.12.7", - "@typescript-eslint/eslint-plugin": "^5.3.0", - "@typescript-eslint/parser": "^5.3.0", + "@typescript-eslint/eslint-plugin": "^5.43.0", + "@typescript-eslint/parser": "^5.43.0", "@woocommerce/eslint-plugin": "workspace:*", "@woocommerce/internal-e2e-builds": "workspace:*", "@wordpress/babel-plugin-import-jsx-pragma": "1.1.3", diff --git a/packages/js/experimental/changelog/fix-34929 b/packages/js/experimental/changelog/fix-34929 new file mode 100644 index 00000000000..2fedcc1e932 --- /dev/null +++ b/packages/js/experimental/changelog/fix-34929 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Check for note actions before checking length diff --git a/packages/js/experimental/src/inbox-note/inbox-note.tsx b/packages/js/experimental/src/inbox-note/inbox-note.tsx index 1df953e5fa0..0ad957e921f 100644 --- a/packages/js/experimental/src/inbox-note/inbox-note.tsx +++ b/packages/js/experimental/src/inbox-note/inbox-note.tsx @@ -171,7 +171,7 @@ const InboxNoteCard: React.FC< InboxNoteProps > = ( { const actionWrapperClassName = classnames( 'woocommerce-inbox-message__actions', { - 'has-multiple-actions': note.actions.length > 1, + 'has-multiple-actions': note.actions?.length > 1, } ); diff --git a/packages/js/explat/changelog/dev-adjust-sync b/packages/js/explat/changelog/dev-adjust-sync new file mode 100644 index 00000000000..395bc6d8d2d --- /dev/null +++ b/packages/js/explat/changelog/dev-adjust-sync @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Dev dependency bump diff --git a/packages/js/explat/package.json b/packages/js/explat/package.json index 918ebf45f47..1546a3faded 100644 --- a/packages/js/explat/package.json +++ b/packages/js/explat/package.json @@ -43,7 +43,7 @@ "@types/jest": "^27.4.1", "@types/node": "^17.0.21", "@types/qs": "^6.9.7", - "@types/react": "^17.0.0", + "@types/react": "^17.0.2", "@woocommerce/eslint-plugin": "workspace:*", "eslint": "^8.12.0", "jest": "^27.5.1", diff --git a/packages/js/extend-cart-checkout-block/CHANGELOG.md b/packages/js/extend-cart-checkout-block/CHANGELOG.md index e69de29bb2d..ade90a4603f 100644 --- a/packages/js/extend-cart-checkout-block/CHANGELOG.md +++ b/packages/js/extend-cart-checkout-block/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.1.0](https://www.npmjs.com/package/@woocommerce/extend-cart-checkout-block/v/1.1.0) - 2022-11-21 + +- Patch - Include an example of adding an inner block to the WooCommerce Blocks Checkout Block [#35609] +- Minor - Fix node and pnpm versions via engines [#35609] +- Minor - Update pnpm version constraint to 7.13.3 to avoid auto-install-peers issues [#35609] + +## [1.0.0](https://www.npmjs.com/package/@woocommerce/extend-cart-checkout-block/v/1.0.0) - 2022-06-01 + +- Patch - Initial release + +[See legacy changelogs for previous versions](https://github.com/woocommerce/woocommerce/blob/68581955106947918d2b17607a01bdfdf22288a9/packages/js/extend-cart-checkout-block/CHANGELOG.md). diff --git a/packages/js/extend-cart-checkout-block/changelog/add-inner-block-example b/packages/js/extend-cart-checkout-block/changelog/add-inner-block-example deleted file mode 100644 index 1a22ae4197f..00000000000 --- a/packages/js/extend-cart-checkout-block/changelog/add-inner-block-example +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: update - -Include an example of adding an inner block to the WooCommerce Blocks Checkout Block diff --git a/packages/js/extend-cart-checkout-block/changelog/dev-bump-pnpm-version-restraint b/packages/js/extend-cart-checkout-block/changelog/dev-bump-pnpm-version-restraint deleted file mode 100644 index f7511cb6974..00000000000 --- a/packages/js/extend-cart-checkout-block/changelog/dev-bump-pnpm-version-restraint +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: dev - -Update pnpm version constraint to 7.13.3 to avoid auto-install-peers issues diff --git a/packages/js/extend-cart-checkout-block/changelog/dev-fix-pnpm-version-engines b/packages/js/extend-cart-checkout-block/changelog/dev-fix-pnpm-version-engines deleted file mode 100644 index a1804a282f0..00000000000 --- a/packages/js/extend-cart-checkout-block/changelog/dev-fix-pnpm-version-engines +++ /dev/null @@ -1,4 +0,0 @@ -Significance: minor -Type: dev - -Fix node and pnpm versions via engines diff --git a/packages/js/extend-cart-checkout-block/changelog/dev-simplify-turbo b/packages/js/extend-cart-checkout-block/changelog/dev-simplify-turbo deleted file mode 100644 index 0d230384010..00000000000 --- a/packages/js/extend-cart-checkout-block/changelog/dev-simplify-turbo +++ /dev/null @@ -1,5 +0,0 @@ -Significance: patch -Type: dev -Comment: Package scripts were modified to support simplified running of turbo commands in the monorepo. - - diff --git a/packages/js/extend-cart-checkout-block/package.json b/packages/js/extend-cart-checkout-block/package.json index c60e8220d88..0d7ff7db5ef 100644 --- a/packages/js/extend-cart-checkout-block/package.json +++ b/packages/js/extend-cart-checkout-block/package.json @@ -1,6 +1,6 @@ { "name": "@woocommerce/extend-cart-checkout-block", - "version": "1.0.0", + "version": "1.1.0", "description": "", "main": "index.js", "engines": { diff --git a/packages/js/internal-style-build/abstracts/_mixins.scss b/packages/js/internal-style-build/abstracts/_mixins.scss index aec22045af3..43ab642052d 100644 --- a/packages/js/internal-style-build/abstracts/_mixins.scss +++ b/packages/js/internal-style-build/abstracts/_mixins.scss @@ -1,7 +1,7 @@ // Rem output with px fallback @mixin font-size( $sizeValue: 16, $lineHeight: false ) { font-size: $sizeValue + px; - font-size: math.div($sizeValue, 16) + rem; + font-size: math.div( $sizeValue, 16 ) + rem; @if ( $lineHeight ) { line-height: $lineHeight; } @@ -106,7 +106,7 @@ background-color: $studio-white; color: $gray-900; box-shadow: inset 0 0 0 1px $gray-400, inset 0 0 0 2px $studio-white, - 0 1px 1px rgba($gray-900, 0.2); + 0 1px 1px rgba( $gray-900, 0.2 ); } @mixin button-style__active() { @@ -145,7 +145,6 @@ } /* stylelint-enable */ - // Gutenberg Switch. @mixin switch-style__focus-active() { box-shadow: 0 0 0 2px $studio-white, 0 0 0 3px $gray-700; @@ -158,19 +157,20 @@ // Sets positions for children of grid elements @mixin set-grid-item-position( $wrap-after, $number-of-items ) { @for $i from 1 through $number-of-items { - &:nth-child(#{$i}) { - grid-column-start: #{($i - 1) % $wrap-after + 1}; - grid-column-end: #{($i - 1) % $wrap-after + 2}; - grid-row-start: #{floor(math.div($i - 1, $wrap-after)) + 1}; - grid-row-end: #{floor(math.div($i - 1, $wrap-after)) + 2}; + &:nth-child( #{$i} ) { + grid-column-start: #{( $i - 1 ) % $wrap-after + 1}; + grid-column-end: #{( $i - 1 ) % $wrap-after + 2}; + grid-row-start: #{floor( math.div( $i - 1, $wrap-after ) ) + 1}; + grid-row-end: #{floor( math.div( $i - 1, $wrap-after ) ) + 2}; } } } // Hide an element from sighted users, but availble to screen reader users. +// @deprecated in favor of screen-reader-only @mixin visually-hidden() { - clip: rect(1px, 1px, 1px, 1px); - clip-path: inset(50%); + clip: rect( 1px, 1px, 1px, 1px ); + clip-path: inset( 50% ); height: 1px; width: 1px; margin: -1px; @@ -180,7 +180,20 @@ word-wrap: normal !important; } +@mixin screen-reader-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect( 0, 0, 0, 0 ); + white-space: nowrap; + border-width: 0; +} + // Unhide a visually hidden element +// @deprecated in favor of not-screen-reader-only @mixin visually-shown() { clip: auto; clip-path: none; @@ -190,6 +203,17 @@ overflow: hidden; } +@mixin not-screen-reader-only { + position: static; + width: auto; + height: auto; + padding: 0; + margin: 0; + overflow: visible; + clip: auto; + white-space: normal; +} + // Create a string-repeat function @function str-repeat( $character, $n ) { @if $n == 0 { diff --git a/packages/js/internal-style-build/abstracts/_variables.scss b/packages/js/internal-style-build/abstracts/_variables.scss index 4b9d4ee625a..73155c89baf 100644 --- a/packages/js/internal-style-build/abstracts/_variables.scss +++ b/packages/js/internal-style-build/abstracts/_variables.scss @@ -44,6 +44,7 @@ $alert-green: $valid-green; // WordPress defaults $adminbar-height: 32px; $adminbar-height-mobile: 46px; +$admin-menu-width: 160px; // wp-admin colors $wp-admin-background: #f1f1f1; diff --git a/packages/js/internal-style-build/changelog/enhancement-35565 b/packages/js/internal-style-build/changelog/enhancement-35565 new file mode 100644 index 00000000000..c69b2129169 --- /dev/null +++ b/packages/js/internal-style-build/changelog/enhancement-35565 @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Add updated versions of screen-reader-only and not-screen-reader-only mixins diff --git a/plugins/woocommerce/changelog/fix-skip-failing-api-test b/packages/js/onboarding/changelog/fix-35704-wcpay-benefit-padding similarity index 51% rename from plugins/woocommerce/changelog/fix-skip-failing-api-test rename to packages/js/onboarding/changelog/fix-35704-wcpay-benefit-padding index 86578e5f08d..77755055d34 100644 --- a/plugins/woocommerce/changelog/fix-skip-failing-api-test +++ b/packages/js/onboarding/changelog/fix-35704-wcpay-benefit-padding @@ -1,4 +1,4 @@ Significance: patch Type: fix -Skip flaky settings API test +Fix wcpay benefits padding diff --git a/packages/js/onboarding/src/components/WCPayBenefits/WCPayBenefits.tsx b/packages/js/onboarding/src/components/WCPayBenefits/WCPayBenefits.tsx index 29c96fbf261..8ec7a90d4f5 100644 --- a/packages/js/onboarding/src/components/WCPayBenefits/WCPayBenefits.tsx +++ b/packages/js/onboarding/src/components/WCPayBenefits/WCPayBenefits.tsx @@ -17,7 +17,7 @@ import { export const WCPayBenefits: React.VFC = () => { return ( - + diff --git a/plugins/woocommerce-admin/babel.config.js b/plugins/woocommerce-admin/babel.config.js index 70215466892..d1987422307 100644 --- a/plugins/woocommerce-admin/babel.config.js +++ b/plugins/woocommerce-admin/babel.config.js @@ -23,28 +23,6 @@ module.exports = function ( api ) { ignore: [ 'packages/**/node_modules' ], env: { production: {}, - - storybook: { - plugins: [ - /** - * We need to set loose mode here because the storybook's default babel config enables the loose mode. - * The 'loose' mode configuration must be the same for those babel plugins. - * - */ - [ - '@babel/plugin-proposal-class-properties', - { loose: true }, - ], - [ - '@babel/plugin-proposal-private-methods', - { loose: true }, - ], - [ - '@babel/plugin-proposal-private-property-in-object', - { loose: true }, - ], - ], - }, }, }; }; diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/constants.ts b/plugins/woocommerce-admin/client/customer-effort-score-tracks/constants.ts new file mode 100644 index 00000000000..49a469270be --- /dev/null +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/constants.ts @@ -0,0 +1,5 @@ +export const SHOWN_FOR_ACTIONS_OPTION_NAME = + 'woocommerce_ces_shown_for_actions'; +export const ADMIN_INSTALL_TIMESTAMP_OPTION_NAME = + 'woocommerce_admin_install_timestamp'; +export const ALLOW_TRACKING_OPTION_NAME = 'woocommerce_allow_tracking'; diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-exit-page.ts b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-exit-page.ts new file mode 100644 index 00000000000..9b261d3de62 --- /dev/null +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-exit-page.ts @@ -0,0 +1,232 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { OPTIONS_STORE_NAME } from '@woocommerce/data'; +import { dispatch, resolveSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { ALLOW_TRACKING_OPTION_NAME } from './constants'; + +const CUSTOMER_EFFORT_SCORE_EXIT_PAGE_KEY = 'customer-effort-score-exit-page'; + +let allowTracking = false; +resolveSelect( OPTIONS_STORE_NAME ) + .getOption( ALLOW_TRACKING_OPTION_NAME ) + .then( ( trackingOption ) => { + allowTracking = trackingOption === 'yes'; + } ); + +/** + * Gets the list of exited pages from Localstorage. + */ +export const getExitPageData = () => { + if ( ! window.localStorage ) { + return []; + } + + const items = window.localStorage.getItem( + CUSTOMER_EFFORT_SCORE_EXIT_PAGE_KEY + ); + const parsedJSONItems = items ? JSON.parse( items ) : []; + const arrayItems = Array.isArray( parsedJSONItems ) ? parsedJSONItems : []; + + return arrayItems; +}; + +/** + * Adds the page to the exit page list in Localstorage. + * + * @param {string} pageId of page exited early. + */ +export const addExitPage = ( pageId: string ) => { + if ( ! window.localStorage ) { + return; + } + + let items = getExitPageData(); + + if ( ! items.find( ( pageExitedId ) => pageExitedId === pageId ) ) { + items.push( pageId ); + } + items = items.slice( -10 ); // Upper limit. + + window.localStorage.setItem( + CUSTOMER_EFFORT_SCORE_EXIT_PAGE_KEY, + JSON.stringify( items ) + ); +}; + +/** + * Removes the passed in page id from the list in Localstorage. + * + * @param {string} pageId of page to be removed. + */ +export const removeExitPage = ( pageId: string ) => { + if ( ! window.localStorage ) { + return; + } + + let items = getExitPageData(); + + items = items.filter( ( pageExitedId ) => pageExitedId !== pageId ); + items = items.slice( -10 ); // Upper limit. + + window.localStorage.setItem( + CUSTOMER_EFFORT_SCORE_EXIT_PAGE_KEY, + JSON.stringify( items ) + ); +}; + +const eventListeners: Record< string, ( event: BeforeUnloadEvent ) => void > = + {}; + +/** + * Adds unload event listener to add pageId to exit page list incase there were unsaved changes. + * + * @param {string} pageId the page id of the page being exited early. + * @param {Function} hasUnsavedChanges callback to check if the page had unsaved changes. + */ +export const addCustomerEffortScoreExitPageListener = ( + pageId: string, + hasUnsavedChanges: () => boolean +) => { + eventListeners[ pageId ] = ( event ) => { + if ( hasUnsavedChanges() && allowTracking ) { + addExitPage( pageId ); + } + }; + window.addEventListener( 'unload', eventListeners[ pageId ] ); +}; + +/** + * Removes the unload exit page listener. + * + * @param {string} pageId the page id to remove the listener from. + */ +export const removeCustomerEffortScoreExitPageListener = ( pageId: string ) => { + if ( eventListeners[ pageId ] ) { + window.removeEventListener( 'unload', eventListeners[ pageId ], { + capture: true, + } ); + } +}; + +/** + * Returns the exit page copy of the passed in pageId. + * + * @param {string} pageId page id. + */ +function getExitPageCESCopy( pageId: string ): { + action: string; + title: string; + firstQuestion: string; + secondQuestion: string; + noticeLabel?: string; + description?: string; + icon?: string; +} | null { + switch ( pageId ) { + case 'product_edit_view': + case 'editing_new_product': + return { + action: + pageId === 'editing_new_product' ? 'new_product' : pageId, + noticeLabel: __( + 'How is your experience with editing products?', + 'woocommerce' + ), + title: __( + "How's your experience with editing products?", + 'woocommerce' + ), + description: __( + 'We noticed you started editing a product, then left. How was it? Your feedback will help create a better experience for thousands of merchants like you.', + 'woocommerce' + ), + firstQuestion: __( + 'The product editing screen is easy to use', + 'woocommerce' + ), + secondQuestion: __( + "The product editing screen's functionality meets my needs", + 'woocommerce' + ), + }; + case 'product_add_view': + case 'new_product': + return { + action: pageId, + noticeLabel: __( + 'How is your experience with creating products?', + 'woocommerce' + ), + title: __( + 'How is your experience with creating products?', + 'woocommerce' + ), + description: __( + 'We noticed you started creating a product, then left. How was it? Your feedback will help create a better experience for thousands of merchants like you.', + 'woocommerce' + ), + firstQuestion: __( + 'The product creation screen is easy to use', + 'woocommerce' + ), + secondQuestion: __( + "The product creation screen's functionality meets my needs", + 'woocommerce' + ), + }; + case 'settings_change': + return { + action: pageId, + icon: '⚙️', + noticeLabel: __( + 'Did you find the right setting?', + 'woocommerce' + ), + title: __( + 'How’s your experience with settings?', + 'woocommerce' + ), + description: __( + 'We noticed you started changing store settings, then left. How was it? Your feedback will help create a better experience for thousands of merchants like you.', + 'woocommerce' + ), + firstQuestion: __( + 'The settings screen is easy to use', + 'woocommerce' + ), + secondQuestion: __( + "The settings screen's functionality meets my needs", + 'woocommerce' + ), + }; + default: + return null; + } +} + +/** + * Checks the exit page list and triggers a CES survey for the first item in the list. + */ +export function triggerExitPageCesSurvey() { + const exitPageItems: string[] = getExitPageData(); + if ( exitPageItems && exitPageItems.length > 0 ) { + const copy = getExitPageCESCopy( exitPageItems[ 0 ] ); + if ( copy && copy.title.length > 0 ) { + dispatch( 'wc/customer-effort-score' ).addCesSurvey( { + ...copy, + pageNow: window.pagenow, + adminPage: window.adminpage, + props: { + ces_location: 'outside', + }, + } ); + } + removeExitPage( exitPageItems[ 0 ] ); + } +} diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-modal-container.tsx b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-modal-container.tsx new file mode 100644 index 00000000000..038f166ac07 --- /dev/null +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-modal-container.tsx @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { CustomerFeedbackModal } from '@woocommerce/customer-effort-score'; +import { recordEvent } from '@woocommerce/tracks'; +import { OPTIONS_STORE_NAME } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { getStoreAgeInWeeks } from './utils'; +import { ADMIN_INSTALL_TIMESTAMP_OPTION_NAME } from './constants'; +import { STORE_KEY } from './data/constants'; + +export const PRODUCT_MVP_CES_ACTION_OPTION_NAME = + 'woocommerce_ces_product_mvp_ces_action'; + +export const CustomerEffortScoreModalContainer: React.FC = () => { + const { createSuccessNotice } = useDispatch( 'core/notices' ); + const { hideCesModal } = useDispatch( STORE_KEY ); + const { + storeAgeInWeeks, + resolving: isLoading, + visibleCESModalData, + } = useSelect( ( select ) => { + const { getOption, hasFinishedResolution } = + select( OPTIONS_STORE_NAME ); + const { getVisibleCESModalData } = select( STORE_KEY ); + + const adminInstallTimestamp = + ( getOption( ADMIN_INSTALL_TIMESTAMP_OPTION_NAME ) as number ) || 0; + + const resolving = + adminInstallTimestamp === null || + ! hasFinishedResolution( 'getOption', [ + ADMIN_INSTALL_TIMESTAMP_OPTION_NAME, + ] ); + + return { + storeAgeInWeeks: getStoreAgeInWeeks( adminInstallTimestamp ), + visibleCESModalData: getVisibleCESModalData(), + resolving, + }; + } ); + + const recordScore = ( + score: number, + secondScore: number, + comments: string + ) => { + recordEvent( 'ces_feedback', { + action: visibleCESModalData.action, + score, + score_second_question: secondScore ?? null, + score_combined: score + ( secondScore ?? 0 ), + comments: comments || '', + store_age: storeAgeInWeeks, + } ); + createSuccessNotice( + visibleCESModalData.onSubmitLabel || + __( + "Thanks for the feedback. We'll put it to good use!", + 'woocommerce' + ), + visibleCESModalData.onSubmitNoticeProps || {} + ); + }; + + if ( ! visibleCESModalData || isLoading ) { + return null; + } + + return ( + { + recordScore( ...args ); + hideCesModal(); + } } + onCloseModal={ () => hideCesModal() } + /> + ); +}; diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-tracks-container.js b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-tracks-container.js index 32fba5c39e6..4b55614aa21 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-tracks-container.js +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-tracks-container.js @@ -49,7 +49,12 @@ function CustomerEffortScoreTracksContainer( { diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-tracks.js b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-tracks.js index b55b39548e3..0b1f7960fbb 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-tracks.js +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/customer-effort-score-tracks.js @@ -7,13 +7,18 @@ import { recordEvent } from '@woocommerce/tracks'; import { CustomerEffortScore } from '@woocommerce/customer-effort-score'; import { compose } from '@wordpress/compose'; import { withSelect, withDispatch } from '@wordpress/data'; -import { OPTIONS_STORE_NAME, WEEK } from '@woocommerce/data'; +import { OPTIONS_STORE_NAME } from '@woocommerce/data'; import { __ } from '@wordpress/i18n'; -const SHOWN_FOR_ACTIONS_OPTION_NAME = 'woocommerce_ces_shown_for_actions'; -const ADMIN_INSTALL_TIMESTAMP_OPTION_NAME = - 'woocommerce_admin_install_timestamp'; -const ALLOW_TRACKING_OPTION_NAME = 'woocommerce_allow_tracking'; +/** + * Internal dependencies + */ +import { + SHOWN_FOR_ACTIONS_OPTION_NAME, + ADMIN_INSTALL_TIMESTAMP_OPTION_NAME, + ALLOW_TRACKING_OPTION_NAME, +} from './constants'; +import { getStoreAgeInWeeks } from './utils'; /** * A CustomerEffortScore wrapper that uses tracks to track the selected @@ -22,7 +27,12 @@ const ALLOW_TRACKING_OPTION_NAME = 'woocommerce_allow_tracking'; * @param {Object} props Component props. * @param {string} props.action The action name sent to Tracks. * @param {Object} props.trackProps Additional props sent to Tracks. - * @param {string} props.label The label displayed in the modal. + * @param {string} props.title The title displayed in the modal. + * @param {string} props.noticeLabel Label for notice, defaults to title. + * @param {string} props.description Description shown in CES modal. + * @param {string} props.firstQuestion The first survey question. + * @param {string} props.secondQuestion The second survey question. + * @param {string} props.icon Optional icon to show in notice. * @param {string} props.onSubmitLabel The label displayed upon survey submission. * @param {Array} props.cesShownForActions The array of actions that the CES modal has been shown for. * @param {boolean} props.allowTracking Whether tracking is allowed or not. @@ -34,7 +44,12 @@ const ALLOW_TRACKING_OPTION_NAME = 'woocommerce_allow_tracking'; function CustomerEffortScoreTracks( { action, trackProps, - label, + title, + description, + noticeLabel, + firstQuestion, + secondQuestion, + icon, onSubmitLabel = __( 'Thank you for your feedback!', 'woocommerce' ), cesShownForActions, allowTracking, @@ -61,7 +76,11 @@ function CustomerEffortScoreTracks( { // (we don't want to return null early), if the modal was shown for this // instantiation, so that the component doesn't go away while we are // still showing it. - if ( cesShownForActions.indexOf( action ) !== -1 && ! modalShown ) { + if ( + cesShownForActions && + cesShownForActions.indexOf( action ) !== -1 && + ! modalShown + ) { return null; } @@ -69,6 +88,7 @@ function CustomerEffortScoreTracks( { recordEvent( 'ces_snackbar_view', { action, store_age: storeAgeInWeeks, + ces_location: 'inside', ...trackProps, } ); }; @@ -77,7 +97,7 @@ function CustomerEffortScoreTracks( { updateOptions( { [ SHOWN_FOR_ACTIONS_OPTION_NAME ]: [ action, - ...cesShownForActions, + ...( cesShownForActions || [] ), ], } ); }; @@ -86,30 +106,44 @@ function CustomerEffortScoreTracks( { recordEvent( 'ces_snackbar_dismiss', { action, store_age: storeAgeInWeeks, + ces_location: 'inside', ...trackProps, } ); addActionToShownOption(); }; + const onModalDismissed = () => { + recordEvent( 'ces_view_dismiss', { + action, + store_age: storeAgeInWeeks, + ces_location: 'inside', + ...trackProps, + } ); + }; + const onModalShown = () => { setModalShown( true ); recordEvent( 'ces_view', { action, store_age: storeAgeInWeeks, + ces_location: 'inside', ...trackProps, } ); addActionToShownOption(); }; - const recordScore = ( score, comments ) => { + const recordScore = ( score, secondScore, comments ) => { recordEvent( 'ces_feedback', { action, score, + score_second_question: secondScore, + score_combined: score + secondScore, comments: comments || '', store_age: storeAgeInWeeks, + ces_location: 'inside', ...trackProps, } ); createNotice( 'success', onSubmitLabel ); @@ -118,17 +152,22 @@ function CustomerEffortScoreTracks( { return ( - ✏️ + { icon || '✏' } } /> @@ -147,7 +186,7 @@ CustomerEffortScoreTracks.propTypes = { /** * The label displayed in the modal. */ - label: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, /** * The label for the snackbar that appears upon survey submission. */ @@ -155,7 +194,7 @@ CustomerEffortScoreTracks.propTypes = { /** * The array of actions that the CES modal has been shown for. */ - cesShownForActions: PropTypes.arrayOf( PropTypes.string ).isRequired, + cesShownForActions: PropTypes.arrayOf( PropTypes.string ), /** * Whether tracking is allowed or not. */ @@ -178,25 +217,12 @@ CustomerEffortScoreTracks.propTypes = { createNotice: PropTypes.func, }; -function getStoreAgeInWeeks( adminInstallTimestamp ) { - if ( adminInstallTimestamp === 0 ) { - return null; - } - - // Date.now() is ms since Unix epoch, adminInstallTimestamp is in - // seconds since Unix epoch. - const storeAgeInMs = Date.now() - adminInstallTimestamp * 1000; - const storeAgeInWeeks = Math.round( storeAgeInMs / WEEK ); - - return storeAgeInWeeks; -} - export default compose( withSelect( ( select ) => { - const { getOption, isResolving } = select( OPTIONS_STORE_NAME ); + const { getOption, hasFinishedResolution } = + select( OPTIONS_STORE_NAME ); - const cesShownForActions = - getOption( SHOWN_FOR_ACTIONS_OPTION_NAME ) || []; + const cesShownForActions = getOption( SHOWN_FOR_ACTIONS_OPTION_NAME ); const adminInstallTimestamp = getOption( ADMIN_INSTALL_TIMESTAMP_OPTION_NAME ) || 0; @@ -207,12 +233,16 @@ export default compose( const allowTracking = allowTrackingOption === 'yes'; const resolving = - isResolving( 'getOption', [ SHOWN_FOR_ACTIONS_OPTION_NAME ] ) || + ! hasFinishedResolution( 'getOption', [ + SHOWN_FOR_ACTIONS_OPTION_NAME, + ] ) || storeAgeInWeeks === null || - isResolving( 'getOption', [ + ! hasFinishedResolution( 'getOption', [ ADMIN_INSTALL_TIMESTAMP_OPTION_NAME, ] ) || - isResolving( 'getOption', [ ALLOW_TRACKING_OPTION_NAME ] ); + ! hasFinishedResolution( 'getOption', [ + ALLOW_TRACKING_OPTION_NAME, + ] ); return { cesShownForActions, diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/action-types.js b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/action-types.js index d3960abc423..74e552b9faf 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/action-types.js +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/action-types.js @@ -1,6 +1,8 @@ const TYPES = { SET_CES_SURVEY_QUEUE: 'SET_CES_SURVEY_QUEUE', ADD_CES_SURVEY: 'ADD_CES_SURVEY', + SHOW_CES_MODAL: 'SHOW_CES_MODAL', + HIDE_CES_MODAL: 'HIDE_CES_MODAL', }; export default TYPES; diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/actions.js b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/actions.js index f2ef83ea83e..101e599c50f 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/actions.js +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/actions.js @@ -23,25 +23,41 @@ export function setCesSurveyQueue( queue ) { /** * Add a new CES track to the state. * - * @param {string} action action name for the survey - * @param {string} label label for the snackback - * @param {string} pageNow value of window.pagenow - * @param {string} adminPage value of window.adminpage - * @param {string} onsubmitLabel label for the snackback onsubmit - * @param {Object} props object for optional props + * @param {Object} args All arguments. + * @param {string} args.action action name for the survey + * @param {string} args.title title for the snackback + * @param {string} args.description description for feedback modal. + * @param {string} args.noticeLabel noticeLabel for notice. + * @param {string} args.firstQuestion first question for modal survey + * @param {string} args.secondQuestion second question for modal survey + * @param {string} args.icon optional icon for notice. + * @param {string} args.pageNow value of window.pagenow + * @param {string} args.adminPage value of window.adminpage + * @param {string} args.onsubmitLabel label for the snackback onsubmit + * @param {Object} args.props object for optional props */ -export function addCesSurvey( +export function addCesSurvey( { action, - label, + title, + description, + noticeLabel, + firstQuestion, + secondQuestion, + icon, pageNow = window.pagenow, adminPage = window.adminpage, onsubmitLabel = undefined, - props = {} -) { + props = {}, +} ) { return { type: TYPES.ADD_CES_SURVEY, action, - label, + title, + description, + noticeLabel, + firstQuestion, + secondQuestion, + icon, pageNow, adminPage, onsubmit_label: onsubmitLabel, @@ -49,30 +65,79 @@ export function addCesSurvey( }; } +/** + * Add show CES modal. + * + * @param {Object} surveyProps props for CES survey, similar to addCesSurvey. + * @param {Object} props object for optional props + * @param {Object} onSubmitNoticeProps object for on submit notice props. + */ +export function showCesModal( + surveyProps = {}, + props = {}, + onSubmitNoticeProps = {} +) { + return { + type: TYPES.SHOW_CES_MODAL, + surveyProps, + onsubmit_label: surveyProps.onsubmitLabel || '', + props, + onSubmitNoticeProps, + }; +} + +/** + * Hide CES Modal. + */ +export function hideCesModal() { + return { + type: TYPES.HIDE_CES_MODAL, + }; +} + /** * Add a new CES survey track for the pages in Analytics menu */ export function addCesSurveyForAnalytics() { - return addCesSurvey( - 'analytics_filtered', - __( 'How easy was it to filter your store analytics?', 'woocommerce' ), - 'woocommerce_page_wc-admin', - 'woocommerce_page_wc-admin' - ); + return addCesSurvey( { + action: 'analytics_filtered', + title: __( + 'How easy was it to filter your store analytics?', + 'woocommerce' + ), + firstQuestion: __( + 'The filters in the analytics screen are easy to use.', + 'woocommerce' + ), + secondQuestion: __( + `The filters' functionality meets my needs.`, + 'woocommerce' + ), + pageNow: 'woocommerce_page_wc-admin', + adminPage: 'woocommerce_page_wc-admin', + } ); } /** * Add a new CES survey track on searching customers. */ export function addCesSurveyForCustomerSearch() { - return addCesSurvey( - 'ces_search', - __( 'How easy was it to use search?', 'woocommerce' ), - 'woocommerce_page_wc-admin', - 'woocommerce_page_wc-admin', - undefined, - { + return addCesSurvey( { + action: 'ces_search', + title: __( 'How easy was it to use search?', 'woocommerce' ), + firstQuestion: __( + 'The search feature in WooCommerce is easy to use.', + 'woocommerce' + ), + secondQuestion: __( + `The search's functionality meets my needs.`, + 'woocommerce' + ), + pageNow: 'woocommerce_page_wc-admin', + adminPage: 'woocommerce_page_wc-admin', + onsubmit_label: undefined, + props: { search_area: 'customer', - } - ); + }, + } ); } diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/reducer.js b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/reducer.js index 3d30a67696e..9cf2d75e330 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/reducer.js +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/reducer.js @@ -5,6 +5,8 @@ import TYPES from './action-types'; const DEFAULT_STATE = { queue: [], + cesModalData: undefined, + showCESModal: false, }; const reducer = ( state = DEFAULT_STATE, action ) => { @@ -12,7 +14,28 @@ const reducer = ( state = DEFAULT_STATE, action ) => { case TYPES.SET_CES_SURVEY_QUEUE: return { ...state, - queue: action.queue, + queue: [ ...state.queue, ...action.queue ], + }; + case TYPES.HIDE_CES_MODAL: + return { + ...state, + showCESModal: false, + cesModalData: undefined, + }; + case TYPES.SHOW_CES_MODAL: + const cesModalData = { + action: action.surveyProps.action, + label: action.surveyProps.label, + onSubmitLabel: action.onSubmitLabel, + firstQuestion: action.surveyProps.firstQuestion, + secondQuestion: action.surveyProps.secondQuestion, + onSubmitNoticeProps: action.onSubmitNoticeProps || {}, + props: action.props, + }; + return { + ...state, + showCESModal: true, + cesModalData, }; case TYPES.ADD_CES_SURVEY: // Prevent duplicate @@ -24,7 +47,12 @@ const reducer = ( state = DEFAULT_STATE, action ) => { } const newTrack = { action: action.action, - label: action.label, + title: action.title, + description: action.description, + noticeLabel: action.noticeLabel, + firstQuestion: action.firstQuestion, + secondQuestion: action.secondQuestion, + icon: action.icon, pagenow: action.pageNow, adminpage: action.adminPage, onSubmitLabel: action.onSubmitLabel, diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/selectors.js b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/selectors.js index ab18e6631af..e4823d8e126 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/selectors.js +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/data/selectors.js @@ -1,3 +1,7 @@ export function getCesSurveyQueue( state ) { return state.queue; } + +export function getVisibleCESModalData( state ) { + return state.showCESModal ? state.cesModalData : undefined; +} diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/index.js b/plugins/woocommerce-admin/client/customer-effort-score-tracks/index.js index 58b35851d2e..bd9b84cbbe4 100644 --- a/plugins/woocommerce-admin/client/customer-effort-score-tracks/index.js +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/index.js @@ -1,2 +1,3 @@ export { default as CustomerEffortScoreTracks } from './customer-effort-score-tracks'; export { default as CustomerEffortScoreTracksContainer } from './customer-effort-score-tracks-container'; +export * from './customer-effort-score-modal-container.tsx'; diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.scss b/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.scss new file mode 100644 index 00000000000..f2449b78321 --- /dev/null +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.scss @@ -0,0 +1,27 @@ +.woocommerce-product-mvp-ces-footer { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: $gap-smaller $gap; + + .woocommerce-pill { + margin-right: $gap-smaller; + } + + .components-button { + margin-left: $gap; + } + + &__close-button { + position: absolute; + right: $gap; + } + + &__container { + margin-right: $gap-larger; + flex-wrap: nowrap; + display: flex; + align-items: center; + } +} diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.tsx b/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.tsx new file mode 100644 index 00000000000..3c1ea3809e3 --- /dev/null +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/product-mvp-ces-footer.tsx @@ -0,0 +1,148 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { closeSmall } from '@wordpress/icons'; +import { Pill } from '@woocommerce/components'; +import { OPTIONS_STORE_NAME } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import './product-mvp-ces-footer.scss'; +import { + ALLOW_TRACKING_OPTION_NAME, + SHOWN_FOR_ACTIONS_OPTION_NAME, +} from './constants'; +import { WooFooterItem } from '~/layout/footer'; +import { STORE_KEY } from './data/constants'; + +export const PRODUCT_MVP_CES_ACTION_OPTION_NAME = + 'woocommerce_ces_product_mvp_ces_action'; + +export const ProductMVPCESFooter: React.FC = () => { + const { showCesModal } = useDispatch( STORE_KEY ); + const { updateOptions } = useDispatch( OPTIONS_STORE_NAME ); + const { + cesAction, + allowTracking, + cesShownForActions, + resolving: isLoading, + } = useSelect( ( select ) => { + const { getOption, hasFinishedResolution } = + select( OPTIONS_STORE_NAME ); + + const action = getOption( + PRODUCT_MVP_CES_ACTION_OPTION_NAME + ) as string; + + const shownForActions = + ( getOption( SHOWN_FOR_ACTIONS_OPTION_NAME ) as string[] ) || []; + + const allowTrackingOption = + getOption( ALLOW_TRACKING_OPTION_NAME ) || 'no'; + + const resolving = + ! hasFinishedResolution( 'getOption', [ + SHOWN_FOR_ACTIONS_OPTION_NAME, + ] ) || + ! hasFinishedResolution( 'getOption', [ + PRODUCT_MVP_CES_ACTION_OPTION_NAME, + ] ) || + ! hasFinishedResolution( 'getOption', [ + ALLOW_TRACKING_OPTION_NAME, + ] ); + + return { + cesShownForActions: shownForActions, + allowTracking: allowTrackingOption === 'yes', + cesAction: action, + resolving, + }; + } ); + + const shareFeedback = () => { + showCesModal( + { + action: cesAction, + label: __( + "How's your experience with the product editor?", + 'woocommerce' + ), + firstQuestion: __( + 'The product editing screen is easy to use', + 'woocommerce' + ), + secondQuestion: __( + "The product editing screen's functionality meets my needs", + 'woocommerce' + ), + onsubmitLabel: __( + "Thanks for the feedback. We'll put it to good use!", + 'woocommerce' + ), + }, + {}, + { + type: 'snackbar', + icon: 🌟, + } + ); + updateOptions( { + [ SHOWN_FOR_ACTIONS_OPTION_NAME ]: [ + cesAction, + ...cesShownForActions, + ], + } ); + }; + + const onDisablingCES = () => { + updateOptions( { + [ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'hide', + } ); + }; + + const showCESFooter = + ! isLoading && allowTracking && cesAction && cesAction !== 'hide'; + + return ( + <> + { showCESFooter && ( + +
+
+ { __( 'BETA', 'woocommerce' ) } + { __( + "You're using the new product editor (currently in development). How is your experience so far?", + 'woocommerce' + ) } + + +
+ +
+
+ ) } + + ); +}; diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/test/customer-effort-score-exit-page.test.ts b/plugins/woocommerce-admin/client/customer-effort-score-tracks/test/customer-effort-score-exit-page.test.ts new file mode 100644 index 00000000000..5693325bb1e --- /dev/null +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/test/customer-effort-score-exit-page.test.ts @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { triggerExitPageCesSurvey } from '../customer-effort-score-exit-page'; + +jest.mock( '@woocommerce/data', () => ( { + OPTIONS_STORE_NAME: 'options', +} ) ); +jest.mock( '@wordpress/data', () => ( { + ...jest.requireActual( '@wordpress/data' ), + useSelect: jest.fn(), + dispatch: jest.fn(), + resolveSelect: jest.fn().mockReturnValue( { + getOption: jest.fn().mockResolvedValue( 'yes' ), + } ), +} ) ); + +describe( 'triggerExitPageCesSurvey', () => { + const addCESSurveyMock = jest.fn(); + beforeEach( () => { + jest.clearAllMocks(); + ( dispatch as jest.Mock ).mockReturnValue( { + addCesSurvey: addCESSurveyMock, + } ); + } ); + + it( 'should not trigger addCESSurvey if local storage is empty', () => { + triggerExitPageCesSurvey(); + expect( addCESSurveyMock ).not.toHaveBeenCalled(); + } ); + + it( 'should not trigger addCESSurvey if copy does not exist for item, but clear localStorage still', () => { + window.localStorage.setItem( + 'customer-effort-score-exit-page', + JSON.stringify( [ 'random-id' ] ) + ); + triggerExitPageCesSurvey(); + expect( addCESSurveyMock ).not.toHaveBeenCalled(); + const list = window.localStorage.getItem( + 'customer-effort-score-exit-page' + ); + expect( list ).toEqual( '[]' ); + } ); + + it( 'should trigger addCESSurvey if copy does exist for item, and clear localStorage still', () => { + window.localStorage.setItem( + 'customer-effort-score-exit-page', + JSON.stringify( [ 'new_product' ] ) + ); + triggerExitPageCesSurvey(); + expect( addCESSurveyMock ).toHaveBeenCalled(); + const list = window.localStorage.getItem( + 'customer-effort-score-exit-page' + ); + expect( list ).toEqual( '[]' ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/use-customer-effort-score-exit-page-tracker.ts b/plugins/woocommerce-admin/client/customer-effort-score-tracks/use-customer-effort-score-exit-page-tracker.ts new file mode 100644 index 00000000000..60121766783 --- /dev/null +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/use-customer-effort-score-exit-page-tracker.ts @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { useEffect, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + addCustomerEffortScoreExitPageListener, + addExitPage, + removeCustomerEffortScoreExitPageListener, +} from './customer-effort-score-exit-page'; + +export const useCustomerEffortScoreExitPageTracker = ( + pageId: string, + hasUnsavedChanges: boolean +) => { + const hasUnsavedChangesRef = useRef( hasUnsavedChanges ); + + // Using unmounting as a way to see when the react router changes. + useEffect( () => { + hasUnsavedChangesRef.current = hasUnsavedChanges; + }, [ hasUnsavedChanges ] ); + + useEffect( () => { + return () => { + if ( hasUnsavedChangesRef.current ) { + // unmounted. + addExitPage( pageId ); + } + }; + }, [] ); + + // This effect listen to the native beforeunload event to show + // a confirmation message + useEffect( () => { + addCustomerEffortScoreExitPageListener( + pageId, + () => hasUnsavedChanges + ); + + return () => { + removeCustomerEffortScoreExitPageListener( pageId ); + }; + }, [ hasUnsavedChanges ] ); +}; diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/use-product-mvp-ces-footer.ts b/plugins/woocommerce-admin/client/customer-effort-score-tracks/use-product-mvp-ces-footer.ts new file mode 100644 index 00000000000..dd072eb93ef --- /dev/null +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/use-product-mvp-ces-footer.ts @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { resolveSelect, useDispatch } from '@wordpress/data'; +import { OPTIONS_STORE_NAME } from '@woocommerce/data'; + +/** + * Internal dependencies + */ +import { PRODUCT_MVP_CES_ACTION_OPTION_NAME } from './product-mvp-ces-footer'; + +async function isProductMVPCESHidden(): Promise< boolean > { + const productCESAction: string = await resolveSelect( + OPTIONS_STORE_NAME + ).getOption( PRODUCT_MVP_CES_ACTION_OPTION_NAME ); + return productCESAction === 'hide'; +} + +export const useProductMVPCESFooter = () => { + const { updateOptions } = useDispatch( OPTIONS_STORE_NAME ); + + const onSaveDraft = async () => { + if ( ( await isProductMVPCESHidden() ) === false ) { + updateOptions( { + [ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'new_product', + } ); + } + }; + + const onPublish = async () => { + if ( ( await isProductMVPCESHidden() ) === false ) { + updateOptions( { + [ PRODUCT_MVP_CES_ACTION_OPTION_NAME ]: 'new_product', + } ); + } + }; + + return { onSaveDraft, onPublish }; +}; diff --git a/plugins/woocommerce-admin/client/customer-effort-score-tracks/utils.ts b/plugins/woocommerce-admin/client/customer-effort-score-tracks/utils.ts new file mode 100644 index 00000000000..9e583b76644 --- /dev/null +++ b/plugins/woocommerce-admin/client/customer-effort-score-tracks/utils.ts @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import { WEEK } from '@woocommerce/data'; + +export function getStoreAgeInWeeks( adminInstallTimestamp: number ) { + if ( adminInstallTimestamp === 0 ) { + return null; + } + + // Date.now() is ms since Unix epoch, adminInstallTimestamp is in + // seconds since Unix epoch. + const storeAgeInMs = Date.now() - adminInstallTimestamp * 1000; + const storeAgeInWeeks = Math.round( storeAgeInMs / WEEK ); + + return storeAgeInWeeks; +} diff --git a/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx b/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx index 166041c6d61..de8c9c40ddd 100644 --- a/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx +++ b/plugins/woocommerce-admin/client/dashboard/components/settings/general/store-address.tsx @@ -357,6 +357,9 @@ export function StoreAddress( { label={ __( 'Country / Region', 'woocommerce' ) + ' *' } required autoComplete="new-password" // disable autocomplete and autofill + getSearchExpression={ ( query: string ) => { + return new RegExp( '^' + query, 'i' ); + } } options={ countryStateOptions } excludeSelectedOptions={ false } showAllOnFocus diff --git a/plugins/woocommerce-admin/client/dashboard/style.scss b/plugins/woocommerce-admin/client/dashboard/style.scss index 8018c82e548..04c551fa392 100644 --- a/plugins/woocommerce-admin/client/dashboard/style.scss +++ b/plugins/woocommerce-admin/client/dashboard/style.scss @@ -90,3 +90,11 @@ .components-card .woocommerce-ellipsis-menu__toggle { padding: 0; } + +.components-modal__frame.woocommerce-cart-modal .components-modal__content { + margin-top: 6rem; + + @include breakpoint( '<600px' ) { + margin-top: 7rem; + } +} diff --git a/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-layout.tsx b/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-layout.tsx index ec31ae5fc9c..98e7aa7c676 100644 --- a/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-layout.tsx +++ b/plugins/woocommerce-admin/client/embedded-body-layout/embedded-body-layout.tsx @@ -2,6 +2,7 @@ * External dependencies */ import { applyFilters } from '@wordpress/hooks'; +import { useEffect } from '@wordpress/element'; import QueryString, { parse } from 'qs'; /** @@ -12,6 +13,7 @@ import { ShippingRecommendations } from '../shipping'; import { EmbeddedBodyProps } from './embedded-body-props'; import { StoreAddressTour } from '../guided-tours/store-address-tour'; import './style.scss'; +import { triggerExitPageCesSurvey } from '~/customer-effort-score-tracks/customer-effort-score-exit-page'; type QueryParams = EmbeddedBodyProps; @@ -34,6 +36,10 @@ const EMBEDDED_BODY_COMPONENT_LIST: React.ElementType[] = [ * Each Fill component receives QueryParams, consisting of a page, tab, and section string. */ export const EmbeddedBodyLayout = () => { + useEffect( () => { + triggerExitPageCesSurvey(); + }, [] ); + const query = parse( location.search.substring( 1 ) ); let queryParams: QueryParams = { page: '', tab: '' }; if ( isWPPage( query ) ) { diff --git a/plugins/woocommerce-admin/client/embedded-body-layout/test/embedded-body-layout.test.tsx b/plugins/woocommerce-admin/client/embedded-body-layout/test/embedded-body-layout.test.tsx index 1561e2077a7..954d90492e1 100644 --- a/plugins/woocommerce-admin/client/embedded-body-layout/test/embedded-body-layout.test.tsx +++ b/plugins/woocommerce-admin/client/embedded-body-layout/test/embedded-body-layout.test.tsx @@ -9,6 +9,18 @@ import { addFilter } from '@wordpress/hooks'; */ import { EmbeddedBodyLayout } from '../embedded-body-layout'; +jest.mock( + '~/customer-effort-score-tracks/customer-effort-score-exit-page', + () => ( { + triggerExitPageCesSurvey: jest.fn(), + } ) +); +jest.mock( '@wordpress/data', () => ( { + ...jest.requireActual( '@wordpress/data' ), + resolveSelect: jest.fn().mockReturnValue( { + getOption: jest.fn(), + } ), +} ) ); jest.mock( '@woocommerce/data', () => ( { useUser: () => ( { currentUserCan: jest.fn(), diff --git a/plugins/woocommerce-admin/client/guided-tours/add-product-tour/index.tsx b/plugins/woocommerce-admin/client/guided-tours/add-product-tour/index.tsx index c2b45f8f4ed..d487cb93141 100644 --- a/plugins/woocommerce-admin/client/guided-tours/add-product-tour/index.tsx +++ b/plugins/woocommerce-admin/client/guided-tours/add-product-tour/index.tsx @@ -15,7 +15,7 @@ import { useActiveEditorType } from './use-active-editor-type'; import { bindEnableGuideModeClickEvent, waitUntilElementTopNotChange, -} from './utils'; +} from '../utils'; import { ProductTourStepName, useProductStepChange, diff --git a/plugins/woocommerce-admin/client/guided-tours/add-product-tour/use-track-publish-button.ts b/plugins/woocommerce-admin/client/guided-tours/add-product-tour/use-track-publish-button.ts index f05eaae5a65..0628f2983ea 100644 --- a/plugins/woocommerce-admin/client/guided-tours/add-product-tour/use-track-publish-button.ts +++ b/plugins/woocommerce-admin/client/guided-tours/add-product-tour/use-track-publish-button.ts @@ -7,7 +7,7 @@ import { recordEvent } from '@woocommerce/tracks'; /** * Internal dependencies */ -import { bindPublishClickEvent } from './utils'; +import { bindPublishClickEvent } from '../utils'; export const useTrackPublishButton = ( showTour: boolean ) => { const unbindPublishClickEvent = useRef< () => void >( () => {} ); diff --git a/plugins/woocommerce-admin/client/guided-tours/add-product-tour/utils.ts b/plugins/woocommerce-admin/client/guided-tours/utils.ts similarity index 72% rename from plugins/woocommerce-admin/client/guided-tours/add-product-tour/utils.ts rename to plugins/woocommerce-admin/client/guided-tours/utils.ts index 079146b1a8d..315a1bc4d1e 100644 --- a/plugins/woocommerce-admin/client/guided-tours/add-product-tour/utils.ts +++ b/plugins/woocommerce-admin/client/guided-tours/utils.ts @@ -19,6 +19,26 @@ export const waitUntilElementTopNotChange = ( return intervalId; }; +// Observer position changes of an element +export const observePositionChange = ( + selector: string, + callback: () => void, + pollMs: number +) => { + const initialElement = document.querySelector( + selector + ) as HTMLElement | null; + let lastInitialElementTop = initialElement?.offsetTop; + + return setInterval( () => { + const top = initialElement?.offsetTop; + if ( lastInitialElementTop !== top ) { + callback(); + } + lastInitialElementTop = top; + }, pollMs ); +}; + // Overwrite the default behavior of click event for the "Enable guided mode" button export const bindEnableGuideModeClickEvent = ( onClick: EventListenerOrEventListenerObject diff --git a/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/get-config.ts b/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/get-config.ts new file mode 100644 index 00000000000..3b167616353 --- /dev/null +++ b/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/get-config.ts @@ -0,0 +1,125 @@ +/** + * External dependencies + */ +import { TourKitTypes } from '@woocommerce/components'; + +/** + * Internal dependencies + */ +import { scrollPopperToVisibleAreaIfNeeded } from './utils'; + +export const getTourConfig = ( { + closeHandler, + onNextStepHandler, + autoScrollBlock, + steps, +}: { + closeHandler: TourKitTypes.CloseHandler; + onNextStepHandler: ( currentStepIndex: number ) => void; + autoScrollBlock: ScrollLogicalPosition; + steps: TourKitTypes.WooStep[]; +} ): TourKitTypes.WooConfig => { + let previousPopperTopPosition: number | null = null; + let perviousPopperRef: unknown = null; + const defaultPlacement = 'top-start'; + + return { + placement: defaultPlacement, + options: { + effects: { + spotlight: { + interactivity: { + enabled: true, + rootElementSelector: '.woocommerce.wc-addons-wrap', + }, + }, + autoScroll: { + behavior: 'auto', + block: autoScrollBlock, + }, + }, + popperModifiers: [ + { + name: 'arrow', + options: { + padding: ( { + popper, + }: { + popper: { width: number }; + } ) => { + return { + // Align the arrow to the left of the popper. + right: popper.width - 34, + }; + }, + }, + }, + { + name: 'offset', + options: { + offset: [ 20, 20 ], + }, + }, + { + name: 'flip', + options: { + allowedAutoPlacements: [ 'right', 'bottom', 'top' ], + fallbackPlacements: [ 'bottom-start', 'right' ], + flipVariations: false, + boundry: 'clippingParents', + }, + }, + { + name: 'inAppTourPopperModifications', + enabled: true, + phase: 'read', + fn( { state, instance } ) { + // 1. First modification - force `right` placement for items in admin menu. + if ( perviousPopperRef !== state.elements.reference ) { + const isAdminMenuItem = ( + state.elements.reference as HTMLElement + ).closest( '#adminmenu' ); + const desiredPlacement = isAdminMenuItem + ? 'right' + : defaultPlacement; + if ( state.placement !== desiredPlacement ) { + instance.setOptions( { + placement: desiredPlacement, + } ); + } + } + + // 2. Second modification - Try to make sure that the popper is visible once when + // the next step is displayed. + const popperBoundingRect = + state.elements.popper.getBoundingClientRect(); + const arrowBoundingRect = + state.elements.arrow?.getBoundingClientRect(); + const arrowHeight = arrowBoundingRect?.height || 0; + + // Try to make sure that the popper is visible if poppers' reference (step) changed and + // if arrowHeight is not 0 (it means that popper's position hasn't been updated yet). + // Also, change if popper's top position changed - the modifier can be called + // multiple times for the same position. + if ( + perviousPopperRef !== state.elements.reference && + arrowHeight !== 0 && + previousPopperTopPosition !== popperBoundingRect.top + ) { + scrollPopperToVisibleAreaIfNeeded( + popperBoundingRect + ); + previousPopperTopPosition = popperBoundingRect.top; + perviousPopperRef = state.elements.reference; + } + }, + }, + ], + callbacks: { + onNextStep: onNextStepHandler, + }, + }, + steps, + closeHandler, + }; +}; diff --git a/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/get-steps.ts b/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/get-steps.ts new file mode 100644 index 00000000000..5eb00fc2ba5 --- /dev/null +++ b/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/get-steps.ts @@ -0,0 +1,153 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createElement, createInterpolateElement } from '@wordpress/element'; +import { TourKitTypes } from '@woocommerce/components'; + +export const getSteps = (): TourKitTypes.WooStep[] => { + const lineBreak = createElement( 'br' ); + return [ + { + referenceElements: { + desktop: '#adminmenu a[href="admin.php?page=wc-addons"]', + }, + focusElement: { + desktop: '#adminmenu a[href="admin.php?page=wc-addons"]', + }, + meta: { + name: 'wc-addons-menu-item', + heading: __( + 'Welcome to the WooCommerce Marketplace', + 'woocommerce' + ), + descriptions: { + desktop: createInterpolateElement( + __( + 'Power up your store by adding extra functionality using extensions, find a fresh new look with themes, or integrate your store with other software and services.

The WooCommerce Marketplace is your go-to for all of the above, and the only place you’ll find products that have been reviewed and approved by the WooCommerce team.

Whether you’re looking to improve your store or grow your business, you can find a solution here. There are hundreds of options available, and new products are added regularly.

The WooCommerce Marketplace is also available at WooCommerce.com.', + 'woocommerce' + ), + { + br: lineBreak, + } + ), + }, + }, + }, + { + referenceElements: { + desktop: '.marketplace-header__search-form', + }, + focusElement: { + desktop: '.marketplace-header__search-form', + }, + meta: { + name: 'wc-addons-search', + heading: __( 'Find exactly what you need', 'woocommerce' ), + descriptions: { + desktop: __( + 'Use the search box to find specific products or solutions.', + 'woocommerce' + ), + }, + }, + }, + { + referenceElements: { + desktop: '#marketplace-current-section-dropdown', + }, + focusElement: { + desktop: '#marketplace-current-section-dropdown', + }, + meta: { + name: 'wc-addons-categories', + heading: __( 'Browse for new ideas', 'woocommerce' ), + descriptions: { + desktop: createInterpolateElement( + __( + 'Or browse all available products by category.', + 'woocommerce' + ), + { + br: lineBreak, + } + ), + }, + }, + }, + { + referenceElements: { + desktop: '.addon-product-group:first-child', + }, + focusElement: { + desktop: '.addon-product-group:first-child', + }, + meta: { + name: 'wc-addons-featured', + heading: __( 'Learn more about products', 'woocommerce' ), + descriptions: { + desktop: createInterpolateElement( + __( + 'Scroll down to see all available products for a search or selected category.

Click on any product to see more information about it, including features, requirements, and available documentation.', + 'woocommerce' + ), + { + br: lineBreak, + } + ), + }, + }, + }, + { + referenceElements: { + desktop: '.marketplace-header__tab-link_helper', + }, + focusElement: { + desktop: '.marketplace-header__tab-link_helper', + }, + meta: { + name: 'wc-addons-my-subscriptions', + heading: __( 'Manage your purchases', 'woocommerce' ), + descriptions: { + desktop: createInterpolateElement( + __( + "Products purchased from the WooCommerce Marketplace can be managed in My Subscriptions, either here or on WooCommerce.com.

Every purchase is backed by our 30-day money-back guarantee, and includes email and live chat support.

That's it! We hope the WooCommerce Marketplace helps you build the business of your dreams.", + 'woocommerce' + ), + { + a1: createElement( + 'a', + { + href: 'https://woocommerce.com/refund-policy/', + 'aria-label': __( + 'Refund policy', + 'woocommerce' + ), + }, + __( + '30-day money-back guarantee', + 'woocommerce' + ) + ), + a2: createElement( + 'a', + { + href: 'https://woocommerce.com/my-account/create-a-ticket/', + 'aria-label': __( + 'Contact support', + 'woocommerce' + ), + }, + __( + 'email and live chat support', + 'woocommerce' + ) + ), + br: lineBreak, + } + ), + }, + }, + }, + ]; +}; diff --git a/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/index.tsx b/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/index.tsx new file mode 100644 index 00000000000..fec151af83f --- /dev/null +++ b/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/index.tsx @@ -0,0 +1,122 @@ +/** + * External dependencies + */ +import { useEffect, useState } from '@wordpress/element'; +import { TourKit, TourKitTypes } from '@woocommerce/components'; +import { recordEvent } from '@woocommerce/tracks'; +import { useDispatch } from '@wordpress/data'; +import { OPTIONS_STORE_NAME } from '@woocommerce/data'; +import qs from 'qs'; + +/** + * Internal dependencies + */ +import { observePositionChange, waitUntilElementTopNotChange } from '../utils'; +import { getTourConfig } from './get-config'; +import { scrollPopperToVisibleAreaIfNeeded } from './utils'; +import { getSteps } from './get-steps'; + +const WCAddonsTour = () => { + const [ showTour, setShowTour ] = useState( false ); + + const { updateOptions } = useDispatch( OPTIONS_STORE_NAME ); + + const steps = getSteps(); + const defaultAutoScrollBlock: ScrollLogicalPosition = 'center'; + + useEffect( () => { + const query = qs.parse( window.location.search.slice( 1 ) ); + if ( query?.tutorial === 'true' ) { + const intervalId = waitUntilElementTopNotChange( + steps[ 0 ].referenceElements?.desktop || '', + () => { + const stepName = steps[ 0 ]?.meta?.name; + setShowTour( true ); + recordEvent( 'in_app_marketplace_tour_started', { + step: stepName, + } ); + }, + 500 + ); + return () => clearInterval( intervalId ); + } + // only run once + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [] ); + + useEffect( () => { + if ( showTour ) { + function showPopper() { + const tourKitElement = document.querySelector( + '.tour-kit-frame__container' + ); + if ( tourKitElement ) { + scrollPopperToVisibleAreaIfNeeded( + tourKitElement.getBoundingClientRect() + ); + } + } + + // In a rare case, admin notices might load before observe is added below (moving `.wc-addons-wrap`). + // In such a case, if Tour is shown before this effect is called, it might not be position correctly. + // Updating popper's position here, ensures it's always visible. + const timeoutId = setTimeout( showPopper, 500 ); + + const intervalId = observePositionChange( + '.wc-addons-wrap', + showPopper, + 150 + ); + return () => { + clearTimeout( timeoutId ); + clearInterval( intervalId ); + }; + } + }, [ showTour ] ); + + if ( ! showTour ) { + return null; + } + + const closeHandler: TourKitTypes.CloseHandler = ( + tourSteps, + currentStepIndex + ) => { + setShowTour( false ); + // mark tour as completed + updateOptions( { + woocommerce_admin_dismissed_in_app_marketplace_tour: 'yes', + } ); + // remove `tutorial` from search query, so it's not shown on page refresh + const url = new URL( window.location.href ); + url.searchParams.delete( 'tutorial' ); + window.history.replaceState( null, '', url ); + + if ( steps.length - 1 === currentStepIndex ) { + recordEvent( 'in_app_marketplace_tour_completed' ); + } else { + const stepName = tourSteps[ currentStepIndex ]?.meta?.name; + recordEvent( 'in_app_marketplace_tour_dismissed', { + step: stepName, + } ); + } + }; + + const onNextStepHandler = ( previousStepIndex: number ) => { + const stepName = steps[ previousStepIndex + 1 ]?.meta?.name || ''; + recordEvent( 'in_app_marketplace_tour_step_viewed', { + step: stepName, + } ); + }; + + const tourConfig = getTourConfig( { + closeHandler, + onNextStepHandler, + autoScrollBlock: defaultAutoScrollBlock, + steps, + } ); + + return ; +}; + +export default WCAddonsTour; diff --git a/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/utils.ts b/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/utils.ts new file mode 100644 index 00000000000..5f05a103750 --- /dev/null +++ b/plugins/woocommerce-admin/client/guided-tours/wc-addons-tour/utils.ts @@ -0,0 +1,18 @@ +// Try to make popper element visible on the screen +export const scrollPopperToVisibleAreaIfNeeded = ( + popperBoundingRect: DOMRect +) => { + // 8px is added for some extra spacing from the top admin bar + const adminBarHeight = + ( document.getElementById( 'wpadminbar' )?.offsetHeight || 0 ) + 8; + + // check if element is cut from the top + if ( popperBoundingRect.top < adminBarHeight ) { + window.scrollBy( 0, popperBoundingRect.top - adminBarHeight ); + } else if ( + // check if element is cut from the bottom + popperBoundingRect.bottom > window.innerHeight + ) { + window.scrollBy( 0, popperBoundingRect.bottom - window.innerHeight ); + } +}; diff --git a/plugins/woocommerce-admin/client/hooks/usePreventLeavingPage.ts b/plugins/woocommerce-admin/client/hooks/usePreventLeavingPage.ts new file mode 100644 index 00000000000..2a7d938eec8 --- /dev/null +++ b/plugins/woocommerce-admin/client/hooks/usePreventLeavingPage.ts @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { useContext, useEffect, useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { UNSAFE_NavigationContext as NavigationContext } from 'react-router-dom'; + +export default function usePreventLeavingPage( + hasUnsavedChanges: boolean, + /** + * Some browsers ignore this message currently on before unload event. + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event#compatibility_notes + */ + message?: string +) { + const confirmMessage = useMemo( + () => + message ?? + __( 'Changes you made may not be saved.', 'woocommerce' ), + [ message ] + ); + const { navigator } = useContext( NavigationContext ); + + // This effect prevent react router from navigate and show + // a confirmation message. It's a work around to beforeunload + // because react router does not triggers that event. + useEffect( () => { + if ( hasUnsavedChanges ) { + const push = navigator.push; + + navigator.push = ( ...args: Parameters< typeof push > ) => { + /* eslint-disable-next-line no-alert */ + const result = window.confirm( confirmMessage ); + if ( result !== false ) { + push( ...args ); + } + }; + + return () => { + navigator.push = push; + }; + } + }, [ navigator, hasUnsavedChanges, confirmMessage ] ); + + // This effect listen to the native beforeunload event to show + // a confirmation message + useEffect( () => { + if ( hasUnsavedChanges ) { + function onBeforeUnload( event: BeforeUnloadEvent ) { + event.preventDefault(); + return ( event.returnValue = confirmMessage ); + } + + window.addEventListener( 'beforeunload', onBeforeUnload, { + capture: true, + } ); + + return () => { + window.removeEventListener( 'beforeunload', onBeforeUnload, { + capture: true, + } ); + }; + } + }, [ hasUnsavedChanges, confirmMessage ] ); +} diff --git a/plugins/woocommerce-admin/client/inbox-panel/index.js b/plugins/woocommerce-admin/client/inbox-panel/index.js index 6917abeb6c6..87184556951 100644 --- a/plugins/woocommerce-admin/client/inbox-panel/index.js +++ b/plugins/woocommerce-admin/client/inbox-panel/index.js @@ -93,11 +93,13 @@ const renderNotes = ( { notes, onDismiss, onNoteActionClick, + onNoteVisible, setShowDismissAllModal: onDismissAll, showHeader = true, loadMoreNotes, allNotesFetched, notesHaveResolved, + unreadNotesCount, } ) => { if ( isBatchUpdating ) { return; @@ -114,17 +116,6 @@ const renderNotes = ( { hasFiredPanelViewTrack = true; } - const screen = getScreenName(); - const onNoteVisible = ( note ) => { - recordEvent( 'inbox_note_view', { - note_content: note.content, - note_name: note.name, - note_title: note.title, - note_type: note.type, - screen, - } ); - }; - const notesArray = Object.keys( notes ).map( ( key ) => notes[ key ] ); return ( @@ -135,7 +126,7 @@ const renderNotes = ( { { __( 'Inbox', 'woocommerce' ) } - +
{ ); const [ allNotesFetched, setAllNotesFetched ] = useState( false ); const [ allNotes, setAllNotes ] = useState( [] ); + const [ viewedNotes, setViewedNotes ] = useState( {} ); const { createNotice } = useDispatch( 'core/notices' ); const { removeNote, @@ -223,6 +215,7 @@ const InboxPanel = ( { showHeader = true } ) => { triggerNoteAction, invalidateResolutionForStoreSelector, } = useDispatch( NOTES_STORE_NAME ); + const screen = getScreenName(); const inboxQuery = useMemo( () => { return { @@ -231,25 +224,34 @@ const InboxPanel = ( { showHeader = true } ) => { }; }, [ noteDisplayQty ] ); - const { isError, notes, notesHaveResolved, isBatchUpdating } = useSelect( - ( select ) => { - const { - getNotes, - getNotesError, - isNotesRequesting, - hasFinishedResolution, - } = select( NOTES_STORE_NAME ); + const { + isError, + notes, + notesHaveResolved, + isBatchUpdating, + unreadNotesCount, + } = useSelect( ( select ) => { + const { + getNotes, + getNotesError, + isNotesRequesting, + hasFinishedResolution, + } = select( NOTES_STORE_NAME ); - return { - notes: getNotes( inboxQuery ), - isError: Boolean( getNotesError( 'getNotes', [ inboxQuery ] ) ), - isBatchUpdating: isNotesRequesting( 'batchUpdateNotes' ), - notesHaveResolved: - ! isNotesRequesting( 'batchUpdateNotes' ) && - hasFinishedResolution( 'getNotes', [ inboxQuery ] ), - }; - } - ); + return { + notes: getNotes( inboxQuery ), + unreadNotesCount: getNotes( { + ...DEFAULT_INBOX_QUERY, + is_read: false, + per_page: -1, + } ).length, + isError: Boolean( getNotesError( 'getNotes', [ inboxQuery ] ) ), + isBatchUpdating: isNotesRequesting( 'batchUpdateNotes' ), + notesHaveResolved: + ! isNotesRequesting( 'batchUpdateNotes' ) && + hasFinishedResolution( 'getNotes', [ inboxQuery ] ), + }; + } ); useEffect( () => { if ( notesHaveResolved && notes.length < noteDisplayQty ) { @@ -284,9 +286,25 @@ const InboxPanel = ( { showHeader = true } ) => { const [ showDismissAllModal, setShowDismissAllModal ] = useState( false ); - const onDismiss = async ( note ) => { - const screen = getScreenName(); + const onNoteVisible = ( note ) => { + if ( ! viewedNotes[ note.id ] && ! note.is_read ) { + setViewedNotes( { ...viewedNotes, [ note.id ]: true } ); + setTimeout( () => { + updateNote( note.id, { + is_read: true, + } ); + }, 3000 ); + } + recordEvent( 'inbox_note_view', { + note_content: note.content, + note_name: note.name, + note_title: note.title, + note_type: note.type, + screen, + } ); + }; + const onDismiss = async ( note ) => { recordEvent( 'inbox_action_dismiss', { note_name: note.name, note_title: note.title, @@ -383,10 +401,12 @@ const InboxPanel = ( { showHeader = true } ) => { onNoteActionClick: ( note, action ) => { triggerNoteAction( note.id, action.id ); }, + onNoteVisible, setShowDismissAllModal, showHeader, allNotesFetched, notesHaveResolved, + unreadNotesCount, } ) } diff --git a/plugins/woocommerce-admin/client/index.js b/plugins/woocommerce-admin/client/index.js index bd077ed4096..41a0866a6d8 100644 --- a/plugins/woocommerce-admin/client/index.js +++ b/plugins/woocommerce-admin/client/index.js @@ -17,6 +17,7 @@ import { PageLayout, EmbedLayout, PrimaryLayout as NoticeArea } from './layout'; import { CustomerEffortScoreTracksContainer } from './customer-effort-score-tracks'; import { EmbeddedBodyLayout } from './embedded-body-layout'; import { WcAdminPaymentsGatewaysBannerSlot } from './payments/payments-settings-banner-slotfill'; +import { WcAdminConflictErrorSlot } from './settings/conflict-error-slotfill.js'; // Modify webpack pubilcPath at runtime based on location of WordPress Plugin. // eslint-disable-next-line no-undef,camelcase @@ -94,6 +95,12 @@ if ( appRoot ) { ); } + const isTaxPage = document.getElementById( 'wc_conflict_error_slotfill' ); + + if ( isTaxPage ) { + render( WcAdminConflictErrorSlot(), isTaxPage ); + } + const wrap = wpBody.querySelector( '.wrap.woocommerce' ) || document.querySelector( '#wpbody-content > .woocommerce' ) || diff --git a/plugins/woocommerce-admin/client/layout/footer/footer.scss b/plugins/woocommerce-admin/client/layout/footer/footer.scss new file mode 100644 index 00000000000..4970b110467 --- /dev/null +++ b/plugins/woocommerce-admin/client/layout/footer/footer.scss @@ -0,0 +1,19 @@ +.woocommerce-layout__footer { + background: $studio-white; + box-sizing: border-box; + padding: 0; + position: fixed; + width: calc(100% - 160px); + bottom: 0; + z-index: 1001; + /* on top of #wp-content-editor-tools */ + + @include breakpoint('782px-960px') { + width: calc(100% - 36px); + } + + @include breakpoint('<782px') { + flex-flow: row wrap; + width: 100%; + } +} diff --git a/plugins/woocommerce-admin/client/layout/footer/footer.tsx b/plugins/woocommerce-admin/client/layout/footer/footer.tsx new file mode 100644 index 00000000000..5269b16fcd9 --- /dev/null +++ b/plugins/woocommerce-admin/client/layout/footer/footer.tsx @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { useSlot } from '@woocommerce/experimental'; + +/** + * Internal dependencies + */ +import './footer.scss'; +import { WC_FOOTER_SLOT_NAME, WooFooterItem } from './utils'; + +export const Footer: React.FC = () => { + const slot = useSlot( WC_FOOTER_SLOT_NAME ); + const hasFills = Boolean( slot?.fills?.length ); + + if ( ! hasFills ) { + return null; + } + return ( +
+ +
+ ); +}; diff --git a/plugins/woocommerce-admin/client/layout/footer/index.ts b/plugins/woocommerce-admin/client/layout/footer/index.ts new file mode 100644 index 00000000000..e74a2f1f615 --- /dev/null +++ b/plugins/woocommerce-admin/client/layout/footer/index.ts @@ -0,0 +1,2 @@ +export * from './footer'; +export * from './utils'; diff --git a/plugins/woocommerce-admin/client/layout/footer/utils.tsx b/plugins/woocommerce-admin/client/layout/footer/utils.tsx new file mode 100644 index 00000000000..9c927785f94 --- /dev/null +++ b/plugins/woocommerce-admin/client/layout/footer/utils.tsx @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { Slot, Fill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { createOrderedChildren, sortFillsByOrder } from '~/utils'; + +export const WC_FOOTER_SLOT_NAME = 'woocommerce_footer_item'; +/** + * Create a Fill for extensions to add items to the WooCommerce Admin footer. + * + * @slotFill WooFooterItem + * @scope woocommerce-admin + * @example + * const MyFooterItem = () => ( + * My header item + * ); + * + * registerPlugin( 'my-extension', { + * render: MyFooterItem, + * scope: 'woocommerce-admin', + * } ); + * @param {Object} param0 + * @param {Array} param0.children - Node children. + * @param {Array} param0.order - Node order. + */ +export const WooFooterItem: React.FC< { order?: number } > & { + Slot: React.FC< Slot.Props >; +} = ( { children, order = 1 } ) => { + return ( + + { ( fillProps: Fill.Props ) => { + return createOrderedChildren( children, order, fillProps ); + } } + + ); +}; + +WooFooterItem.Slot = ( { fillProps } ) => ( + + { sortFillsByOrder } + +); diff --git a/plugins/woocommerce-admin/client/layout/index.js b/plugins/woocommerce-admin/client/layout/index.js index 1ff5ff0b42c..51c928048b2 100644 --- a/plugins/woocommerce-admin/client/layout/index.js +++ b/plugins/woocommerce-admin/client/layout/index.js @@ -34,9 +34,12 @@ import { PluginArea } from '@wordpress/plugins'; import './style.scss'; import { Controller, getPages } from './controller'; import { Header } from '../header'; +import { Footer } from './footer'; import Notices from './notices'; import TransientNotices from './transient-notices'; +import { CustomerEffortScoreModalContainer } from '../customer-effort-score-tracks'; import { getAdminSetting } from '~/utils/admin-settings'; +import { triggerExitPageCesSurvey } from '~/customer-effort-score-tracks/customer-effort-score-exit-page'; import '~/activity-panel'; import '~/mobile-banner'; import './navigation'; @@ -133,6 +136,7 @@ class _Layout extends Component { componentDidMount() { this.recordPageViewTrack(); + triggerExitPageCesSurvey(); } componentDidUpdate( prevProps ) { @@ -145,6 +149,9 @@ class _Layout extends Component { if ( previousPath !== currentPath ) { this.recordPageViewTrack(); + setTimeout( () => { + triggerExitPageCesSurvey(); + }, 0 ); } } @@ -246,6 +253,8 @@ class _Layout extends Component { ) } +