diff --git a/.codecov.yml b/.codecov.yml index d0261f64326..bec8da4c270 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -8,8 +8,12 @@ coverage: range: "50...100" status: - project: off - patch: off + project: + default: + informational: true + patch: + default: + informational: true changes: off parsers: diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 884d1852ddb..3e3b424196e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -13,7 +13,7 @@ There are many ways to contribute to the project! If you wish to contribute code, please read the information in the sections below. Then [fork](https://help.github.com/articles/fork-a-repo/) WooCommerce, commit your changes, and [submit a pull request](https://help.github.com/articles/using-pull-requests/) 🎉 -We use the `good first issue` label to mark issues that are suitable for new contributors. You can find all the issues with this label [here](https://github.com/woocommerce/woocommerce/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22). +We use the `good first issue` label to mark issues that are suitable for new contributors. You can find all the issues with this label [here](https://github.com/woocommerce/woocommerce/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+good+first+issue%22). WooCommerce is licensed under the GPLv3+, and all contributions to the project will be released under the same license. You maintain copyright over any contribution you make, and by submitting a pull request, you are agreeing to release that contribution under the GPLv3+ license. @@ -26,8 +26,8 @@ If you have questions about the process to contribute code or want to discuss de - [Minification of SCSS and JS](https://github.com/woocommerce/woocommerce/wiki/Minification-of-SCSS-and-JS) - [Naming conventions](https://github.com/woocommerce/woocommerce/wiki/Naming-conventions) - [String localisation guidelines](https://github.com/woocommerce/woocommerce/wiki/String-localisation-guidelines) -- [Running unit tests](https://github.com/woocommerce/woocommerce/blob/trunk/tests/README.md) -- [Running e2e tests](https://github.com/woocommerce/woocommerce/wiki/End-to-end-Testing) +- [Running unit tests](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/tests/README.md) +- [Running e2e tests](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/tests/e2e/README.md) ## Coding Guidelines and Development 🛠 @@ -41,15 +41,14 @@ If you have questions about the process to contribute code or want to discuss de - Make sure to write good and detailed commit messages (see [this post](https://chris.beams.io/posts/git-commit/) for more on this) and follow all the applicable sections of the pull request template. - Please avoid modifying the changelog directly or updating the .pot files. These will be updated by the WooCommerce team. -If you are contributing code to the (Javascript-driven) WooCommerce Admin project or to Gutenberg blocks, note that these are developed in external packages. +If you are contributing code to the (Javascript-driven) Gutenberg blocks, note that it's developed in an external package. -- [WooCommerce Admin](https://github.com/woocommerce/woocommerce-admin) - [Blocks](https://github.com/woocommerce/woocommerce-gutenberg-products-block) ## Feature Requests 🚀 -Feature requests can be [submitted to our issue tracker](https://github.com/woocommerce/woocommerce/issues/new?template=6-Feature-request.md). Be sure to include a description of the expected behavior and use case, and before submitting a request, please search for similar ones in the closed issues. +Feature requests can be [submitted to our issue tracker](https://github.com/woocommerce/woocommerce/issues/new?assignees=&labels=type%3A+enhancement%2Cstatus%3A+awaiting+triage&template=2-enhancement.yml&title=%5BEnhancement%5D%3A+). Be sure to include a description of the expected behavior and use case, and before submitting a request, please search for similar ones in the closed issues. Feature request issues will remain closed until we see sufficient interest via comments and [👍 reactions](https://help.github.com/articles/about-discussions-in-issues-and-pull-requests/) from the community. -You can see a [list of current feature requests which require votes here](https://github.com/woocommerce/woocommerce/issues?q=is%3Aclosed+label%3A%22type%3A+enhancement%22+label%3A%22votes+needed%22+sort%3Areactions-%2B1-desc). +You can see a [list of current feature requests which require votes here](https://github.com/woocommerce/woocommerce/issues?q=is%3Aissue+sort%3Areactions-%2B1-desc+label%3A%22needs%3A+votes%22+). diff --git a/.github/ISSUE_TEMPLATE/2-enhancement.yml b/.github/ISSUE_TEMPLATE/2-enhancement.yml index e3fc4f7c854..3c7637cea8c 100644 --- a/.github/ISSUE_TEMPLATE/2-enhancement.yml +++ b/.github/ISSUE_TEMPLATE/2-enhancement.yml @@ -1,7 +1,7 @@ name: ✨ Enhancement Request description: If you have an idea to improve an existing feature in core or need something for development (such as a new hook) please let us know or submit a Pull Request! title: "[Enhancement]: " -labels: ["type: enhancement"] +labels: ["type: enhancement", "status: awaiting triage"] body: - type: markdown attributes: @@ -10,7 +10,7 @@ body: Please provide us with the information requested in this form. - Make sure to look through [existing `type: enhancement` issues](https://github.com/woocommerce/woocommerce/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+enhancement%22) and [existing `votes needed` issues](https://github.com/woocommerce/woocommerce/issues?q=is%3Aissue+sort%3Areactions-%2B1-desc+label%3A%22votes+needed%22) to see whether your idea is already being discussed. + Make sure to look through [existing `type: enhancement` issues](https://github.com/woocommerce/woocommerce/issues?q=is%3Aopen+is%3Aissue+label%3A%22type%3A+enhancement%22) and [existing `votes needed` issues](https://github.com/woocommerce/woocommerce/issues?q=is%3Aissue+sort%3Areactions-%2B1-desc+label%3A%22needs%3A+votes%22+) to see whether your idea is already being discussed. Feel free to contribute to any existing issues. Search tip: You can filter our issues using [our enhancement label](https://github.com/woocommerce/woocommerce/issues?q=is%3Aissue+label%3A%22type%3A+enhancement%22+). Search tip: Make use of [GitHub's search syntax to refine your search](https://help.github.com/en/github/searching-for-information-on-github/searching-issues-and-pull-requests). diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 22aae818eee..dae2134fb39 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,9 +6,6 @@ contact_links: - name: ❓ Support Question url: https://woocommerce.com/document/woocommerce-self-service-guide/ about: If you have a question please see our docs or use our forums, helpdesk, or Slack community! - - name: WooCommerce Admin - url: https://github.com/woocommerce/woocommerce-admin - about: Please report issues for WooCommerce Admin (such as Analytics and Onboarding) directly to it's repository. - name: WooCommerce Blocks url: https://github.com/woocommerce/woocommerce-gutenberg-products-block about: Please report issues for WooCommerce Blocks directly to it's repository. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b5140fcd7f0..dd08989d3c3 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,13 +25,10 @@ Closes # . * [ ] Have you added an explanation of what your changes do and why you'd like us to include them? * [ ] Have you written new tests for your changes, as applicable? * [ ] Have you successfully run tests with your changes locally? +* [ ] Have you created a changelog file by running `pnpm nx affected --target=changelog`? -### Changelog entry - -> Enter a summary of all changes on this Pull Request. This will appear in the changelog if accepted. - ### FOR PR REVIEWER ONLY: * [ ] I have reviewed that everything is sanitized/escaped appropriately for any SQL or XSS injection possibilities. I made sure Linting is not ignored or disabled. diff --git a/.github/project-pr-labeler.yml b/.github/project-pr-labeler.yml new file mode 100644 index 00000000000..6303a123f00 --- /dev/null +++ b/.github/project-pr-labeler.yml @@ -0,0 +1,76 @@ +'package: @woocommerce/api': +- packages/js/api/**/* + +'package: @woocommerce/e2e-utils': +- packages/js/e2e-utils/**/* + +'package: @woocommerce/e2e-environment': +- packages/js/e2e-environment/**/* + +'package: @woocommerce/api-core-tests': +- packages/js/api-core-tests/**/* + +'package: @woocommerce/e2e-core-tests': +- packages/js/e2e-core-tests/**/* + +'package: @woocommerce/admin-e2e-tests': +- packages/js/admin-e2e-tests/**/* + +'package: @woocommerce/components': +- packages/js/components/**/* + +'package: @woocommerce/csv-export': +- packages/js/csv-export/**/* + +'package: @woocommerce/currency': +- packages/js/currency/**/* + +'package: @woocommerce/customer-effort-score': +- packages/js/customer-effort-score/**/* + +'package: @woocommerce/data': +- packages/js/data/**/* + +'package: @woocommerce/date': +- packages/js/date/**/* + +'package: dependency-extraction-webpack-plugin': +- packages/js/dependency-extraction-webpack-plugin/**/* + +'package: @woocommerce/eslint-plugin': +- packages/js/eslint-plugin/**/* + +'package: @woocommerce/experimental': +- packages/js/experimental/**/* + +'package: @woocommerce/explat': +- packages/js/explat/**/* + +'package: @woocommerce/js-tests': +- packages/js/js-tests/**/* + +'package: @woocommerce/navigation': +- packages/js/navigation/**/* + +'package: @woocommerce/notices': +- packages/js/notices/**/* + +'package: @woocommerce/number': +- packages/js/number/**/* + +'package: @woocommerce/onboarding': +- packages/js/onboarding/**/* + +'package: @woocommerce/style-build': +- packages/js/style-build/**/* + +'package: @woocommerce/tracks': +- packages/js/tracks/**/* + +'plugin: woocommerce': +- plugins/woocommerce/**/* + +'focus: react admin': +- plugins/woocommerce/src/Admin/**/* +- plugins/woocommerce/src/Internal/Admin/**/* +- plugins/woocommerce-admin/**/* diff --git a/.github/workflows/build-release-zip-file.yml b/.github/workflows/build-release-zip-file.yml index 8c1a61a3dce..f0b92090401 100644 --- a/.github/workflows/build-release-zip-file.yml +++ b/.github/workflows/build-release-zip-file.yml @@ -9,10 +9,10 @@ on: jobs: build: name: Build release zip file - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: ${{ github.event.inputs.ref || github.ref }} - name: Build the zip file diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 7081582b688..99089114de2 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -5,10 +5,10 @@ on: jobs: build: name: Build release asset - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Build id: build uses: woocommerce/action-build@trunk @@ -25,7 +25,7 @@ jobs: if: github.event.release.prerelease == false && github.event.release.draft == false && github.repository_owner == 'woocommerce' name: Update Code Reference needs: build - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Invoke Code Reference build and deploy workflow uses: aurelien-baudet/workflow-dispatch@v2 @@ -35,3 +35,16 @@ jobs: token: ${{ secrets.CUSTOM_GH_TOKEN }} ref: refs/heads/trunk inputs: '{ "version": "${{ github.event.release.tag_name }}" }' + run-release-smoke-tests: + name: Execute Smoke test release + needs: build + runs-on: ubuntu-20.04 + steps: + - name: Invoke release smoke testing workflow + uses: aurelien-baudet/workflow-dispatch@v2 + with: + workflow: Smoke test release + repo: ${{ github.repository }} + token: ${{ secrets.E2E_WORKFLOW_GH_TOKEN }} + ref: refs/heads/trunk + inputs: '{ "release_id": "${{ github.event.release.id }}" }' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index df8ad097394..887a9dd1905 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,24 +7,26 @@ on: defaults: run: shell: bash - working-directory: plugins/woocommerce +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: name: PHP ${{ matrix.php }} WP ${{ matrix.wp }} - timeout-minutes: 15 - runs-on: ubuntu-latest + timeout-minutes: 20 + runs-on: ubuntu-20.04 continue-on-error: ${{ matrix.wp == 'nightly' }} strategy: fail-fast: false matrix: - php: [ '7.0', '7.1', '7.2', '7.3', '7.4', '8.0' ] + php: [ '7.2', '7.3', '7.4', '8.0' ] wp: [ 'latest' ] include: - wp: nightly php: '7.4' - - wp: '5.7' + - wp: '5.8' php: 7.2 - - wp: '5.6' + - wp: '5.7' php: 7.2 services: database: @@ -36,7 +38,7 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -51,25 +53,33 @@ jobs: php --version composer --version - - name: Get cached composer directories - uses: actions/cache@v2 + - name: Get cached composer and pnpm directories + uses: actions/cache@v3 + id: cache-deps with: path: | - ./packages - ./vendor - key: ${{ runner.os }}-${{ hashFiles('./composer.lock') }} + ~/.pnpm-store + plugins/woocommerce/packages + plugins/woocommerce/**/vendor + key: ${{ runner.os }}-npm-composer-${{ hashFiles('**/composer.lock', '**/pnpm-lock.yaml') }} - - name: Install PNPM and install dependencies - run: | - npm install -g pnpm - pnpm install + - name: Install PNPM + run: npm install -g pnpm - - name: Setup and install composer + - name: Install dependencies + run: pnpm install + + - name: Install Composer dependencies + if: steps.cache-deps.outputs.cache-hit != 'true' run: pnpm nx composer-install woocommerce + - name: Build Admin feature config + run: pnpm nx build:feature-config woocommerce + - name: Add PHP8 Compatibility. run: | if [ "$(php -r "echo version_compare(PHP_VERSION,'8.0','>=');")" ]; then + cd plugins/woocommerce curl -L https://github.com/woocommerce/phpunit/archive/add-compatibility-with-php8-to-phpunit-7.zip -o /tmp/phpunit-7.5-fork.zip unzip -d /tmp/phpunit-7.5-fork /tmp/phpunit-7.5-fork.zip composer bin phpunit config --unset platform @@ -80,6 +90,7 @@ jobs: fi - name: Init DB and WP + working-directory: plugins/woocommerce run: ./tests/bin/install.sh woo_test root root 127.0.0.1 ${{ matrix.wp }} - name: Run tests diff --git a/.github/workflows/mirrors.yml b/.github/workflows/mirrors.yml index 8bdb1ddf9c6..82f35bda1ab 100644 --- a/.github/workflows/mirrors.yml +++ b/.github/workflows/mirrors.yml @@ -1,70 +1,64 @@ name: Mirrors on: - push: - branches: ['trunk', 'release/**'] + push: + branches: ["trunk", "release/**"] jobs: - build: - name: Build WooCommerce zip - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Build - id: build - uses: woocommerce/action-build@trunk - env: - BUILD_ENV: mirrors - - - name: Upload PR zip - uses: actions/upload-artifact@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - name: woocommerce - path: ${{ steps.build.outputs.zip_path }} - retention-days: 7 - - mirror: - name: Push to Mirror - runs-on: ubuntu-latest - needs: [build] - steps: - - name: Create directories - run: | - mkdir -p tmp/woocommerce-build - mkdir -p monorepo - - - name: Checkout monorepo - uses: actions/checkout@v2 - with: - path: monorepo - - - name: Download WooCommerce ZIP - uses: actions/download-artifact@v2 - with: - name: woocommerce - path: tmp/woocommerce-build - - - name: Extract and replace WooCommerce zip. - working-directory: tmp/woocommerce-build - run: | - mkdir -p woocommerce/woocommerce-production - unzip woocommerce.zip -d woocommerce/woocommerce-production - mv woocommerce/woocommerce-production/woocommerce/* woocommerce/woocommerce-production - rm -rf woocommerce/woocommerce-production/woocommerce - - - name: Set up mirror - working-directory: tmp/woocommerce-build - run: | - touch mirrors.txt - echo "woocommerce/woocommerce-production" >> mirrors.txt - - - name: Push to mirror - uses: Automattic/action-push-to-mirrors@v1 - with: - source-directory: ${{ github.workspace }}/monorepo - token: ${{ secrets.API_TOKEN_GITHUB }} - username: matticbot - working-directory: ${{ github.workspace }}/tmp/woocommerce-build - timeout-minutes: 5 # 2021-01-18: Successful runs seem to take about half a minute. + build: + if: github.repository == 'woocommerce/woocommerce' + name: Build WooCommerce zip + runs-on: ubuntu-20.04 + steps: + - name: Checkout code + uses: actions/checkout@v3 + - name: Build + id: build + uses: woocommerce/action-build@trunk + env: + BUILD_ENV: mirrors + - name: Upload PR zip + uses: actions/upload-artifact@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + name: woocommerce + path: ${{ steps.build.outputs.zip_path }} + retention-days: 7 + mirror: + if: github.repository == 'woocommerce/woocommerce' + name: Push to Mirror + runs-on: ubuntu-20.04 + needs: [build] + steps: + - name: Create directories + run: | + mkdir -p tmp/woocommerce-build + mkdir -p monorepo + - name: Checkout monorepo + uses: actions/checkout@v2 + with: + path: monorepo + - name: Download WooCommerce ZIP + uses: actions/download-artifact@v2 + with: + name: woocommerce + path: tmp/woocommerce-build + - name: Extract and replace WooCommerce zip. + working-directory: tmp/woocommerce-build + run: | + mkdir -p woocommerce/woocommerce-production + unzip woocommerce.zip -d woocommerce/woocommerce-production + mv woocommerce/woocommerce-production/woocommerce/* woocommerce/woocommerce-production + rm -rf woocommerce/woocommerce-production/woocommerce + - name: Set up mirror + working-directory: tmp/woocommerce-build + run: | + touch mirrors.txt + echo "woocommerce/woocommerce-production" >> mirrors.txt + - name: Push to mirror + uses: Automattic/action-push-to-mirrors@v1 + with: + source-directory: ${{ github.workspace }}/monorepo + token: ${{ secrets.API_TOKEN_GITHUB }} + username: matticbot + working-directory: ${{ github.workspace }}/tmp/woocommerce-build + timeout-minutes: 5 # 2021-01-18: Successful runs seem to take about half a minute. diff --git a/.github/workflows/nightly-builds.yml b/.github/workflows/nightly-builds.yml index e174617c992..7e4f88448d3 100644 --- a/.github/workflows/nightly-builds.yml +++ b/.github/workflows/nightly-builds.yml @@ -10,10 +10,10 @@ jobs: fail-fast: false matrix: build: [trunk] - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: ref: ${{ matrix.build }} - name: Build @@ -32,7 +32,7 @@ jobs: max_releases: 1 update: name: Update nightly tag commit ref - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Update nightly tag uses: richardsimko/github-tag-action@v1.0.5 diff --git a/.github/workflows/pr-build-and-e2e-tests.yml b/.github/workflows/pr-build-and-e2e-tests.yml index e0fbcc7e718..a4ca35b3300 100644 --- a/.github/workflows/pr-build-and-e2e-tests.yml +++ b/.github/workflows/pr-build-and-e2e-tests.yml @@ -1,12 +1,16 @@ name: Build zip for PR on: pull_request +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: build: name: Build zip for PR - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Build id: build @@ -15,7 +19,7 @@ jobs: BUILD_ENV: e2e - name: Upload PR zip - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -25,7 +29,7 @@ jobs: e2e-tests-run: name: Runs E2E tests. - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 needs: [build] steps: - name: Create dirs. @@ -33,28 +37,17 @@ jobs: mkdir -p code/woocommerce mkdir -p package/woocommerce mkdir -p tmp/woocommerce - mkdir -p node_modules - name: Checkout code. - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: package/woocommerce - - name: Install PNPM and install dependencies - working-directory: package/woocommerce - run: | - npm install -g pnpm - pnpm install - - - name: Load docker images and start containers. - working-directory: package/woocommerce/plugins/woocommerce - run: pnpx wc-e2e docker:up - - name: Move current directory to code. We will install zip file in this dir later. run: mv ./package/woocommerce/plugins/woocommerce/* ./code/woocommerce - name: Download WooCommerce ZIP. - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: woocommerce path: tmp @@ -65,11 +58,23 @@ jobs: unzip woocommerce.zip -d woocommerce mv woocommerce/woocommerce/* ../package/woocommerce/plugins/woocommerce/ - - name: Install dependencies again + - name: Cache modules + uses: actions/cache@v3 + with: + path: | + ~/.pnpm-store + key: ${{ runner.os }}-npm-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install PNPM + run: npm install -g pnpm + + - name: Install dependencies working-directory: package/woocommerce - run: | - npm install -g pnpm - pnpm install + run: pnpm install + + - name: Load docker images and start containers. + working-directory: package/woocommerce/plugins/woocommerce + run: pnpm exec wc-e2e docker:up - name: Run tests command. working-directory: package/woocommerce/plugins/woocommerce @@ -77,11 +82,20 @@ jobs: WC_E2E_SCREENSHOTS: 1 E2E_SLACK_TOKEN: ${{ secrets.E2E_SLACK_TOKEN }} E2E_SLACK_CHANNEL: ${{ secrets.E2E_SLACK_CHANNEL }} - run: pnpx wc-e2e test:e2e - + run: pnpm exec wc-e2e test:e2e + + - name: Archive E2E test screenshots + uses: actions/upload-artifact@v3 + if: always() + with: + name: E2E Screenshots + path: package/woocommerce/plugins/woocommerce/tests/e2e/screenshots + if-no-files-found: ignore + retention-days: 5 + api-tests-run: name: Runs API tests. - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 needs: [build] steps: - name: Create dirs. @@ -89,28 +103,17 @@ jobs: mkdir -p code/woocommerce mkdir -p package/woocommerce mkdir -p tmp/woocommerce - mkdir -p node_modules - name: Checkout code. - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: package/woocommerce - - name: Install PNPM and install dependencies - working-directory: package/woocommerce - run: | - npm install -g pnpm - pnpm install - - - name: Load docker images and start containers. - working-directory: package/woocommerce/plugins/woocommerce - run: pnpx wc-e2e docker:up - - name: Move current directory to code. We will install zip file in this dir later. run: mv ./package/woocommerce/plugins/woocommerce/* ./code/woocommerce - name: Download WooCommerce ZIP. - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: woocommerce path: tmp @@ -121,11 +124,23 @@ jobs: unzip woocommerce.zip -d woocommerce mv woocommerce/woocommerce/* ../package/woocommerce/plugins/woocommerce/ - - name: Install dependencies again + - name: Cache modules + uses: actions/cache@v3 + with: + path: | + ~/.pnpm-store + key: ${{ runner.os }}-npm-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install PNPM + run: npm install -g pnpm + + - name: Install dependencies working-directory: package/woocommerce - run: | - npm install -g pnpm - pnpm install + run: pnpm install + + - name: Load docker images and start containers. + working-directory: package/woocommerce/plugins/woocommerce + run: pnpm exec wc-e2e docker:up - name: Run tests command. working-directory: package/woocommerce/plugins/woocommerce @@ -133,4 +148,75 @@ jobs: BASE_URL: http://localhost:8084 USER_KEY: admin USER_SECRET: password - run: pnpx wc-api-tests test api + run: pnpm exec wc-api-tests test api + + - name: Upload API test report + uses: actions/upload-artifact@v3 + with: + name: api-test-report---pr-${{ github.event.number }} + path: | + package/woocommerce/packages/js/api-core-tests/allure-results + package/woocommerce/packages/js/api-core-tests/allure-report + retention-days: 7 + + k6-tests-run: + name: Runs k6 Performance tests + runs-on: ubuntu-20.04 + needs: [build] + steps: + - name: Create dirs. + run: | + mkdir -p code/woocommerce + mkdir -p package/woocommerce + mkdir -p tmp/woocommerce + + - name: Checkout code. + uses: actions/checkout@v3 + with: + path: package/woocommerce + + - name: Move current directory to code. We will install zip file in this dir later. + run: mv ./package/woocommerce/plugins/woocommerce/* ./code/woocommerce + + - name: Download WooCommerce ZIP. + uses: actions/download-artifact@v3 + with: + name: woocommerce + path: tmp + + - name: Extract and replace WooCommerce zip. + working-directory: tmp + run: | + unzip woocommerce.zip -d woocommerce + mv woocommerce/woocommerce/* ../package/woocommerce/plugins/woocommerce/ + + - name: Cache modules + uses: actions/cache@v3 + with: + path: | + ~/.pnpm-store + key: ${{ runner.os }}-npm-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install PNPM + run: npm install -g pnpm + + - name: Install dependencies + working-directory: package/woocommerce + run: pnpm install + + - name: Workaround to use initialization file with prepopulated data. + working-directory: package/woocommerce/plugins/woocommerce/tests/e2e/docker + run: | + cp init-sample-products.sh initialize.sh + + - name: Load docker images and start containers. + working-directory: package/woocommerce/plugins/woocommerce + run: pnpm exec wc-e2e docker:up + + - name: Install k6 + run: | + curl https://github.com/grafana/k6/releases/download/v0.33.0/k6-v0.33.0-linux-amd64.tar.gz -L | tar xvz --strip-components 1 + + - name: Run k6 tests + run: | + ./k6 run package/woocommerce/plugins/woocommerce/tests/performance/tests/gh-action-pr-requests.js diff --git a/.github/workflows/pr-code-coverage.yml b/.github/workflows/pr-code-coverage.yml index c0340682f42..c683295a36d 100644 --- a/.github/workflows/pr-code-coverage.yml +++ b/.github/workflows/pr-code-coverage.yml @@ -4,12 +4,14 @@ on: defaults: run: shell: bash - working-directory: plugins/woocommerce +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: name: Code coverage (PHP 7.4, WP Latest) - timeout-minutes: 15 - runs-on: ubuntu-latest + timeout-minutes: 20 + runs-on: ubuntu-20.04 services: database: image: mysql:5.6 @@ -20,7 +22,7 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 100 @@ -37,21 +39,29 @@ jobs: php --version composer --version - - name: Get cached composer directories - uses: actions/cache@v2 + - name: Get cached composer and pnpm directories + uses: actions/cache@v3 + id: cache-deps with: path: | - ./packages - ./vendor - key: ${{ runner.os }}-${{ hashFiles('./composer.lock') }} + ~/.pnpm-store + plugins/woocommerce/packages + plugins/woocommerce/**/vendor + key: ${{ runner.os }}-npm-composer-${{ hashFiles('**/composer.lock', '**/pnpm-lock.yaml') }} - - name: Install PNPM and install dependencies - run: | - npm install -g pnpm - pnpm install + - name: Install PNPM + run: npm install -g pnpm - - name: Setup and install composer + - name: Install dependencies + run: pnpm install + + - name: Install Composer dependencies + if: steps.cache-deps.outputs.cache-hit != 'true' run: pnpm nx composer-install woocommerce + + - name: Build Admin feature config + run: | + pnpm nx build:feature-config woocommerce - name: Init DB and WP run: pnpm nx install-unit-test-db woocommerce diff --git a/.github/workflows/pr-code-sniff.yml b/.github/workflows/pr-code-sniff.yml index 4978488aefa..583499f8c65 100644 --- a/.github/workflows/pr-code-sniff.yml +++ b/.github/workflows/pr-code-sniff.yml @@ -4,15 +4,17 @@ on: defaults: run: shell: bash - working-directory: plugins/woocommerce +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: test: name: Code sniff (PHP 7.4, WP Latest) timeout-minutes: 15 - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: fetch-depth: 100 @@ -27,25 +29,31 @@ jobs: php --version composer --version - - name: Get cached composer directories - uses: actions/cache@v2 + - name: Get cached composer and pnpm directories + uses: actions/cache@v3 + id: cache-deps with: path: | - ./packages - ./vendor - key: ${{ runner.os }}-${{ hashFiles('./composer.lock') }} + ~/.pnpm-store + plugins/woocommerce/packages + plugins/woocommerce/**/vendor + key: ${{ runner.os }}-npm-composer-${{ hashFiles('**/composer.lock', '**/pnpm-lock.yaml') }} - - name: Install PNPM and install dependencies - run: | - npm install -g pnpm - pnpm install + - name: Install PNPM + run: npm install -g pnpm - - name: Setup and install composer + - name: Install dependencies + run: pnpm install + + - name: Install Composer dependencies + if: steps.cache-deps.outputs.cache-hit != 'true' run: pnpm nx composer-install woocommerce - name: Run code sniff continue-on-error: true + working-directory: plugins/woocommerce run: ./tests/bin/phpcs.sh "${{ github.event.pull_request.base.sha }}" "${{ github.event.after }}" - name: Show PHPCS results in PR + working-directory: plugins/woocommerce run: cs2pr ./phpcs-report.xml diff --git a/.github/workflows/pr-lint-monorepo.yml b/.github/workflows/pr-lint-monorepo.yml new file mode 100644 index 00000000000..021296ad674 --- /dev/null +++ b/.github/workflows/pr-lint-monorepo.yml @@ -0,0 +1,26 @@ +name: Run lint checks potentially affecting projects across the monorepo +on: pull_request +concurrency: + group: changelogger-${{ github.event_name }}-${{ github.ref }} + cancel-in-progress: true +jobs: + changelogger_used: + name: Changelogger use + runs-on: ubuntu-20.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + + - name: Check change files are touched for touched projects + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: php tools/monorepo/check-changelogger-use.php --debug "$BASE" "$HEAD" diff --git a/.github/workflows/pr-lint-test-js.yml b/.github/workflows/pr-lint-test-js.yml new file mode 100644 index 00000000000..b13f9c9d4ab --- /dev/null +++ b/.github/workflows/pr-lint-test-js.yml @@ -0,0 +1,47 @@ +name: Lint and tests for JS packages and woocommerce-admin/client + +on: + pull_request: + paths: + - 'packages/js/**/**' + - 'plugins/woocommerce-admin/client/**' + - '!**.md' +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint-test-js: + name: Lint and Test JS + runs-on: ubuntu-20.04 + steps: + - name: Check out repository code + uses: actions/checkout@v3 + + - uses: actions/setup-node@v2 + with: + node-version: '16' + + - name: Cache modules + uses: actions/cache@v3 + with: + path: | + ~/.pnpm-store + key: ${{ runner.os }}-npm-${{ hashFiles('**/pnpm-lock.yaml') }} + + - name: Install PNPM + run: npm install -g pnpm + + - name: Install dependencies + run: pnpm install + + - name: Lint + run: | + pnpm nx lint woocommerce-admin + pnpm nx lint:js-packages woocommerce-admin + + - name: Test + run: | + pnpm nx build woocommerce-admin + pnpm nx test woocommerce-admin + pnpm nx test:packages woocommerce-admin diff --git a/.github/workflows/pr-project-label.yml b/.github/workflows/pr-project-label.yml new file mode 100644 index 00000000000..8c189eace6b --- /dev/null +++ b/.github/workflows/pr-project-label.yml @@ -0,0 +1,18 @@ +name: 'Label Pull Request Project' +on: + pull_request_target: + types: + - opened + - synchronize +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + label_project: + runs-on: ubuntu-20.04 + steps: + - uses: actions/labeler@v3 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + configuration-path: .github/project-pr-labeler.yml diff --git a/.github/workflows/pr-smoke-test.yml b/.github/workflows/pr-smoke-test.yml index 97cf35a0676..f2e65c43b7a 100644 --- a/.github/workflows/pr-smoke-test.yml +++ b/.github/workflows/pr-smoke-test.yml @@ -1,105 +1,125 @@ name: Run smoke tests against pull request. on: - pull_request: - branches: - - trunk - types: - - labeled + pull_request: + branches: + - trunk + types: + - labeled +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: - prcheck: - name: Smoke test a pull request. - if: "${{ contains(github.event.label.name, 'run: smoke tests') }}" - runs-on: ubuntu-18.04 - steps: - - name: Create dirs. - run: | - mkdir -p code/woocommerce - mkdir -p package/woocommerce - mkdir -p tmp/woocommerce - mkdir -p node_modules + prcheck: + name: Smoke test a pull request. + if: "${{ contains(github.event.label.name, 'run: smoke tests') }}" + runs-on: ubuntu-20.04 + steps: + - name: Create dirs. + run: | + mkdir -p code/woocommerce + mkdir -p package/woocommerce + mkdir -p tmp/woocommerce - - name: Checkout code. - uses: actions/checkout@v2 - with: - path: package/woocommerce + - name: Checkout code. + uses: actions/checkout@v3 + with: + path: package/woocommerce - - name: Install prerequisites. - working-directory: package/woocommerce/plugins/woocommerce - id: installation - run: | - npm install -g pnpm - pnpm install - pnpm nx composer-install-no-dev woocommerce - pnpm nx build-assets woocommerce - pnpm install jest + - name: Get cached composer and pnpm directories + uses: actions/cache@v3 + id: cache-deps + with: + path: | + ~/.pnpm-store + package/woocommerce/plugins/woocommerce/packages + package/woocommerce/plugins/woocommerce/**/vendor + key: ${{ runner.os }}-smoke-test-npm-composer-${{ hashFiles('**/composer.lock', '**/pnpm-lock.yaml') }} - - name: Run smoke test. - working-directory: package/woocommerce/plugins/woocommerce - if: always() - env: - SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} - SMOKE_TEST_ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }} - SMOKE_TEST_ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }} - SMOKE_TEST_ADMIN_USER_EMAIL: ${{ secrets.SMOKE_TEST_ADMIN_USER_EMAIL }} - SMOKE_TEST_CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }} - SMOKE_TEST_CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }} - WC_E2E_SCREENSHOTS: 1 - E2E_RETEST: 1 - E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} - E2E_SLACK_CHANNEL: ${{ secrets.SMOKE_TEST_SLACK_CHANNEL }} - UPDATE_WC: 1 - DEFAULT_TIMEOUT_OVERRIDE: 120000 - run: | - pnpx wc-e2e test:e2e tests/e2e/specs/smoke-tests/update-woocommerce.js + - name: Install PNPM + run: npm install -g pnpm - - name: Post Smoke tests results comment on PR - if: always() - uses: actions/github-script@v5 - env: - TITLE: 'Smoke Test Results' - SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} - with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - const script = require( './package/woocommerce/packages/js/e2e-environment/bin/post-results-to-github-pr.js' ) - await script({github, context}) + - name: Install dependencies + run: pnpm install - - name: Run E2E tests. - working-directory: package/woocommerce/plugins/woocommerce - if: always() - env: - SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} - SMOKE_TEST_ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }} - SMOKE_TEST_ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }} - SMOKE_TEST_ADMIN_USER_EMAIL: ${{ secrets.SMOKE_TEST_ADMIN_USER_EMAIL }} - SMOKE_TEST_CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }} - SMOKE_TEST_CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }} - WC_E2E_SCREENSHOTS: 1 - E2E_RETEST: 1 - E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} - E2E_SLACK_CHANNEL: ${{ secrets.SMOKE_TEST_SLACK_CHANNEL }} - UPDATE_WC: 1 - DEFAULT_TIMEOUT_OVERRIDE: 120000 - run: | - pnpx wc-e2e test:e2e + - name: Install Composer dependencies + if: steps.cache-deps.outputs.cache-hit != 'true' + run: pnpm nx composer-install-no-dev woocommerce - - name: Post E2E tests results comment on PR - if: always() - uses: actions/github-script@v5 - env: - TITLE: 'E2E Test Results' - SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} - with: - github-token: ${{secrets.GITHUB_TOKEN}} - script: | - const script = require( './package/woocommerce/packages/js/e2e-environment/bin/post-results-to-github-pr.js' ) - await script({github, context}) + - name: Install prerequisites. + working-directory: package/woocommerce/plugins/woocommerce + id: installation + run: | + pnpm nx build-assets woocommerce + pnpm install jest - - name: Remove label from pull request. - if: | - always() - && contains( github.event.pull_request.labels.*.name, format('run{0} smoke tests', ':')) - uses: actions-ecosystem/action-remove-labels@v1 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - labels: 'run: smoke tests' + - name: Run smoke test. + working-directory: package/woocommerce/plugins/woocommerce + if: always() + env: + SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} + SMOKE_TEST_ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }} + SMOKE_TEST_ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }} + SMOKE_TEST_ADMIN_USER_EMAIL: ${{ secrets.SMOKE_TEST_ADMIN_USER_EMAIL }} + SMOKE_TEST_CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }} + SMOKE_TEST_CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }} + WC_E2E_SCREENSHOTS: 1 + E2E_RETEST: 1 + E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} + E2E_SLACK_CHANNEL: ${{ secrets.SMOKE_TEST_SLACK_CHANNEL }} + UPDATE_WC: 1 + DEFAULT_TIMEOUT_OVERRIDE: 120000 + run: | + pnpm exec wc-e2e test:e2e tests/e2e/specs/smoke-tests/update-woocommerce.js + + - name: Post Smoke tests results comment on PR + if: always() + uses: actions/github-script@v5 + env: + TITLE: 'Smoke Test Results' + SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + const script = require( './package/woocommerce/packages/js/e2e-environment/bin/post-results-to-github-pr.js' ) + await script({github, context}) + + - name: Run E2E tests. + working-directory: package/woocommerce/plugins/woocommerce + if: always() + env: + SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} + SMOKE_TEST_ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }} + SMOKE_TEST_ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }} + SMOKE_TEST_ADMIN_USER_EMAIL: ${{ secrets.SMOKE_TEST_ADMIN_USER_EMAIL }} + SMOKE_TEST_CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }} + SMOKE_TEST_CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }} + WC_E2E_SCREENSHOTS: 1 + E2E_RETEST: 1 + E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} + E2E_SLACK_CHANNEL: ${{ secrets.SMOKE_TEST_SLACK_CHANNEL }} + UPDATE_WC: 1 + DEFAULT_TIMEOUT_OVERRIDE: 120000 + run: | + pnpm exec wc-e2e test:e2e + + - name: Post E2E tests results comment on PR + if: always() + uses: actions/github-script@v5 + env: + TITLE: 'E2E Test Results' + SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} + with: + github-token: ${{secrets.GITHUB_TOKEN}} + script: | + const script = require( './package/woocommerce/packages/js/e2e-environment/bin/post-results-to-github-pr.js' ) + await script({github, context}) + + - name: Remove label from pull request. + if: | + always() + && contains( github.event.pull_request.labels.*.name, format('run{0} smoke tests', ':')) + uses: actions-ecosystem/action-remove-labels@v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + labels: 'run: smoke tests' diff --git a/.github/workflows/pr-unit-tests.yml b/.github/workflows/pr-unit-tests.yml index fc1c4ce5d0c..95057adbbc9 100644 --- a/.github/workflows/pr-unit-tests.yml +++ b/.github/workflows/pr-unit-tests.yml @@ -4,24 +4,27 @@ on: defaults: run: shell: bash - working-directory: plugins/woocommerce +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: test: name: PHP ${{ matrix.php }} WP ${{ matrix.wp }} - timeout-minutes: 15 - runs-on: ubuntu-latest + timeout-minutes: 20 + runs-on: ubuntu-20.04 continue-on-error: ${{ matrix.wp == 'nightly' }} strategy: fail-fast: false matrix: - php: [ '7.0', '7.1', '7.2', '7.3', '7.4', '8.0' ] + php: [ '7.2', '7.3', '7.4', '8.0' ] wp: [ "latest" ] include: - wp: nightly php: '7.4' - - wp: '5.7' + - wp: '5.8' php: 7.2 - - wp: '5.6' + - wp: '5.7' php: 7.2 services: database: @@ -48,33 +51,45 @@ jobs: php --version composer --version - - name: Get cached composer directories - uses: actions/cache@v2 + - name: Get cached composer and pnpm directories + uses: actions/cache@v3 + id: cache-deps with: path: | - ./packages - ./vendor - key: ${{ runner.os }}-${{ hashFiles('./composer.lock') }} + ~/.pnpm-store + plugins/woocommerce/packages + plugins/woocommerce/**/vendor + key: ${{ runner.os }}-npm-composer-${{ hashFiles('**/composer.lock', '**/pnpm-lock.yaml') }} - - name: Install PNPM and install dependencies + - name: Install PNPM + run: npm install -g pnpm + + - name: Install dependencies + run: pnpm install + + - name: Install Composer dependencies + if: steps.cache-deps.outputs.cache-hit != 'true' + run: pnpm nx composer-install woocommerce + + - name: Build Admin feature config run: | - npm install -g pnpm - pnpm install - pnpm nx composer-install woocommerce + pnpm nx build:feature-config woocommerce - name: Add PHP8 Compatibility. run: | if [ "$(php -r "echo version_compare(PHP_VERSION,'8.0','>=');")" ]; then + cd plugins/woocommerce curl -L https://github.com/woocommerce/phpunit/archive/add-compatibility-with-php8-to-phpunit-7.zip -o /tmp/phpunit-7.5-fork.zip unzip -d /tmp/phpunit-7.5-fork /tmp/phpunit-7.5-fork.zip composer bin phpunit config --unset platform composer bin phpunit config repositories.0 '{"type": "path", "url": "/tmp/phpunit-7.5-fork/phpunit-add-compatibility-with-php8-to-phpunit-7", "options": {"symlink": false}}' composer bin phpunit require --dev -W phpunit/phpunit:@dev --ignore-platform-reqs rm -rf ./vendor/phpunit/ - composer dump-autoload + pnpm nx composer-dump-autoload woocommerce fi - name: Init DB and WP + working-directory: plugins/woocommerce run: ./tests/bin/install.sh woo_test root root 127.0.0.1 ${{ matrix.wp }} - name: Run tests diff --git a/.github/workflows/pull-request-post-merge-processing.yml b/.github/workflows/pull-request-post-merge-processing.yml index 42bd8aa47da..08501ca1f9e 100644 --- a/.github/workflows/pull-request-post-merge-processing.yml +++ b/.github/workflows/pull-request-post-merge-processing.yml @@ -7,7 +7,7 @@ jobs: process-pull-request-after-merge: name: "Process a pull request after it's merged" if: github.event.pull_request.merged == true - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 steps: - name: "Get the action scripts" run: | @@ -30,12 +30,16 @@ jobs: with: php-version: '7.4' - name: "Run the script to assign a milestone" - if: "!github.event.pull_request.milestone" + if: | + contains(github.event.pull_request.labels.*.name, 'plugin: woocommerce') && + !github.event.pull_request.milestone && + github.event.pull_request.base.ref == 'trunk' run: php assign-milestone-to-merged-pr.php env: PULL_REQUEST_ID: ${{ github.event.pull_request.node_id }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: "Run the script to post a comment with next steps hint" + if: "contains(github.event.pull_request.labels.*.name, 'plugin: woocommerce')" run: php add-post-merge-comment.php env: PULL_REQUEST_ID: ${{ github.event.pull_request.node_id }} diff --git a/.github/workflows/release-code-freeze.yml b/.github/workflows/release-code-freeze.yml new file mode 100644 index 00000000000..eb645fbaabb --- /dev/null +++ b/.github/workflows/release-code-freeze.yml @@ -0,0 +1,35 @@ +name: "Enforce release code freeze" +on: + schedule: + - cron: '0 16 * * 4' # Run at 1600 UTC on Thursdays. + +jobs: + maybe-create-next-milestone-and-release-branch: + name: "Maybe create next milestone and release branch" + runs-on: ubuntu-20.04 + steps: + - name: "Get the action script" + run: | + scripts="post-request-shared.php release-code-freeze.php" + for script in $scripts + do + curl \ + --silent \ + --fail \ + --header 'Authorization: bearer ${{ secrets.GITHUB_TOKEN }}' \ + --header 'User-Agent: GitHub action to enforce release code freeze' \ + --header 'Accept: application/vnd.github.v3.raw' \ + --output $script \ + --location "$GITHUB_API_URL/repos/${{ github.repository }}/contents/.github/workflows/scripts/$script?ref=$GITHUB_REF" + done + env: + GITHUB_API_URL: ${{ env.GITHUB_API_URL }} + GITHUB_REF: ${{ env.GITHUB_REF }} + - name: "Install PHP" + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + - name: "Run the script to enforce the code freeze" + run: php release-code-freeze.php + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/scripts/add-post-merge-comment.php b/.github/workflows/scripts/add-post-merge-comment.php index fbe3d37bc45..afd6ec803f4 100644 --- a/.github/workflows/scripts/add-post-merge-comment.php +++ b/.github/workflows/scripts/add-post-merge-comment.php @@ -57,8 +57,7 @@ echo "The pull request was merged by: $merger_user_name\n"; $comment_body = "Hi @$merger_user_name, thanks for merging this pull request. Please take a look at these follow-up tasks you may need to perform: -- [ ] Add the `status: needs changelog` label -- [ ] Add the `status: needs testing instructions` label"; +- [ ] Add the `release: add testing instructions` label"; $add_comment_mutation = " addComment(input: {subjectId: \"$pr_id\", body: \"$comment_body\", clientMutationId: \"$github_token\"}) { diff --git a/.github/workflows/scripts/assign-milestone-to-merged-pr.php b/.github/workflows/scripts/assign-milestone-to-merged-pr.php index 62b32c040ca..ecf31ab7bd1 100644 --- a/.github/workflows/scripts/assign-milestone-to-merged-pr.php +++ b/.github/workflows/scripts/assign-milestone-to-merged-pr.php @@ -9,73 +9,7 @@ require_once __DIR__ . '/post-request-shared.php'; -/* - * Select the milestone to be added: - * - * 1. Get the first 10 milestones sorted by creation date descending. - * (we'll never have more than 2 or 3 active milestones but let's get 10 to be sure). - * 2. Discard those not open or whose title is not a proper version number ("X.Y.Z"). - * 3. Sort descending using version_compare. - * 4. Get the oldest one that does not have a corresponding "release/X.Y" branch. - */ - -echo "Getting the list of milestones...\n"; - -$query = " - repository(owner:\"$repo_owner\", name:\"$repo_name\") { - milestones(first: 10, states: [OPEN], orderBy: {field: CREATED_AT, direction: DESC}) { - nodes { - id - title - state - } - } - } -"; -$json = do_graphql_api_request( $query ); -$milestones = $json['data']['repository']['milestones']['nodes']; -$milestones = array_map( - function( $x ) { - return 1 === preg_match( '/^\d+\.\d+\.\d+$/D', $x['title'] ) ? $x : null; - }, - $milestones -); -$milestones = array_filter( $milestones ); -usort( - $milestones, - function( $a, $b ) { - return version_compare( $b['title'], $a['title'] ); - } -); - -echo 'Latest open milestone: ' . $milestones[0]['title'] . "\n"; - -$chosen_milestone = null; -foreach ( $milestones as $milestone ) { - $milestone_title_parts = explode( '.', $milestone['title'] ); - $milestone_release_branch = 'release/' . $milestone_title_parts[0] . '.' . $milestone_title_parts[1]; - - $query = " - repository(owner:\"$repo_owner\", name:\"$repo_name\") { - ref(qualifiedName: \"refs/heads/$milestone_release_branch\") { - id - } - } - "; - $result = do_graphql_api_request( $query ); - - if ( is_null( $result['data']['repository']['ref'] ) ) { - $chosen_milestone = $milestone; - } else { - break; - } -} - -// If all the milestones have a release branch, just take the newest one. -if ( is_null( $chosen_milestone ) ) { - echo "WARNING: No milestone without release branch found, the newest one will be assigned.\n"; - $chosen_milestone = $milestones[0]; -} +$chosen_milestone = get_latest_milestone_from_api( true ); echo 'Milestone that will be assigned: ' . $chosen_milestone['title'] . "\n"; diff --git a/.github/workflows/scripts/fetch-asset-id.js b/.github/workflows/scripts/fetch-asset-id.js new file mode 100644 index 00000000000..658168fe333 --- /dev/null +++ b/.github/workflows/scripts/fetch-asset-id.js @@ -0,0 +1,54 @@ +/** + * A script that fetches the asset id of a given release and sets it as the output for the step that calls it + */ +const https = require('https'); + +const options = { + hostname: 'api.github.com', + port: 443, + path: `/repos/${process.env.REPO}/releases/${process.env.RELEASE_ID}/assets`, + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${ process.env.GITHUB_TOKEN }`, + 'User-Agent': 'WooCommerce Smoke Build' + }, +}; + +/** + * + * @returns {Promise} + */ +const fetchAssetId = () => { + return new Promise( ( resolve, reject ) => { + const request = https.get( options, ( response ) => { + response.setEncoding('utf8'); + + let responseBody = ''; + + response.on( 'data', ( chunk ) => { + responseBody += chunk; + } ); + + response.on( 'end', () => { + const assets = JSON.parse( responseBody ); + // use the most recently uploaded asset + resolve( assets[ assets.length - 1 ].id ); + } ); + } ); + + request.on('error', ( error ) => { + reject( error ); + } ); + + request.end(); + + } ); +} + +module.exports = async ( { github, context, core } ) => { + const id = await fetchAssetId(); + + // set asset_id as the output + core.setOutput( 'asset_id', id ); +} diff --git a/.github/workflows/scripts/post-request-shared.php b/.github/workflows/scripts/post-request-shared.php index 21e9219f2a6..67a6794feef 100644 --- a/.github/workflows/scripts/post-request-shared.php +++ b/.github/workflows/scripts/post-request-shared.php @@ -19,9 +19,237 @@ $repo_name = $repo_parts[1]; $pr_id = getenv( 'PULL_REQUEST_ID' ); $github_token = getenv( 'GITHUB_TOKEN' ); +$github_api_url = getenv( 'GITHUB_API_URL' ); $graphql_api_url = getenv( 'GITHUB_GRAPHQL_URL' ); +/** + * Function to get the latest milestone. + * + * @param bool $use_latest_when_null When true, the function returns the latest milestone regardless of release branch status. + * @return string The title of the latest milestone. + */ +function get_latest_milestone_from_api( $use_latest_when_null = false ) { + global $repo_owner, $repo_name; + + echo 'Getting the list of milestones...' . PHP_EOL; + + $query = " + repository(owner:\"$repo_owner\", name:\"$repo_name\") { + milestones(first: 10, states: [OPEN], orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + id + title + state + } + } + } + "; + $json = do_graphql_api_request( $query ); + $milestones = $json['data']['repository']['milestones']['nodes']; + $milestones = array_map( + function( $x ) { + return 1 === preg_match( '/^\d+\.\d+\.\d+$/D', $x['title'] ) ? $x : null; + }, + $milestones + ); + $milestones = array_filter( $milestones ); + usort( + $milestones, + function( $a, $b ) { + return version_compare( $b['title'], $a['title'] ); + } + ); + + echo 'Latest open milestone: ' . $milestones[0]['title'] . PHP_EOL; + + $chosen_milestone = null; + foreach ( $milestones as $milestone ) { + $milestone_title_parts = explode( '.', $milestone['title'] ); + $milestone_release_branch = 'release/' . $milestone_title_parts[0] . '.' . $milestone_title_parts[1]; + + $query = " + repository(owner:\"$repo_owner\", name:\"$repo_name\") { + ref(qualifiedName: \"refs/heads/$milestone_release_branch\") { + id + } + } + "; + $result = do_graphql_api_request( $query ); + + if ( is_null( $result['data']['repository']['ref'] ) ) { + $chosen_milestone = $milestone; + } else { + break; + } + } + + // If all the milestones have a release branch, just take the newest one. + if ( $use_latest_when_null && is_null( $chosen_milestone ) ) { + echo 'WARNING: No milestone without release branch found, the newest one will be assigned.' . PHP_EOL; + $chosen_milestone = $milestones[0]; + } + + return $chosen_milestone; +} + +/** + * Function to get the last major.minor version with a release from the API. + * + * @return string Returns the latest version with a release formatted as "major.minor". + */ +function get_latest_version_with_release() { + global $repo_owner, $repo_name; + + echo 'Getting the list of releases...' . PHP_EOL; + + $query = " + repository(owner:\"$repo_owner\", name:\"$repo_name\") { + releases(first: 25, orderBy: { field: CREATED_AT, direction: DESC}) { + nodes { + tagName + } + } + } + "; + $json = do_graphql_api_request( $query ); + $releases = $json['data']['repository']['releases']['nodes']; + $releases = array_map( + function( $x ) { + return 1 === preg_match( '/^\d+\.\d+\.\d+/D', $x['tagName'] ) ? $x : null; + }, + $releases + ); + $releases = array_filter( $releases ); + usort( + $releases, + function( $a, $b ) { + return version_compare( $b['tagName'], $a['tagName'] ); + } + ); + + $major_minor = preg_replace( '/(^\d+\.\d+).*?$/', '\1', $releases[0]['tagName'] ); + echo 'Most recent version with a release: ' . $major_minor . PHP_EOL; + + return $major_minor; +} + +/** + * Function to retreive the sha1 reference for a given branch name. + * + * @param string $branch The name of the branch. + * @return string Returns the name of the branch, or a falsey value on error. + */ +function get_ref_from_branch( $branch ) { + global $repo_owner, $repo_name; + $query = " + repository(owner:\"$repo_owner\", name:\"$repo_name\") { + ref(qualifiedName: \"refs/heads/{$branch}\") { + target { + ... on Commit { + history(first: 1) { + edges{ node{ oid } } + } + } + } + } + } + "; + $result = do_graphql_api_request( $query ); + + // Warnings suppressed here because traversing this level of arrays with isset / is_array checks would be messy. + return @$result['data']['repository']['ref']['target']['history']['edges'][0]['node']['oid']; +} + +/** + * Function to create milestone using the GitHub REST API. + * + * @param string $title The title of the milestone to be created. + * @return bool True on success, False otherwise. + */ +function create_github_milestone( $title ) { + global $repo_owner, $repo_name; + + $result = do_github_api_post_request( "/repos/{$repo_owner}/{$repo_name}/milestones", array( + 'title' => $title, + ) ); + + return is_array( $result ) && $result['title'] === $title; +} + +/** + * Function to create branch using the GitHub REST API. + * + * @param string $branch The branch to be created. + * @param string $sha The sha1 reference for the branch. + * @return bool True on success, False otherwise. + */ +function create_github_branch( $branch, $sha ) { + global $repo_owner, $repo_name; + + $ref = "refs/heads/{$branch}"; + $result = do_github_api_post_request( "/repos/{$repo_owner}/{$repo_name}/git/refs", array( + 'ref' => $ref, + 'sha' => $sha, + ) ); + + return is_array( $result ) && $result['ref'] === $ref; +} + +/** + * Function to create branch using the GitHub REST API from an existing branch. + * + * @param string $source The branch from which to create. + * @param string $target The branch to be created. + * @return bool True on success, False otherwise. + */ +function create_github_branch_from_branch( $source, $target ) { + $ref = get_ref_from_branch( $source ); + if ( ! $ref ) { + return false; + } + return create_github_branch( $target, $ref ); +} + +/** + * Function to do a GitHub API POST Request. + * + * @param array $request_url + * @param array $body The body of the request to be json encoded. + * @return mixed The json-decoded response if a response is received, 'false' (or whatever file_get_contents returns) otherwise. + */ +function do_github_api_post_request( $request_path, $body ) { + global $github_token, $github_api_url, $github_api_response_code; + + $context = stream_context_create( + array( + 'http' => array( + 'method' => 'POST', + 'header' => array( + 'Accept: application/vnd.github.v3+json', + 'Content-Type: application/json', + 'User-Agent: GitHub Actions for creation of milestones', + 'Authorization: bearer ' . $github_token, + ), + 'content' => json_encode( $body ), + ), + ) + ); + + $full_request_url = rtrim( $github_api_url, '/' ) . '/' . ltrim( $request_path, '/' ); + $result = @file_get_contents( $full_request_url, false, $context ); + + // Verify that the post request was sucessful. + $status_line = $http_response_header[0]; + preg_match( "/^HTTPS?\/\d\.\d\s+(\d{3})\s+/i", $status_line, $matches ); + $github_api_response_code = $matches[1]; + if ( '2' !== substr( $github_api_response_code, 0, 1 ) ) { + return false; + } + + return is_string( $result ) ? json_decode( $result, true ) : $result; +} + /** * Function to query the GitHub GraphQL API. * diff --git a/.github/workflows/scripts/release-code-freeze.php b/.github/workflows/scripts/release-code-freeze.php new file mode 100644 index 00000000000..607480e850c --- /dev/null +++ b/.github/workflows/scripts/release-code-freeze.php @@ -0,0 +1,65 @@ + 14 ) { + echo 'Info: Today is not the Thursday of the code freeze.' . PHP_EOL; + return; +} + +$latest_version_with_release = get_latest_version_with_release(); + +if ( empty( $latest_version_with_release ) ) { + echo '*** Error: Unable to get latest version with release' . PHP_EOL; + return; +} + +// Because we go from 5.9 to 6.0, we can get the next major_minor by adding 0.1 and formatting appropriately. +$latest_float = (float) $latest_version_with_release; +$branch_major_minor = number_format( $latest_float + 0.1, 1 ); +$milestone_major_minor = number_format( $latest_float + 0.2, 1 ); + +// We use those values to get the release branch and next milestones that we need to create. +$release_branch_to_create = "release/{$branch_major_minor}"; +$milestone_to_create = "{$milestone_major_minor}.0"; + +if ( getenv( 'DRY_RUN' ) ) { + echo 'DRY RUN: Skipping actual creation of release branch and milestone...' . PHP_EOL; + echo "Release Branch: {$release_branch_to_create}" . PHP_EOL; + echo "Milestone: {$milestone_to_create}" . PHP_EOL; + return; +} + +if ( create_github_milestone( $milestone_to_create ) ) { + echo "Created milestone {$milestone_to_create}" . PHP_EOL; +} else if ( '422' === $github_api_response_code ) { + // The milestone already existed when GitHub returns a 422 status. + echo "Notice: Unable to create {$milestone_to_create} milestone. Maybe it already exists? Skipping..." . PHP_EOL; +} else { + echo "*** Error: Unable to create {$milestone_to_create} milestone" . PHP_EOL; +} + +if ( create_github_branch_from_branch( 'trunk', $release_branch_to_create ) ) { + echo "Created branch {$release_branch_to_create}" . PHP_EOL; +} else if ( '422' === $github_api_response_code ) { + // The release branch already existed when GitHub returns a 422 status. + echo "Notice: Unable to create {$release_branch_to_create} branch. Maybe it already exists? Skipping..." . PHP_EOL; +} else { + echo "*** Error: Unable to create {$release_branch_to_create}" . PHP_EOL; +} diff --git a/.github/workflows/smoke-test-daily-site-check.yml b/.github/workflows/smoke-test-daily-site-check.yml new file mode 100644 index 00000000000..ac71633d3b1 --- /dev/null +++ b/.github/workflows/smoke-test-daily-site-check.yml @@ -0,0 +1,24 @@ +name: Check daily smoke test site status. +on: + schedule: + - cron: '25 7 * * *' + +jobs: + ping_site: + runs-on: ubuntu-20.04 + name: Check site and notify if not found + steps: + - name: Check site status + id: sitecheck + uses: srt32/uptime@958231f4d95c117f08eb0fc70907e80d0dfedf2b + with: + url-to-hit: "${{ secrets.SMOKE_TEST_URL }}ready/" + expected-statuses: "200,301" + - name: Send message to Slack API + if: failure() + uses: archive/github-actions-slack@deecc2edc496dc642d643de1d7cf3a47f51fb27a + id: notify + with: + slack-bot-user-oauth-access-token: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} + slack-channel: ${{ secrets.SMOKE_TEST_SLACK_CHANNEL }} + slack-text: ':warning: FYI the URL ${{ secrets.SMOKE_TEST_URL }}ready/ appears to be returning `404 not found` :x:' diff --git a/.github/workflows/smoke-test-daily.yml b/.github/workflows/smoke-test-daily.yml index e9126b249b2..e57d54d371a 100644 --- a/.github/workflows/smoke-test-daily.yml +++ b/.github/workflows/smoke-test-daily.yml @@ -1,154 +1,153 @@ name: Smoke test daily on: - schedule: - - cron: '25 3 * * *' + schedule: + - cron: '25 3 * * *' jobs: - login-run: - name: Daily smoke test on trunk. - runs-on: ubuntu-18.04 - steps: + login-run: + name: Daily smoke test on trunk. + runs-on: ubuntu-20.04 + steps: + - name: Create dirs. + run: | + mkdir -p code/woocommerce + mkdir -p package/woocommerce + mkdir -p tmp/woocommerce + mkdir -p node_modules - - name: Create dirs. - run: | - mkdir -p code/woocommerce - mkdir -p package/woocommerce - mkdir -p tmp/woocommerce - mkdir -p node_modules + - name: Checkout code. + uses: actions/checkout@v3 + with: + path: package/woocommerce + ref: trunk - - name: Checkout code. - uses: actions/checkout@v2 - with: - path: package/woocommerce - ref: trunk + - name: Install prerequisites. + working-directory: package/woocommerce/plugins/woocommerce + run: | + npm install -g pnpm + pnpm install + pnpm nx composer-install-no-dev woocommerce + pnpm nx build-assets woocommerce + pnpm install jest - - name: Install prerequisites. - working-directory: package/woocommerce/plugins/woocommerce - run: | - npm install -g pnpm - pnpm install - pnpm nx composer-install-no-dev woocommerce - pnpm nx build-assets woocommerce - pnpm install jest - - - name: Run smoke test. - working-directory: package/woocommerce/plugins/woocommerce - env: - SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} - SMOKE_TEST_ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }} - SMOKE_TEST_ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }} - SMOKE_TEST_ADMIN_USER_EMAIL: ${{ secrets.SMOKE_TEST_ADMIN_USER_EMAIL }} - SMOKE_TEST_CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }} - SMOKE_TEST_CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }} - WC_E2E_SCREENSHOTS: 1 - E2E_RETEST: 1 - E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} - E2E_SLACK_CHANNEL: ${{ secrets.SMOKE_TEST_SLACK_CHANNEL }} - UPDATE_WC: 1 - DEFAULT_TIMEOUT_OVERRIDE: 120000 - BASE_URL: ${{ secrets.SMOKE_TEST_URL }} - USER_KEY: ${{ secrets.SMOKE_TEST_ADMIN_USER }} - USER_SECRET: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }} - run: | - pnpx wc-e2e test:e2e tests/e2e/specs/smoke-tests/update-woocommerce.js - pnpx wc-e2e test:e2e - pnpx wc-api-tests test api + - name: Run smoke test. + working-directory: package/woocommerce/plugins/woocommerce + env: + SMOKE_TEST_URL: ${{ secrets.SMOKE_TEST_URL }} + SMOKE_TEST_ADMIN_USER: ${{ secrets.SMOKE_TEST_ADMIN_USER }} + SMOKE_TEST_ADMIN_PASSWORD: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }} + SMOKE_TEST_ADMIN_USER_EMAIL: ${{ secrets.SMOKE_TEST_ADMIN_USER_EMAIL }} + SMOKE_TEST_CUSTOMER_USER: ${{ secrets.SMOKE_TEST_CUSTOMER_USER }} + SMOKE_TEST_CUSTOMER_PASSWORD: ${{ secrets.SMOKE_TEST_CUSTOMER_PASSWORD }} + WC_E2E_SCREENSHOTS: 1 + E2E_RETEST: 1 + E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} + E2E_SLACK_CHANNEL: 'C01U0H617MY' + UPDATE_WC: 1 + DEFAULT_TIMEOUT_OVERRIDE: 120000 + BASE_URL: ${{ secrets.SMOKE_TEST_URL }} + USER_KEY: ${{ secrets.SMOKE_TEST_ADMIN_USER }} + USER_SECRET: ${{ secrets.SMOKE_TEST_ADMIN_PASSWORD }} + run: | + pnpm exec wc-e2e test:e2e tests/e2e/specs/smoke-tests/update-woocommerce.js + pnpm exec wc-e2e test:e2e + pnpm exec wc-api-tests test api - build: - name: Build zip for PR - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v2 + build: + name: Build zip for PR + runs-on: ubuntu-20.04 + steps: + - name: Checkout code + uses: actions/checkout@v3 - - name: Build - id: build - uses: woocommerce/action-build@trunk - env: - BUILD_ENV: e2e + - name: Build + id: build + uses: woocommerce/action-build@trunk + env: + BUILD_ENV: e2e - - name: Upload PR zip - uses: actions/upload-artifact@v2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - name: woocommerce - path: ${{ steps.build.outputs.zip_path }} - retention-days: 7 - - test-plugins: - name: Smoke tests with ${{ matrix.plugin }} plugin installed - runs-on: ubuntu-18.04 - needs: [build] - strategy: - fail-fast: false - matrix: - include: - - plugin: 'WooCommerce Payments' - repo: 'automattic/woocommerce-payments' - - plugin: 'WooCommerce PayPal Payments' - repo: 'woocommerce/woocommerce-paypal-payments' - - plugin: 'WooCommerce Shipping & Tax' - repo: 'automattic/woocommerce-services' - - plugin: 'WooCommerce Subscriptions' - repo: WC_SUBSCRIPTIONS_REPO - private: true - - plugin: 'WordPress SEO' # Yoast SEO in the UI, but the slug is wordpress-seo - repo: 'Yoast/wordpress-seo' - - plugin: 'Contact Form 7' - repo: 'takayukister/contact-form-7' - steps: - - name: Create dirs. - run: | - mkdir -p code/woocommerce - mkdir -p package/woocommerce - mkdir -p tmp/woocommerce - mkdir -p node_modules + - name: Upload PR zip + uses: actions/upload-artifact@v3 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + name: woocommerce + path: ${{ steps.build.outputs.zip_path }} + retention-days: 7 - - name: Checkout code. - uses: actions/checkout@v2 - with: - path: package/woocommerce + test-plugins: + name: Smoke tests with ${{ matrix.plugin }} plugin installed + runs-on: ubuntu-20.04 + needs: [build] + strategy: + fail-fast: false + matrix: + include: + - plugin: 'WooCommerce Payments' + repo: 'automattic/woocommerce-payments' + - plugin: 'WooCommerce PayPal Payments' + repo: 'woocommerce/woocommerce-paypal-payments' + - plugin: 'WooCommerce Shipping & Tax' + repo: 'automattic/woocommerce-services' + - plugin: 'WooCommerce Subscriptions' + repo: WC_SUBSCRIPTIONS_REPO + private: true + - plugin: 'WordPress SEO' # Yoast SEO in the UI, but the slug is wordpress-seo + repo: 'Yoast/wordpress-seo' + - plugin: 'Contact Form 7' + repo: 'takayukister/contact-form-7' + steps: + - name: Create dirs. + run: | + mkdir -p code/woocommerce + mkdir -p package/woocommerce + mkdir -p tmp/woocommerce + mkdir -p node_modules - - name: Install PNPM and install dependencies - working-directory: package/woocommerce - run: | - npm install -g pnpm - pnpm install + - name: Checkout code. + uses: actions/checkout@v2 + with: + path: package/woocommerce - - name: Load docker images and start containers. - working-directory: package/woocommerce/plugins/woocommerce - run: pnpx wc-e2e docker:up + - name: Install PNPM and install dependencies + working-directory: package/woocommerce + run: | + npm install -g pnpm + pnpm install - - name: Move current directory to code. We will install zip file in this dir later. - run: mv ./package/woocommerce/plugins/woocommerce/* ./code/woocommerce + - name: Load docker images and start containers. + working-directory: package/woocommerce/plugins/woocommerce + run: pnpm exec wc-e2e docker:up - - name: Download WooCommerce ZIP. - uses: actions/download-artifact@v2 - with: - name: woocommerce - path: tmp + - name: Move current directory to code. We will install zip file in this dir later. + run: mv ./package/woocommerce/plugins/woocommerce/* ./code/woocommerce - - name: Extract and replace WooCommerce zip. - working-directory: tmp - run: | - unzip woocommerce.zip -d woocommerce - mv woocommerce/woocommerce/* ../package/woocommerce/plugins/woocommerce/ + - name: Download WooCommerce ZIP. + uses: actions/download-artifact@v3 + with: + name: woocommerce + path: tmp - - name: Install dependencies again - working-directory: package/woocommerce - run: | - npm install -g pnpm - pnpm install + - name: Extract and replace WooCommerce zip. + working-directory: tmp + run: | + unzip woocommerce.zip -d woocommerce + mv woocommerce/woocommerce/* ../package/woocommerce/plugins/woocommerce/ - - name: Run tests command. - working-directory: package/woocommerce/plugins/woocommerce - env: - WC_E2E_SCREENSHOTS: 1 - E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} - E2E_SLACK_CHANNEL: ${{ secrets.RELEASE_TEST_SLACK_CHANNEL }} - PLUGIN_REPOSITORY: ${{ matrix.private && secrets[matrix.repo] || matrix.repo }} - PLUGIN_NAME: ${{ matrix.plugin }} - GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }} - run: | - pnpx wc-e2e test:e2e tests/e2e/specs/smoke-tests/upload-plugin.js - pnpx wc-e2e test:e2e + - name: Install dependencies again + working-directory: package/woocommerce + run: | + npm install -g pnpm + pnpm install + + - name: Run tests command. + working-directory: package/woocommerce/plugins/woocommerce + env: + WC_E2E_SCREENSHOTS: 1 + E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} + E2E_SLACK_CHANNEL: ${{ secrets.RELEASE_TEST_SLACK_CHANNEL }} + PLUGIN_REPOSITORY: ${{ matrix.private && secrets[matrix.repo] || matrix.repo }} + PLUGIN_NAME: ${{ matrix.plugin }} + GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }} + run: | + pnpm exec wc-e2e test:e2e tests/e2e/specs/smoke-tests/upload-plugin.js + pnpm exec wc-e2e test:e2e diff --git a/.github/workflows/smoke-test-release.yml b/.github/workflows/smoke-test-release.yml index 4ea577ad353..c06fe653d89 100644 --- a/.github/workflows/smoke-test-release.yml +++ b/.github/workflows/smoke-test-release.yml @@ -1,178 +1,197 @@ name: Smoke test release on: - release: - types: [published] + workflow_dispatch: + inputs: + release_id: + description: 'WooCommerce Release Id' + required: true jobs: - login-run: - name: Daily smoke test on release. - runs-on: ubuntu-18.04 - steps: + login-run: + name: Daily smoke test on release. + runs-on: ubuntu-20.04 + steps: + - name: Create dirs. + run: | + mkdir -p code/woocommerce + mkdir -p package/woocommerce + mkdir -p tmp/woocommerce + mkdir -p node_modules - - name: Create dirs. - run: | - mkdir -p code/woocommerce - mkdir -p package/woocommerce - mkdir -p tmp/woocommerce - mkdir -p node_modules + - name: Checkout code. + uses: actions/checkout@v3 + with: + path: package/woocommerce + ref: trunk - - name: Checkout code. - uses: actions/checkout@v2 - with: - path: package/woocommerce - ref: trunk + - name: Install prerequisites. + working-directory: package/woocommerce/plugins/woocommerce + run: | + npm install -g pnpm + pnpm install + pnpm nx composer-install-no-dev woocommerce + pnpm nx build-assets woocommerce + pnpm install jest - - name: Install prerequisites. - working-directory: package/woocommerce/plugins/woocommerce - run: | - npm install -g pnpm - pnpm install - pnpm nx composer-install-no-dev woocommerce - pnpm nx build-assets woocommerce - pnpm install jest - - - name: Run smoke test. - working-directory: package/woocommerce/plugins/woocommerce - env: - SMOKE_TEST_URL: ${{ secrets.RELEASE_TEST_URL }} - SMOKE_TEST_ADMIN_USER: ${{ secrets.RELEASE_TEST_ADMIN_USER }} - SMOKE_TEST_ADMIN_PASSWORD: ${{ secrets.RELEASE_TEST_ADMIN_PASSWORD }} - SMOKE_TEST_ADMIN_USER_EMAIL: ${{ secrets.RELEASE_TEST_ADMIN_USER_EMAIL }} - SMOKE_TEST_CUSTOMER_USER: ${{ secrets.RELEASE_TEST_CUSTOMER_USER }} - SMOKE_TEST_CUSTOMER_PASSWORD: ${{ secrets.RELEASE_TEST_CUSTOMER_PASSWORD }} - WC_E2E_SCREENSHOTS: 1 - E2E_RETEST: 1 - E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} - E2E_SLACK_CHANNEL: ${{ secrets.RELEASE_TEST_SLACK_CHANNEL }} - TEST_RELEASE: 1 - UPDATE_WC: 1 - DEFAULT_TIMEOUT_OVERRIDE: 120000 - BASE_URL: ${{ secrets.RELEASE_TEST_URL }} - USER_KEY: ${{ secrets.RELEASE_TEST_ADMIN_USER }} - USER_SECRET: ${{ secrets.RELEASE_TEST_ADMIN_PASSWORD }} - run: | - pnpx wc-e2e test:e2e tests/e2e/specs/smoke-tests/update-woocommerce.js - pnpx wc-e2e test:e2e - pnpx wc-api-tests test api - test-wp-version: - name: Smoke test on L-${{ matrix.wp }} WordPress version - runs-on: ubuntu-18.04 - strategy: - matrix: - wp: [ '1', '2' ] - steps: + - name: Run smoke test. + working-directory: package/woocommerce/plugins/woocommerce + env: + SMOKE_TEST_URL: ${{ secrets.RELEASE_TEST_URL }} + SMOKE_TEST_ADMIN_USER: ${{ secrets.RELEASE_TEST_ADMIN_USER }} + SMOKE_TEST_ADMIN_PASSWORD: ${{ secrets.RELEASE_TEST_ADMIN_PASSWORD }} + SMOKE_TEST_ADMIN_USER_EMAIL: ${{ secrets.RELEASE_TEST_ADMIN_USER_EMAIL }} + SMOKE_TEST_CUSTOMER_USER: ${{ secrets.RELEASE_TEST_CUSTOMER_USER }} + SMOKE_TEST_CUSTOMER_PASSWORD: ${{ secrets.RELEASE_TEST_CUSTOMER_PASSWORD }} + WC_E2E_SCREENSHOTS: 1 + E2E_RETEST: 1 + E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} + E2E_SLACK_CHANNEL: 'C02DS4NE72S' + TEST_RELEASE: 1 + UPDATE_WC: 1 + DEFAULT_TIMEOUT_OVERRIDE: 120000 + BASE_URL: ${{ secrets.RELEASE_TEST_URL }} + USER_KEY: ${{ secrets.RELEASE_TEST_ADMIN_USER }} + USER_SECRET: ${{ secrets.RELEASE_TEST_ADMIN_PASSWORD }} + run: | + pnpm exec wc-e2e test:e2e tests/e2e/specs/smoke-tests/update-woocommerce.js + pnpm exec wc-e2e test:e2e + pnpm exec wc-api-tests test api + test-wp-version: + name: Smoke test on L-${{ matrix.wp }} WordPress version + runs-on: ubuntu-20.04 + strategy: + matrix: + wp: ['1', '2'] + steps: + - name: Create dirs. + run: | + mkdir -p code/woocommerce + mkdir -p package/woocommerce + mkdir -p tmp/woocommerce + mkdir -p node_modules - - name: Create dirs. - run: | - mkdir -p code/woocommerce - mkdir -p package/woocommerce - mkdir -p tmp/woocommerce - mkdir -p node_modules + - name: Checkout code. + uses: actions/checkout@v3 + with: + path: package/woocommerce + - name: Fetch Asset ID + id: fetch_asset_id + uses: actions/github-script@v5 + env: + RELEASE_ID: ${{ github.event.inputs.release_id }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + with: + script: | + const script = require( './package/woocommerce/.github/workflows/scripts/fetch-asset-id.js' ) + await script({github, context, core}) - - name: Checkout code. - uses: actions/checkout@v2 - with: - path: package/woocommerce + - name: Install PNPM and install dependencies + working-directory: package/woocommerce + run: | + npm install -g pnpm + pnpm install - - name: Install PNPM and install dependencies - working-directory: package/woocommerce - run: | - npm install -g pnpm - pnpm install + - name: Load docker images and start containers. + working-directory: package/woocommerce/plugins/woocommerce + env: + LATEST_WP_VERSION_MINUS: ${{ matrix.wp }} + run: pnpm nx docker-up woocommerce - - name: Load docker images and start containers. - working-directory: package/woocommerce/plugins/woocommerce - env: - LATEST_WP_VERSION_MINUS: ${{ matrix.wp }} - run: pnpm nx docker-up woocommerce + - name: Move current directory to code. We will install zip file in this dir later. + run: mv ./package/woocommerce/plugins/woocommerce/* ./code/woocommerce - - name: Move current directory to code. We will install zip file in this dir later. - run: mv ./package/woocommerce/plugins/woocommerce/* ./code/woocommerce + - name: Download WooCommerce release zip + working-directory: tmp + run: | + curl https://api.github.com/repos/${{ github.repository }}/releases/assets/${{ steps.fetch_asset_id.outputs.asset_id }} -LJOH 'Accept: application/octet-stream' - - name: Download WooCommerce release zip - working-directory: tmp - run: | - ASSET_ID=$(jq ".release.assets[0].id" $GITHUB_EVENT_PATH) + unzip woocommerce.zip -d woocommerce + mv woocommerce/woocommerce/* ../package/woocommerce/plugins/woocommerce/ - curl https://api.github.com/repos/woocommerce/woocommerce/releases/assets/${ASSET_ID} -LJOH 'Accept: application/octet-stream' + - name: Run tests command. + working-directory: package/woocommerce/plugins/woocommerce + env: + WC_E2E_SCREENSHOTS: 1 + E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} + E2E_SLACK_CHANNEL: ${{ secrets.RELEASE_TEST_SLACK_CHANNEL }} + run: pnpm nx test-e2e woocommerce - unzip woocommerce.zip -d woocommerce - mv woocommerce/woocommerce/* ../package/woocommerce/plugins/woocommerce/ + test-plugins: + name: Smoke tests with ${{ matrix.plugin }} plugin installed + runs-on: ubuntu-20.04 + strategy: + fail-fast: false + matrix: + include: + - plugin: 'WooCommerce Payments' + repo: 'automattic/woocommerce-payments' + - plugin: 'WooCommerce PayPal Payments' + repo: 'woocommerce/woocommerce-paypal-payments' + - plugin: 'WooCommerce Shipping & Tax' + repo: 'automattic/woocommerce-services' + - plugin: 'WooCommerce Subscriptions' + repo: WC_SUBSCRIPTIONS_REPO + private: true + - plugin: 'WordPress SEO' # Yoast SEO in the UI, but the slug is wordpress-seo + repo: 'Yoast/wordpress-seo' + - plugin: 'Contact Form 7' + repo: 'takayukister/contact-form-7' + steps: + - name: Create dirs. + run: | + mkdir -p code/woocommerce + mkdir -p package/woocommerce + mkdir -p tmp/woocommerce + mkdir -p node_modules - - name: Run tests command. - working-directory: package/woocommerce/plugins/woocommerce - env: - WC_E2E_SCREENSHOTS: 1 - E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} - E2E_SLACK_CHANNEL: ${{ secrets.RELEASE_TEST_SLACK_CHANNEL }} - run: pnpm nx test-e2e woocommerce + - name: Checkout code. + uses: actions/checkout@v2 + with: + path: package/woocommerce + - name: Fetch Asset ID + id: fetch_asset_id + uses: actions/github-script@v5 + env: + RELEASE_ID: ${{ github.event.inputs.release_id }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + with: + script: | + const script = require( './package/woocommerce/.github/workflows/scripts/fetch-asset-id.js' ) + await script({github, context, core}) - test-plugins: - name: Smoke tests with ${{ matrix.plugin }} plugin installed - runs-on: ubuntu-18.04 - strategy: - fail-fast: false - matrix: - include: - - plugin: 'WooCommerce Payments' - repo: 'automattic/woocommerce-payments' - - plugin: 'WooCommerce PayPal Payments' - repo: 'woocommerce/woocommerce-paypal-payments' - - plugin: 'WooCommerce Shipping & Tax' - repo: 'automattic/woocommerce-services' - - plugin: 'WooCommerce Subscriptions' - repo: WC_SUBSCRIPTIONS_REPO - private: true - - plugin: 'WordPress SEO' # Yoast SEO in the UI, but the slug is wordpress-seo - repo: 'Yoast/wordpress-seo' - - plugin: 'Contact Form 7' - repo: 'takayukister/contact-form-7' - steps: - - name: Create dirs. - run: | - mkdir -p code/woocommerce - mkdir -p package/woocommerce - mkdir -p tmp/woocommerce - mkdir -p node_modules + - name: Install PNPM and install dependencies + working-directory: package/woocommerce + run: | + npm install -g pnpm + pnpm install - - name: Checkout code. - uses: actions/checkout@v2 - with: - path: package/woocommerce + - name: Load docker images and start containers. + working-directory: package/woocommerce/plugins/woocommerce + env: + LATEST_WP_VERSION_MINUS: ${{ matrix.wp }} + run: pnpm nx docker-up woocommerce - - name: Install PNPM and install dependencies - working-directory: package/woocommerce - run: | - npm install -g pnpm - pnpm install + - name: Move current directory to code. We will install zip file in this dir later. + run: mv ./package/woocommerce/plugins/woocommerce/* ./code/woocommerce - - name: Load docker images and start containers. - working-directory: package/woocommerce/plugins/woocommerce - env: - LATEST_WP_VERSION_MINUS: ${{ matrix.wp }} - run: pnpm nx docker-up woocommerce + - name: Download WooCommerce release zip + working-directory: tmp + run: | + curl https://api.github.com/repos/${{ github.repository }}/releases/assets/${{ steps.fetch_asset_id.outputs.asset_id }} -LJOH 'Accept: application/octet-stream' - - name: Move current directory to code. We will install zip file in this dir later. - run: mv ./package/woocommerce/plugins/woocommerce/* ./code/woocommerce + unzip woocommerce.zip -d woocommerce + mv woocommerce/woocommerce/* ../package/woocommerce/plugins/woocommerce/ - - name: Download WooCommerce release zip - working-directory: tmp - run: | - ASSET_ID=$(jq ".release.assets[0].id" $GITHUB_EVENT_PATH) - - curl https://api.github.com/repos/woocommerce/woocommerce/releases/assets/${ASSET_ID} -LJOH 'Accept: application/octet-stream' - - unzip woocommerce.zip -d woocommerce - mv woocommerce/woocommerce/* ../package/woocommerce/plugins/woocommerce/ - - - name: Run tests command. - working-directory: package/woocommerce/plugins/woocommerce - env: - WC_E2E_SCREENSHOTS: 1 - E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} - E2E_SLACK_CHANNEL: ${{ secrets.RELEASE_TEST_SLACK_CHANNEL }} - PLUGIN_REPOSITORY: ${{ matrix.private && secrets[matrix.repo] || matrix.repo }} - PLUGIN_NAME: ${{ matrix.plugin }} - GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }} - run: | - pnpx wc-e2e test:e2e tests/e2e/specs/smoke-tests/upload-plugin.js - pnpx wc-e2e test:e2e + - name: Run tests command. + working-directory: package/woocommerce/plugins/woocommerce + env: + WC_E2E_SCREENSHOTS: 1 + E2E_SLACK_TOKEN: ${{ secrets.SMOKE_TEST_SLACK_TOKEN }} + E2E_SLACK_CHANNEL: ${{ secrets.RELEASE_TEST_SLACK_CHANNEL }} + PLUGIN_REPOSITORY: ${{ matrix.private && secrets[matrix.repo] || matrix.repo }} + PLUGIN_NAME: ${{ matrix.plugin }} + GITHUB_TOKEN: ${{ secrets.E2E_GH_TOKEN }} + run: | + pnpm exec wc-e2e test:e2e tests/e2e/specs/smoke-tests/upload-plugin.js + pnpm exec wc-e2e test:e2e diff --git a/.github/workflows/stalebot.yml b/.github/workflows/stalebot.yml index 7257ea1b7ec..dc7e2c9b034 100644 --- a/.github/workflows/stalebot.yml +++ b/.github/workflows/stalebot.yml @@ -6,17 +6,19 @@ on: jobs: stale: if: | - ! contains(github.event.issue.labels.*.name, 'enhancement') - runs-on: ubuntu-latest + ! contains(github.event.issue.labels.*.name, 'type: enhancement') + runs-on: ubuntu-20.04 steps: - uses: actions/stale@v3 with: repo-token: ${{ secrets.GITHUB_TOKEN }} - stale-issue-message: "As a part of this repository’s maintenance, this issue is being marked as stale due to inactivity. Please feel free to comment on it in case we missed something.\n\n###### After 7 days with no activity this issue will be automatically be closed." + stale-issue-message: "As a part of this repository's maintenance, this issue is being marked as stale due to inactivity. Please feel free to comment on it in case we missed something.\n\n###### After 7 days with no activity this issue will be automatically be closed." close-issue-message: 'This issue was closed because it has been 14 days with no activity.' days-before-issue-stale: 7 days-before-issue-close: 7 days-before-pr-close: -1 - only-issue-labels: 'needs feedback' - close-issue-label: "category: can't reproduce" + stale-issue-label: 'status: stale' + stale-pr-label: 'status: stale' + only-issue-labels: 'needs: author feedback' + close-issue-label: "status: can't reproduce" ascending: true diff --git a/.github/workflows/triage-label.yml b/.github/workflows/triage-label.yml new file mode 100644 index 00000000000..ef9c5852437 --- /dev/null +++ b/.github/workflows/triage-label.yml @@ -0,0 +1,15 @@ +name: Add Triage Label + +on: + issues: + types: opened + +jobs: + add_label: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v3 + - uses: actions-ecosystem/action-add-labels@v1 + if: github.event.issue.labels[0] == null + with: + labels: 'status: awaiting triage' diff --git a/.github/workflows/triage-replies.yml b/.github/workflows/triage-replies.yml index 11e77830e2f..61ab77b2198 100644 --- a/.github/workflows/triage-replies.yml +++ b/.github/workflows/triage-replies.yml @@ -5,8 +5,8 @@ on: - labeled jobs: add-dev-comment: - if: "github.event.label.name == 'needs developer feedback'" - runs-on: ubuntu-latest + if: "github.event.label.name == 'needs: developer feedback'" + runs-on: ubuntu-20.04 permissions: issues: write steps: @@ -25,8 +25,8 @@ jobs: Please note it may take a few days for them to get to this issue. Thank you for your patience.' }) add-reproduction-comment: - if: "github.event.label.name == 'status: needs reproduction'" - runs-on: ubuntu-latest + if: "github.event.label.name == 'status: reproduction'" + runs-on: ubuntu-20.04 permissions: issues: write steps: @@ -45,7 +45,7 @@ jobs: }) add-support-comment: if: "github.event.label.name == 'type: support request'" - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 permissions: issues: write steps: @@ -84,8 +84,8 @@ jobs: state: 'closed' }) add-votes-comment: - if: "github.event.label.name == 'votes needed'" - runs-on: ubuntu-latest + if: "github.event.label.name == 'needs: votes'" + runs-on: ubuntu-20.04 permissions: issues: write steps: @@ -98,14 +98,14 @@ jobs: issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, - body: 'Thanks for the suggestion @${{ github.event.issue.user.login }},\n\n\ - While we appreciate you sharing your ideas with us, it doesn’t fit in with our current priorities for the project.\n\ + body: "Thanks for the suggestion @${{ github.event.issue.user.login }},\n\n\ + While we appreciate you sharing your ideas with us, it doesn't fit in with our current priorities for the project.\n\ At some point, we may revisit our priorities and look through the list of suggestions like this one to see if it \ warrants a second look.\n\n\ In the meantime, we are going to close this issue with the `votes needed` label and evaluate over time if this \ issue collects more feedback.\n\n\ - Don’t be alarmed if you don’t see any activity on this issue for a while. \ - We'll keep an eye on the popularity of this request.' + Don't be alarmed if you don't see any activity on this issue for a while. \ + We'll keep an eye on the popularity of this request." }) - name: Close votes needed issue uses: actions/github-script@v5 @@ -118,3 +118,49 @@ jobs: issue_number: context.issue.number, state: 'closed' }) + fill-template-comment: + if: "github.event.label.name == 'needs: template'" + runs-on: ubuntu-20.04 + permissions: + issues: write + steps: + - name: Add reply to fill template + uses: actions/github-script@v5 + with: + github-token: ${{ secrets.WC_BOT_TRIAGE_TOKEN }} + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: "Hi @${{ github.event.issue.user.login }},\n\n\ + Thank you for submitting the issue. However, you didn't fill out the details of the bug report template that we ask for. Without these details, we can't fully evaluate this issue. Please provide us with the information requested so we could take a look further.\n\n\ + **Describe the bug**\n\n\ + A clear and concise description of what the bug is. Please be as descriptive as possible; issues lacking detail, or for any other reason than to report a bug, may be closed without action.\n\n\ + **To Reproduce**\n\n\ + Steps to reproduce the behavior:\n\n\ + 1. Go to '...'\n\ + 2. Click on '....'\n\ + 3. Scroll down to '....'\n\ + 4. See error\n\n\ + **Screenshots**\n\n\ + If applicable, add screenshots to help explain your problem.\n\n\ + **Expected behavior**\n\n\ + A clear and concise description of what you expected to happen.\n\n\ + **Isolating the problem (mark completed items with an [x]):**\n\n\ + - [ ] I have deactivated other plugins and confirmed this bug occurs when only WooCommerce plugin is active.\n\ + - [ ] This bug happens with a default WordPress theme active, or [Storefront](https://woocommerce.com/storefront/).\n\ + - [ ] I can reproduce this bug consistently using the steps above.\n\n\ + **WordPress Environment**\n\n\ + Copy and paste the system status report from **WooCommerce > System Status** in WordPress admin." + }) + - name: remove-needs-template-label + uses: actions-ecosystem/action-remove-labels@v1 + with: + github-token: ${{ secrets.WC_BOT_TRIAGE_TOKEN }} + labels: 'needs: template' + - name: add-needs-author-feedback-label + uses: actions-ecosystem/action-add-labels@v1 + with: + github-token: ${{ secrets.WC_BOT_TRIAGE_TOKEN }} + labels: 'needs: author feedback' diff --git a/.github/workflows/update-feedback-labels.yml b/.github/workflows/update-feedback-labels.yml index bb0af46f64b..34caa1c31a1 100644 --- a/.github/workflows/update-feedback-labels.yml +++ b/.github/workflows/update-feedback-labels.yml @@ -5,23 +5,24 @@ jobs: feedback: if: | github.actor != 'github-actions' && + github.actor == github.event.issue.user.login && github.event.issue && github.event.issue.state == 'open' && - contains(github.event.issue.labels.*.name, 'needs feedback') - runs-on: ubuntu-latest + contains(github.event.issue.labels.*.name, 'needs: author feedback') + runs-on: ubuntu-20.04 steps: - name: Add has feedback uses: actions-ecosystem/action-add-labels@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} - labels: 'has feedback' + labels: 'needs: triage feedback' - name: remove needs feedback uses: actions-ecosystem/action-remove-labels@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} - labels: 'needs feedback' + labels: 'needs: author feedback' - name: remove stale uses: actions-ecosystem/action-remove-labels@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} - labels: Stale + labels: 'status: stale' diff --git a/.gitignore b/.gitignore index f6fa98bdbca..a2e8f72a8cf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ Thumbs.db # IDE files .idea -.vscode/ +.vscode/* project.xml project.properties .project @@ -13,6 +13,9 @@ project.properties *.sublime-workspace .sublimelinterrc +# Excluded IDE Files for developer experience tooling within workspace +!.vscode/tasks.json + # Grunt none @@ -40,6 +43,7 @@ npm-debug.log build/ build-module/ build-style/ +build-types/ dist/ # Project files @@ -73,3 +77,9 @@ nbproject/private/ # Test Results test-results.json + +# Admin Feature config +plugins/woocommerce/includes/react-admin/feature-config.php + +# PHP lint +phpcs-report.xml diff --git a/.husky/post-merge b/.husky/post-merge new file mode 100755 index 00000000000..48bc4affda4 --- /dev/null +++ b/.husky/post-merge @@ -0,0 +1,5 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +pnpm install +pnpm nx affected --target="composer-install" --base=ORIG_HEAD --head=HEAD diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000000..5e592735698 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +pnpm exec lint-staged diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000000..e52fd7c5635 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +./bin/pre-push.sh diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000000..309c50d8e8d --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,29 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "command": "pnpm tsc -b tsconfig.base.json", + "type": "shell", + "problemMatcher": [ "$tsc" ], + "label": "Typescript compile", + "detail": "Run tsc against tsconfig.base.json", + "runOptions": { + "runOn": "default" + } + }, + { + "command": "pnpm tsc -b tsconfig.base.json --watch", + "type": "shell", + "problemMatcher": { + "base": "$tsc-watch", + "applyTo": "allDocuments" + }, + "isBackground": true, + "label": "Incremental Typescript compile", + "detail": "Incremental background type checks", + "runOptions": { + "runOn": "folderOpen" + } + } + ] +} diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 3138c399b4e..8affc6b777e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -45,6 +45,21 @@ You might also want to run `pnpm start` to watch your CSS and JS changes if you You're now ready to develop! +### Typescript Checking + +Typescript is progressively being implemented in this repository, and you might come across some files that are `.ts` or `.tsx`. By default, a VSCode environment will run type checking on such files that are currently open. + +As of now, some parts of the codebase that were imported from the Woocommerce-Admin repository, into the `plugins/woocommerce-admin/client` directory, still fail Typescript checking. This has been scheduled on the team's backlog to be fixed. + +In order to run type checking across the entire repository, you can run this command in your shell, from the root of this repository: + +```sh +pnpm tsc -b tsconfig.base.json +``` + +For better developer experience, the folder `.vscode/tasks.json` has two VSCode tasks to run these commands automatically as well as to parse the output and highlight the errors in the `Problems` tab and in the file explorer pane. The first task runs it once, the second one runs it in the background upon saving of any modified files. This task is also automatically prompted by VSCode to be run upon opening the folder. + + ## Using Xdebug Please refer to [WP-ENV official README](https://github.com/WordPress/gutenberg/tree/master/packages/env#using-xdebug) section for setting up Xdebug. @@ -102,13 +117,12 @@ You can get the current MySQL port from the output of `wp-env start` command. 1. Open your choice of MySQL tool. 2. Use the following values to access the MySQL container. -3. You can omit the username and password. | Name | Value | | -------- | --------------------- | | Host | 127.0.0.1 | -| Username | | -| Password | | +| Username | root | +| Password | password | | Port | Port from the command | ## HOWTOs diff --git a/README.md b/README.md index 4ff98b84e02..b65934c26c6 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ If you are not a developer, please use the [WooCommerce plugin page](https://wor * [WooCommerce Developer Documentation](https://github.com/woocommerce/woocommerce/wiki) * [WooCommerce Code Reference](https://docs.woocommerce.com/wc-apidocs/) * [WooCommerce REST API Docs](https://woocommerce.github.io/woocommerce-rest-api-docs/) +* [Setting up a development environment](https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment) ## Reporting Security Issues To disclose a security issue to our team, [please submit a report via HackerOne here](https://hackerone.com/automattic/). diff --git a/bin/pre-push.sh b/bin/pre-push.sh new file mode 100755 index 00000000000..9b2346b136f --- /dev/null +++ b/bin/pre-push.sh @@ -0,0 +1,24 @@ +#!/bin/sh + +PROTECTED_BRANCH="trunk" +CURRENT_BRANCH=$(git branch --show-current) +if [ $PROTECTED_BRANCH = $CURRENT_BRANCH ]; then + if [ "$TERM" = "dumb" ]; then + >&2 echo "Sorry, you are unable to push to $PROTECTED_BRANCH using a GUI client! Please use git CLI." + exit 1 + fi + + printf "%sYou're about to push to $PROTECTED_BRANCH, is that what you intended? [y/N]: %s" "$(tput setaf 3)" "$(tput sgr0)" + read -r PROCEED < /dev/tty + echo + + if [ "$(echo "${PROCEED:-n}" | tr "[:upper:]" "[:lower:]")" = "y" ]; then + echo "$(tput setaf 2)Brace yourself! Pushing to the $PROTECTED_BRANCH branch...$(tput sgr0)" + echo + exit 0 + fi + + echo "$(tput setaf 2)Push to $PROTECTED_BRANCH cancelled!$(tput sgr0)" + echo + exit 1 +fi diff --git a/changelog.txt b/changelog.txt index e11e6daf4e7..f199b021ec1 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,347 @@ == Changelog == += 6.4.1 2022-04-15 = + +**WooCommerce** + +- Revert - incorrect position value for registering menu pages. ([#32623](https://github.com/woocommerce/woocommerce/pull/32623)) + +**WooCommerce Blocks - 7.2.2** + +- Fix - page load problem due to incorrect URL to certain assets. ([#6260](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/6260)) + += 6.4.0 2022-04-12 = + +**WooCommerce** + +- Add - Scaffolding for the custom orders table feature. ([#31692](https://github.com/woocommerce/woocommerce/pull/31692)) +- Add - Add DB table structure for custom order tables. ([#31811](https://github.com/woocommerce/woocommerce/pull/31811)) +- Add - Primary key for the product attributes lookup table. ([#32067](https://github.com/woocommerce/woocommerce/pull/32067)) +- Add - Tracks to the dashboard status widget and setup widget. ([#31857](https://github.com/woocommerce/woocommerce/pull/31857)) +- Add - Check around setup widget display when features are disabled. ([#31884](https://github.com/woocommerce/woocommerce/pull/31884)) +- Add - 'woocommerce_get_formatted_meta_data_include_all_meta_lines' filter hook. This can be used to control whether metadata lines are shown in the order meta box. ([#30948](https://github.com/woocommerce/woocommerce/pull/30948)) +- Enhancement - Introduce rate_limit_remaining column in the wc_rate_limits table. ([#32041](https://github.com/woocommerce/woocommerce/pull/32041)) +- Tweak - Update PayPal Standard JS used in the admin environment to avoid deprecated functionality. ([#32076](https://github.com/woocommerce/woocommerce/pull/32076)) +- Tweak - Change level of escaping used to render the CSV import error log. ([#32000](https://github.com/woocommerce/woocommerce/pull/32000)) +- Tweak - Make the payment_url field available via the REST API's orders endpoint. ([#31826](https://github.com/woocommerce/woocommerce/pull/31826)) +- Tweak - Rename WC_API_Exception code woocommerce_api_cannot_edit_product_catgory into woocommerce_api_cannot_edit_product_category ([#31785](https://github.com/woocommerce/woocommerce/pull/31785)) +- Tweak - Updated default email color to new Woo purple ([#30586](https://github.com/woocommerce/woocommerce/pull/30586)) +- Fix - Avoid depending on the presence of a theme header template to clear the cart after payment is made. ([#31877](https://github.com/woocommerce/woocommerce/pull/31877)) +- Fix - Payments tab tracking. ([#31844](https://github.com/woocommerce/woocommerce/pull/31844)) +- Fix - Remove unnecessary duplicate style in email-styles template. ([#31860](https://github.com/woocommerce/woocommerce/pull/31860)) +- Fix - incorrect position value for registering menu pages. ([#31779](https://github.com/woocommerce/woocommerce/pull/31779)) +- Fix - SZL currency symbol. Updated from 'L' to 'E'. ([#30602](https://github.com/woocommerce/woocommerce/pull/30602)) +- Fix - Removed execution of at least one hook ignoring the `woocommerce_load_webhooks_limit` filter value. ([#29002](https://github.com/woocommerce/woocommerce/pull/29002)) +- Dev - Added has_options() to REST API v3 product endpoint response. ([#32031](https://github.com/woocommerce/woocommerce/pull/32031)) +- Dev - Added woocommerce_admin_order_should_render_refunds hook to allow control over the refunds UI within the order editor. ([#31414](https://github.com/woocommerce/woocommerce/pull/31414)) + +**WooCommerce Admin - 3.3.0 & 3.3.1 & 3.3.2** + +- Add - Add asynchronous plugin install and activation endpoints ([#8079](https://github.com/woocommerce/woocommerce-admin/pull/8079)) +- Performance - Avoid expensive get_notes() queries in CouponPageMoved admin_init actions by using new Notes::get_note_by_name() helper method. ([#8202](https://github.com/woocommerce/woocommerce-admin/pull/8202)) +- Enhancement - Add chart color filter for overriding default chart colors. ([#8258](https://github.com/woocommerce/woocommerce-admin/pull/8258)) +- Enhancement - Added Typescript type declarations to build for @woocommerce/components ([#8282](https://github.com/woocommerce/woocommerce-admin/pull/8282)) +- Enhancement - Increase color selection limit to ten and add additional colors. ([#8258](https://github.com/woocommerce/woocommerce-admin/pull/8258)) +- Enhancement - Made @woocommerce/components/Stepper a Typescript file ([#8286](https://github.com/woocommerce/woocommerce-admin/pull/8286)) +- Enhancement - Prompts a modal to save any unsaved changes when the users try to move to a different step ([#8278](https://github.com/woocommerce/woocommerce-admin/pull/8278)) +- Tweak - OBW: Override Country/Region label line-height style to avoid truncated descenders. ([#8186](https://github.com/woocommerce/woocommerce-admin/pull/8186)) +- Tweak - Show single success message for theme install and activation ([#8236](https://github.com/woocommerce/woocommerce-admin/pull/8236)) +- Tweak - Use WC_VERSION as cache buster for assets ([#8308](https://github.com/woocommerce/woocommerce-admin/pull/8308)) +- Update - Adjust time range and add an image for the Jetpack Backup note. ([#8293](https://github.com/woocommerce/woocommerce-admin/pull/8293)) +- Update - Implement MailChimp API request threshold for MailchimpScheduler. ([#8342](https://github.com/woocommerce/woocommerce-admin/pull/8342)) +- Update - Reintroduce CES on product add, product update, and order update. ([#8238](https://github.com/woocommerce/woocommerce-admin/pull/8238)) +- Update - Replace mysql image with mariadb ([#8220](https://github.com/woocommerce/woocommerce-admin/pull/8220)) +- Update - Update country support list for WooCommerce Payments Task. ([#8517](https://github.com/woocommerce/woocommerce-admin/pull/8517)) +- Fix - Fix handling of paid themes in purchase task. ([#8493](https://github.com/woocommerce/woocommerce-admin/pull/8493)) +- Fix - Make sure the paid extension task is also shown for themes. ([#8412](https://github.com/woocommerce/woocommerce-admin/pull/8412)) +- Fix - Reintroduce emphasis on inbox note action button. ([#8411](https://github.com/woocommerce/woocommerce-admin/pull/8411)) +- Fix - Remove class ExtendedPayments. ([#8461](https://github.com/woocommerce/woocommerce-admin/pull/8461)) +- Fix - Added random IDs to SVG checkmarks in stepper component ([#8222](https://github.com/woocommerce/woocommerce-admin/pull/8222)) +- Fix - Fix Google Listings plugin is always shown in free features despite already activated. ([#8330](https://github.com/woocommerce/woocommerce-admin/pull/8330)) +- Fix - Fix hidden notes in `admin/notes` endpoint when the user is not in the tasklist experiment. ([#8328](https://github.com/woocommerce/woocommerce-admin/pull/8328)) +- Fix - Fix missing product name in variation analytic page for the deleted products. ([#8255](https://github.com/woocommerce/woocommerce-admin/pull/8255)) +- Fix - Fix payments extensions displayed below the offline payments options. ([#8232](https://github.com/woocommerce/woocommerce-admin/pull/8232)) +- Fix - Fix setup wizard title and flash of content ([#8201](https://github.com/woocommerce/woocommerce-admin/pull/8201)) +- Fix - Fix too many pending run_remote_notifications actions. ([#8285](https://github.com/woocommerce/woocommerce-admin/pull/8285)) +- Fix - Fix view logic for Setup additional payment providers task. ([#8391](https://github.com/woocommerce/woocommerce-admin/pull/8391)) +- Fix - OBW: fix copy on Business Details when "WooCommerce Shipping" is not listed ([#8324](https://github.com/woocommerce/woocommerce-admin/pull/8324)) +- Fix - Only add product data on REST requests and task list ([#8235](https://github.com/woocommerce/woocommerce-admin/pull/8235)) +- Fix - Stop showing actioned inbox items ([#8394](https://github.com/woocommerce/woocommerce-admin/pull/8394)) +- Fix - WC Payments task is not visible after installing the plugin ([#8514](https://github.com/woocommerce/woocommerce-admin/pull/8514)) +- Fix - PHP warning when default param is missing in payments spec. ([#8519](https://github.com/woocommerce/woocommerce-admin/pull/8519)) +- Dev - Added a test for tracks event recording for PaymentGatewaySuggestions ([#8306](https://github.com/woocommerce/woocommerce-admin/pull/8306)) +- Dev - Add README to hook reference generation script ([#8004](https://github.com/woocommerce/woocommerce-admin/pull/8004)) +- Dev - Add reset WooCommerce functionality to E2E tests, so tests have a fresh state. ([#8219](https://github.com/woocommerce/woocommerce-admin/pull/8219)) +- Dev - Enabled optional typescript checking on ./client subfolder ([#8372](https://github.com/woocommerce/woocommerce-admin/pull/8372)) +- Dev - Fix formatting and add filter param for changelog types for the testing instructions script. ([#8256](https://github.com/woocommerce/woocommerce-admin/pull/8256)) +- Dev - Refactor MerchantEmailNotifications ([#8304](https://github.com/woocommerce/woocommerce-admin/pull/8304)) +- Dev - Remove preloaded countries from data endpoints and use data store instead. ([#8380](https://github.com/woocommerce/woocommerce-admin/pull/8380)) +- Dev - Remove unused pre loaded setting data displaying all the routes. ([#8379](https://github.com/woocommerce/woocommerce-admin/pull/8379)) +- Dev - Remove unused task styling classes ([#8234](https://github.com/woocommerce/woocommerce-admin/pull/8234)) +- Dev - Update dependencies to support react 17 and drop support for IE11. ([#8305](https://github.com/woocommerce/woocommerce-admin/pull/8305)) +- Dev - Update task list data structure to better handle new designs. ([#8332](https://github.com/woocommerce/woocommerce-admin/pull/8332)) + +**WooCommerce Blocks - 7.2.0 & 7.2.1** + +- Enhancement - Add Global Styles support to the Product Price block. ([5950](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5950)) +- Enhancement - Add Global Styles support to the Add To Cart Button block. ([5816](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5816)) +- Enhancement - Store API - Introduced `wc/store/v1` namespace. ([5911](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5911)) +- Enhancement - Renamed WooCommerce block templates to more e-commerce related names. ([5935](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5935)) +- Enhancement - Featured Product block: Add the ability to reset to a previously set custom background image. ([5886](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5886)) +- Enhancement - Add a remove image button to the WooCommerce Feature Category block. ([5719](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5719)) +- Enhancement - Add support for the global style for the On-Sale Badge block. ([5565](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5565)) +- Enhancement - Add support for the global style for the Attribute Filter block. ([5557](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5557)) +- Enhancement - Category List block: Add support for global style. ([5516](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5516)) +- Fix - Fixed typo in `wooocommerce_store_api_validate_add_to_cart` and `wooocommerce_store_api_validate_cart_item` hook names. ([5926](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5926)) +- Fix - Fix loading WC core translations in locales where WC Blocks is not localized for some strings. ([5910](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5910)) +- Fix - Fixed an issue where clear customizations functionality was not working for WooCommerce templates. ([5746](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5746)) +- Fix - Fixed hover and focus states for button components. ([5712](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5712)) +- Fix - Add to Cart button on Products listing blocks will respect the "Redirect to the cart page after successful addition" setting. ([5708](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5708)) +- Fix - Fixes Twenty Twenty Two issues with sales price and added to cart "View Cart" call out styling in the "Products by Category" block. ([5684](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5684)) +- Fix - StoreAPI: Clear all wc notice types in the cart validation context [#5983](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5983) +- Fix - Don't trigger class deprecations notices if headers are already sent [#6074](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/6074) +- Various - Remove v1 string from Store Keys. ([5987](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5987)) +- Various - Introduce the `InvalidCartException` for handling cart validation. ([5904](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5904)) +- Various - Renamed Store API custom headers to remove `X-WC-Store-API` prefixes. [#5983](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5983) +- Various - Normalised Store API error codes [#5992](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5992) +- Various - Deprecated `woocommerce_blocks_checkout_order_processed` in favour of `woocommerce_store_api_checkout_order_processed` +- Various - Deprecated `woocommerce_blocks_checkout_update_order_meta` in favour of `woocommerce_store_api_checkout_update_order_meta` +- Various - Deprecated `woocommerce_blocks_checkout_update_order_from_request` in favour of `woocommerce_store_api_checkout_update_order_from_request` + += 6.3.1 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + += 6.3.0 2022-03-08 = + +**WooCommerce** + +* Add - Add states for Germany. ([#31825](https://github.com/woocommerce/woocommerce/pull/31825)) +* Add - Track WcPay settings in WC Tracker. ([#31663](https://github.com/woocommerce/woocommerce/pull/31663)) +* Add - Add filter woocommerce_set_cookie_enabled to allow disabling specific cookies.([#31317](https://github.com/woocommerce/woocommerce/pull/31317)) +* Tweak - Enhancement - Add indices to the product attributes lookup table. ([#31819](https://github.com/woocommerce/woocommerce/pull/31819)) +* Tweak - Adjust input styles for the Twenty Twenty One theme. ([#31734](https://github.com/woocommerce/woocommerce/pull/31734)) +* Tweak - Adjust input styles for the Twenty Twenty theme. ([#31698](https://github.com/woocommerce/woocommerce/pull/31698)) +* Tweak - 2022 theme store notice styling. ([#31683](https://github.com/woocommerce/woocommerce/pull/31683)) +* Tweak - Guatemalan postcode field is now visible by default but remains unrequired. ([#31303](https://github.com/woocommerce/woocommerce/pull/31303)) +* Tweak - Ensure the WC_CSV_Batch_Exporter::get_percent_complete() method returns an int. ([#31138](https://github.com/woocommerce/woocommerce/pull/31138)) +* Fix - Support custom Product Taxonomies in block template loader. ([#31610](https://github.com/woocommerce/woocommerce/pull/31610)) +* Fix - Support special chars in email subjects using wp_specialchars_decode. ([#31589](https://github.com/woocommerce/woocommerce/pull/31589)) +* Fix - Ensure that WooCommerce panel within the Customizer is showing a back button. ([#31508](https://github.com/woocommerce/woocommerce/pull/31508)) +* Fix - Avoids incorrectly setting the search argument under some conditions, when the Customers endpoint of the REST API is used. ([#31295](https://github.com/woocommerce/woocommerce/pull/31295)) +* Fix - Reverted #31593 that caused the returned line item price to be a string instead of a float. ([#31935](https://github.com/woocommerce/woocommerce/pull/31935)) +* Fix - Add prefix to easy identify guest sessions. +* Dev - The cart session is now updated later on during the woocommerce_after_calculate_totals action (priority 1000, instead of priority 10 as previously). ([#31711](https://github.com/woocommerce/woocommerce/pull/31711)) +* Dev - Enable browser-suggested passwords on checkout signup. ([#31701](https://github.com/woocommerce/woocommerce/pull/31701)) +* Dev - Add method can_view_woocommerce_menu_item to check if the user can access the top-level WooCommerce menu item. ([#31689](https://github.com/woocommerce/woocommerce/pull/31689)) +* Dev - Wrap terms and conditions required asterisk symbol with tag. ([#31673](https://github.com/woocommerce/woocommerce/pull/31673)) +* Dev - Allow to use use all get_image function parameters by woocommerce_get_product_thumbnail to customize image. ([#31605](https://github.com/woocommerce/woocommerce/pull/31605)) +* Dev - Format price decimal places correctly in the order API. ([#31593](https://github.com/woocommerce/woocommerce/pull/31593)) +* Dev - Update text for generating an account in admin menu to be more clear. ([#31590](https://github.com/woocommerce/woocommerce/pull/31590)) +* Dev - Adds the option to filter coupons by status when calling the GET /coupons endpoint. ([#31577](https://github.com/woocommerce/woocommerce/pull/31577)) +* Dev - Made the $loop position counter available via the 'woocommerce_variation_header' hook. ([#31565](https://github.com/woocommerce/woocommerce/pull/31565)) +* Dev - Change '__return_true' to true in the apply_filters() call for the woocommerce_product_recount_terms filter. ([#31506](https://github.com/woocommerce/woocommerce/pull/31506)) +* Dev - Add $key field as well to woocommerce_checkout_required_field_notice filter. ([#31435](https://github.com/woocommerce/woocommerce/pull/31435)) +* Dev - Add product meta data to published product tracks. ([#31355](https://github.com/woocommerce/woocommerce/pull/31355)) +* Dev - Allow auto-draft in API orders. ([#31290](https://github.com/woocommerce/woocommerce/pull/31290)) +* Dev - A $file param is now available via the woocommerce_[product_]csv_importer_check_import_file_path filter hooks. ([#31266](https://github.com/woocommerce/woocommerce/pull/31266)) +* Dev - Data migration to create and activate the product attributes lookup table. ([#31256](https://github.com/woocommerce/woocommerce/pull/31256)) +* Dev - A new filter hook woocommerce_cart_item_removed_because_modified_message($message, $product) which allows to update the notice message if a product is modified and page is loaded while product is in cart. ([#31193](https://github.com/woocommerce/woocommerce/pull/31193)) +* Security - Add prefix to identify guest sessions. + +**WooCommerce Admin - 3.2.0 & 3.2.1** + +* Fix - Adjusted task list logic to fix conflict between current and experimental task list. ([#8321](https://github.com/woocommerce/woocommerce-admin/pull/8321)) +* Fix - changed email validation in Store Details onboarding task to more closely match PHP backend validation. ([#8197](https://github.com/woocommerce/woocommerce-admin/pull/8197)) +* Fix - Disallow whitespace as the platform name input. ([#8090](https://github.com/woocommerce/woocommerce-admin/pull/8090)) +* Fix - Ensure setup-wizard redirection on homescreen is stable. ([#8114](https://github.com/woocommerce/woocommerce-admin/pull/8114)) +* Fix - Fix category report query returns invalid net sales. ([#8153](https://github.com/woocommerce/woocommerce-admin/pull/8153)) +* Fix - Fix clicking the error message opens the dropdown. ([#8094](https://github.com/woocommerce/woocommerce-admin/pull/8094)) +* Fix - Fix country/region selection not preserved in store details task. ([#8228](https://github.com/woocommerce/woocommerce-admin/pull/8228)) +* Fix - Fixed email address not being optional in OBW ([#8263](https://github.com/woocommerce/woocommerce-admin/pull/8263)) +* Fix - Fix get_automated_tax_supported_countries doesn't include UK. ([#8180](https://github.com/woocommerce/woocommerce-admin/pull/8180)) +* Fix - Fix incorrect date options when the "Default Date Range" is set from Analytics settings. ([#8189](https://github.com/woocommerce/woocommerce-admin/pull/8189)) +* Fix - Fix incorrectly displayed note created date. ([#8179](https://github.com/woocommerce/woocommerce-admin/pull/8179)) +* Fix - Fix incorrect screen reader text generated for data points on charts table. ([#8181](https://github.com/woocommerce/woocommerce-admin/pull/8181)) +* Fix - Fix incorrect total count of downloads on the analytics download report. ([#8182](https://github.com/woocommerce/woocommerce-admin/pull/8182)) +* Fix - Fix misaligned status column on order report. ([#8121](https://github.com/woocommerce/woocommerce-admin/pull/8121)) +* Fix - Fix shipping rate error message overlaps with the 'Proceed' button. ([#8165](https://github.com/woocommerce/woocommerce-admin/pull/8165)) +* Fix - Fix Shipping task sometimes skipping the set shipping costs step. ([#8260](https://github.com/woocommerce/woocommerce-admin/pull/8260)) +* Fix - Fix Uncaught TypeError count(NULL) for php8+ in Marketing.php. ([#8213](https://github.com/woocommerce/woocommerce-admin/pull/8213)) +* Fix - Fix undefined derived_currency value for the track 'wcadmin_storeprofiler_store_details_continue'. ([#8193](https://github.com/woocommerce/woocommerce-admin/pull/8193)) +* Fix - Fix variations table product filter query. ([#8120](https://github.com/woocommerce/woocommerce-admin/pull/8120)) +* Fix - Make sure free subscriptions does not show when cbd industry is selected. ([#8323](https://github.com/woocommerce/woocommerce-admin/pull/8323)) +* Fix - Make sure WooCommerce Payments tasklist_payment_setup is triggered again. ([#8146](https://github.com/woocommerce/woocommerce-admin/pull/8146)) +* Fix - Preserve HTML markup in server-side error messages received from sample product import request. ([#8173](https://github.com/woocommerce/woocommerce-admin/pull/8173)) +* Fix - Remove border between email input and newsletter checkbox in OBW store details. ([#8148](https://github.com/woocommerce/woocommerce-admin/pull/8148)) +* Fix - Reset "install_timestamp" if it's not numeric to avoid TypeError. ([#8100](https://github.com/woocommerce/woocommerce-admin/pull/8100)) +* Fix - Truncate the long site title with an ellipses on the second line. ([#8112](https://github.com/woocommerce/woocommerce-admin/pull/8112)) +* Fix - Fix backwards compatibility with SkyVerge payment gateway.([#8371](https://github.com/woocommerce/woocommerce-admin/pull/8371)) +* Add - Add additional store profiler track for the business details tab. ([#8265](https://github.com/woocommerce/woocommerce-admin/pull/8265)) +* Add - Add countries data store ([#8119](https://github.com/woocommerce/woocommerce-admin/pull/8119)) +* Add - Add extra tracking for plugin installation performance during onboarding. ([#8042](https://github.com/woocommerce/woocommerce-admin/pull/8042)) +* Add - Adding tooltip to describe the lack of refund deductions from revenue summaries. ([#8187](https://github.com/woocommerce/woocommerce-admin/pull/8187)) +* Add - Add localized validation to store address ([#8123](https://github.com/woocommerce/woocommerce-admin/pull/8123)) +* Add - Add Magento migration note ([#8145](https://github.com/woocommerce/woocommerce-admin/pull/8145)) +* Add - Add REST endpoint to retrieve address locales ([#8116](https://github.com/woocommerce/woocommerce-admin/pull/8116)) +* Add - Add Spain to Square country suggestion list. ([#8210](https://github.com/woocommerce/woocommerce-admin/pull/8210)) +* Add - Add wc_version property to the store profile onboarding tracks for view and complete steps. ([#8290](https://github.com/woocommerce/woocommerce-admin/pull/8290)) +* Add - Change the reviews empty state panels logic ([#8147](https://github.com/woocommerce/woocommerce-admin/pull/8147)) +* Update - Add custom error for store details email and allow continue ([#8110](https://github.com/woocommerce/woocommerce-admin/pull/8110)) +* Update - Adding "allow-plugins" property for composer configuration. ([#8139](https://github.com/woocommerce/woocommerce-admin/pull/8139)) +* Dev - Remove wc-admin-settings package and rename getSetting to getAdminSetting. ([#8057](https://github.com/woocommerce/woocommerce-admin/pull/8057)) +* Tweak - Fix WCPay in core texts and promo slug ([#8296](https://github.com/woocommerce/woocommerce-admin/pull/8296)) +* Tweak - Grow and center buttons in all WooCommerce ellipsis menu popover containers. ([#8168](https://github.com/woocommerce/woocommerce-admin/pull/8168)) +* Tweak - Hide store address fields in regions that specify hidden ([#8172](https://github.com/woocommerce/woocommerce-admin/pull/8172)) +* Tweak - Make activity panel badges margin consistent. ([#8152](https://github.com/woocommerce/woocommerce-admin/pull/8152)) +* Tweak - Padding tweak for marketing tools plugin list headings. ([#8171](https://github.com/woocommerce/woocommerce-admin/pull/8171)) +* Performance - Speed up customer syncing action. ([#8021](https://github.com/woocommerce/woocommerce-admin/pull/8021)) +* Enhancement - Enhance report chart i18n support. ([#8129](https://github.com/woocommerce/woocommerce-admin/pull/8129)) +* Enhancement - Make ExPlat request URL args filterable. Added woocommerce_explat_request_args filter. ([#8231](https://github.com/woocommerce/woocommerce-admin/pull/8231)) +* Enhancement - Show MailPoet in Installed marketing extensions. ([#8091](https://github.com/woocommerce/woocommerce-admin/pull/8091)) +* Enhancement - Update headercard to use filter to add ExPlat parameter ([#8233](https://github.com/woocommerce/woocommerce-admin/pull/8233)) + +**WooCommerce Blocks - 6.8.0 & 6.9.0** + +* Add - Add support for the global style for the Price Filter block. ([#5559](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5559)) +* Add - Hold stock for 60mins if the order is pending payment. ([#5546](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5546)) +* Add - Allow users to reinsert the WooCommerce Legacy Template block in their block template if it is a WooCommerce block template. ([#5545](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5545)) +* Add - Add support for the global style for the Stock Indicator block. ([#5525](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5525)) +* Add - Add support for the global style for the Summary Product block. ([#5524](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5524)) +* Add - Add support for the global style for the Product Title block. ([#5515](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5515)) +* Add - Store API: Throw errors when attempting to pay with a non-available payment method. ([#5440](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5440)) +* Add - Add support for the wide and full alignment for the legacy template block. ([#5433](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5433)) +* Add - Store API and Cart block now support defining a quantity stepper and a minimum quantity. ([#5406](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5406)) +* Add - Added controls to product grid blocks for filtering by stock levels. ([#4943](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4943)) +* Add - Add support for the global style for the Featured Category block. ([#5542](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5542)) +* Fix duplicated checkout error notices. ([#5476](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5476)) +* Fix - Use consistent HTML code for all rating sections, so that screen readers pronounce the rating correctly. ([#5552](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5552)) +* Fix - All Products block displays thumbnails. ([#5551](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5551)) +* Fix - Fixed a styling issue in the Checkout block when an order has multiple shipping packages. ([#5529](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5529)) +* Fix - Fixed a visual bug (#5152) with the points and rewards plugin. ([#5430](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5430)) +* Fix - Filter Products By Price block: Don't allow to insert negative values on inputs. ([#5123](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5123)) +* Fix - Enable Mini Cart template-parts only for experimental builds. ([#5606](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5606)) +* Fix - Show express payment button in full width if only one express payment method is available. ([#5601](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5601)) +* Fix - Wrapped cart item product contents in inner div. ([#5240](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5240)) +* Fix - Fix alignment issue with the "create account" section on the checkout block in the editor ([#5633](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5633)) +* Dev - Remove invalid `$wpdb->prepare()` statement in Featured Category Block. ([#5471](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5471)) +* Dev - Remove Stripe Payment Method Integration (which is now part of the Stripe Payment Method extension itself). ([#5449](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5449)) +* Dev - Update the block theme folders to latest Gutenberg convention (i.e. `templates` and `parts`). ([#5464](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5464)) +* Dev - Revert "Allow LegacyTemplate block to be reinserted, only on WooCommerce block templates.". ([#5643](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5643)) + += 6.2.2 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + += 6.2.1 2022-02-22 = + +**WooCommerce** + +* Security - Fixed permission check for reviews in v1 & v2 REST API. +* Security - Fixed Path Traversal in Importers. + += 6.2.0 2022-02-08 = + +**WooCommerce** + +* Add - Admin notice warning about the upcoming minimum PHP 7.2 version bump coming in WooCommerce 6.5. ([#31557](https://github.com/woocommerce/woocommerce/pull/31557)) +* Tweak - Removed images referred to from deprecated functions. ([#31395](https://github.com/woocommerce/woocommerce/pull/31395)) +* Tweak - Update store setup widget to use task list API. ([#31755](https://github.com/woocommerce/woocommerce/pull/31755)) +* Fix - Fixed styling of "pay for order" form for 2022 theme. ([#31682](https://github.com/woocommerce/woocommerce/pull/31682)) +* Fix - Search Blocks form for 2022 theme. ([#31687](https://github.com/woocommerce/woocommerce/pull/31687)) +* Fix - Checkout scroll to notices fallback scroll element. ([#30955](https://github.com/woocommerce/woocommerce/pull/30955)) +* Fix - Prevent PhotoSwipe tap from interacting with elements directly underneath. Props @Edsuns and @andi34. ([#31591](https://github.com/woocommerce/woocommerce/pull/31591)) +* Fix - Double notice about the upcoming change in the PHP version requirement. ([#31744](https://github.com/woocommerce/woocommerce/pull/31744)) +* Dev - Added logic to `do_variation_action` prematurely return on custom actions. ([#31088](https://github.com/woocommerce/woocommerce/pull/31088)) +* Dev - REST API - Adds `status` field to the GET `/coupons` endpoint. ([#31561](https://github.com/woocommerce/woocommerce/pull/31561)) +* Dev - Use `calc` function to prevent deprecated warnings when building SCSS. + +**WooCommerce Admin - 3.1.0** + +* Enhancement - Add SlotFill areas to header. ([#7805](https://github.com/woocommerce/woocommerce-admin/pull/7805)) +* Add - Add featured pill for MailPoet and Google Listings in marketing task. ([#8009](https://github.com/woocommerce/woocommerce-admin/pull/8009)) +* Add - Add inbox_action_click track when a note gets clicked. ([#8086](https://github.com/woocommerce/woocommerce-admin/pull/8086)) +* Add - Activate promo note after WC Pay is activated. ([#8104](https://github.com/woocommerce/woocommerce-admin/pull/8104)) +* Add - Add payment remind me later note. ([#8085](https://github.com/woocommerce/woocommerce-admin/pull/8085)) +* Add - Add WC Pay welcome page. ([#8083](https://github.com/woocommerce/woocommerce-admin/pull/8083)) +* Update - Allow content data note props to be passed from remote sources ([#8047](https://github.com/woocommerce/woocommerce-admin/pull/8047)) +* Update - Update @woocommerce/e2e-environment package to latest. ([#8000](https://github.com/woocommerce/woocommerce-admin/pull/8000)) +* Tweak - OBW Update WC Pay label on recommended extensions list ([#8038](https://github.com/woocommerce/woocommerce-admin/pull/8038)) +* Fix - Fix Onboarding flow where extensions might not be selected and installed. ([#7979](https://github.com/woocommerce/woocommerce-admin/pull/7979)) +* Fix - Fix pagination issue with Analytics Coupons page. ([#8001](https://github.com/woocommerce/woocommerce-admin/pull/8001)) +* Fix - Fix select-control component label/value alignment. ([#8045](https://github.com/woocommerce/woocommerce-admin/pull/8045)) +* Fix - Fix unexpected analytics report table filter results. ([#8072](https://github.com/woocommerce/woocommerce-admin/pull/8072)) +* Fix - Prevent coupon move notice for new installs. ([#7995](https://github.com/woocommerce/woocommerce-admin/pull/7995)) +* Fix - Remove calls to read_meta_data in the Note DataStore. ([#7988](https://github.com/woocommerce/woocommerce-admin/pull/7988)) +* Fix - Fix free extensions list isn't updated after store location or industry is changed. ([#8099](https://github.com/woocommerce/woocommerce-admin/pull/8099)) +* Fix - Fix misaligned "Rows per page" dropdown. ([#8113](https://github.com/woocommerce/woocommerce-admin/pull/8113)) +* Fix - Hide the extensions header when no available plugins in the category. ([#8089](https://github.com/woocommerce/woocommerce-admin/pull/8089)) +* Fix - Replace all docs.woocommerce.com links with woocommerce.com/document. ([#8105](https://github.com/woocommerce/woocommerce-admin/pull/8105)) +* Fix - Fixing marketing task not displaying on Atomic sites ([#8150](https://github.com/woocommerce/woocommerce-admin/pull/8150)) +* Fix - Fix setup wizard free features checkbox re-check itself. ([#8169](https://github.com/woocommerce/woocommerce-admin/pull/8169)) +* Dev - Add payment gateway suggestion docs and example extensions ([#7966](https://github.com/woocommerce/woocommerce-admin/pull/7966)) +* Dev - Remove low performing inbox notes. ([#8054](https://github.com/woocommerce/woocommerce-admin/pull/8054)) +* Dev - Remove navigation feedback note. ([#8055](https://github.com/woocommerce/woocommerce-admin/pull/8055)) +* Dev - Fix task ID class check and add tests around tracking ([#8185](https://github.com/woocommerce/woocommerce-admin/pull/8185)) + +**WooCommerce Blocks - 6.6.0 & 6.7.0 & 6.7.1 & 6.7.2 & 6.7.3** + +* Enhancement - Added global styles (text color) to the Active Filters block. ([5465](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5465)) +* Enhancement - Prevent a 0 value shipping price being shown in the Checkout if no shipping methods are available. ([5444](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5444)) +* Fix - Convert token to string when setting the active payment method. ([5535](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5535)) +* Fix - Fixed an issue where the checkout address fields would be blank for logged in customers. ([5473](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5473)) +* Fix - Account for products without variations in the On Sale Products block. ([5470](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5470)) +* Fix - Update the template retrieving logic to allow for older Gutenberg convention and newer one (`block-templates`/`block-template-parts` vs. `templates`/`parts`). ([5455](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5455)) +* Fix - Ensure that the translation of the "Proceed to Checkout" button is working. ([5453](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5453)) +* Fix - Fix custom templates with fallback to archive being incorrectly attributed to the user in the editor instead of the parent theme. ([5447](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5447)) +* Fix - Remove text decorations from product filtering blocks items. ([5384](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5384)) +* Fix - "Added By" template column value is user friendly for modified WooCommerce block templates. ([5420](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5420)) +* Fix - Fixed a performance issue with the cart by preventing an extra network request on mount. ([5394](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5394)) +* Fix - Use the themes product archive block template for product category & product tag pages if the theme does not have more specific templates for those. ([5380](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5380)) +* Fix - Cart block: Switch to correct view if inner block is selected. ([5358](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5358)) +* Fix - Respect implicit quantity updates coming from server or directly from data stores. ([5352](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5352)) +* Fix - Fixed a case where payments could fail after validation errors when using saved cards. ([5350](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5350)) +* Fix - Add error handling for network errors during checkout. ([5341](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5341)) +* Fix - Fix cart and checkout margin problem by removing the full-width option. ([5315](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5315)) +* Fix - Enable Mini Cart template parts only for experimental builds. ([#5606](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5606)) +* Tweak - Sync draft orders whenever cart data changes. ([5379](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5379)) +* Tweak - Removed legacy handling for shipping_phone in Store API. ([5326](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5326)) +* Tweak - Site Editor template list: Fix wrong icon displayed on WooCommerce templates after they have been edited. ([5375](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5375)) +* Tweak - Fix validation error handling after using browser autofill. ([5373](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5373)) +* Tweak - Update loading skeleton animations. ([5362](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5362)) +* Tweak - Add error handling to `get_routes_from_namespace` method. ([5319](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5319)) +* Tweak - Update WooCommerce plugin slug for Block Templates. ([#5519](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5519)) + += 6.1.2 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + += 6.1.1 2022-01-20 = + +**WooCommerce** + +* Enhancement - Add support for 2022 theme shop and product pages. ( [#31536](https://github.com/woocommerce/woocommerce/pull/31536) ) +* Enhancement - Add support for 2022 my account pages. ( [#31575](https://github.com/woocommerce/woocommerce/pull/31575) ) +* Enhancement - Add support for 2022 checkout page. ( [#31630](https://github.com/woocommerce/woocommerce/pull/31630) ) +* Fix - Use inline `onload` event instead of jQuery `load`. ( [#31623](https://github.com/woocommerce/woocommerce/pull/31623) ) +* Fix - Removes the revert warning about is_ajax. ( [#31672](https://github.com/woocommerce/woocommerce/pull/31672) ) +* Tweak - Better styling for checkout form for 2022 theme. ( [#31619](https://github.com/woocommerce/woocommerce/pull/31619) ) +* Tweak - Center product cards in the 2022 theme. ( [#31626](https://github.com/woocommerce/woocommerce/pull/31626) ) +* Tweak - Fix font sizes in single product tabs area in 2022 theme. ( [#31632](https://github.com/woocommerce/woocommerce/pull/31632) ) +* Tweak - Modify background color for `mark` elements in 2022 theme. ( [#31631](https://github.com/woocommerce/woocommerce/pull/31631) ) +* Tweak - Adjusts basis of overlay in 2022 theme checkout. ( [#31633](https://github.com/woocommerce/woocommerce/pull/31633) ) +* Tweak - Improve order details table on narrow viewports in 2022 theme. ( [#31634](https://github.com/woocommerce/woocommerce/pull/31634) ) + +**WooCommerce Blocks - 6.5.2** + +* Fix - Update WooCommerce plugin slug for Block Templates. ( [#5519](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5519) ) + = 6.1.0 2022-01-11 = **WooCommerce** @@ -98,6 +440,12 @@ * Fix - Fix error when reverting WooCommerce templates. ( [#5342](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5342) ) * Fix - Fix: WooCommerce block templates loading for WP 5.9 without Gutenberg plugin. ( [#5335](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5335) ) += 6.0.1 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 6.0.0 2021-12-14 = **WooCommerce** @@ -203,6 +551,12 @@ * Fix - Store API – Ensure returned customer address state is valid. ( [#4844](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4844) ) * Fix - fatal error in certain WP 5.9 pre-release versions. ( [#5183](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/5183) ) += 5.9.1 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.9.0 2021-11-09 = **WooCommerce** @@ -260,6 +614,12 @@ * Fix - Remove IntersectionObserver shim in favor of dropping IE11 support. #4808 * Enhancement - Added global styles to All Reviews, Reviews by Category and Reviews by Product blocks. Now it's possible to change the text color and font size of those blocks. #4323 += 5.8.2 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.8.0 2021-10-12 = **WooCommerce** @@ -333,6 +693,12 @@ * Fix - Improves compatibility with environments where NO_ZERO_DATE is enabled. #519 * Fix - Adds safety checks to guard against errors when our database tables cannot be created. #645 += 5.7.2 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.7.1 2021-09-23 = **WooCommerce** @@ -440,6 +806,12 @@ - Fix - Fix memory leak when previewing transform options for the All reviews block. #4428 - Fix - Disable Cart, Checkout, All Products & filters blocks from the widgets screen. #4646 += 5.6.2 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.6.0 2021-08-17 = **WooCommerce** @@ -546,6 +918,12 @@ - Tweak - Allow products to be added by SKU in the Hand-Picked Products block. #4366 - Tweak - Add Slot in the Discounts section of the Checkout sidebar to allow third party extensions to render their own components there. #4310 += 5.5.4 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.5.2 2021-07-22 = * Fix - Add a new option allowing product downloads to be served using redirects as a last resort. #30288 @@ -749,6 +1127,12 @@ * Fix - Add extra safety/account for different versions of AS and different loading patterns. #714 * Fix - Handle hidden columns (Tools → Scheduled Actions) | #600. += 5.4.4 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.4.2 2021-07-14 = **WooCommerce** @@ -848,6 +1232,12 @@ * Fix - Prevent parts of old addresses being displayed in the shipping calculator when changing countries. #4038 * Fix - issue in which email and phone fields are cleared when using a separate billing address. #4162 += 5.3.3 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.3.1 2021-07-14 = **WooCommerce** @@ -985,6 +1375,12 @@ * Tweak - Store profiler - Changed MailPoet's title and description #6886 * Tweak - Update PayU logo #6829 += 5.2.5 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.2.3 2021-07-14 = **WooCommerce** @@ -1154,6 +1550,12 @@ * Fix - Ensure sale badges have a uniform height in the Cart block. ([3897](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3897)) * Note - Internally, this release has modified how `AbstractBlock` (the base class for all of our blocks) functions, and how it loads assets. `AbstractBlock` is internal to this project and does not seem like something that would ever need to be extended by 3rd parties, but note if you are doing so for whatever reason, your implementation would need to be updated to match. ([3829](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3829)) += 5.1.3 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.1.1 2021-07-14 = **WooCommerce** @@ -1268,6 +1670,12 @@ * Dev - Added formatting classes to the Store API for extensions to consume. * Dev - Refactored and reordered Store API checkout processing to handle various edge cases and better support future extensibility. ([3454](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3454)) += 5.0.3 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 5.0.1 2021-07-14 = **WooCommerce** @@ -1341,6 +1749,12 @@ * Enhancement - Add an "unread" indicator to inbox messages. #6047 * Add - Manage activity from home screen inbox message. #6072 += 4.9.5 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.9.3 2021-07-14 = **WooCommerce** @@ -1471,6 +1885,12 @@ * Dev - Expose store/cart via ExtendRestApi to extensions. ([3445](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3445)) * Dev - Added formatting classes to the Store API for extensions to consume. += 4.8.3 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.8.1 2021-07-14 = **WooCommerce** @@ -1590,6 +2010,12 @@ * Fix - Twenty Twenty One Button and Placeholder Styling. #3443 * Fix - checkbox and textarea styles in Twenty Twenty One when it has dark controls active. #3450 += 4.7.4 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.7.2 2021-07-14 = **WooCommerce** @@ -1658,6 +2084,12 @@ * Tweak: Add BR and IN to list of stripe countries [#5377](https://github.com/woocommerce/woocommerce-admin/pull/5377) * Fix: Redirect instead of stalling on WCPay Inbox note action [#5413](https://github.com/woocommerce/woocommerce-admin/pull/5413) += 4.6.5 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.6.3 2021-07-14 = **WooCommerce** @@ -1786,6 +2218,12 @@ - Create DebouncedValidatedTextInput component. ([3108](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3108)) - Merge ProductPrice atomic block and component. ([3065](https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/3065)) += 4.5.5 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.5.3 2021-07-14 = **WooCommerce** @@ -1840,6 +2278,12 @@ * Dev - Task list - add a shortcut back to store setup. #4853 * Dev - Update the colors of the illustrations in the welcome modal. #4945 += 4.4.4 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.4.2 2021-07-14 = **WooCommerce** @@ -1994,6 +2438,12 @@ * Fix - 'Product Summary' in All Products block is not pulling in the short description of the product. #2913 * Dev - Add query filter when searching for a table. #2886 += 4.3.6 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.3.4 2021-07-14 = **WooCommerce** @@ -2181,6 +2631,12 @@ * Dev - Table creation validation for install routine #2287 * Dev - Update the icons used in the blocks. #1644 += 4.2.5 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.2.3 2021-07-14 = **WooCommerce** @@ -2265,6 +2721,12 @@ * Dev - Dynamic Currency with Context API #4027 * Dev - Remove Duplicate array entry #4049 += 4.1.4 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.1.2 2021-07-14 = **WooCommerce** @@ -2330,6 +2792,12 @@ * Dev - Adds usage data for the of cart & checkout blocks (currently in development in WooCommmerce Blocks plugin) to the WC Tracker snapshot. #26084 * Dev - Implement some additional tracks for coupons, orders, and products. #26085 += 4.0.4 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 4.0.2 2021-07-14 = **WooCommerce** @@ -2454,6 +2922,12 @@ * Dev - Applies woocommerce_maxmind_geolocation_database_path in MaxMind database migration. #25681 * Dev - Support both .data() and .dataset for formdata in add to cart requests. #25726 += 3.9.5 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 3.9.4 2021-07-14 = **WooCommerce** @@ -2569,6 +3043,12 @@ * Localization - Fixed translatable string comments for translators. #24928 * Localization - Add postcode validation for Slovenia. #25174 += 3.8.3 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 3.8.2 2021-07-14 = **WooCommerce** @@ -2691,6 +3171,12 @@ * Security - Add an exit after the redirect when checking author archive capabilities for customers. * Security - Ensure 404 pages with single product urls cannot be exploited using Open Redirect. += 3.7.3 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 3.7.2 2021-07-14 = **WooCommerce** @@ -2803,6 +3289,12 @@ * Localization - Add new currency for São Tomé, Príncipe dobra and Mauritanian ouguiya. #23950 * Localization - Change Canada poscode label to `Postal code`. #23740 += 3.6.7 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 3.6.6 2021-07-14 = **WooCommerce** @@ -3132,6 +3624,12 @@ * Localization - Update CA address format. #22692 * Localization - Updated JP field order. #22774 += 3.5.10 2022-03-10 = + +**WooCommerce** + +* Security - Address an issue with the PayPal Standard Payment Gateway. See https://developer.woocommerce.com/2022/03/10/woocommerce-3-5-10-6-3-1-security-releases/. ([#32057](https://github.com/woocommerce/woocommerce/pull/32057)) + = 3.5.9 2021-07-14 = **WooCommerce** diff --git a/package.json b/package.json index 654f1c1891a..a945a056869 100644 --- a/package.json +++ b/package.json @@ -14,22 +14,28 @@ "url": "https://github.com/woocommerce/woocommerce/issues" }, "scripts": { - "preinstall": "npx only-allow pnpm" + "preinstall": "npx only-allow pnpm", + "postinstall": "pnpm git:update-hooks", + "git:update-hooks": "rm -r .git/hooks && mkdir -p .git/hooks && husky install", + "create-extension": "node ./tools/create-extension/index.js" }, "devDependencies": { "@automattic/nx-composer": "^0.1.0", "@nrwl/cli": "^13.3.4", - "@nrwl/linter": "^13.3.4", "@nrwl/devkit": "^13.1.4", + "@nrwl/linter": "^13.3.4", "@nrwl/tao": "13.3.4", "@nrwl/web": "^13.3.4", "@nrwl/workspace": "^13.3.4", "@types/node": "14.14.33", - "@woocommerce/eslint-plugin": "^1.3.0", + "@woocommerce/eslint-plugin": "workspace:*", + "@wordpress/eslint-plugin": "^11.0.0", "@wordpress/prettier-config": "^1.1.1", "chalk": "^4.1.2", "glob": "^7.2.0", + "husky": "^7.0.4", "jest": "^27.3.1", + "lint-staged": "^12.3.7", "mkdirp": "^1.0.4", "node-stream-zip": "^1.15.0", "prettier": "npm:wp-prettier@^2.2.1-beta-1", @@ -40,7 +46,9 @@ "@babel/core": "7.12.9", "@wordpress/babel-plugin-import-jsx-pragma": "^3.1.0", "@wordpress/babel-preset-default": "^6.4.1", + "fs-extra": "^10.0.1", "lodash": "^4.17.21", + "promptly": "^3.2.0", "wp-textdomain": "1.0.1" } } diff --git a/packages/js/README.md b/packages/js/README.md new file mode 100644 index 00000000000..5f82f22a152 --- /dev/null +++ b/packages/js/README.md @@ -0,0 +1,85 @@ +# WooCommerce Packages + +Currently we have a small set of public-facing packages that can be dowloaded from [npm](https://www.npmjs.com/org/woocommerce) and used in external applications. + +- `@woocommerce/components`: A library of components that can be used to create pages in the WooCommerce dashboard and reports pages. +- `@woocommerce/csv-export`: A set of functions to convert data into CSV values, and enable a browser download of the CSV data. +- `@woocommerce/currency`: A class to display and work with currency values. +- `@woocommerce/date`: A collection of utilities to display and work with date values. +- `@woocommerce/navigation`: A collection of navigation-related functions for handling query parameter objects, serializing query parameters, updating query parameters, and triggering path changes. +- `@woocommerce/tracks`: User event tracking utility functions for Automattic based projects. + +## Working with existing packages + +- You can make changes to packages files as normal, and running `pnpm start` will compile and watch both app files and packages. +- :warning: Make sure any dependencies you add to a package are also added to that package's `package.json`, not just the woocommerce-admin package.json +- :warning: Make sure you're not importing from any woocommerce-admin files outside of the package (you can import from other packages, just use the `import from @woocommerce/package` syntax). +- Add your change to the CHANGELOG for that package under the next version number, creating one if necessary (we use semantic versioning for packages, [see these guidelines](https://github.com/WordPress/gutenberg/blob/master/CONTRIBUTING.md#maintaining-changelogs)). +- Don't change the version in `package.json`. +- Label your PR with the `Packages` label. +- Once merged, you can wait for the next package release roundup, or you can publish a release now (see below, "Publishing packages"). + +--- + +## Creating a new package + +Most of this is pulled [from the Gutenberg workflow](https://github.com/WordPress/gutenberg/blob/master/CONTRIBUTING.md#creating-new-package). + +To create a new package, add a new folder to `/packages`, containing… + +1. `package.json` based on the template: + ```json + { + "name": "@woocommerce/package-name", + "version": "1.0.0-beta.0", + "description": "Package description.", + "author": "Automattic", + "license": "GPL-2.0-or-later", + "keywords": [ "wordpress", "woocommerce" ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/main/packages/[_YOUR_PACKAGE_]/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "publishConfig": { + "access": "public" + } + } + ``` +2. `.npmrc` file which disables creating `package-lock.json` file for the package: + ``` + package-lock=false + ``` +3. `README.md` file containing at least: + - Package name + - Package description + - Installation details + - Usage example +4. A `src` directory for the source of your module, which will be built by default using the `pnpm run build:packages` command. Note that you'll want an `index.js` file that exports the package contents, see other packages for examples. + +5. Add the new package name to `packages/dependency-extraction-webpack-plugin/assets/packages.js` so that users of that plugin will also be able to use the new package without enqueuing it. + +--- + +## Publishing packages + +- Run `pnpm run publish-packages:check` to run pnpm publish with the `--dry-run` option +- Create a PR with a CHANGELOG for each updated package (or try to add to the CHANGELOG with any PR editing `packages/`) +- Run `pnpm run publish-packages:prod` to publish the package +- _OR_ Run `pnpm run publish-packages:dev` to publish "next" releases (installed as `pnpm i @woocommerce/package@next`). Only use `:dev` if you have a reason to. +- Both commands will run `build:packages` before the publishing task, just to catch any last updates. + +### Publishing a single package + +Sometimes, its helpful to release a singular package. This can be done directly through pnpm. Be sure versions and builds are correct. + +- Bump the version in the package's package.json as well as its CHANGELOG file. +- `pnpm install && pnpm run build:packages` to build packages. +- `cd packages/` +- `pnpm publish` diff --git a/packages/js/admin-e2e-tests/.eslintrc.js b/packages/js/admin-e2e-tests/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/admin-e2e-tests/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/admin-e2e-tests/.npmrc b/packages/js/admin-e2e-tests/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/admin-e2e-tests/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/admin-e2e-tests/CHANGELOG.md b/packages/js/admin-e2e-tests/CHANGELOG.md new file mode 100644 index 00000000000..5836c843674 --- /dev/null +++ b/packages/js/admin-e2e-tests/CHANGELOG.md @@ -0,0 +1,56 @@ +# Unreleased + +- Add E2E tests to disabled welcome modal #32505 + +- Update test for payment task. #32467 + +- Increase timeout threshold for payment task. #32605 + +# 1.0.0 + +- Add returned type annotations and remove unused vars. #8020 + +- Add E2E tests for checking store currency if it matches the onboarded country. #7712 + +- Make unchecking free features more robust. #7761 + +- Fix typescript type error in admin-e2e-tests package #7765 + +- Add extension deactivation util function addition. #7804 + +- Add tests to Subscriptions inclusion. #7804 + +- Add missing dependencies. #8349 + +- Update all js packages with minor/patch version changes. #8392 + +- Add E2E test for checking onboarding tab clickable after going back. #8469 +## Breaking changes + +- Update `@types/jest` to v27 +- Update the peer dependency constraint `@typescript-eslint/eslint-plugin` to ^5. + - eslint-plugin: ban-types no longer reports object by default. + + +# 0.1.2 + +- Add Customers to analytics pages tested #7573 +- Add `waitForTimeout` utility function #7572 +- Update analytics overview tests to allow re-running the tests. + +# 0.1.1 + +- Allow packages to be built in isolation. #7286 +- Add scope to BACS slotfill #7405 +- Update e2e matcher for tasklist header #7406 +- Update homescreen, utils, payment task, payments setup. #7338 +- Refactor package style builds #7531 +- Updated onboarding tests to include email prefill and move client setup checkbox to business step. +- Payment task update. #7577 +- Add test cases for the home screen tasklist and activity panels. #7509 +- Add wait for orders text on activity panel. #7550 +- Allow CBD to be optional in business details in E2E. #7675 + +# 0.1.0 + +- Released initial package diff --git a/packages/js/admin-e2e-tests/README.md b/packages/js/admin-e2e-tests/README.md new file mode 100644 index 00000000000..19f168934fa --- /dev/null +++ b/packages/js/admin-e2e-tests/README.md @@ -0,0 +1,56 @@ +# Admin E2E Tests + +An end-to-end test suite for WooCommerce setup, onboarding, home screen/task list, and analytics. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/admin-e2e-tests --save +``` + +## Usage + +Create a E2E test specification file under `/tests/e2e/specs/example.test.js`: + +```js +const { testAdminBasicSetup } = require( '@woocommerce/admin-e2e-tests' ); + +testAdminBasicSetup(); +``` + +See the [wooCommerce E2E Boilerplate](https://github.com/woocommerce/woocommerce-e2e-boilerplate) for instructions on setting up an E2E test environment. + +### Configuration + +Add the following entries to `tests/e2e/config/default.json` + +```json + "onboardingwizard": { + "industry": "Test industry", + "numberofproducts": "1 - 10", + "sellingelsewhere": "No" + }, + "settings": { + "shipping": { + "zonename": "United States", + "zoneregions": "United States (US)", + "shippingmethod": "Free shipping" + } + } +``` + +### Available tests + +The following test functions are included in the package: + +| Function | Description | +| --- | --- | +| `testAdminBasicSetup` | Test that WooCommerce can be activated with pretty permalinks | +| `testAdminOnboardingWizard` | Complete the onboarding wizard with US merchant | +| `testAdminNonUSRecommendedFeatures` | Complete the onboarding wizard with non-US merchant | +| `testSelectiveBundleWCPay` | Ensure onboarding wizard offers WC Payments in appropriate contexts | +| `testAdminAnalyticsPages` | Test that the React App is functional on Analytics pages | +| `testAdminCouponsPage` | Test that the Coupons is functional | +| `testAdminPaymentSetupTask` | Test that payment methods can be configured | diff --git a/packages/js/admin-e2e-tests/package.json b/packages/js/admin-e2e-tests/package.json new file mode 100644 index 00000000000..38c82638c0a --- /dev/null +++ b/packages/js/admin-e2e-tests/package.json @@ -0,0 +1,65 @@ +{ + "name": "@woocommerce/admin-e2e-tests", + "version": "1.0.0", + "author": "Automattic", + "description": "E2E tests for the new WooCommerce interface.", + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/admin-e2e-tests/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "keywords": [ + "woocommerce", + "e2e" + ], + "license": "GPL-3.0+", + "main": "build/index.js", + "types": "build/index.d.ts", + "files": [ + "/build/", + "!*.ts.map", + "!*.tsbuildinfo" + ], + "sideEffects": false, + "dependencies": { + "@jest/globals": "^26.6.2", + "@types/jest": "^27.4.1", + "config": "^3.3.7" + }, + "peerDependencies": { + "@woocommerce/e2e-environment": "^0.2.3 || ^0.3.0", + "@woocommerce/e2e-utils": "^0.2.0", + "puppeteer": "^2.0.0" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@types/expect-puppeteer": "^4.4.7", + "@types/puppeteer": "^5.4.5", + "@typescript-eslint/eslint-plugin": "^5.14.0", + "@woocommerce/api": "^0.2.0", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "jest-mock-extended": "^1.0.18", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepare": "pnpm run build", + "build": "tsc --build", + "start": "tsc --build --watch", + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "lint": "eslint src", + "prepack": "pnpm run clean && pnpm run build" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] + } +} diff --git a/packages/js/admin-e2e-tests/project.json b/packages/js/admin-e2e-tests/project.json new file mode 100644 index 00000000000..be3b68c506c --- /dev/null +++ b/packages/js/admin-e2e-tests/project.json @@ -0,0 +1,44 @@ +{ + "root": "packages/js/admin-e2e-tests", + "sourceRoot": "packages/js/admin-e2e-tests/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/admin-e2e-tests" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + }, + "clean": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "clean" + } + }, + "prepare": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "prepare" + } + } + } + } diff --git a/packages/js/admin-e2e-tests/src/constants/taskTitles.ts b/packages/js/admin-e2e-tests/src/constants/taskTitles.ts new file mode 100644 index 00000000000..fbc316e12a6 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/constants/taskTitles.ts @@ -0,0 +1,10 @@ +export const TaskTitles = { + storeDetails: 'Store details', + addPayments: 'Set up payments', + wooPayments: + 'Set up WooCommerce PaymentsBy setting up, you are agreeing to the Terms of Service2 minutes', + addProducts: 'Add my products', + taxSetup: 'Set up tax', + setUpShippingCosts: 'Set up shipping', + personalizeStore: 'Personalize my store', +}; diff --git a/packages/js/admin-e2e-tests/src/elements/BaseElement.ts b/packages/js/admin-e2e-tests/src/elements/BaseElement.ts new file mode 100644 index 00000000000..1d0c33dbabf --- /dev/null +++ b/packages/js/admin-e2e-tests/src/elements/BaseElement.ts @@ -0,0 +1,14 @@ +/** + * External dependencies + */ +import { Page } from 'puppeteer'; + +export abstract class BaseElement { + protected page: Page; + protected selector: string; + + constructor( page: Page, selector: string ) { + this.page = page; + this.selector = selector; + } +} diff --git a/packages/js/admin-e2e-tests/src/elements/DropdownField.ts b/packages/js/admin-e2e-tests/src/elements/DropdownField.ts new file mode 100644 index 00000000000..ec307998166 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/elements/DropdownField.ts @@ -0,0 +1,29 @@ +/** + * Internal dependencies + */ +import { getElementByText, getInputValue } from '../utils/actions'; +import { BaseElement } from './BaseElement'; + +export class DropdownField extends BaseElement { + async select( value: string ): Promise< void > { + const currentVal = await getInputValue( this.selector + ' input' ); + if ( currentVal !== value ) { + await this.page.click( + this.selector + ' .woocommerce-select-control__control' + ); + const button = await getElementByText( + 'button', + value, + this.selector + ); + await button?.click(); + await this.checkSelected( value ); + } + return undefined; + } + + async checkSelected( value: string ): Promise< void > { + const currentVal = await getInputValue( this.selector + ' input' ); + expect( currentVal ).toBe( value ); + } +} diff --git a/packages/js/admin-e2e-tests/src/elements/DropdownTypeaheadField.ts b/packages/js/admin-e2e-tests/src/elements/DropdownTypeaheadField.ts new file mode 100644 index 00000000000..ac2a990a18c --- /dev/null +++ b/packages/js/admin-e2e-tests/src/elements/DropdownTypeaheadField.ts @@ -0,0 +1,31 @@ +/** + * Internal dependencies + */ +import { BaseElement } from './BaseElement'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { clearAndFillInput } = require( '@woocommerce/e2e-utils' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +export class DropdownTypeaheadField extends BaseElement { + async search( text: string ): Promise< void > { + await clearAndFillInput( this.selector + '-0__control-input', text ); + } + async select( selector: string ): Promise< void > { + await this.page.click( this.selector + `__option-0-${ selector }` ); + } + + async checkSelected( value: string ): Promise< void > { + const selector = this.selector + '-0__control-input'; + await page.focus( selector ); + const field = await this.page.$( selector ); + const curValue = await field?.getProperty( 'value' ); + if ( curValue ) { + const fieldValue = ( await curValue.jsonValue() ) as string; + // Only compare alphanumeric characters + expect( fieldValue?.replace( /\W/g, '' ) ).toBe( + value.replace( /\W/g, '' ) + ); + } + } +} diff --git a/packages/js/admin-e2e-tests/src/elements/FormToggle.ts b/packages/js/admin-e2e-tests/src/elements/FormToggle.ts new file mode 100644 index 00000000000..ad7917f937e --- /dev/null +++ b/packages/js/admin-e2e-tests/src/elements/FormToggle.ts @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import type { ElementHandle } from 'puppeteer'; +/** + * Internal dependencies + */ +import { BaseElement } from './BaseElement'; +import { hasClass } from '../utils/actions'; + +export class FormToggle extends BaseElement { + // Represents a FormToggle input. Use `selector` to represent the container its found in. + async switchOn(): Promise< void > { + const container = await this.getCheckboxContainer(); + if ( container && ! ( await hasClass( container, 'is-checked' ) ) ) { + const input = await this.getCheckboxInput(); + + if ( ! input ) { + throw new Error( + `Could not find form toggle with selector ${ this.selector }` + ); + } + input.click(); + + // Wait for it to be checked. + await this.page.waitForSelector( + `${ this.selector } .components-form-toggle.is-checked` + ); + } + } + + async switchOff(): Promise< void > { + const container = await this.getCheckboxContainer(); + if ( container && ( await hasClass( container, 'is-checked' ) ) ) { + const input = await this.getCheckboxInput(); + + if ( ! input ) { + throw new Error( + `Could not find form toggle with selector ${ this.selector }` + ); + } + input.click(); + + // Wait for a not checked toggle to be present. + await page.waitForFunction( + ( selector ) => { + return document.querySelectorAll( selector ).length; + }, + {}, + `${ this.selector } .components-form-toggle:not(.is-checked)` + ); + } + } + + async getCheckboxContainer(): Promise< ElementHandle< Element > | null > { + return this.page.$( `${ this.selector } .components-form-toggle` ); + } + + async getCheckboxInput(): Promise< ElementHandle< Element > | null > { + return this.page.$( + `${ this.selector } .components-form-toggle__input` + ); + } + + async isEnabled(): Promise< void > { + await this.page.waitForSelector( + `${ this.selector } .components-form-toggle.is-checked` + ); + } +} diff --git a/packages/js/admin-e2e-tests/src/elements/HelpMenu.ts b/packages/js/admin-e2e-tests/src/elements/HelpMenu.ts new file mode 100644 index 00000000000..eb509dbcc0d --- /dev/null +++ b/packages/js/admin-e2e-tests/src/elements/HelpMenu.ts @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { Page } from 'puppeteer'; +/** + * Internal dependencies + */ +import { getElementByText, waitForElementByText } from '../utils/actions'; +import { BaseElement } from './BaseElement'; + +export class HelpMenu extends BaseElement { + protected helpMenuId = '#contextual-help-columns'; + + constructor( page: Page ) { + super( page, '' ); + } + + async openHelpMenu(): Promise< void > { + const el = await getElementByText( 'button', 'Help' ); + await el?.click(); + } + + async openSetupWizardTab(): Promise< void > { + const el = await waitForElementByText( '*', 'Setup wizard' ); + await el?.click(); + } + + async enableTaskList(): Promise< void > { + await this.openSetupWizardTab(); + + const enableLink = await getElementByText( + '*', + 'Enable', + this.helpMenuId + ); + await enableLink?.click(); + } +} diff --git a/packages/js/admin-e2e-tests/src/elements/OrdersActivityPanel.ts b/packages/js/admin-e2e-tests/src/elements/OrdersActivityPanel.ts new file mode 100644 index 00000000000..b13a47353c4 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/elements/OrdersActivityPanel.ts @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { ElementHandle, Page } from 'puppeteer'; +/** + * Internal dependencies + */ +import { BaseElement } from './BaseElement'; + +export class OrdersActivityPanel extends BaseElement { + constructor( page: Page ) { + super( page, '.woocommerce-order-activity-card' ); + } + + async getDisplayedOrders(): Promise< string[] > { + await this.page.waitForSelector( + '.woocommerce-order-activity-card h3' + ); + const list = await this.page.$$( + '.woocommerce-order-activity-card h3' + ); + return Promise.all( + list.map( async ( item: ElementHandle ) => { + const textContent = await page.evaluate( + ( el ) => el.textContent, + item + ); + return textContent.trim(); + } ) + ); + } +} diff --git a/packages/js/admin-e2e-tests/src/fixtures/action-scheduler.ts b/packages/js/admin-e2e-tests/src/fixtures/action-scheduler.ts new file mode 100644 index 00000000000..f114933d1fc --- /dev/null +++ b/packages/js/admin-e2e-tests/src/fixtures/action-scheduler.ts @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import { httpClient } from './http-client'; + +const actionSchedulerEndpoint = '/woocommerce-reset/v1/cron/run'; + +export async function runActionScheduler() { + const response = await httpClient.post( actionSchedulerEndpoint ); + if ( response.statusCode !== 404 ) { + expect( response.statusCode ).toEqual( 200 ); + } +} diff --git a/packages/js/admin-e2e-tests/src/fixtures/http-client.ts b/packages/js/admin-e2e-tests/src/fixtures/http-client.ts new file mode 100644 index 00000000000..37bba809dfc --- /dev/null +++ b/packages/js/admin-e2e-tests/src/fixtures/http-client.ts @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { HTTPClientFactory } from '@woocommerce/api'; +/* eslint-disable @typescript-eslint/no-var-requires */ +const config = require( 'config' ); + +// Prepare the HTTP client that will be consumed by the repository. +// This is necessary so that it can make requests to the REST API. +const admin = config.get( 'users.admin' ); +const url = config.get( 'url' ); + +export const httpClient = HTTPClientFactory.build( url ) + .withBasicAuth( admin.username, admin.password ) + .create(); diff --git a/packages/js/admin-e2e-tests/src/fixtures/index.ts b/packages/js/admin-e2e-tests/src/fixtures/index.ts new file mode 100644 index 00000000000..d1c7b73662e --- /dev/null +++ b/packages/js/admin-e2e-tests/src/fixtures/index.ts @@ -0,0 +1,4 @@ +export * from './orders'; +export * from './options'; +export * from './reset'; +export * from './action-scheduler'; diff --git a/packages/js/admin-e2e-tests/src/fixtures/options.ts b/packages/js/admin-e2e-tests/src/fixtures/options.ts new file mode 100644 index 00000000000..dd9fcbbd555 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/fixtures/options.ts @@ -0,0 +1,23 @@ +/** + * Internal dependencies + */ +import { httpClient } from './http-client'; + +const optionsEndpoint = '/wc-admin/options'; + +export async function updateOption( + optionName: string, + optionValue: string +): Promise< void > { + const response = await httpClient.post( optionsEndpoint, { + [ optionName ]: optionValue, + } ); + expect( response.statusCode ).toEqual( 200 ); +} + +export async function unhideTaskList( id: string ): Promise< void > { + const response = await httpClient.post( + `/wc-admin/onboarding/tasks/${ id }/unhide` + ); + expect( response.statusCode ).toEqual( 200 ); +} diff --git a/packages/js/admin-e2e-tests/src/fixtures/orders.ts b/packages/js/admin-e2e-tests/src/fixtures/orders.ts new file mode 100644 index 00000000000..5606b90c0b0 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/fixtures/orders.ts @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { Order } from '@woocommerce/api'; +/** + * Internal dependencies + */ +import { httpClient } from './http-client'; + +const repository = Order.restRepository( httpClient ); + +export async function createOrder( status = 'completed' ): Promise< Order > { + // The repository can now be used to create models. + return await repository.create( { + paymentMethod: 'cod', + status, + } ); +} + +export async function removeAllOrders(): Promise< ( boolean | undefined )[] > { + const products = await repository.list(); + return await Promise.all( + products + .map( ( pr ) => ( pr.id ? repository.delete( pr.id ) : undefined ) ) + .filter( ( pr ) => !! pr ) + ); +} diff --git a/packages/js/admin-e2e-tests/src/fixtures/plugins.ts b/packages/js/admin-e2e-tests/src/fixtures/plugins.ts new file mode 100644 index 00000000000..f155aeecb06 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/fixtures/plugins.ts @@ -0,0 +1,70 @@ +/** + * Internal dependencies + */ +import { httpClient } from './http-client'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { utils } = require( '@woocommerce/e2e-utils' ); + +const wpPluginsEndpoint = '/wp/v2/plugins'; + +type Plugin = { + author: string; + name: string; + plugin: string; + plugin_uri: string; + status: 'active' | 'inactive'; + version: string; + description: { + raw: string; + rendered: string; + }; +}; + +export async function getPlugins(): Promise< Plugin[] > { + const response = await httpClient.get( wpPluginsEndpoint ); + expect( response.statusCode ).toEqual( 200 ); + return response.data; +} + +export async function deletePlugin( pluginName: string ) { + const response = await httpClient.delete( + wpPluginsEndpoint + '/' + pluginName + ); + expect( response.statusCode ).toEqual( 200 ); +} + +export async function deactivatePlugin( pluginName: string ) { + const response = await httpClient.post( + wpPluginsEndpoint + '/' + pluginName, + { + status: 'inactive', + } + ); + expect( response.statusCode ).toEqual( 200 ); +} + +async function deactivateAndDeletePlugin( pluginName: string ) { + await deactivatePlugin( pluginName ); + await deletePlugin( pluginName ); +} +export async function deactivateAndDeleteAllPlugins( except: string[] = [] ) { + let plugins = await getPlugins(); + const skippedPlugins = []; + const promises = []; + for ( const plugin of plugins ) { + const splitPluginName = plugin.plugin.split( '/' ); + const slug = splitPluginName[ 1 ] || splitPluginName[ 0 ]; + const slugFromName = utils.getSlug( + plugin.name.replace( ' &', '' ) + ); + if ( ! except.includes( slug ) && ! except.includes( slugFromName ) ) { + promises.push( deactivateAndDeletePlugin( plugin.plugin ) ); + } else { + skippedPlugins.push( slug ); + } + } + await Promise.all( promises ); + plugins = await getPlugins(); + expect( plugins.length ).toEqual( skippedPlugins.length ); +} diff --git a/packages/js/admin-e2e-tests/src/fixtures/reset.ts b/packages/js/admin-e2e-tests/src/fixtures/reset.ts new file mode 100644 index 00000000000..c7cc5d07c2b --- /dev/null +++ b/packages/js/admin-e2e-tests/src/fixtures/reset.ts @@ -0,0 +1,33 @@ +/** + * Internal dependencies + */ +import { httpClient } from './http-client'; +import { deactivateAndDeleteAllPlugins } from './plugins'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { utils } = require( '@woocommerce/e2e-utils' ); + +const { PLUGIN_NAME } = process.env; + +const resetEndpoint = '/woocommerce-reset/v1/state'; + +const pluginName = PLUGIN_NAME ? PLUGIN_NAME : 'WooCommerce'; +const pluginNameSlug = utils.getSlug( pluginName ); + +const skippedPlugins = [ + 'woocommerce', + 'woocommerce-admin', + 'woocommerce-reset', + 'basic-auth', + 'wp-mail-logging', + pluginNameSlug, +]; + +export async function resetWooCommerceState() { + const response = await httpClient.delete( resetEndpoint ); + expect( response.data.options ).toEqual( true ); + expect( response.data.transients ).toEqual( true ); + expect( response.data.notes ).toEqual( true ); + expect( response.statusCode ).toEqual( 200 ); + await deactivateAndDeleteAllPlugins( skippedPlugins ); +} diff --git a/packages/js/admin-e2e-tests/src/globalTypes.ts b/packages/js/admin-e2e-tests/src/globalTypes.ts new file mode 100644 index 00000000000..e46b34b68c8 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/globalTypes.ts @@ -0,0 +1,10 @@ +/** + * External dependencies + */ +import { Browser, Page } from 'puppeteer'; + +declare global { + const page: Page; + const browser: Browser; + const browserName: string; +} diff --git a/packages/js/admin-e2e-tests/src/index.ts b/packages/js/admin-e2e-tests/src/index.ts new file mode 100644 index 00000000000..60a182ae95b --- /dev/null +++ b/packages/js/admin-e2e-tests/src/index.ts @@ -0,0 +1 @@ +export * from './specs'; diff --git a/packages/js/admin-e2e-tests/src/pages/AllOrdersView.ts b/packages/js/admin-e2e-tests/src/pages/AllOrdersView.ts new file mode 100644 index 00000000000..48405d9273f --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/AllOrdersView.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { BasePage } from './BasePage'; + +export class AllOrdersView extends BasePage { + url = 'wp-admin/edit.php?post_type=shop_order'; +} diff --git a/packages/js/admin-e2e-tests/src/pages/Analytics.ts b/packages/js/admin-e2e-tests/src/pages/Analytics.ts new file mode 100644 index 00000000000..38acae29760 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/Analytics.ts @@ -0,0 +1,32 @@ +/** + * Internal dependencies + */ +import { BasePage } from './BasePage'; + +export type AnalyticsSection = + | 'overview' + | 'products' + | 'revenue' + | 'orders' + | 'variations' + | 'categories' + | 'coupons' + | 'taxes' + | 'downloads' + | 'stock' + | 'settings'; + +export class Analytics extends BasePage { + // If you need to navigate to the base analytics page you can go to the overview + url = 'wp-admin/admin.php?page=wc-admin&path=%2Fanalytics%2Foverview'; + + // If you need to go to a specific single page of the analytics use `navigateToSection` + async navigateToSection( section: AnalyticsSection ): Promise< void > { + await this.goto( this.url.replace( 'overview', section ) ); + } + + async isDisplayed(): Promise< void > { + // This is a smoke test that ensures the single page was rendered without crashing + await this.page.waitForSelector( '#woocommerce-layout__primary' ); + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/AnalyticsOverview.ts b/packages/js/admin-e2e-tests/src/pages/AnalyticsOverview.ts new file mode 100644 index 00000000000..1927bd50e09 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/AnalyticsOverview.ts @@ -0,0 +1,154 @@ +/** + * External dependencies + */ +import { ElementHandle } from 'puppeteer'; + +/** + * Internal dependencies + */ +import { + waitForElementByText, + waitUntilElementStopsMoving, +} from '../utils/actions'; +import { Analytics } from './Analytics'; + +type Section = { + title: string; + element: ElementHandle< Element >; +}; +const isSection = ( item: Section | undefined ): item is Section => { + return !! item; +}; + +export class AnalyticsOverview extends Analytics { + async navigate(): Promise< void > { + await this.navigateToSection( 'overview' ); + } + + async getSections(): Promise< Section[] > { + const list = await this.page.$$( + '.woocommerce-dashboard-section .woocommerce-section-header' + ); + const sections = await Promise.all( + list.map( async ( item ) => { + const title = await item.evaluate( ( element ) => { + const header = element.querySelector( 'h2' ); + return header?.textContent; + } ); + if ( title ) { + return { + title, + element: item, + }; + } + return undefined; + } ) + ); + return sections.filter( isSection ); + } + + async getSectionTitles(): Promise< string[] > { + const sections = ( await this.getSections() ).map( + ( section ) => section.title + ); + return sections; + } + + async openSectionEllipsis( sectionTitle: string ): Promise< void > { + const section = ( await this.getSections() ).find( + ( thisSection ) => thisSection.title === sectionTitle + ); + if ( section ) { + const ellipsisMenu = await section.element.$( + '.woocommerce-ellipsis-menu .woocommerce-ellipsis-menu__toggle' + ); + await ellipsisMenu?.click(); + await this.page.waitForSelector( + '.woocommerce-ellipsis-menu div[role=menu]' + ); + } + } + + async closeSectionEllipsis( sectionTitle: string ): Promise< void > { + const section = ( await this.getSections() ).find( + ( thisSection ) => thisSection.title === sectionTitle + ); + if ( section ) { + const ellipsisMenu = await section.element.$( + '.woocommerce-ellipsis-menu .woocommerce-ellipsis-menu__toggle' + ); + await ellipsisMenu?.click(); + await page.waitForFunction( + () => + ! document.querySelector( + '.woocommerce-ellipsis-menu div[role=menu]' + ) + ); + } + } + + async removeSection( sectionTitle: string ): Promise< void > { + await this.openSectionEllipsis( sectionTitle ); + const item = await waitForElementByText( 'div', 'Remove section' ); + await item?.click(); + } + + async addSection( sectionTitle: string ): Promise< void > { + await this.page.waitForSelector( "button[title='Add more sections']" ); + await this.page.click( "button[title='Add more sections']" ); + const addSectionSelector = `button[title='Add ${ sectionTitle } section']`; + await this.page.waitForSelector( addSectionSelector ); + await waitUntilElementStopsMoving( addSectionSelector ); + await this.page.click( addSectionSelector ); + } + + async moveSectionDown( sectionTitle: string ): Promise< void > { + await this.openSectionEllipsis( sectionTitle ); + const item = await waitForElementByText( 'div', 'Move down' ); + await item?.click(); + } + + async moveSectionUp( sectionTitle: string ): Promise< void > { + await this.openSectionEllipsis( sectionTitle ); + const item = await waitForElementByText( 'div', 'Move up' ); + await item?.click(); + } + + async getEllipsisMenuItems( + sectionTitle: string + ): Promise< + { title: string | null; element: ElementHandle< Element > }[] + > { + await this.openSectionEllipsis( sectionTitle ); + const list = await this.page.$$( + '.woocommerce-ellipsis-menu div[role=menuitem]' + ); + return Promise.all( + list.map( async ( item ) => ( { + title: await item.evaluate( + ( element ) => element?.textContent + ), + element: item, + } ) ) + ); + } + + async getEllipsisMenuCheckboxItems( + sectionTitle: string + ): Promise< + { title: string | null; element: ElementHandle< Element > }[] + > { + await this.openSectionEllipsis( sectionTitle ); + const list = await this.page.$$( + '.woocommerce-ellipsis-menu div[role=menuitemcheckbox]' + ); + return Promise.all( + list.map( async ( item ) => ( { + title: await item.evaluate( + ( element ) => element?.textContent + ), + element: item, + } ) ) + ); + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/BasePage.ts b/packages/js/admin-e2e-tests/src/pages/BasePage.ts new file mode 100644 index 00000000000..7b4ff463909 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/BasePage.ts @@ -0,0 +1,162 @@ +/** + * External dependencies + */ +import { ElementHandle, Page } from 'puppeteer'; + +/** + * Internal dependencies + */ +import { DropdownField } from '../elements/DropdownField'; +import { DropdownTypeaheadField } from '../elements/DropdownTypeaheadField'; +import { FormToggle } from '../elements/FormToggle'; +import { getElementByText, waitForTimeout } from '../utils/actions'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const config = require( 'config' ); +/* eslint-enable @typescript-eslint/no-var-requires */ +const baseUrl = config.get( 'url' ); + +// Represents a page that can be navigated to +export abstract class BasePage { + protected page: Page; + protected url = ''; + protected baseUrl: string = baseUrl; + + // cache of elements that have been setup, note that they are unique "per page/per selector" + private dropDownElements: Record< string, DropdownField > = {}; + private dropDownTypeAheadElements: Record< + string, + DropdownTypeaheadField + > = {}; + private formToggleElements: Record< string, FormToggle > = {}; + + constructor( page: Page ) { + this.page = page; + } + + getDropdownField( selector: string ): DropdownField { + if ( ! this.dropDownElements[ selector ] ) { + this.dropDownElements[ selector ] = new DropdownField( + page, + selector + ); + } + + return this.dropDownElements[ selector ]; + } + + getDropdownTypeahead( selector: string ): DropdownTypeaheadField { + if ( ! this.dropDownTypeAheadElements[ selector ] ) { + this.dropDownTypeAheadElements[ + selector + ] = new DropdownTypeaheadField( page, selector ); + } + + return this.dropDownTypeAheadElements[ selector ]; + } + + getFormToggle( selector: string ): FormToggle { + if ( ! this.formToggleElements[ selector ] ) { + this.formToggleElements[ selector ] = new FormToggle( + page, + selector + ); + } + + return this.formToggleElements[ selector ]; + } + + async click( selector: string ): Promise< void > { + await this.page.waitForSelector( selector ); + await this.page.click( selector ); + } + + async clickButtonWithText( text: string ): Promise< void > { + const el = await getElementByText( 'button', text ); + await el?.click(); + } + + async clickElementWithText( + element: string, + text: string + ): Promise< void > { + const el = await getElementByText( element, text ); + await el?.click(); + } + + async setCheckboxWithText( text: string ): Promise< void > { + let checkbox = await getElementByText( 'label', text ); + + if ( ! checkbox ) { + checkbox = await getElementByText( 'span', text ); + } + + if ( checkbox ) { + const checkboxStatus = await ( + await checkbox.getProperty( 'checked' ) + ).jsonValue(); + + if ( checkboxStatus !== true ) { + await checkbox.click(); + } + } else { + throw new Error( `Could not find checkbox with text "${ text }"` ); + } + } + + async unsetAllCheckboxes( selector: string ): Promise< void > { + const checkboxes = await page.$$( selector ); + // Uncheck all checkboxes, to avoid installing plugins + for ( const checkbox of checkboxes ) { + await this.toggleCheckbox( checkbox, false ); + await waitForTimeout( 200 ); + } + } + + async setAllCheckboxes( selector: string ): Promise< void > { + const checkboxes = await page.$$( selector ); + // Uncheck all checkboxes, to avoid installing plugins + for ( const checkbox of checkboxes ) { + await this.toggleCheckbox( checkbox, true ); + await waitForTimeout( 200 ); + } + } + + // Set or unset a checkbox based on `checked` value passed. + async toggleCheckbox( + checkbox: ElementHandle< Element >, + checked: boolean + ): Promise< void > { + const checkboxStatus = await ( + await checkbox.getProperty( 'checked' ) + ).jsonValue(); + + if ( checkboxStatus !== checked ) { + await checkbox.click(); + } + } + + async navigate(): Promise< void > { + if ( ! this.url ) { + throw new Error( 'You must define a url for the page object' ); + } + + await this.goto( this.url ); + } + + protected async goto( url: string ): Promise< void > { + const fullUrl = baseUrl + url; + try { + await this.page.goto( fullUrl, { + waitUntil: 'networkidle0', + timeout: 10000, + } ); + } catch ( e ) { + if ( e instanceof Error ) { + throw new Error( + `Could not navigate to url: ${ fullUrl } with error: ${ e.message }` + ); + } + } + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/Coupons.ts b/packages/js/admin-e2e-tests/src/pages/Coupons.ts new file mode 100644 index 00000000000..264ea6da2ac --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/Coupons.ts @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import { BasePage } from './BasePage'; + +export class Coupons extends BasePage { + url = 'wp-admin/edit.php?post_type=shop_coupon&legacy_coupon_menu=1'; + + async isDisplayed(): Promise< void > { + // This is a smoke test that ensures the single page was rendered without crashing + await this.page.waitForSelector( '#woocommerce-layout__primary' ); + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/Customers.ts b/packages/js/admin-e2e-tests/src/pages/Customers.ts new file mode 100644 index 00000000000..55cecd48a25 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/Customers.ts @@ -0,0 +1,9 @@ +/** + * Internal dependencies + */ +import { Analytics } from './Analytics'; + +export class Customers extends Analytics { + // The analytics pages are `analytics-{slug}`. + url = 'wp-admin/admin.php?page=wc-admin&path=%2Fcustomers'; +} diff --git a/packages/js/admin-e2e-tests/src/pages/Dashboard.ts b/packages/js/admin-e2e-tests/src/pages/Dashboard.ts new file mode 100644 index 00000000000..a8a37264f17 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/Dashboard.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { BasePage } from './BasePage'; + +export class Dashboard extends BasePage { + url = 'wp-admin'; +} diff --git a/packages/js/admin-e2e-tests/src/pages/Login.ts b/packages/js/admin-e2e-tests/src/pages/Login.ts new file mode 100644 index 00000000000..0c6ec4e1596 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/Login.ts @@ -0,0 +1,53 @@ +/** + * Internal dependencies + */ +import { getElementByText } from '../utils/actions'; +import { BasePage } from './BasePage'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { clearAndFillInput } = require( '@woocommerce/e2e-utils' ); +const config = require( 'config' ); + +export class Login extends BasePage { + url = 'wp-login.php'; + + async login(): Promise< void > { + await this.navigate(); + + await getElementByText( 'label', 'Username or Email Address' ); + await clearAndFillInput( '#user_login', ' ' ); + + await this.page.type( + '#user_login', + config.get( 'users.admin.username' ) + ); + await this.page.type( + '#user_pass', + config.get( 'users.admin.password' ) + ); + + await Promise.all( [ + this.page.click( 'input[type=submit]' ), + this.page.waitForNavigation( { + waitUntil: 'networkidle0', + timeout: 10000, + } ), + ] ); + } + + async logout(): Promise< void > { + // Log out link in admin bar is not visible so can't be clicked directly. + const logoutLinks = await this.page.$$eval( + '#wp-admin-bar-logout a', + ( am ) => + am + .filter( ( e ) => ( e as HTMLLinkElement ).href ) + .map( ( e ) => ( e as HTMLLinkElement ).href ) + ); + + await page.goto( logoutLinks[ 0 ], { + waitUntil: 'networkidle0', + timeout: 10000, + } ); + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/NewCoupon.ts b/packages/js/admin-e2e-tests/src/pages/NewCoupon.ts new file mode 100644 index 00000000000..c8d9acdeb32 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/NewCoupon.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { BasePage } from './BasePage'; + +export class NewCoupon extends BasePage { + url = 'wp-admin/post-new.php?post_type=shop_coupon'; +} diff --git a/packages/js/admin-e2e-tests/src/pages/NewOrder.ts b/packages/js/admin-e2e-tests/src/pages/NewOrder.ts new file mode 100644 index 00000000000..07089676412 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/NewOrder.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { BasePage } from './BasePage'; + +export class NewOrder extends BasePage { + url = 'wp-admin/post-new.php?post_type=shop_order'; +} diff --git a/packages/js/admin-e2e-tests/src/pages/NewProduct.ts b/packages/js/admin-e2e-tests/src/pages/NewProduct.ts new file mode 100644 index 00000000000..5ef75b355b4 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/NewProduct.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { BasePage } from './BasePage'; + +export class NewProduct extends BasePage { + url = 'wp-admin/post-new.php?post_type=product'; +} diff --git a/packages/js/admin-e2e-tests/src/pages/OnboardingWizard.ts b/packages/js/admin-e2e-tests/src/pages/OnboardingWizard.ts new file mode 100644 index 00000000000..f1e3aea0260 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/OnboardingWizard.ts @@ -0,0 +1,157 @@ +/** + * External dependencies + */ +import { Page } from 'puppeteer'; + +/** + * Internal dependencies + */ +import { BusinessSection } from '../sections/onboarding/BusinessSection'; +import { IndustrySection } from '../sections/onboarding/IndustrySection'; +import { ProductTypeSection } from '../sections/onboarding/ProductTypesSection'; +import { + StoreDetails, + StoreDetailsSection, +} from '../sections/onboarding/StoreDetailsSection'; +import { ThemeSection } from '../sections/onboarding/ThemeSection'; +import { BasePage } from './BasePage'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { expect } = require( '@jest/globals' ); +const config = require( 'config' ); + +export class OnboardingWizard extends BasePage { + url = 'wp-admin/admin.php?page=wc-admin&path=/setup-wizard'; + + storeDetails: StoreDetailsSection; + industry: IndustrySection; + productTypes: ProductTypeSection; + business: BusinessSection; + themes: ThemeSection; + + constructor( page: Page ) { + super( page ); + this.storeDetails = new StoreDetailsSection( page ); + this.industry = new IndustrySection( page ); + this.productTypes = new ProductTypeSection( page ); + this.business = new BusinessSection( page ); + this.themes = new ThemeSection( page ); + } + + async skipStoreSetup(): Promise< void > { + await this.clickButtonWithText( 'Skip setup store details' ); + await this.optionallySelectUsageTracking( false ); + } + + async continue(): Promise< void > { + await this.clickButtonWithText( 'Continue' ); + } + + async optionallySelectUsageTracking( select = false ): Promise< void > { + const usageTrackingHeader = await this.page.waitForSelector( + '.components-modal__header-heading', + { + timeout: 5000, + } + ); + if ( ! usageTrackingHeader ) { + return; + } + await expect( page ).toMatchElement( + '.components-modal__header-heading', + { + text: 'Build a better WooCommerce', + } + ); + + // Query for primary buttons: "Continue" and "Yes, count me in" + const primaryButtons = await this.page.$$( 'button.is-primary' ); + expect( primaryButtons ).toHaveLength( 2 ); + + if ( select ) { + await this.clickButtonWithText( 'Yes, count me in' ); + } else { + await this.clickButtonWithText( 'No thanks' ); + } + + await this.page.waitForNavigation( { + waitUntil: 'networkidle0', + timeout: 4000, + } ); + } + + async goToOBWStep( step: string ): Promise< void > { + await this.clickElementWithText( 'span', step ); + } + + async walkThroughAndCompleteOnboardingWizard( + options: { + storeDetails?: StoreDetails; + industries?: string[]; + products?: string[]; + businessDetails?: { + productNumber: string; + currentlySelling: string; + }; + themeTitle?: string; + } = {} + ): Promise< void > { + await this.navigate(); + await this.storeDetails.completeStoreDetailsSection( + options.storeDetails + ); + + // Wait for "Continue" button to become active + await this.continue(); + + // Wait for usage tracking pop-up window to appear + await this.optionallySelectUsageTracking(); + // Query for the industries checkboxes + await this.industry.isDisplayed(); + const industries = options.industries || [ 'Other' ]; + for ( const industry of industries ) { + await this.industry.selectIndustry( industry ); + } + await this.continue(); + await this.productTypes.isDisplayed( 7 ); + const products = options.products || [ + 'Physical products', + 'Downloads', + ]; + for ( const product of products ) { + await this.productTypes.selectProduct( product ); + } + + await this.continue(); + await page.waitForNavigation( { + waitUntil: 'networkidle0', + } ); + await this.business.isDisplayed(); + + const businessDetails = options.businessDetails || { + productNumber: config.get( 'onboardingwizard.numberofproducts' ), + currentlySelling: config.get( 'onboardingwizard.sellingelsewhere' ), + }; + await this.business.selectProductNumber( + businessDetails.productNumber + ); + await this.business.selectCurrentlySelling( + businessDetails.currentlySelling + ); + + await this.continue(); + await this.business.freeFeaturesIsDisplayed(); + await this.business.expandRecommendedBusinessFeatures(); + await this.business.uncheckAllRecommendedBusinessFeatures(); + + await this.continue(); + await this.themes.isDisplayed(); + + // This navigates to the home screen + if ( options.themeTitle ) { + await this.themes.continueWithTheme( options.themeTitle ); + } else { + await this.themes.continueWithActiveTheme(); + } + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/PaymentsSetup.ts b/packages/js/admin-e2e-tests/src/pages/PaymentsSetup.ts new file mode 100644 index 00000000000..8efc7dc4ed4 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/PaymentsSetup.ts @@ -0,0 +1,63 @@ +/** + * Internal dependencies + */ +import { waitForElementByText, getElementByText } from '../utils/actions'; +import { BasePage } from './BasePage'; + +type PaymentMethodWithSetupButton = + | 'wcpay' + | 'stripe' + | 'paypal' + | 'klarna_payments' + | 'mollie' + | 'bacs'; + +type PaymentMethod = PaymentMethodWithSetupButton | 'cod'; + +export class PaymentsSetup extends BasePage { + url = 'wp-admin/admin.php?page=wc-admin&task=payments'; + + async isDisplayed(): Promise< void > { + await waitForElementByText( 'h1', 'Set up payments' ); + } + + async possiblyCloseHelpModal(): Promise< void > { + try { + await waitForElementByText( 'div', "We're here for help", { + timeout: 2000, + } ); + await this.clickButtonWithText( 'Got it' ); + } catch ( e ) {} + } + + async showOtherPaymentMethods(): Promise< void > { + const selector = '.woocommerce-task-payments button.toggle-button'; + await this.page.waitForSelector( selector ); + const toggleButton = await this.page.$( + `${ selector }[aria-expanded=false]` + ); + await toggleButton?.click(); + await waitForElementByText( 'h2', 'Offline payment methods' ); + } + + async goToPaymentMethodSetup( + method: PaymentMethodWithSetupButton + ): Promise< void > { + const selector = `.woocommerce-task-payment-${ method } button`; + await this.page.waitForSelector( selector ); + const button = await this.page.$( selector ); + + if ( ! button ) { + throw new Error( + `Could not find button with selector: ${ selector }` + ); + } else { + await button.click(); + } + } + + async enableCashOnDelivery(): Promise< void > { + await this.page.waitForSelector( '.woocommerce-task-payment-cod' ); + await this.clickButtonWithText( 'Enable' ); + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/PermalinkSettings.ts b/packages/js/admin-e2e-tests/src/pages/PermalinkSettings.ts new file mode 100644 index 00000000000..ebaf2cf9693 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/PermalinkSettings.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { BasePage } from './BasePage'; + +export class PermalinkSettings extends BasePage { + url = 'wp-admin/options-permalink.php'; +} diff --git a/packages/js/admin-e2e-tests/src/pages/Plugins.ts b/packages/js/admin-e2e-tests/src/pages/Plugins.ts new file mode 100644 index 00000000000..ff60cdf7802 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/Plugins.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { BasePage } from './BasePage'; + +export class Plugins extends BasePage { + url = 'wp-admin/plugins.php'; +} diff --git a/packages/js/admin-e2e-tests/src/pages/ProductsSetup.ts b/packages/js/admin-e2e-tests/src/pages/ProductsSetup.ts new file mode 100644 index 00000000000..80cfa91ff20 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/ProductsSetup.ts @@ -0,0 +1,28 @@ +/** + * Internal dependencies + */ +import { waitForElementByText } from '../utils/actions'; +import { BasePage } from './BasePage'; + +export class ProductsSetup extends BasePage { + url = 'wp-admin/admin.php?page=wc-admin&task=products'; + + async isDisplayed(): Promise< void > { + await waitForElementByText( 'h1', 'Add my products' ); + } + + async isStartWithATemplateDisplayed( + templatesCount: number + ): Promise< void > { + await waitForElementByText( 'h1', 'Start with a template' ); + const length = await this.page.$$eval( + '.components-radio-control__input', + ( items ) => items.length + ); + expect( length === templatesCount ).toBeTruthy(); + } + + async clickStartWithTemplate(): Promise< void > { + await this.clickElementWithText( '*', 'Start with a template' ); + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/WcHomescreen.ts b/packages/js/admin-e2e-tests/src/pages/WcHomescreen.ts new file mode 100644 index 00000000000..d1c6c90e06a --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/WcHomescreen.ts @@ -0,0 +1,145 @@ +/** + * External dependencies + */ +import { ElementHandle } from 'puppeteer'; + +/** + * Internal dependencies + */ +import { + waitForElementByText, + getElementByAttributeAndValue, + waitForElementByTextWithoutThrow, + getElementByText, + waitForTimeout, +} from '../utils/actions'; +import { BasePage } from './BasePage'; + +export class WcHomescreen extends BasePage { + url = 'wp-admin/admin.php?page=wc-admin'; + + async isDisplayed(): Promise< void > { + // Wait for Benefits section to appear + await waitForElementByText( 'h1', 'Home' ); + } + + async possiblyDismissWelcomeModal(): Promise< void > { + const modal = await this.isWelcomeModalVisible(); + + if ( modal ) { + await this.clickButtonWithText( 'Next' ); + await waitForTimeout( 1000 ); + await this.clickButtonWithText( 'Next' ); + await waitForTimeout( 1000 ); + await this.click( '.components-guide__finish-button' ); + await waitForTimeout( 500 ); + } + } + + async isWelcomeModalVisible(): Promise< boolean > { + const modalText = 'Welcome to your WooCommerce store’s online HQ!'; + const modal = await waitForElementByTextWithoutThrow( + 'h2', + modalText, + 10 + ); + return modal; + } + + async getTaskList(): Promise< Array< string | null > > { + await page.waitForSelector( + '.woocommerce-task-card .woocommerce-task-list__item-title' + ); + await waitForElementByText( '*', 'Get ready to start selling' ); + const list = await this.page.$$eval( + '.woocommerce-task-card .woocommerce-task-list__item-title', + ( items ) => items.map( ( item ) => item.textContent ) + ); + return list.map( ( item: string | null ) => { + const match = item?.match( /(.+)[0-9] minute/ ); + if ( match && match.length > 1 ) { + return match[ 1 ]; + } + return item; + } ); + } + + async isTaskListDisplayed(): Promise< boolean > { + return !! ( await waitForElementByTextWithoutThrow( + '*', + 'Get ready to start selling' + ) ); + } + + async clickOnTaskList( taskTitle: string ): Promise< void > { + const item = await waitForElementByText( '*', taskTitle ); + + if ( ! item ) { + throw new Error( + `Could not find task list item with title: ${ taskTitle }` + ); + } else { + await item.click(); + await waitForElementByText( 'h1', taskTitle ); + } + } + + async hideTaskList(): Promise< void > { + const taskListOptions = await getElementByAttributeAndValue( + 'button', + 'title', + 'Task List Options' + ); + await taskListOptions?.click(); + await waitForElementByText( 'button', 'Hide this' ); + await waitForTimeout( 200 ); // Transition of popup. + const hideThisButton = await getElementByText( 'button', 'Hide this' ); + await hideThisButton?.click(); + await waitForTimeout( 500 ); + } + + async waitForNotesRequestToBeLoaded(): Promise< void > { + await this.page.waitForResponse( ( response ) => { + const url = encodeURIComponent( response.url() ); + return url.includes( '/wc-analytics/admin/notes' ) && response.ok(); + } ); + } + + async isActivityPanelShown(): Promise< boolean > { + return !! ( await this.page.$( '.woocommerce-activity-panel' ) ); + } + + async getActivityPanels(): Promise< + Array< { title: string; count?: number; element?: ElementHandle } > + > { + const panelContainer = await page.waitForSelector( + '.woocommerce-activity-panel' + ); + const list = await panelContainer.$$( 'h2' ); + return Promise.all( + list.map( async ( item: ElementHandle ) => { + const textContent = await page.evaluate( + ( el ) => el.textContent, + item + ); + const match = textContent?.match( /([a-zA-Z]+)([0-9]+)/ ); + if ( match && match.length > 2 ) { + return { + title: match[ 1 ], + count: parseInt( match[ 2 ], 10 ), + element: item, + }; + } + return { title: textContent }; + } ) + ); + } + + async expandActivityPanel( title: string ): Promise< void > { + const activityPanels = await this.getActivityPanels(); + const panel = activityPanels.find( ( p ) => p.title === title ); + if ( panel ) { + await panel.element?.click(); + } + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/WcSettings.ts b/packages/js/admin-e2e-tests/src/pages/WcSettings.ts new file mode 100644 index 00000000000..0b5f78cf0a7 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/WcSettings.ts @@ -0,0 +1,76 @@ +/** + * Internal dependencies + */ +import { getAttribute, hasClass, waitForElementByText } from '../utils/actions'; +import { BasePage } from './BasePage'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { setCheckbox } = require( '@woocommerce/e2e-utils' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +export class WcSettings extends BasePage { + url = 'wp-admin/admin.php?page=wc-settings'; + + async navigate( tab = 'general', section = '' ): Promise< void > { + let settingsUrl = this.url + `&tab=${ tab }`; + + if ( section ) { + settingsUrl += `§ion=${ section }`; + } + + await this.goto( settingsUrl ); + await waitForElementByText( 'a', 'General' ); + } + + async enableTaxRates(): Promise< void > { + await waitForElementByText( 'th', 'Enable taxes' ); + await setCheckbox( '#woocommerce_calc_taxes' ); + } + + async getTaxRateValue(): Promise< unknown > { + return await getAttribute( '#woocommerce_calc_taxes', 'checked' ); + } + + async saveSettings(): Promise< void > { + this.clickButtonWithText( 'Save changes' ); + await this.page.waitForNavigation( { + waitUntil: 'networkidle0', + } ); + await waitForElementByText( + 'strong', + 'Your settings have been saved.' + ); + } + + async paymentMethodIsEnabled( method = '' ): Promise< boolean > { + await this.navigate( 'checkout' ); + await waitForElementByText( 'h2', 'Payment methods' ); + const className = await getAttribute( + `tr[data-gateway_id=${ method }] .woocommerce-input-toggle`, + 'className' + ); + return ( + ( className as string ).indexOf( + 'woocommerce-input-toggle--disabled' + ) === -1 + ); + } + + async cleanPaymentMethods(): Promise< void > { + await this.navigate( 'checkout' ); + await waitForElementByText( 'h2', 'Payment methods' ); + const paymentMethods = await page.$$( 'span.woocommerce-input-toggle' ); + for ( const method of paymentMethods ) { + if ( + method && + ( await hasClass( + method, + 'woocommerce-input-toggle--enabled' + ) ) + ) { + await method?.click(); + } + } + await this.saveSettings(); + } +} diff --git a/packages/js/admin-e2e-tests/src/pages/WpSettings.ts b/packages/js/admin-e2e-tests/src/pages/WpSettings.ts new file mode 100644 index 00000000000..e429a2dd236 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/pages/WpSettings.ts @@ -0,0 +1,20 @@ +/** + * Internal dependencies + */ +import { waitForElementByText } from '../utils/actions'; +import { BasePage } from './BasePage'; + +export class WpSettings extends BasePage { + url = 'wp-admin/options-permalink.php'; + + async openPermalinkSettings(): Promise< void > { + await waitForElementByText( 'h1', 'Permalink Settings' ); + } + + async saveSettings(): Promise< void > { + await this.click( '#submit' ); + await this.page.waitForNavigation( { + waitUntil: 'networkidle0', + } ); + } +} diff --git a/packages/js/admin-e2e-tests/src/sections/onboarding/BusinessSection.ts b/packages/js/admin-e2e-tests/src/sections/onboarding/BusinessSection.ts new file mode 100644 index 00000000000..035a56cee21 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/sections/onboarding/BusinessSection.ts @@ -0,0 +1,114 @@ +/** + * Internal dependencies + */ +import { BasePage } from '../../pages/BasePage'; +import { waitForElementByText } from '../../utils/actions'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { + setCheckbox, + unsetCheckbox, + verifyCheckboxIsSet, + verifyCheckboxIsUnset, +} = require( '@woocommerce/e2e-utils' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +export class BusinessSection extends BasePage { + async isDisplayed(): Promise< void > { + await waitForElementByText( 'h2', 'Tell us about your business' ); + } + + async freeFeaturesIsDisplayed(): Promise< void > { + await waitForElementByText( 'h2', 'Included business features' ); + } + + async selectProductNumber( productLabel: string ): Promise< void > { + const howManyProductsDropdown = this.getDropdownField( + '.woocommerce-profile-wizard__product-count' + ); + + await howManyProductsDropdown.select( productLabel ); + } + + async selectCurrentlySelling( currentlySelling: string ): Promise< void > { + const sellingElsewhereDropdown = this.getDropdownField( + '.woocommerce-profile-wizard__selling-venues' + ); + + await sellingElsewhereDropdown.select( currentlySelling ); + } + async selectEmployeesNumber( employeesNumber: string ) { + const employeesNumberDropdown = this.getDropdownField( + '.woocommerce-profile-wizard__number-employees' + ); + + await employeesNumberDropdown.select( employeesNumber ); + } + async selectRevenue( revenue: string ) { + const revenueDropdown = this.getDropdownField( + '.woocommerce-profile-wizard__revenue' + ); + + await revenueDropdown.select( revenue ); + } + async selectOtherPlatformName( otherPlatformName: string ) { + const otherPlatformNameDropdown = this.getDropdownField( + '.woocommerce-profile-wizard__other-platform' + ); + + await otherPlatformNameDropdown.select( otherPlatformName ); + } + + async selectInstallFreeBusinessFeatures( + select: boolean + ): Promise< void > { + if ( select ) { + await setCheckbox( '#woocommerce-business-extensions__checkbox' ); + } else { + await unsetCheckbox( '#woocommerce-business-extensions__checkbox' ); + } + } + + async expandRecommendedBusinessFeatures(): Promise< void > { + const expandButtonSelector = + '.woocommerce-admin__business-details__selective-extensions-bundle__expand'; + + await this.page.waitForSelector( + expandButtonSelector + ':not([disabled])' + ); + await this.click( expandButtonSelector ); + + // Confirm that expanding the list shows all the extensions available to install. + await this.page.waitForFunction( () => { + const inputsNum = document.querySelectorAll( + '.components-checkbox-control__input' + ).length; + return inputsNum > 1; + } ); + } + + async uncheckAllRecommendedBusinessFeatures(): Promise< void > { + await this.unsetAllCheckboxes( '.components-checkbox-control__input' ); + } + + // The old list displayed on the dropdown page + async uncheckBusinessFeatures(): Promise< void > { + await this.unsetAllCheckboxes( + '.woocommerce-profile-wizard__benefit .components-form-toggle__input' + ); + } + + async selectSetupForClient(): Promise< void > { + await setCheckbox( '.components-checkbox-control__input' ); + } + + async checkClientSetupCheckbox( selected: boolean ): Promise< void > { + if ( selected ) { + await verifyCheckboxIsSet( '.components-checkbox-control__input' ); + } else { + await verifyCheckboxIsUnset( + '.components-checkbox-control__input' + ); + } + } +} diff --git a/packages/js/admin-e2e-tests/src/sections/onboarding/IndustrySection.ts b/packages/js/admin-e2e-tests/src/sections/onboarding/IndustrySection.ts new file mode 100644 index 00000000000..4764b85c53b --- /dev/null +++ b/packages/js/admin-e2e-tests/src/sections/onboarding/IndustrySection.ts @@ -0,0 +1,40 @@ +/** + * Internal dependencies + */ +import { BasePage } from '../../pages/BasePage'; +import { waitForElementByText } from '../../utils/actions'; + +export class IndustrySection extends BasePage { + async isDisplayed( + industryCount?: number, + industryCountMax?: number + ): Promise< void > { + await waitForElementByText( + 'h2', + 'In which industry does the store operate?' + ); + + if ( industryCount ) { + const length = await this.page.$$eval( + '.components-checkbox-control__input', + ( items ) => items.length + ); + + if ( industryCountMax ) { + expect( + length >= industryCount && length <= industryCountMax + ).toBeTruthy(); + } else { + expect( length === industryCount ).toBeTruthy(); + } + } + } + + async uncheckIndustries(): Promise< void > { + await this.unsetAllCheckboxes( '.components-checkbox-control__input' ); + } + + async selectIndustry( industryLabel: string ): Promise< void > { + await this.setCheckboxWithText( industryLabel ); + } +} diff --git a/packages/js/admin-e2e-tests/src/sections/onboarding/ProductTypesSection.ts b/packages/js/admin-e2e-tests/src/sections/onboarding/ProductTypesSection.ts new file mode 100644 index 00000000000..a292898fc59 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/sections/onboarding/ProductTypesSection.ts @@ -0,0 +1,27 @@ +/** + * Internal dependencies + */ +import { BasePage } from '../../pages/BasePage'; +import { waitForElementByText } from '../../utils/actions'; + +export class ProductTypeSection extends BasePage { + async isDisplayed( productCount: number ): Promise< void > { + await waitForElementByText( + 'h2', + 'What type of products will be listed?' + ); + const length = await this.page.$$eval( + '.components-checkbox-control__input', + ( items ) => items.length + ); + expect( length === productCount ).toBeTruthy(); + } + + async uncheckProducts(): Promise< void > { + await this.unsetAllCheckboxes( '.components-checkbox-control__input' ); + } + + async selectProduct( productLabel: string ): Promise< void > { + await this.setCheckboxWithText( productLabel ); + } +} diff --git a/packages/js/admin-e2e-tests/src/sections/onboarding/StoreDetailsSection.ts b/packages/js/admin-e2e-tests/src/sections/onboarding/StoreDetailsSection.ts new file mode 100644 index 00000000000..a6c89337fe9 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/sections/onboarding/StoreDetailsSection.ts @@ -0,0 +1,124 @@ +/** + * Internal dependencies + */ +import { DropdownTypeaheadField } from '../../elements/DropdownTypeaheadField'; +import { BasePage } from '../../pages/BasePage'; +import { waitForElementByText } from '../../utils/actions'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { + clearAndFillInput, + verifyCheckboxIsSet, + verifyCheckboxIsUnset, +} = require( '@woocommerce/e2e-utils' ); +const config = require( 'config' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +export interface StoreDetails { + addressLine1?: string; + addressLine2?: string; + countryRegionSubstring?: string; + countryRegionSelector?: string; + countryRegion?: string; + city?: string; + postcode?: string; + storeEmail?: string; +} + +export class StoreDetailsSection extends BasePage { + private get countryDropdown(): DropdownTypeaheadField { + return this.getDropdownTypeahead( '#woocommerce-select-control' ); + } + + async isDisplayed(): Promise< void > { + await waitForElementByText( 'h2', 'Welcome to WooCommerce' ); + } + + async completeStoreDetailsSection( + storeDetails: StoreDetails = {} + ): Promise< void > { + // const onboardingWizard = new OnboardingWizard( page ); + // Fill store's address - first line + await this.fillAddress( + storeDetails.addressLine1 || + config.get( 'addresses.admin.store.addressfirstline' ) + ); + + // Fill store's address - second line + await this.fillAddressLineTwo( + storeDetails.addressLine2 || + config.get( 'addresses.admin.store.addresssecondline' ) + ); + + // Type the requested country/region substring or 'cali' in the + // country/region select, then select the requested country/region + // substring or 'US:CA'. + await this.selectCountry( + storeDetails.countryRegionSubstring || 'cali', + storeDetails.countryRegionSelector || 'US\\:CA' + ); + + if ( storeDetails.countryRegion ) { + await this.checkCountrySelected( storeDetails.countryRegion ); + } + + // Fill the city where the store is located + await this.fillCity( + storeDetails.city || config.get( 'addresses.admin.store.city' ) + ); + + // Fill postcode of the store + await this.fillPostalCode( + storeDetails.postcode || + config.get( 'addresses.admin.store.postcode' ) + ); + + // Fill store's email address + await this.fillEmailAddress( + storeDetails.storeEmail || + config.get( 'addresses.admin.store.email' ) + ); + + // Verify that checkbox next to "Get tips, product updates and inspiration straight to your mailbox" is selected + await this.checkMarketingCheckbox( true ); + } + + async fillAddress( address: string ): Promise< void > { + await clearAndFillInput( '#inspector-text-control-0', address ); + } + + async fillAddressLineTwo( address: string ): Promise< void > { + await clearAndFillInput( '#inspector-text-control-1', address ); + } + + async selectCountry( search: string, selector: string ): Promise< void > { + await this.countryDropdown.search( search ); + await this.countryDropdown.select( selector ); + } + + async checkCountrySelected( country: string ): Promise< void > { + await this.countryDropdown.checkSelected( country ); + } + + async fillCity( city: string ): Promise< void > { + await clearAndFillInput( '#inspector-text-control-2', city ); + } + + async fillPostalCode( postalCode: string ): Promise< void > { + await clearAndFillInput( '#inspector-text-control-3', postalCode ); + } + + async fillEmailAddress( email: string ): Promise< void > { + await clearAndFillInput( '#inspector-text-control-4', email ); + } + + async checkMarketingCheckbox( selected: boolean ): Promise< void > { + if ( selected ) { + await verifyCheckboxIsSet( '.components-checkbox-control__input' ); + } else { + await verifyCheckboxIsUnset( + '.components-checkbox-control__input' + ); + } + } +} diff --git a/packages/js/admin-e2e-tests/src/sections/onboarding/ThemeSection.ts b/packages/js/admin-e2e-tests/src/sections/onboarding/ThemeSection.ts new file mode 100644 index 00000000000..8439fa01fc8 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/sections/onboarding/ThemeSection.ts @@ -0,0 +1,29 @@ +/** + * Internal dependencies + */ +import { BasePage } from '../../pages/BasePage'; +import { waitForElementByText } from '../../utils/actions'; + +export class ThemeSection extends BasePage { + async isDisplayed(): Promise< void > { + await waitForElementByText( 'h2', 'Choose a theme' ); + await waitForElementByText( 'button', 'All themes' ); + } + + async continueWithActiveTheme(): Promise< void > { + await this.clickButtonWithText( 'Continue with my active theme' ); + } + + async continueWithTheme( themeTitle: string ): Promise< void > { + const title = await waitForElementByText( 'h2', themeTitle ); + const chooseButton = await title?.evaluateHandle( ( element ) => { + const card = element.closest( '.components-card' ); + return Array.from( card?.querySelectorAll( 'button' ) || [] ).find( + ( el ) => el.textContent === 'Choose' + ); + } ); + if ( chooseButton ) { + await chooseButton.asElement()?.click(); + } + } +} diff --git a/packages/js/admin-e2e-tests/src/sections/payment-setup/BankAccountTransferSetup.ts b/packages/js/admin-e2e-tests/src/sections/payment-setup/BankAccountTransferSetup.ts new file mode 100644 index 00000000000..eb2a454b2dc --- /dev/null +++ b/packages/js/admin-e2e-tests/src/sections/payment-setup/BankAccountTransferSetup.ts @@ -0,0 +1,42 @@ +/** + * Internal dependencies + */ +import { BasePage } from '../../pages/BasePage'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { clearAndFillInput } = require( '@woocommerce/e2e-utils' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +type AccountDetails = { + accountName: string; + accountNumber: string; + bankName: string; + sortCode: string; + iban: string; + swiftCode: string; +}; + +export class BankAccountTransferSetup extends BasePage { + url = 'wp-admin/admin.php?page=wc-admin&task=payments&method=bacs'; + + async saveAccountDetails( { + accountName, + accountNumber, + bankName, + sortCode, + iban, + swiftCode, + }: AccountDetails ): Promise< void > { + await clearAndFillInput( '[placeholder="Account name"]', accountName ); + await clearAndFillInput( + '[placeholder="Account number"]', + accountNumber + ); + await clearAndFillInput( '[placeholder="Bank name"]', bankName ); + await clearAndFillInput( '[placeholder="Sort code"]', sortCode ); + await clearAndFillInput( '[placeholder="IBAN"]', iban ); + await clearAndFillInput( '[placeholder="BIC / Swift"]', swiftCode ); + + await this.clickButtonWithText( 'Save' ); + } +} diff --git a/packages/js/admin-e2e-tests/src/specs/activate-and-setup/basic-setup.ts b/packages/js/admin-e2e-tests/src/specs/activate-and-setup/basic-setup.ts new file mode 100644 index 00000000000..7dd30207fdd --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/activate-and-setup/basic-setup.ts @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ +import { WcSettings } from '../../pages/WcSettings'; +import { WpSettings } from '../../pages/WpSettings'; +import { Login } from '../../pages/Login'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { + clearAndFillInput, + verifyValueOfInputField, +} = require( '@woocommerce/e2e-utils' ); +const { + afterAll, + beforeAll, + describe, + it, + expect, +} = require( '@jest/globals' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const testAdminBasicSetup = () => { + describe( 'Store owner can finish initial store setup', () => { + const wcSettings = new WcSettings( page ); + const wpSettings = new WpSettings( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + } ); + afterAll( async () => { + await login.logout(); + } ); + + it( 'can enable tax rates and calculations', async () => { + // Go to general settings page + await wcSettings.navigate( 'general' ); + await wcSettings.enableTaxRates(); + await wcSettings.saveSettings(); + + // Verify that settings have been saved + const taxRate = await wcSettings.getTaxRateValue(); + expect( taxRate ).toEqual( true ); + } ); + + it( 'can configure permalink settings', async () => { + // Go to Permalink Settings page + await wpSettings.navigate(); + await wpSettings.openPermalinkSettings(); + + // Select "Post name" option in common settings section + await page.click( 'input[value="/%postname%/"]' ); + + // Select "Custom base" in product permalinks section + await page.click( '#woocommerce_custom_selection' ); + + // Fill custom base slug to use + await clearAndFillInput( '#woocommerce_permalink_structure', '' ); + await page.type( '#woocommerce_permalink_structure', '/product/' ); + + await wpSettings.saveSettings(); + + // Verify that settings have been saved + await Promise.all( [ + expect( page ).toMatchElement( + '#setting-error-settings_updated', + { + text: 'Permalink structure updated.', + } + ), + verifyValueOfInputField( + '#permalink_structure', + '/%postname%/' + ), + verifyValueOfInputField( + '#woocommerce_permalink_structure', + '/product/' + ), + ] ); + } ); + } ); +}; + +module.exports = { testAdminBasicSetup }; diff --git a/packages/js/admin-e2e-tests/src/specs/activate-and-setup/complete-onboarding-wizard.ts b/packages/js/admin-e2e-tests/src/specs/activate-and-setup/complete-onboarding-wizard.ts new file mode 100644 index 00000000000..717cacf4186 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/activate-and-setup/complete-onboarding-wizard.ts @@ -0,0 +1,633 @@ +/** + * Internal dependencies + */ +import { OnboardingWizard } from '../../pages/OnboardingWizard'; +import { WcHomescreen } from '../../pages/WcHomescreen'; +import { TaskTitles } from '../../constants/taskTitles'; +import { Login } from '../../pages/Login'; +import { WcSettings } from '../../pages/WcSettings'; +import { ProductsSetup } from '../../pages/ProductsSetup'; +import { resetWooCommerceState } from '../../fixtures/reset'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { + afterAll, + beforeAll, + describe, + it, + expect, +} = require( '@jest/globals' ); +const config = require( 'config' ); + +const { verifyValueOfInputField } = require( '@woocommerce/e2e-utils' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +/** + * This tests a default, happy path for the onboarding wizard. + */ +const testAdminOnboardingWizard = () => { + describe( 'Store owner can complete onboarding wizard', () => { + const profileWizard = new OnboardingWizard( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + await resetWooCommerceState(); + } ); + afterAll( async () => { + await login.logout(); + } ); + + it( 'can start the profile wizard', async () => { + await profileWizard.navigate(); + } ); + + it( 'can complete the store details section', async () => { + await profileWizard.storeDetails.isDisplayed(); + await profileWizard.storeDetails.completeStoreDetailsSection(); + // Wait for "Continue" button to become active + await profileWizard.continue(); + + // Wait for usage tracking pop-up window to appear + await profileWizard.optionallySelectUsageTracking(); + } ); + + it( 'can complete the industry section', async () => { + // Query for the industries checkboxes + await profileWizard.industry.isDisplayed( 7, 8 ); + + // Select just "fashion" and "health/beauty" to get the single checkbox business section when + // filling out details for a US store. + await profileWizard.industry.selectIndustry( + 'Fashion, apparel, and accessories' + ); + await profileWizard.industry.selectIndustry( 'Health and beauty' ); + + await profileWizard.continue(); + } ); + + it( 'can click industry tab after going back', async () => { + await profileWizard.navigate(); + await profileWizard.goToOBWStep( 'Store Details' ); + await profileWizard.storeDetails.isDisplayed(); + + await profileWizard.goToOBWStep( 'Industry' ); + await profileWizard.industry.isDisplayed(); + + await profileWizard.continue(); + } ); + + it( 'can complete the product types section', async () => { + await profileWizard.productTypes.isDisplayed( 7 ); + + // Select Physical and Downloadable products + await profileWizard.productTypes.selectProduct( + 'Physical products' + ); + await profileWizard.productTypes.selectProduct( 'Downloads' ); + + await profileWizard.continue(); + } ); + + it( 'can complete the business section', async () => { + await profileWizard.business.isDisplayed(); + await profileWizard.business.selectProductNumber( + config.get( 'onboardingwizard.numberofproducts' ) + ); + await profileWizard.business.selectCurrentlySelling( + config.get( 'onboardingwizard.sellingelsewhere' ) + ); + await profileWizard.business.checkClientSetupCheckbox( false ); + await profileWizard.continue(); + } ); + + it( 'can unselect all business features and continue', async () => { + await profileWizard.business.freeFeaturesIsDisplayed(); + // Add WC Pay check + await profileWizard.business.expandRecommendedBusinessFeatures(); + + expect( page ).toMatchElement( 'a', { + text: 'WooCommerce Payments', + } ); + + await profileWizard.business.uncheckAllRecommendedBusinessFeatures(); + await profileWizard.continue(); + } ); + + it( 'can complete the theme selection section', async () => { + await profileWizard.themes.isDisplayed(); + await profileWizard.themes.continueWithActiveTheme(); + } ); + + it( 'can select the right currency on settings page related to the onboarding country', async () => { + const settingsScreen = new WcSettings( page ); + await settingsScreen.navigate(); + verifyValueOfInputField( '#woocommerce_currency', 'USD' ); + } ); + } ); +}; + +const testSelectiveBundleWCPay = () => { + describe( 'A japanese store can complete the selective bundle install but does not include WCPay.', () => { + const profileWizard = new OnboardingWizard( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + await resetWooCommerceState(); + } ); + afterAll( async () => { + await login.logout(); + } ); + + it( 'can start the profile wizard', async () => { + await profileWizard.navigate(); + } ); + + it( 'can complete the store details section', async () => { + await profileWizard.storeDetails.completeStoreDetailsSection( { + countryRegionSubstring: 'japan', + countryRegionSelector: 'JP\\:JP01', + countryRegion: 'Japan — Hokkaido', + } ); + + // Wait for "Continue" button to become active + await profileWizard.continue(); + + // Wait for usage tracking pop-up window to appear + await profileWizard.optionallySelectUsageTracking(); + } ); + + // JP:JP01 + it( 'can choose the "Other" industry', async () => { + // Query for the industries checkboxes + await profileWizard.industry.isDisplayed(); + await profileWizard.industry.selectIndustry( 'Other' ); + await profileWizard.continue(); + } ); + + it( 'can complete the product types section', async () => { + await profileWizard.productTypes.isDisplayed( 7 ); + + // Select Physical and Downloadable products + await profileWizard.productTypes.selectProduct( + 'Physical products' + ); + await profileWizard.productTypes.selectProduct( 'Downloads' ); + + await profileWizard.continue(); + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + } ); + + it( 'can complete the business details tab', async () => { + await profileWizard.business.isDisplayed(); + + await profileWizard.business.selectProductNumber( + config.get( 'onboardingwizard.numberofproducts' ) + ); + await profileWizard.business.selectCurrentlySelling( + config.get( 'onboardingwizard.sellingelsewhere' ) + ); + + await profileWizard.continue(); + } ); + + it( 'can choose not to install any extensions', async () => { + await profileWizard.business.freeFeaturesIsDisplayed(); + // Add WC Pay check + await profileWizard.business.expandRecommendedBusinessFeatures(); + + expect( page ).not.toMatchElement( 'a', { + text: 'WooCommerce Payments', + } ); + + await profileWizard.business.uncheckAllRecommendedBusinessFeatures(); + await profileWizard.continue(); + } ); + + it( 'can finish the rest of the wizard successfully', async () => { + await profileWizard.themes.isDisplayed(); + + // This navigates to the home screen + await profileWizard.themes.continueWithActiveTheme(); + } ); + + it( 'should display the choose payments task, and not the woocommerce payments task', async () => { + const homescreen = new WcHomescreen( page ); + await homescreen.isDisplayed(); + await homescreen.possiblyDismissWelcomeModal(); + const tasks = await homescreen.getTaskList(); + expect( tasks ).toContain( TaskTitles.addPayments ); + expect( tasks ).not.toContain( TaskTitles.wooPayments ); + } ); + + it( 'can select the right currency on settings page related to the onboarding country', async () => { + const settingsScreen = new WcSettings( page ); + await settingsScreen.navigate(); + verifyValueOfInputField( '#woocommerce_currency', 'JPY' ); + } ); + } ); +}; + +const testDifferentStoreCurrenciesWCPay = () => { + const testCountryCurrencyPairs = [ + { + countryRegionSubstring: 'australia', + countryRegionSelector: 'AU\\:QLD', + countryRegion: 'Australia — Queensland', + expectedCurrency: 'AUD', + isWCPaySupported: true, + }, + { + countryRegionSubstring: 'canada', + countryRegionSelector: 'CA\\:QC', + countryRegion: 'Canada — Quebec', + expectedCurrency: 'CAD', + isWCPaySupported: true, + }, + { + countryRegionSubstring: 'china', + countryRegionSelector: 'CN\\:CN2', + countryRegion: 'China — Beijing', + expectedCurrency: 'CNY', + isWCPaySupported: false, + }, + { + countryRegionSubstring: 'spain', + countryRegionSelector: 'ES\\:CO', + countryRegion: 'Spain — Córdoba', + expectedCurrency: 'EUR', + isWCPaySupported: true, + }, + { + countryRegionSubstring: 'india', + countryRegionSelector: 'IN\\:DL', + countryRegion: 'India — Delhi', + expectedCurrency: 'INR', + isWCPaySupported: false, + }, + { + countryRegionSubstring: 'kingd', + countryRegionSelector: 'GB', + countryRegion: 'United Kingdom (UK)', + expectedCurrency: 'GBP', + isWCPaySupported: true, + }, + ]; + + testCountryCurrencyPairs.forEach( ( spec ) => { + describe( 'A store can onboard with any country and have the correct currency selected after onboarding.', () => { + const profileWizard = new OnboardingWizard( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + await resetWooCommerceState(); + } ); + afterAll( async () => { + await login.logout(); + } ); + + it( `can complete the profile wizard with selecting ${ spec.countryRegion } as the country`, async () => { + await profileWizard.navigate(); + await profileWizard.storeDetails.completeStoreDetailsSection( { + countryRegionSubstring: spec.countryRegionSubstring, + countryRegionSelector: spec.countryRegionSelector, + countryRegion: spec.countryRegion, + } ); + + // Wait for "Continue" button to become active + await profileWizard.continue(); + + // Wait for usage tracking pop-up window to appear + await profileWizard.optionallySelectUsageTracking(); + // Query for the industries checkboxes + await profileWizard.industry.isDisplayed(); + await profileWizard.industry.selectIndustry( 'Other' ); + await profileWizard.continue(); + await profileWizard.productTypes.isDisplayed( 7 ); + await profileWizard.productTypes.selectProduct( + 'Physical products' + ); + await profileWizard.productTypes.selectProduct( 'Downloads' ); + + await profileWizard.continue(); + await page.waitForNavigation( { + waitUntil: 'networkidle0', + } ); + await profileWizard.business.isDisplayed(); + + await profileWizard.business.selectProductNumber( + config.get( 'onboardingwizard.numberofproducts' ) + ); + await profileWizard.business.selectCurrentlySelling( + config.get( 'onboardingwizard.sellingelsewhere' ) + ); + + await profileWizard.continue(); + await profileWizard.business.freeFeaturesIsDisplayed(); + // Add WC Pay check + await profileWizard.business.expandRecommendedBusinessFeatures(); + + if ( spec.isWCPaySupported ) { + expect( page ).toMatchElement( 'a', { + text: 'WooCommerce Payments', + } ); + } else { + expect( page ).not.toMatchElement( 'a', { + text: 'WooCommerce Payments', + } ); + } + + await profileWizard.business.uncheckAllRecommendedBusinessFeatures(); + await profileWizard.continue(); + await profileWizard.themes.isDisplayed(); + + // This navigates to the home screen + await profileWizard.themes.continueWithActiveTheme(); + } ); + + it( `can select ${ spec.expectedCurrency } as the currency for ${ spec.countryRegion }`, async () => { + const settingsScreen = new WcSettings( page ); + await settingsScreen.navigate(); + verifyValueOfInputField( + '#woocommerce_currency', + spec.expectedCurrency + ); + } ); + } ); + } ); +}; + +const testSubscriptionsInclusion = () => { + describe( 'A non-US store will not see the Subscriptions inclusion', () => { + const profileWizard = new OnboardingWizard( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + await resetWooCommerceState(); + } ); + + it( 'can complete the store details section', async () => { + await profileWizard.navigate(); + await profileWizard.storeDetails.completeStoreDetailsSection( { + countryRegionSubstring: 'fran', + countryRegionSelector: 'FR', + countryRegion: 'France', + } ); + + // Wait for "Continue" button to become active + await profileWizard.continue(); + + // Wait for usage tracking pop-up window to appear + await profileWizard.optionallySelectUsageTracking(); + } ); + + it( 'can complete the product types section, Subscriptions copy is not visible', async () => { + // Query for the industries checkboxes + await profileWizard.industry.isDisplayed(); + await profileWizard.industry.selectIndustry( 'Health and beauty' ); + await profileWizard.continue(); + await profileWizard.productTypes.isDisplayed( 7 ); + await profileWizard.productTypes.selectProduct( 'Subscriptions' ); + await expect( page ).not.toMatchElement( 'p', { + text: + 'The following extensions will be added to your site for free: WooCommerce Payments. An account is required to use this feature.', + } ); + + await profileWizard.continue(); + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + } ); + + it( 'can complete the business details tab', async () => { + await profileWizard.business.isDisplayed(); + + await profileWizard.business.selectProductNumber( + config.get( 'onboardingwizard.numberofproducts' ) + ); + await profileWizard.business.selectCurrentlySelling( + config.get( 'onboardingwizard.sellingelsewhere' ) + ); + + await profileWizard.continue(); + } ); + + it( 'should display the WooCommerce Payments extension after it has been installed', async () => { + await profileWizard.business.freeFeaturesIsDisplayed(); + await profileWizard.business.expandRecommendedBusinessFeatures(); + + expect( page ).toMatchElement( 'a', { + text: 'WooCommerce Payments', + } ); + } ); + + it( 'should display the task "Add Subscriptions to my store"', async () => { + await profileWizard.navigate(); + await profileWizard.goToOBWStep( 'Store Details' ); + await profileWizard.skipStoreSetup(); + const homescreen = new WcHomescreen( page ); + await homescreen.isDisplayed(); + await homescreen.possiblyDismissWelcomeModal(); + const tasks = await homescreen.getTaskList(); + expect( tasks ).toContain( 'Add Subscriptions to my store' ); + } ); + + it( 'can select the Subscription option in the "Start with a template" modal', async () => { + const productsSetup = new ProductsSetup( page ); + await productsSetup.navigate(); + await productsSetup.isDisplayed(); + await productsSetup.clickStartWithTemplate(); + await productsSetup.isStartWithATemplateDisplayed( 3 ); + } ); + } ); + describe( 'A US store will see the Subscriptions inclusion', () => { + const profileWizard = new OnboardingWizard( page ); + const login = new Login( page ); + + beforeAll( async () => { + await resetWooCommerceState(); + } ); + + it( 'can complete the store details section', async () => { + await profileWizard.navigate(); + await profileWizard.storeDetails.completeStoreDetailsSection( { + countryRegionSubstring: 'cali', + countryRegionSelector: 'US\\:CA', + countryRegion: 'United States (US) — California', + } ); + + // Wait for "Continue" button to become active + await profileWizard.continue(); + + // Wait for usage tracking pop-up window to appear + await profileWizard.optionallySelectUsageTracking(); + } ); + + it( 'can complete the product types section, the Subscriptions copy now is visible', async () => { + // Query for the industries checkboxes + await profileWizard.industry.isDisplayed(); + await profileWizard.industry.selectIndustry( 'Health and beauty' ); + await profileWizard.continue(); + await profileWizard.productTypes.isDisplayed( 7 ); + await profileWizard.productTypes.selectProduct( 'Subscriptions' ); + await expect( page ).toMatchElement( 'p', { + text: + 'The following extensions will be added to your site for free: WooCommerce Payments. An account is required to use this feature.', + } ); + + await profileWizard.continue(); + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + } ); + + it( 'can complete the business details tab', async () => { + await profileWizard.business.isDisplayed(); + + await profileWizard.business.selectProductNumber( + config.get( 'onboardingwizard.numberofproducts' ) + ); + await profileWizard.business.selectCurrentlySelling( + config.get( 'onboardingwizard.sellingelsewhere' ) + ); + + await profileWizard.continue(); + } ); + + it( 'cannot see the WooCommerce Payments extension after it has been installed', async () => { + await profileWizard.business.freeFeaturesIsDisplayed(); + await profileWizard.business.expandRecommendedBusinessFeatures(); + + expect( page ).not.toMatchElement( 'a', { + text: 'WooCommerce Payments', + } ); + } ); + + it( 'should not display the task "Add Subscriptions to my store"', async () => { + await profileWizard.navigate(); + await profileWizard.goToOBWStep( 'Store Details' ); + await profileWizard.skipStoreSetup(); + const homescreen = new WcHomescreen( page ); + await homescreen.isDisplayed(); + await homescreen.possiblyDismissWelcomeModal(); + const tasks = await homescreen.getTaskList(); + expect( tasks ).not.toContain( 'Add Subscriptions to my store' ); + } ); + + it( 'can select the Subscription option in the "Start with a template" modal', async () => { + const productsSetup = new ProductsSetup( page ); + await productsSetup.navigate(); + await productsSetup.isDisplayed(); + await productsSetup.clickStartWithTemplate(); + await productsSetup.isStartWithATemplateDisplayed( 4 ); + } ); + } ); +}; + +const testBusinessDetailsForm = () => { + describe( 'A store that is selling elsewhere will see the "Number of employees” dropdown menu', () => { + const profileWizard = new OnboardingWizard( page ); + const login = new Login( page ); + + beforeAll( async () => { + await resetWooCommerceState(); + } ); + + afterAll( async () => { + await login.logout(); + } ); + + it( 'can complete the store details and product types sections', async () => { + await profileWizard.navigate(); + await profileWizard.storeDetails.isDisplayed(); + await profileWizard.storeDetails.completeStoreDetailsSection(); + + // Wait for "Continue" button to become active + await profileWizard.continue(); + + // Wait for usage tracking pop-up window to appear + await profileWizard.optionallySelectUsageTracking(); + + // Query for the industries checkboxes + await profileWizard.industry.isDisplayed(); + await profileWizard.industry.selectIndustry( + 'Fashion, apparel, and accessories' + ); + await profileWizard.continue(); + await profileWizard.productTypes.isDisplayed( 7 ); + // Select Physical + await profileWizard.productTypes.selectProduct( + 'Physical products' + ); + await profileWizard.productTypes.selectProduct( 'Downloads' ); + + await profileWizard.continue(); + await page.waitForNavigation( { + waitUntil: 'networkidle0', + } ); + } ); + + it( 'can complete the business details tab', async () => { + await profileWizard.business.isDisplayed(); + + await profileWizard.business.selectProductNumber( + config.get( 'onboardingwizard.numberofproducts' ) + ); + await profileWizard.business.selectCurrentlySelling( + config.get( 'onboardingwizard.sellingOnAnotherPlatform' ) + ); + expect( page ).toMatchElement( 'label', { + text: 'How many employees do you have?', + } ); + await profileWizard.business.selectEmployeesNumber( + config.get( 'onboardingwizard.number_employees' ) + ); + await profileWizard.business.selectRevenue( + config.get( 'onboardingwizard.revenue' ) + ); + await profileWizard.business.selectOtherPlatformName( + config.get( 'onboardingwizard.other_platform_name' ) + ); + + await profileWizard.continue(); + await profileWizard.business.expandRecommendedBusinessFeatures(); + await profileWizard.business.uncheckAllRecommendedBusinessFeatures(); + await profileWizard.continue(); + await profileWizard.themes.isDisplayed(); + } ); + } ); +}; + +const testAdminHomescreen = () => { + describe( 'Homescreen', () => { + const profileWizard = new OnboardingWizard( page ); + const homeScreen = new WcHomescreen( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + await resetWooCommerceState(); + await profileWizard.navigate(); + await profileWizard.skipStoreSetup(); + } ); + + afterAll( async () => { + await login.logout(); + } ); + + it( 'should not show welcome modal', async () => { + await homeScreen.isDisplayed(); + await expect( homeScreen.isWelcomeModalVisible() ).resolves.toBe( + false + ); + } ); + } ); +}; + +module.exports = { + testAdminOnboardingWizard, + testSelectiveBundleWCPay, + testDifferentStoreCurrenciesWCPay, + testSubscriptionsInclusion, + testBusinessDetailsForm, + testAdminHomescreen, +}; diff --git a/packages/js/admin-e2e-tests/src/specs/analytics/analytics-overview.ts b/packages/js/admin-e2e-tests/src/specs/analytics/analytics-overview.ts new file mode 100644 index 00000000000..2e47c12f794 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/analytics/analytics-overview.ts @@ -0,0 +1,98 @@ +/** + * Internal dependencies + */ +import { AnalyticsOverview } from '../../pages/AnalyticsOverview'; +import { Login } from '../../pages/Login'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { afterAll, beforeAll, describe, it } = require( '@jest/globals' ); + +const testAdminAnalyticsOverview = () => { + describe( 'Analytics pages', () => { + const analyticsPage = new AnalyticsOverview( page ); + const login = new Login( page ); + const sectionTitles = [ 'Performance', 'Charts', 'Leaderboards' ]; + const titlesString = sectionTitles.join( ', ' ); + + beforeAll( async () => { + await login.login(); + await analyticsPage.navigate(); + await analyticsPage.isDisplayed(); + // Restore original order to sections + for ( let t = 0; t < sectionTitles.length; t++ ) { + const visibleSections = await analyticsPage.getSectionTitles(); + if ( visibleSections.indexOf( sectionTitles[ t ] ) < 0 ) { + await analyticsPage.addSection( sectionTitles[ t ] ); + } + } + } ); + afterAll( async () => { + await login.logout(); + } ); + + it( `a user should see ${ sectionTitles.length } sections by default - ${ titlesString }`, async () => { + const sections = await analyticsPage.getSectionTitles(); + for ( let t = 0; t < sectionTitles.length; t++ ) { + expect( sections ).toContain( sectionTitles[ t ] ); + } + } ); + + it( 'should allow a user to remove a section', async () => { + await analyticsPage.removeSection( sectionTitles[ 0 ] ); + const sections = await analyticsPage.getSectionTitles(); + expect( sections ).not.toContain( sectionTitles[ 0 ] ); + } ); + + it( 'should allow a user to add a section back in', async () => { + let sections = await analyticsPage.getSectionTitles(); + expect( sections ).not.toContain( sectionTitles[ 0 ] ); + await analyticsPage.addSection( sectionTitles[ 0 ] ); + + sections = await analyticsPage.getSectionTitles(); + expect( sections ).toContain( sectionTitles[ 0 ] ); + } ); + + describe( 'moving sections', () => { + it( 'should not display move up for the top, or move down for the bottom section', async () => { + const sections = await analyticsPage.getSections(); + for ( const section of sections ) { + const index = sections.indexOf( section ); + const menuItems = ( + await analyticsPage.getEllipsisMenuItems( + section.title + ) + ).map( ( item ) => item.title ); + if ( index === 0 ) { + expect( menuItems ).toContain( 'Move down' ); + expect( menuItems ).not.toContain( 'Move up' ); + } else if ( index === sections.length - 1 ) { + expect( menuItems ).not.toContain( 'Move down' ); + expect( menuItems ).toContain( 'Move up' ); + } else { + expect( menuItems ).toContain( 'Move down' ); + expect( menuItems ).toContain( 'Move up' ); + } + await analyticsPage.closeSectionEllipsis( section.title ); + } + } ); + + it( 'should allow a user to move a section down', async () => { + const sections = await analyticsPage.getSectionTitles(); + await analyticsPage.moveSectionDown( sections[ 0 ] ); + const newSections = await analyticsPage.getSectionTitles(); + expect( sections[ 0 ] ).toEqual( newSections[ 1 ] ); + expect( sections[ 1 ] ).toEqual( newSections[ 0 ] ); + } ); + + it( 'should allow a user to move a section up', async () => { + const sections = await analyticsPage.getSectionTitles(); + await analyticsPage.moveSectionUp( sections[ 1 ] ); + const newSections = await analyticsPage.getSectionTitles(); + expect( sections[ 0 ] ).toEqual( newSections[ 1 ] ); + expect( sections[ 1 ] ).toEqual( newSections[ 0 ] ); + } ); + } ); + } ); +}; + +module.exports = { testAdminAnalyticsOverview }; diff --git a/packages/js/admin-e2e-tests/src/specs/analytics/analytics.ts b/packages/js/admin-e2e-tests/src/specs/analytics/analytics.ts new file mode 100644 index 00000000000..65a8a41a1e4 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/analytics/analytics.ts @@ -0,0 +1,86 @@ +/** + * Internal dependencies + */ +import { Analytics } from '../../pages/Analytics'; +import { Customers } from '../../pages/Customers'; +import { Login } from '../../pages/Login'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { afterAll, beforeAll, describe, it } = require( '@jest/globals' ); + +const testAdminAnalyticsPages = () => { + describe( 'Analytics pages', () => { + const analyticsPage = new Analytics( page ); + const customersPage = new Customers( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + } ); + afterAll( async () => { + await login.logout(); + } ); + + it( 'A user can view the analytics overview without it crashing', async () => { + await analyticsPage.navigate(); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for products without it crashing', async () => { + await analyticsPage.navigateToSection( 'products' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for revenue without it crashing', async () => { + await analyticsPage.navigateToSection( 'revenue' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for orders without it crashing', async () => { + await analyticsPage.navigateToSection( 'orders' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for variations without it crashing', async () => { + await analyticsPage.navigateToSection( 'variations' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for categories without it crashing', async () => { + await analyticsPage.navigateToSection( 'categories' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for coupons without it crashing', async () => { + await analyticsPage.navigateToSection( 'coupons' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for taxes without it crashing', async () => { + await analyticsPage.navigateToSection( 'taxes' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for downloads without it crashing', async () => { + await analyticsPage.navigateToSection( 'downloads' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for stock without it crashing', async () => { + await analyticsPage.navigateToSection( 'stock' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the analytics for settings without it crashing', async () => { + await analyticsPage.navigateToSection( 'settings' ); + await analyticsPage.isDisplayed(); + } ); + + it( 'A user can view the customers page without it crashing', async () => { + await customersPage.navigate(); + await customersPage.isDisplayed(); + } ); + } ); +}; + +module.exports = { testAdminAnalyticsPages }; diff --git a/packages/js/admin-e2e-tests/src/specs/homescreen/activity-panel.ts b/packages/js/admin-e2e-tests/src/specs/homescreen/activity-panel.ts new file mode 100644 index 00000000000..ef234bfa37b --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/homescreen/activity-panel.ts @@ -0,0 +1,135 @@ +/** + * External dependencies + */ +import { createSimpleProduct, withRestApi } from '@woocommerce/e2e-utils'; + +/** + * Internal dependencies + */ +import { Login } from '../../pages/Login'; +import { OnboardingWizard } from '../../pages/OnboardingWizard'; +import { WcHomescreen } from '../../pages/WcHomescreen'; +import { + createOrder, + removeAllOrders, + unhideTaskList, + runActionScheduler, + updateOption, + resetWooCommerceState, +} from '../../fixtures'; +import { OrdersActivityPanel } from '../../elements/OrdersActivityPanel'; +import { addReviewToProduct, waitForElementByText } from '../../utils/actions'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { afterAll, beforeAll, describe, it } = require( '@jest/globals' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const simpleProductName = 'Simple order'; +const testAdminHomescreenActivityPanel = () => { + describe( 'Homescreen activity panel', () => { + const profileWizard = new OnboardingWizard( page ); + const homeScreen = new WcHomescreen( page ); + const ordersPanel = new OrdersActivityPanel( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + await resetWooCommerceState(); + await profileWizard.navigate(); + await profileWizard.skipStoreSetup(); + + await homeScreen.isDisplayed(); + await homeScreen.possiblyDismissWelcomeModal(); + } ); + + afterAll( async () => { + await withRestApi.deleteAllProducts(); + await removeAllOrders(); + await unhideTaskList( 'setup' ); + await runActionScheduler(); + await updateOption( 'woocommerce_task_list_hidden', 'no' ); + await login.logout(); + } ); + + it( 'should not show activity panel while task list is displayed', async () => { + await expect( homeScreen.isTaskListDisplayed() ).resolves.toBe( + true + ); + await expect( homeScreen.isActivityPanelShown() ).resolves.toBe( + false + ); + } ); + + it( 'should not show panels when there are no orders or products yet with task list hidden', async () => { + await homeScreen.hideTaskList(); + await expect( homeScreen.isTaskListDisplayed() ).resolves.toBe( + false + ); + await expect( homeScreen.isActivityPanelShown() ).resolves.toBe( + false + ); + } ); + + it( 'should show Reviews panel when we have at-least one product', async () => { + const productId = await createSimpleProduct( + simpleProductName, + '9.99' + ); + await addReviewToProduct( productId, simpleProductName ); + await homeScreen.navigate(); + await homeScreen.isDisplayed(); + const activityPanels = await homeScreen.getActivityPanels(); + expect( activityPanels ).toHaveLength( 1 ); + expect( activityPanels ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { title: 'Reviews' } ), + ] ) + ); + } ); + + it( 'should show Orders and Stock panels when at-least one order is added', async () => { + await createOrder(); + await page.reload( { + waitUntil: [ 'networkidle0', 'domcontentloaded' ], + } ); + const activityPanels = await homeScreen.getActivityPanels(); + expect( activityPanels ).toHaveLength( 3 ); + expect( activityPanels ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { title: 'Orders' } ), + ] ) + ); + expect( activityPanels ).toEqual( + expect.arrayContaining( [ + expect.objectContaining( { title: 'Stock' } ), + ] ) + ); + } ); + + describe( 'Orders panel', () => { + it( 'should show: "you have fullfilled all your orders" when expanding Orders panel if no actionable orders', async () => { + await homeScreen.expandActivityPanel( 'Orders' ); + await waitForElementByText( + 'h4', + 'You’ve fulfilled all your orders' + ); + await expect( page ).toMatchElement( 'h4', { + text: 'You’ve fulfilled all your orders', + } ); + } ); + + it( 'should show actionable Orders when expanding Orders panel', async () => { + const order1 = await createOrder( 'processing' ); + const order2 = await createOrder( 'on-hold' ); + await homeScreen.navigate(); + await homeScreen.expandActivityPanel( 'Orders' ); + const orders = await ordersPanel.getDisplayedOrders(); + expect( orders ).toHaveLength( 2 ); + expect( orders ).toContain( `Order #${ order1.id }` ); + expect( orders ).toContain( `Order #${ order2.id }` ); + } ); + } ); + } ); +}; + +module.exports = { testAdminHomescreenActivityPanel }; diff --git a/packages/js/admin-e2e-tests/src/specs/homescreen/task-list.ts b/packages/js/admin-e2e-tests/src/specs/homescreen/task-list.ts new file mode 100644 index 00000000000..669ac4d2493 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/homescreen/task-list.ts @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import { takeScreenshotFor } from '@woocommerce/e2e-environment'; + +/** + * Internal dependencies + */ +import { Login } from '../../pages/Login'; +import { OnboardingWizard } from '../../pages/OnboardingWizard'; +import { WcHomescreen } from '../../pages/WcHomescreen'; +import { TaskTitles } from '../../constants/taskTitles'; +import { HelpMenu } from '../../elements/HelpMenu'; +import { WcSettings } from '../../pages/WcSettings'; +import { resetWooCommerceState, unhideTaskList } from '../../fixtures'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { afterAll, beforeAll, describe, it } = require( '@jest/globals' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const testAdminHomescreenTasklist = () => { + describe( 'Homescreen task list', () => { + const profileWizard = new OnboardingWizard( page ); + const homeScreen = new WcHomescreen( page ); + const helpMenu = new HelpMenu( page ); + const settings = new WcSettings( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + await resetWooCommerceState(); + + // This makes this test more isolated, by always navigating to the + // profile wizard and skipping, this behaves the same as if the + // profile wizard had not been run yet and the user is redirected + // to it when trying to go to wc-admin. + await profileWizard.navigate(); + await profileWizard.skipStoreSetup(); + + await homeScreen.isDisplayed(); + await homeScreen.possiblyDismissWelcomeModal(); + await takeScreenshotFor( 'WooCommerce Admin Home Screen' ); + } ); + + afterAll( async () => { + await unhideTaskList( 'setup' ); + await login.logout(); + } ); + + it( 'should show 6 or more tasks on the home screen', async () => { + const tasks = await homeScreen.getTaskList(); + expect( tasks.length ).toBeGreaterThanOrEqual( 6 ); + expect( tasks ).toContain( TaskTitles.storeDetails ); + expect( tasks ).toContain( TaskTitles.addProducts ); + expect( tasks ).toContain( TaskTitles.taxSetup ); + expect( tasks ).toContain( TaskTitles.personalizeStore ); + } ); + + it( 'should be able to hide the task list', async () => { + await homeScreen.hideTaskList(); + expect( await homeScreen.isTaskListDisplayed() ).toBe( false ); + } ); + + it( 'should be able to show the task list again through the help menu', async () => { + await settings.navigate(); + await helpMenu.openHelpMenu(); + await helpMenu.enableTaskList(); + // redirects to homescreen + await homeScreen.isDisplayed(); + await expect( homeScreen.isTaskListDisplayed() ).resolves.toBe( + true + ); + } ); + } ); +}; + +module.exports = { testAdminHomescreenTasklist }; diff --git a/packages/js/admin-e2e-tests/src/specs/index.ts b/packages/js/admin-e2e-tests/src/specs/index.ts new file mode 100644 index 00000000000..dcb88e59853 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/index.ts @@ -0,0 +1,9 @@ +export * from './activate-and-setup/basic-setup'; +export * from './activate-and-setup/complete-onboarding-wizard'; +export * from './analytics/analytics'; +export * from './analytics/analytics-overview'; +export * from './marketing/coupons'; +export * from './tasks/payment'; +export * from './tasks/purchase'; +export * from './homescreen/task-list'; +export * from './homescreen/activity-panel'; diff --git a/packages/js/admin-e2e-tests/src/specs/marketing/coupons.ts b/packages/js/admin-e2e-tests/src/specs/marketing/coupons.ts new file mode 100644 index 00000000000..9d19dcf0895 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/marketing/coupons.ts @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import { Coupons } from '../../pages/Coupons'; +import { Login } from '../../pages/Login'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { afterAll, beforeAll, describe, it } = require( '@jest/globals' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const testAdminCouponsPage = () => { + describe( 'Coupons page', () => { + const couponsPage = new Coupons( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + } ); + afterAll( async () => { + await login.logout(); + } ); + + it( 'A user can view the coupons overview without it crashing', async () => { + await couponsPage.navigate(); + await couponsPage.isDisplayed(); + } ); + } ); +}; + +module.exports = { testAdminCouponsPage }; diff --git a/packages/js/admin-e2e-tests/src/specs/tasks/payment.ts b/packages/js/admin-e2e-tests/src/specs/tasks/payment.ts new file mode 100644 index 00000000000..9d377b05db0 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/tasks/payment.ts @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { takeScreenshotFor } from '@woocommerce/e2e-environment'; + +/** + * Internal dependencies + */ +import { Login } from '../../pages/Login'; +import { OnboardingWizard } from '../../pages/OnboardingWizard'; +import { PaymentsSetup } from '../../pages/PaymentsSetup'; +import { WcHomescreen } from '../../pages/WcHomescreen'; +import { BankAccountTransferSetup } from '../../sections/payment-setup/BankAccountTransferSetup'; +import { waitForTimeout } from '../../utils/actions'; +import { WcSettings } from '../../pages/WcSettings'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { afterAll, beforeAll, describe, it } = require( '@jest/globals' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const testAdminPaymentSetupTask = () => { + describe( 'Payment setup task', () => { + const profileWizard = new OnboardingWizard( page ); + const homeScreen = new WcHomescreen( page ); + const paymentsSetup = new PaymentsSetup( page ); + const bankTransferSetup = new BankAccountTransferSetup( page ); + const login = new Login( page ); + const settings = new WcSettings( page ); + + beforeAll( async () => { + await login.login(); + + // This makes this test more isolated, by always navigating to the + // profile wizard and skipping, this behaves the same as if the + // profile wizard had not been run yet and the user is redirected + // to it when trying to go to wc-admin. + await profileWizard.navigate(); + await profileWizard.skipStoreSetup(); + + await homeScreen.isDisplayed(); + await takeScreenshotFor( 'Payment setup task home screen' ); + await homeScreen.possiblyDismissWelcomeModal(); + } ); + + afterAll( async () => { + await login.logout(); + } ); + + it( 'Can visit the payment setup task from the homescreen if the setup wizard has been skipped', async () => { + await homeScreen.clickOnTaskList( 'Set up payments' ); + await paymentsSetup.possiblyCloseHelpModal(); + await paymentsSetup.isDisplayed(); + } ); + + it( 'Saving valid bank account transfer details enables the payment method', async () => { + await paymentsSetup.showOtherPaymentMethods(); + await paymentsSetup.goToPaymentMethodSetup( 'bacs' ); + await bankTransferSetup.saveAccountDetails( { + accountNumber: '1234', + accountName: 'Savings', + bankName: 'TestBank', + sortCode: '12', + iban: '12 3456 7890', + swiftCode: 'ABBA', + } ); + await waitForTimeout( 1500 ); + expect( await settings.paymentMethodIsEnabled( 'bacs' ) ).toBe( + true + ); + } ); + + it( 'Enabling cash on delivery enables the payment method', async () => { + await settings.cleanPaymentMethods(); + await homeScreen.navigate(); + await homeScreen.isDisplayed(); + await waitForTimeout( 1000 ); + await homeScreen.clickOnTaskList( 'Set up payments' ); + await paymentsSetup.possiblyCloseHelpModal(); + await paymentsSetup.isDisplayed(); + await paymentsSetup.showOtherPaymentMethods(); + await paymentsSetup.enableCashOnDelivery(); + await waitForTimeout( 1500 ); + expect( await settings.paymentMethodIsEnabled( 'cod' ) ).toBe( + true + ); + } ); + } ); +}; + +module.exports = { testAdminPaymentSetupTask }; diff --git a/packages/js/admin-e2e-tests/src/specs/tasks/purchase.ts b/packages/js/admin-e2e-tests/src/specs/tasks/purchase.ts new file mode 100644 index 00000000000..8c0e59eb558 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/specs/tasks/purchase.ts @@ -0,0 +1,100 @@ +/** + * Internal dependencies + */ +import { resetWooCommerceState } from '../../fixtures'; +import { Login } from '../../pages/Login'; +import { OnboardingWizard } from '../../pages/OnboardingWizard'; +import { WcHomescreen } from '../../pages/WcHomescreen'; +import { getElementByText, waitForElementByText } from '../../utils/actions'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { afterAll, beforeAll, describe, it } = require( '@jest/globals' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +const testAdminPurchaseSetupTask = () => { + describe( 'Purchase setup task', () => { + const profileWizard = new OnboardingWizard( page ); + const homeScreen = new WcHomescreen( page ); + const login = new Login( page ); + + beforeAll( async () => { + await login.login(); + } ); + + afterAll( async () => { + await login.logout(); + } ); + + describe( 'selecting paid product', () => { + beforeAll( async () => { + await resetWooCommerceState(); + + await profileWizard.navigate(); + await profileWizard.walkThroughAndCompleteOnboardingWizard( { + products: [ 'Memberships' ], + } ); + + await homeScreen.isDisplayed(); + await homeScreen.possiblyDismissWelcomeModal(); + } ); + + it( 'should display add to my store task', async () => { + expect( + await getElementByText( '*', 'Add Memberships to my store' ) + ).toBeDefined(); + } ); + + it( 'should show paid features modal with option to buy now', async () => { + const task = await getElementByText( + '*', + 'Add Memberships to my store' + ); + await task?.click(); + await waitForElementByText( + 'h1', + 'Would you like to add the following paid features to your store now?' + ); + expect( + await getElementByText( 'button', 'Buy now' ) + ).toBeDefined(); + } ); + } ); + + describe( 'selecting paid theme', () => { + beforeAll( async () => { + await resetWooCommerceState(); + + await profileWizard.navigate(); + await profileWizard.walkThroughAndCompleteOnboardingWizard( { + themeTitle: 'Blooms', + } ); + + await homeScreen.isDisplayed(); + await homeScreen.possiblyDismissWelcomeModal(); + } ); + + it( 'should display add to my store task', async () => { + expect( + await getElementByText( '*', 'Add Blooms to my store' ) + ).toBeDefined(); + } ); + + it( 'should show paid features modal with option to buy now', async () => { + const task = await getElementByText( + '*', + 'Add Blooms to my store' + ); + await task?.click(); + await waitForElementByText( + 'h1', + 'Would you like to add the following paid features to your store now?' + ); + expect( + await getElementByText( 'button', 'Buy now' ) + ).toBeDefined(); + } ); + } ); + } ); +}; + +module.exports = { testAdminPurchaseSetupTask }; diff --git a/packages/js/admin-e2e-tests/src/utils/actions.ts b/packages/js/admin-e2e-tests/src/utils/actions.ts new file mode 100644 index 00000000000..54218cbd227 --- /dev/null +++ b/packages/js/admin-e2e-tests/src/utils/actions.ts @@ -0,0 +1,269 @@ +/** + * External dependencies + */ +import { ElementHandle } from 'puppeteer'; + +/** + * Internal dependencies + */ +import { Login } from '../pages/Login'; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { expect } = require( '@jest/globals' ); +const config = require( 'config' ); +/* eslint-enable @typescript-eslint/no-var-requires */ + +/** + * Wait for UI blocking to end. + */ +const uiUnblocked = async (): Promise< void > => { + await page.waitForFunction( + () => ! Boolean( document.querySelector( '.blockUI' ) ) + ); +}; + +/** + * Suspend processing for the specified time + * + * @param {number} timeout in milliseconds + */ +const waitForTimeout = async ( timeout: number ): Promise< void > => { + await new Promise( ( resolve ) => setTimeout( resolve, timeout ) ); +}; + +/** + * Publish, verify that item was published. Trash, verify that item was trashed. + * + * @param {string} button (Publish) + * @param {string} publishNotice + * @param {string} publishVerification + */ +const verifyPublishAndTrash = async ( + button: string, + publishNotice: string, + publishVerification: string, + trashVerification: string +): Promise< void > => { + // Wait for auto save + await waitForTimeout( 2000 ); + // Publish + await page.click( button ); + + // Verify + await expect( page ).toMatchElement( publishNotice, { + text: publishVerification, + } ); + if ( button === '.order_actions li .save_order' ) { + await expect( page ).toMatchElement( + '#select2-order_status-container', + { text: 'Processing' } + ); + await expect( page ).toMatchElement( + '#woocommerce-order-notes .note_content', + { + text: + 'Order status changed from Pending payment to Processing.', + } + ); + } + + // Trash + await expect( page ).toClick( 'a', { text: 'Move to Trash' } ); + await page.waitForSelector( '#message' ); + + // Verify + await expect( page ).toMatchElement( publishNotice, { + text: trashVerification, + } ); +}; + +const hasClass = async ( + element: ElementHandle, + elementClass: string +): Promise< boolean > => { + const classNameProp = await element.getProperty( 'className' ); + const classNameValue = ( await classNameProp.jsonValue() ) as string; + + return classNameValue.includes( elementClass ); +}; + +const getInputValue = async ( selector: string ): Promise< unknown > => { + const field = await page.$( selector ); + if ( field ) { + const fieldValue = await ( + await field.getProperty( 'value' ) + ).jsonValue(); + + return fieldValue; + } + return null; +}; + +const getAttribute = async ( + selector: string, + attribute: string +): Promise< unknown > => { + await page.focus( selector ); + const field = await page.$( selector ); + if ( field ) { + const fieldValue = await ( + await field.getProperty( attribute ) + ).jsonValue(); + + return fieldValue; + } + return null; +}; + +const getElementByText = async ( + element: string, + text: string, + parentSelector?: string +): Promise< ElementHandle | null > => { + let parent: ElementHandle | null = null; + if ( parentSelector ) { + parent = await page.$( parentSelector ); + } + const els = await ( parent || page ).$x( + `//${ element }[contains(text(), "${ text }")]` + ); + return els[ 0 ]; +}; + +const getElementByAttributeAndValue = async ( + element: string, + attribute: string, + value: string, + parentSelector?: string +): Promise< ElementHandle | null > => { + let parent: ElementHandle | null = null; + if ( parentSelector ) { + parent = await page.$( parentSelector ); + } + const els = await ( parent || page ).$x( + `//${ element }[@${ attribute }="${ value }"]` + ); + return els[ 0 ]; +}; + +const waitForElementByText = async ( + element: string, + text: string, + options?: { timeout?: number } +): Promise< ElementHandle | null > => { + const els = await page.waitForXPath( + `//${ element }[contains(text(), "${ text }")]`, + options + ); + return els; +}; + +export const waitForElementByTextWithoutThrow = async ( + element: string, + text: string, + timeoutInSeconds = 5 +): Promise< boolean > => { + let selected = await getElementByText( element, text ); + for ( let s = 0; s < timeoutInSeconds; s++ ) { + if ( selected ) { + break; + } + await waitForTimeout( 1000 ); + selected = await getElementByText( element, text ); + } + return Boolean( selected ); +}; + +const waitUntilElementStopsMoving = async ( selector: string ) => { + return await page.waitForFunction( + ( elementSelector ) => { + const element = document.querySelector( elementSelector ); + const elementRect = element.getBoundingClientRect(); + const jsWindow: Window & + typeof globalThis & { + elementX?: number; + elementY?: number; + } = window; + + if ( + jsWindow.elementX !== elementRect.x.toFixed( 1 ) || + jsWindow.elementY !== elementRect.y.toFixed( 1 ) + ) { + jsWindow.elementX = elementRect.x.toFixed( 1 ); + jsWindow.elementY = elementRect.y.toFixed( 1 ); + return false; + } + + delete jsWindow.elementX; + delete jsWindow.elementY; + return true; + }, + {}, + selector + ); +}; + +const deactivateAndDeleteExtension = async ( + extension: string +): Promise< void > => { + const baseUrl = config.get( 'url' ); + const pluginsAdmin = 'wp-admin/plugins.php?plugin_status=all&paged=1&s'; + await page.goto( baseUrl + pluginsAdmin, { + waitUntil: 'networkidle0', + timeout: 10000, + } ); + await waitForElementByText( 'h1', 'Plugins' ); + // deactivate extension + const deactivateExtension = await page.$( `#deactivate-${ extension }` ); + await deactivateExtension?.click(); + await waitForElementByText( 'p', 'Plugin deactivated.' ); + // delete extension + const deleteExtension = await page.$( `#delete-${ extension }` ); + await deleteExtension?.click(); +}; + +const addReviewToProduct = async ( productId: number, productName: string ) => { + // we need a guest user + const login = new Login( page ); + await login.logout(); + + const baseUrl = config.get( 'url' ); + const productUrl = `/?p=${ productId }`; + await page.goto( baseUrl + productUrl, { + waitUntil: 'networkidle0', + timeout: 10000, + } ); + await waitForElementByText( 'h1', productName ); + + // Reviews tab + const reviewTab = await page.$( '#tab-title-reviews' ); + await reviewTab?.click(); + const fiveStars = await page.$( '.star-5' ); + await fiveStars?.click(); + + // write a comment + await page.type( '#comment', 'My comment' ); + await page.type( '#author', 'John Doe' ); + await page.type( '#email', 'john.doe@john.doe' ); + + const submit = await page.$( '#submit' ); + await submit?.click(); + // the comment was published + await waitForElementByText( 'p', 'My comment' ); + await login.login(); +}; + +export { + uiUnblocked, + verifyPublishAndTrash, + getInputValue, + getAttribute, + getElementByText, + getElementByAttributeAndValue, + waitForElementByText, + waitUntilElementStopsMoving, + hasClass, + waitForTimeout, + deactivateAndDeleteExtension, + addReviewToProduct, +}; diff --git a/packages/js/admin-e2e-tests/tsconfig.json b/packages/js/admin-e2e-tests/tsconfig.json new file mode 100644 index 00000000000..0957caa0bac --- /dev/null +++ b/packages/js/admin-e2e-tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": "src", + "outDir": "build" + } +} diff --git a/packages/js/admin-e2e-tests/typings/index.d.ts b/packages/js/admin-e2e-tests/typings/index.d.ts new file mode 100644 index 00000000000..99697491829 --- /dev/null +++ b/packages/js/admin-e2e-tests/typings/index.d.ts @@ -0,0 +1,2 @@ +declare module '@woocommerce/e2e-environment'; +declare module '@woocommerce/e2e-utils'; diff --git a/packages/js/api-core-tests/.gitignore b/packages/js/api-core-tests/.gitignore index 5a5072cb91d..80ab55bf63f 100644 --- a/packages/js/api-core-tests/.gitignore +++ b/packages/js/api-core-tests/.gitignore @@ -1,2 +1,6 @@ # Collection output collection.json + +# Allure directories +allure-report +allure-results diff --git a/packages/js/api-core-tests/README.md b/packages/js/api-core-tests/README.md index 694e7e9a134..8d9f4220043 100644 --- a/packages/js/api-core-tests/README.md +++ b/packages/js/api-core-tests/README.md @@ -77,6 +77,15 @@ jest --group=api ## Writing tests +### Conventions + +1. All tests should be placed in the `tests` directory. +1. Always provide a JS doc in all tests, and tag them with `@group`. See the [Test groups](#test-groups) section for more info on grouping tests. +1. Use functions in the `data` folder when generating test data instead of constructing them from scratch within the test. +1. Use functions in the `endpoints` folder to send requests instead of directly using SuperTest's `request()` function. +1. Use `describe.each()` or `it.each()` when writing repetitive tests. +1. Always clean up all test data generated by the tests. + ### Test groups This package makes use of the `jest-runner-groups` package, which allows grouping tests together around semantic groups (such as `orders` API calls, or `coupons` API calls) to make running test suites more granular. @@ -116,6 +125,20 @@ const queryString = { const response = await getRequest('/orders', queryString); ``` +## Creating test data + +Most of the time, test data would be in the form of a request payload. Instead of building them from scratch inside the test, create a test data file inside the `data` directory. Create a model of the request payload within that file, and export it as an object or a function that generates this object. + +Afterwards, make sure to add the test data file to the `data/index.js` file. + +This way, the test data would be decoupled from the test itself, allowing for easier test data management, and more readable tests. + +## Creating endpoint functions + +All functions for sending requests to endpoints should be placed in the `endpoints` directory. + +Newly created files should be added to the `endpoints/index.js` file. + ## Debugging tests You can make use of the REST API log plugin to see how requests are being made, and check the request payload, response, and more. diff --git a/packages/js/api-core-tests/allure.config.js b/packages/js/api-core-tests/allure.config.js new file mode 100644 index 00000000000..340065110e1 --- /dev/null +++ b/packages/js/api-core-tests/allure.config.js @@ -0,0 +1,15 @@ +/** + * + * Use this configuration file to set certain Allure options. + * + */ + +// ALLURE_OUTPUT_DIR is the environment variable for the directory where you want the "allure-results" and "allure-report" folders to be generated in. +const { ALLURE_OUTPUT_DIR } = process.env; + +// If ALLURE_OUTPUT_DIR was specified, use it as the target for the "allure-results" directory. +if ( ALLURE_OUTPUT_DIR ) { + reporter.allure.setOptions( { + targetDir: `${ ALLURE_OUTPUT_DIR }/allure-results`, + } ); +} diff --git a/packages/js/api-core-tests/bin/wc-api-tests.sh b/packages/js/api-core-tests/bin/wc-api-tests.sh index 7b57c2e80d8..24054b1bc2b 100755 --- a/packages/js/api-core-tests/bin/wc-api-tests.sh +++ b/packages/js/api-core-tests/bin/wc-api-tests.sh @@ -23,6 +23,26 @@ OLDPATH=$(pwd) # Return value for CI test runs TESTRESULT=0 +# Function to generate report +report() { + + # Set the ALLURE_OUTPUT_DIR to $PWD if it wasn't set + ALLURE_RESULTS_DIR="${ALLURE_OUTPUT_DIR:-$PWD}/allure-results" + ALLURE_REPORT_DIR="${ALLURE_OUTPUT_DIR:-$PWD}/allure-report" + + echo "Generating report..." + allure generate --clean "$ALLURE_RESULTS_DIR" --output "$ALLURE_REPORT_DIR" + REPORT_EXIT_CODE=$? + + # Suggest opening the report + if [[ $REPORT_EXIT_CODE -eq 0 && $GITHUB_ACTIONS != "true" ]]; then + echo "To view the report on your browser, run:" + echo "" + echo "pnpm dlx allure open \"$ALLURE_REPORT_DIR\"" + echo "" + fi +} + # Use the script symlink to find and change directory to the root of the package SCRIPTPATH=$(dirname "$0") REALPATH=$(readlink "$0") @@ -30,17 +50,18 @@ cd "$SCRIPTPATH/$(dirname "$REALPATH")/.." # Run scripts case $1 in - 'test') - jest --group=$2 --runInBand - TESTRESULT=$? - ;; - 'make:collection') - node utils/api-collection/build-collection.js $2 - TESTRESULT=$? - ;; - *) - usage - ;; +'test') + jest --group=$2 --runInBand + TESTRESULT=$? + report + ;; +'make:collection') + node utils/api-collection/build-collection.js $2 + TESTRESULT=$? + ;; +*) + usage + ;; esac # Restore working path diff --git a/packages/js/api-core-tests/jest.config.js b/packages/js/api-core-tests/jest.config.js index 704edcbb8e2..10e1ced5bb2 100644 --- a/packages/js/api-core-tests/jest.config.js +++ b/packages/js/api-core-tests/jest.config.js @@ -1,12 +1,12 @@ -require('dotenv').config(); +require( 'dotenv' ).config(); const { BASE_URL, VERBOSE, USE_INDEX_PERMALINKS } = process.env; const verboseOutput = VERBOSE === 'true'; // Update the API path if the `USE_INDEX_PERMALINKS` flag is set const useIndexPermalinks = USE_INDEX_PERMALINKS === 'true'; -let apiPath = `${BASE_URL}/?rest_route=/wc/v3/`; +let apiPath = `${ BASE_URL }/?rest_route=/wc/v3/`; if ( useIndexPermalinks ) { - apiPath = `${BASE_URL}/wp-json/wc/v3/`; + apiPath = `${ BASE_URL }/wp-json/wc/v3/`; } module.exports = { @@ -20,4 +20,22 @@ module.exports = { // Indicates whether each individual test should be reported during the run verbose: verboseOutput, + + /** + * Configure `jest-allure` for Jest v24 and above. + * + * @see https://www.npmjs.com/package/jest-allure#jest--v-24- + */ + setupFilesAfterEnv: [ + 'jest-allure/dist/setup', + '/allure.config.js', + ], + + /** + * Make sure Jest is using Jasmine as its test runner. + * `jest-allure` does not work with the `jest-circus` test runner, which is the default test runner of Jest starting from v27. + * + * @see https://github.com/zaqqaz/jest-allure#uses-jest-circus-or-jest--v-27- + */ + testRunner: 'jasmine2', }; diff --git a/packages/js/api-core-tests/package.json b/packages/js/api-core-tests/package.json index 2fc58990a3a..f83420e77d3 100644 --- a/packages/js/api-core-tests/package.json +++ b/packages/js/api-core-tests/package.json @@ -7,7 +7,8 @@ "test": "jest", "test:api": "jest --group=api", "test:hello": "jest --group=hello", - "make:collection": "node utils/api-collection/build-collection.js" + "make:collection": "node utils/api-collection/build-collection.js", + "report": "allure generate --clean && allure serve" }, "repository": { "type": "git", @@ -19,8 +20,10 @@ }, "homepage": "https://github.com/woocommerce/woocommerce#readme", "dependencies": { + "allure-commandline": "^2.17.2", "dotenv": "^10.0.0", "jest": "^25.1.0", + "jest-allure": "^0.1.3", "jest-runner-groups": "^2.1.0", "postman-collection": "^4.1.0", "supertest": "^6.1.4" @@ -30,5 +33,10 @@ }, "bin": { "wc-api-tests": "bin/wc-api-tests.sh" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] } } diff --git a/packages/js/api/README.md b/packages/js/api/README.md index b27722004cc..e014365465f 100644 --- a/packages/js/api/README.md +++ b/packages/js/api/README.md @@ -1,13 +1,21 @@ # WooCommerce API Client -An isometric API client for interacting with WooCommerce installations. Here are the current and planned +An API client for interacting with WooCommerce installations that works both in the browser and in Node environments. Here are the current and planned features: -- [x] TypeScript Definitions +- [x] TypeScript Definitions \* - [x] Axios API Client with support for OAuth & basic auth -- [x] Repositories to simplify interaction with basic data types +- [X] Partial support to Repositories, to simplify interaction with basic data types \* - [x] Service classes for common activities such as changing settings +_\* TypeScript Definitions and Repositories are currently only supported for [Products](https://woocommerce.github.io/woocommerce-rest-api-docs/#products), and partially supported for [Orders](https://woocommerce.github.io/woocommerce-rest-api-docs/#orders)._ + +## Differences from @woocommerce/woocomerce-rest-api + +WooCommerce has two API clients in JavaScript for interacting with a WooCommerce installation's RESTful API. This package, and the [@woocommerce/woocomerce-rest-api](https://www.npmjs.com/package/@woocommerce/woocommerce-rest-api) package. + +The main difference between them is the Repositories and the TypeScript definitions for the supported endpoints. When using Axios directly, as you can do with both libraries, you query the WooCommerce API in a raw object format, following the [API documentation](https://woocommerce.github.io/woocommerce-rest-api-docs/#introduction) parameters. Comparatively, with the Repositories provided in this package, you have the parameters as properties of an object, which gives you the benefits of auto-complete and strict types, for instance. + ## Usage ```bash diff --git a/packages/js/api/package.json b/packages/js/api/package.json index 56388920230..913125e6bcd 100644 --- a/packages/js/api/package.json +++ b/packages/js/api/package.json @@ -46,11 +46,16 @@ "@typescript-eslint/parser": "^5.3.1", "axios-mock-adapter": "^1.20.0", "eslint": "^8.2.0", - "jest": "^27.3.1", - "ts-jest": "25.5.0", + "jest": "^25", + "ts-jest": "^25", "typescript": "^4.4.4" }, "publishConfig": { "access": "public" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] } } diff --git a/packages/js/api/src/framework/__tests__/model-repository.spec.ts b/packages/js/api/src/framework/__tests__/model-repository.spec.ts index abb39998638..37fe671ed2f 100644 --- a/packages/js/api/src/framework/__tests__/model-repository.spec.ts +++ b/packages/js/api/src/framework/__tests__/model-repository.spec.ts @@ -15,7 +15,12 @@ import { } from '../model-repository'; import { DummyModel } from '../../__test_data__/dummy-model'; -type DummyModelParams = ModelRepositoryParams< DummyModel, never, { search: string }, 'name' > +type DummyModelParams = ModelRepositoryParams< + DummyModel, + never, + { search: string }, + 'name' +>; class DummyChildModel extends Model { public childName: string = ''; @@ -25,7 +30,12 @@ class DummyChildModel extends Model { Object.assign( this, partial ); } } -type DummyChildParams = ModelRepositoryParams< DummyChildModel, { parent: string }, { childSearch: string }, 'childName' > +type DummyChildParams = ModelRepositoryParams< + DummyChildModel, + { parent: string }, + { childSearch: string }, + 'childName' +>; describe( 'ModelRepository', () => { it( 'should list', async () => { @@ -36,7 +46,7 @@ describe( 'ModelRepository', () => { null, null, null, - null, + null ); const listed = await repository.list( { search: 'test' } ); @@ -52,12 +62,18 @@ describe( 'ModelRepository', () => { null, null, null, - null, + null ); - const listed = await repository.list( { parent: 'test' }, { childSearch: 'test' } ); + const listed = await repository.list( + { parent: 'test' }, + { childSearch: 'test' } + ); expect( listed ).toContain( model ); - expect( callback ).toHaveBeenCalledWith( { parent: 'test' }, { childSearch: 'test' } ); + expect( callback ).toHaveBeenCalledWith( + { parent: 'test' }, + { childSearch: 'test' } + ); } ); it( 'should throw error on list without callback', () => { @@ -66,7 +82,7 @@ describe( 'ModelRepository', () => { null, null, null, - null, + null ); expect( () => repository.list() ).toThrowError( /not supported/i ); @@ -80,7 +96,7 @@ describe( 'ModelRepository', () => { callback, null, null, - null, + null ); const created = await repository.create( { name: 'test' } ); @@ -96,12 +112,18 @@ describe( 'ModelRepository', () => { callback, null, null, - null, + null ); - const created = await repository.create( { parent: 'yes' }, { childName: 'test' } ); + const created = await repository.create( + { parent: 'yes' }, + { childName: 'test' } + ); expect( created ).toBe( model ); - expect( callback ).toHaveBeenCalledWith( { parent: 'yes' }, { childName: 'test' } ); + expect( callback ).toHaveBeenCalledWith( + { parent: 'yes' }, + { childName: 'test' } + ); } ); it( 'should throw error on create without callback', () => { @@ -110,10 +132,12 @@ describe( 'ModelRepository', () => { null, null, null, - null, + null ); - expect( () => repository.create( { name: 'test' } ) ).toThrowError( /not supported/i ); + expect( () => repository.create( { name: 'test' } ) ).toThrowError( + /not supported/i + ); } ); it( 'should read', async () => { @@ -124,7 +148,7 @@ describe( 'ModelRepository', () => { null, callback, null, - null, + null ); const created = await repository.read( 1 ); @@ -140,7 +164,7 @@ describe( 'ModelRepository', () => { null, callback, null, - null, + null ); const created = await repository.read( { parent: 'yes' }, 1 ); @@ -154,7 +178,7 @@ describe( 'ModelRepository', () => { null, null, null, - null, + null ); expect( () => repository.read( 1 ) ).toThrowError( /not supported/i ); @@ -168,7 +192,7 @@ describe( 'ModelRepository', () => { null, null, callback, - null, + null ); const updated = await repository.update( 1, { name: 'new-name' } ); @@ -184,12 +208,16 @@ describe( 'ModelRepository', () => { null, null, callback, - null, + null ); - const updated = await repository.update( { parent: 'test' }, 1, { childName: 'new-name' } ); + const updated = await repository.update( { parent: 'test' }, 1, { + childName: 'new-name', + } ); expect( updated ).toBe( model ); - expect( callback ).toHaveBeenCalledWith( { parent: 'test' }, 1, { childName: 'new-name' } ); + expect( callback ).toHaveBeenCalledWith( { parent: 'test' }, 1, { + childName: 'new-name', + } ); } ); it( 'should throw error on update without callback', () => { @@ -198,10 +226,12 @@ describe( 'ModelRepository', () => { null, null, null, - null, + null ); - expect( () => repository.update( 1, { name: 'new-name' } ) ).toThrowError( /not supported/i ); + expect( () => + repository.update( 1, { name: 'new-name' } ) + ).toThrowError( /not supported/i ); } ); it( 'should delete', async () => { @@ -211,7 +241,7 @@ describe( 'ModelRepository', () => { null, null, null, - callback, + callback ); const success = await repository.delete( 1 ); @@ -226,7 +256,7 @@ describe( 'ModelRepository', () => { null, null, null, - callback, + callback ); const success = await repository.delete( { parent: 'yes' }, 1 ); @@ -240,7 +270,7 @@ describe( 'ModelRepository', () => { null, null, null, - null, + null ); expect( () => repository.delete( 1 ) ).toThrowError( /not supported/i ); diff --git a/packages/js/api/src/framework/__tests__/model-transformer.spec.ts b/packages/js/api/src/framework/__tests__/model-transformer.spec.ts index de29e834f79..ad39d875a58 100644 --- a/packages/js/api/src/framework/__tests__/model-transformer.spec.ts +++ b/packages/js/api/src/framework/__tests__/model-transformer.spec.ts @@ -35,15 +35,15 @@ describe( 'ModelTransformer', () => { const fn2 = jest.fn(); fn2.mockReturnValue( { name: 'fn2' } ); - const transformer = new ModelTransformer< DummyModel >( - [ - // Ensure the orders are backwards so sorting is tested. - new DummyTransformation( 1, fn2 ), - new DummyTransformation( 0, fn1 ), - ], - ); + const transformer = new ModelTransformer< DummyModel >( [ + // Ensure the orders are backwards so sorting is tested. + new DummyTransformation( 1, fn2 ), + new DummyTransformation( 0, fn1 ), + ] ); - let transformed = transformer.fromModel( new DummyModel( { name: 'fn0' } ) ); + let transformed = transformer.fromModel( + new DummyModel( { name: 'fn0' } ) + ); expect( fn1 ).toHaveBeenCalledWith( { name: 'fn0' } ); expect( fn2 ).toHaveBeenCalledWith( { name: 'fn1' } ); @@ -61,17 +61,12 @@ describe( 'ModelTransformer', () => { } ); it( 'should transform to model', () => { - const transformer = new ModelTransformer< DummyModel >( - [ - new DummyTransformation( - 0, - ( p: any ) => { - p.name = 'Transformed-' + p.name; - return p; - }, - ), - ], - ); + const transformer = new ModelTransformer< DummyModel >( [ + new DummyTransformation( 0, ( p: any ) => { + p.name = 'Transformed-' + p.name; + return p; + } ), + ] ); const model = transformer.toModel( DummyModel, { name: 'Test' } ); @@ -80,19 +75,16 @@ describe( 'ModelTransformer', () => { } ); it( 'should transform from model', () => { - const transformer = new ModelTransformer< DummyModel >( - [ - new DummyTransformation( - 0, - ( p: any ) => { - p.name = 'Transformed-' + p.name; - return p; - }, - ), - ], - ); + const transformer = new ModelTransformer< DummyModel >( [ + new DummyTransformation( 0, ( p: any ) => { + p.name = 'Transformed-' + p.name; + return p; + } ), + ] ); - const transformed = transformer.fromModel( new DummyModel( { name: 'Test' } ) ); + const transformed = transformer.fromModel( + new DummyModel( { name: 'Test' } ) + ); expect( transformed ).not.toBeInstanceOf( DummyModel ); expect( transformed.name ).toEqual( 'Transformed-Test' ); diff --git a/packages/js/api/src/framework/model-repository.ts b/packages/js/api/src/framework/model-repository.ts index 9c30993a83a..c6ed77a1f5b 100644 --- a/packages/js/api/src/framework/model-repository.ts +++ b/packages/js/api/src/framework/model-repository.ts @@ -7,7 +7,7 @@ import { Model, ModelID } from '../models'; * @alias Object. */ export interface ModelParentID { - [ key: number ]: ModelID + [ key: number ]: ModelID; } /** @@ -21,8 +21,8 @@ export interface ModelRepositoryParams< // @ts-ignore ListParams = never, // @ts-ignore - UpdateParams extends keyof T = never, - > { + UpdateParams extends keyof T = never +> { // Since TypeScript's type system is structural we need to add something to this type to prevent // it from matching with everything else (since it is an empty interface). thisTypeIsDeclarativeOnly: string; @@ -31,14 +31,34 @@ export interface ModelRepositoryParams< /** * These helpers will extract information about a model from its repository params to be used in the repository. */ -export type ModelClass< T extends ModelRepositoryParams > = [ T ] extends [ ModelRepositoryParams< infer X > ] ? X : never; -export type ParentID< T extends ModelRepositoryParams > = [ T ] extends [ ModelRepositoryParams< any, infer X > ] ? X : never; -export type HasParent< T extends ModelRepositoryParams, P, C > = [ ParentID< T > ] extends [ never ] ? C : P; -type ListParams< T extends ModelRepositoryParams > = [ T ] extends [ ModelRepositoryParams< any, any, infer X > ] ? X : never; -type PickUpdateParams = { [P in K]?: T[P]; }; -type UpdateParams< T extends ModelRepositoryParams > = [ T ] extends [ ModelRepositoryParams< infer C, any, any, infer X > ] ? - ( [ X ] extends [ keyof C ] ? PickUpdateParams< C, X > : never ) : - never; +export type ModelClass< T extends ModelRepositoryParams > = [ T ] extends [ + ModelRepositoryParams< infer X > +] + ? X + : never; +export type ParentID< T extends ModelRepositoryParams > = [ T ] extends [ + ModelRepositoryParams< any, infer X > +] + ? X + : never; +export type HasParent< T extends ModelRepositoryParams, P, C > = [ + ParentID< T > +] extends [ never ] + ? C + : P; +type ListParams< T extends ModelRepositoryParams > = [ T ] extends [ + ModelRepositoryParams< any, any, infer X > +] + ? X + : never; +type PickUpdateParams< T, K extends keyof T > = { [ P in K ]?: T[ P ] }; +type UpdateParams< T extends ModelRepositoryParams > = [ T ] extends [ + ModelRepositoryParams< infer C, any, any, infer X > +] + ? [ X ] extends [ keyof C ] + ? PickUpdateParams< C, X > + : never + : never; /** * A callback for listing models using a data source. @@ -49,7 +69,9 @@ type UpdateParams< T extends ModelRepositoryParams > = [ T ] extends [ ModelRepo * @template {Model} T * @template L */ -export type ListFn< T extends ModelRepositoryParams > = ( params?: ListParams< T > ) => Promise< ModelClass< T >[] >; +export type ListFn< T extends ModelRepositoryParams > = ( + params?: ListParams< T > +) => Promise< ModelClass< T >[] >; /** * A callback for listing child models using a data source. @@ -75,7 +97,9 @@ export type ListChildFn< T extends ModelRepositoryParams > = ( * @return {Promise.} Resolves to the created model. * @template {Model} T */ -export type CreateFn< T extends ModelRepositoryParams > = ( properties: Partial< ModelClass< T > > ) => Promise< ModelClass< T > >; +export type CreateFn< T extends ModelRepositoryParams > = ( + properties: Partial< ModelClass< T > > +) => Promise< ModelClass< T > >; /** * A callback for creating a child model using a data source. @@ -99,7 +123,9 @@ export type CreateChildFn< T extends ModelRepositoryParams > = ( * @return {Promise.} Resolves to the read model. * @template {Model} T */ -export type ReadFn< T extends ModelRepositoryParams > = ( id: ModelID ) => Promise< ModelClass< T > >; +export type ReadFn< T extends ModelRepositoryParams > = ( + id: ModelID +) => Promise< ModelClass< T > >; /** * A callback for reading a child model using a data source. @@ -111,7 +137,10 @@ export type ReadFn< T extends ModelRepositoryParams > = ( id: ModelID ) => Promi * @template {Model} T * @template {ModelParentID} P */ -export type ReadChildFn< T extends ModelRepositoryParams > = ( parent: ParentID< T >, childID: ModelID ) => Promise< ModelClass< T > >; +export type ReadChildFn< T extends ModelRepositoryParams > = ( + parent: ParentID< T >, + childID: ModelID +) => Promise< ModelClass< T > >; /** * A callback for updating a model using a data source. @@ -124,7 +153,7 @@ export type ReadChildFn< T extends ModelRepositoryParams > = ( parent: ParentID< */ export type UpdateFn< T extends ModelRepositoryParams > = ( id: ModelID, - properties: UpdateParams< T >, + properties: UpdateParams< T > ) => Promise< ModelClass< T > >; /** @@ -141,7 +170,7 @@ export type UpdateFn< T extends ModelRepositoryParams > = ( export type UpdateChildFn< T extends ModelRepositoryParams > = ( parent: ParentID< T >, childID: ModelID, - properties: UpdateParams< T >, + properties: UpdateParams< T > ) => Promise< ModelClass< T > >; /** @@ -162,7 +191,10 @@ export type DeleteFn = ( id: ModelID ) => Promise< boolean >; * @return {Promise.} Resolves to true once the model has been deleted. * @template {ModelParentID} P */ -export type DeleteChildFn< T extends ModelRepositoryParams > = ( parent: ParentID< T >, childID: ModelID ) => Promise< boolean >; +export type DeleteChildFn< T extends ModelRepositoryParams > = ( + parent: ParentID< T >, + childID: ModelID +) => Promise< boolean >; /** * An interface for repositories that can list models. @@ -173,7 +205,9 @@ export type DeleteChildFn< T extends ModelRepositoryParams > = ( parent: ParentI * @template L */ export interface ListsModels< T extends ModelRepositoryParams > { - list( params?: HasParent< T, never, ListParams< T > > ): Promise< ModelClass< T >[] >; + list( + params?: HasParent< T, never, ListParams< T > > + ): Promise< ModelClass< T >[] >; } /** @@ -188,7 +222,7 @@ export interface ListsModels< T extends ModelRepositoryParams > { export interface ListsChildModels< T extends ModelRepositoryParams > { list( parent: HasParent< T, ParentID< T >, never >, - params?: HasParent< T, ListParams< T >, never >, + params?: HasParent< T, ListParams< T >, never > ): Promise< ModelClass< T >[] >; } @@ -200,7 +234,9 @@ export interface ListsChildModels< T extends ModelRepositoryParams > { * @template {Model} T */ export interface CreatesModels< T extends ModelRepositoryParams > { - create( properties: Partial< ModelClass< T > > ): Promise< ModelClass< T > >; + create( + properties: Partial< ModelClass< T > > + ): Promise< ModelClass< T > >; } /** @@ -214,7 +250,7 @@ export interface CreatesModels< T extends ModelRepositoryParams > { export interface CreatesChildModels< T extends ModelRepositoryParams > { create( parent: HasParent< T, ParentID< T >, never >, - properties: HasParent< T, Partial< ModelClass< T > >, never >, + properties: HasParent< T, Partial< ModelClass< T > >, never > ): Promise< ModelClass< T > >; } @@ -240,7 +276,7 @@ export interface ReadsModels< T extends ModelRepositoryParams > { export interface ReadsChildModels< T extends ModelRepositoryParams > { read( parent: HasParent< T, ParentID< T >, never >, - childID: HasParent< T, ModelID, never >, + childID: HasParent< T, ModelID, never > ): Promise< ModelClass< T > >; } @@ -254,7 +290,7 @@ export interface ReadsChildModels< T extends ModelRepositoryParams > { export interface UpdatesModels< T extends ModelRepositoryParams > { update( id: HasParent< T, never, ModelID >, - properties: HasParent< T, never, UpdateParams< T > >, + properties: HasParent< T, never, UpdateParams< T > > ): Promise< ModelClass< T > >; } @@ -270,7 +306,7 @@ export interface UpdatesChildModels< T extends ModelRepositoryParams > { update( parent: HasParent< T, ParentID< T >, never >, childID: HasParent< T, ModelID, never >, - properties: HasParent< T, UpdateParams< T >, never >, + properties: HasParent< T, UpdateParams< T >, never > ): Promise< ModelClass< T > >; } @@ -294,7 +330,7 @@ export interface DeletesModels< T extends ModelRepositoryParams > { export interface DeletesChildModels< T extends ModelRepositoryParams > { delete( parent: HasParent< T, ParentID< T >, never >, - childID: HasParent< T, ModelID, never >, + childID: HasParent< T, ModelID, never > ): Promise< boolean >; } @@ -307,22 +343,27 @@ export interface DeletesChildModels< T extends ModelRepositoryParams > { * @template {ModelParentID} P * @template {Object} L */ -export class ModelRepository< T extends ModelRepositoryParams > implements - ListsModels< T >, - ListsChildModels< T >, - ReadsModels< T >, - ReadsChildModels< T >, - UpdatesModels< T >, - UpdatesChildModels< T >, - DeletesModels< T >, - DeletesChildModels< T > { +export class ModelRepository< T extends ModelRepositoryParams > +implements + ListsModels< T >, + ListsChildModels< T >, + ReadsModels< T >, + ReadsChildModels< T >, + UpdatesModels< T >, + UpdatesChildModels< T >, + DeletesModels< T >, + DeletesChildModels< T > { /** * The hook used to list models. * * @type {ListFn.|ListChildFn} * @private */ - private readonly listHook: HasParent< T, ListChildFn< T >, ListFn< T > > | null; + private readonly listHook: HasParent< + T, + ListChildFn< T >, + ListFn< T > + > | null; /** * The hook used to create models @@ -330,7 +371,11 @@ export class ModelRepository< T extends ModelRepositoryParams > implements * @type {CreateFn.} * @private */ - private readonly createHook: HasParent< T, CreateChildFn< T >, CreateFn< T > > | null; + private readonly createHook: HasParent< + T, + CreateChildFn< T >, + CreateFn< T > + > | null; /** * The hook used to read models. @@ -338,7 +383,11 @@ export class ModelRepository< T extends ModelRepositoryParams > implements * @type {ReadFn.|ReadChildFn.} * @private */ - private readonly readHook: HasParent< T, ReadChildFn< T >, ReadFn< T > > | null; + private readonly readHook: HasParent< + T, + ReadChildFn< T >, + ReadFn< T > + > | null; /** * The hook used to update models. @@ -346,7 +395,11 @@ export class ModelRepository< T extends ModelRepositoryParams > implements * @type {UpdateFn.|UpdateChildFn.} * @private */ - private readonly updateHook: HasParent< T, UpdateChildFn< T >, UpdateFn< T > > | null; + private readonly updateHook: HasParent< + T, + UpdateChildFn< T >, + UpdateFn< T > + > | null; /** * The hook used to delete models. @@ -354,7 +407,11 @@ export class ModelRepository< T extends ModelRepositoryParams > implements * @type {DeleteFn|DeleteChildFn.

} * @private */ - private readonly deleteHook: HasParent< T, DeleteChildFn< T >, DeleteFn > | null; + private readonly deleteHook: HasParent< + T, + DeleteChildFn< T >, + DeleteFn + > | null; /** * Creates a new repository instance. @@ -370,7 +427,7 @@ export class ModelRepository< T extends ModelRepositoryParams > implements createHook: HasParent< T, CreateChildFn< T >, CreateFn< T > > | null, readHook: HasParent< T, ReadChildFn< T >, ReadFn< T > > | null, updateHook: HasParent< T, UpdateChildFn< T >, UpdateFn< T > > | null, - deleteHook: HasParent< T, DeleteChildFn< T >, DeleteFn > | null, + deleteHook: HasParent< T, DeleteChildFn< T >, DeleteFn > | null ) { this.listHook = listHook; this.createHook = createHook; @@ -388,21 +445,23 @@ export class ModelRepository< T extends ModelRepositoryParams > implements */ public list( paramsOrParent?: HasParent< T, ParentID< T >, ListParams< T > >, - params?: HasParent< T, ListParams< T >, never >, + params?: HasParent< T, ListParams< T >, never > ): Promise< ModelClass< T >[] > { if ( ! this.listHook ) { - throw new Error( 'The \'list\' operation is not supported on this model.' ); + throw new Error( + "The 'list' operation is not supported on this model." + ); } if ( params === undefined ) { return ( this.listHook as ListFn< T > )( - paramsOrParent as ListParams< T >, + paramsOrParent as ListParams< T > ); } return ( this.listHook as ListChildFn< T > )( ( paramsOrParent as unknown ) as ParentID< T >, - params, + params ); } @@ -414,22 +473,28 @@ export class ModelRepository< T extends ModelRepositoryParams > implements * @return {Promise.} Resolves to the created model. */ public create( - propertiesOrParent?: HasParent< T, ParentID< T >, Partial< ModelClass > >, - properties?: HasParent< T, Partial< ModelClass >, never >, + propertiesOrParent?: HasParent< + T, + ParentID< T >, + Partial< ModelClass< T > > + >, + properties?: HasParent< T, Partial< ModelClass< T > >, never > ): Promise< ModelClass< T > > { if ( ! this.createHook ) { - throw new Error( 'The \'create\' operation is not supported on this model.' ); + throw new Error( + "The 'create' operation is not supported on this model." + ); } if ( properties === undefined ) { return ( this.createHook as CreateFn< T > )( - propertiesOrParent as Partial< ModelClass >, + propertiesOrParent as Partial< ModelClass< T > > ); } return ( this.createHook as CreateChildFn< T > )( - ( propertiesOrParent as unknown ) as ParentID, - properties as Partial< ModelClass >, + ( propertiesOrParent as unknown ) as ParentID< T >, + properties as Partial< ModelClass< T > > ); } @@ -442,21 +507,21 @@ export class ModelRepository< T extends ModelRepositoryParams > implements */ public read( idOrParent: HasParent< T, ParentID< T >, ModelID >, - childID?: HasParent< T, ModelID, never >, + childID?: HasParent< T, ModelID, never > ): Promise< ModelClass< T > > { if ( ! this.readHook ) { - throw new Error( 'The \'read\' operation is not supported on this model.' ); + throw new Error( + "The 'read' operation is not supported on this model." + ); } if ( childID === undefined ) { - return ( this.readHook as ReadFn< T > )( - idOrParent as ModelID, - ); + return ( this.readHook as ReadFn< T > )( idOrParent as ModelID ); } return ( this.readHook as ReadChildFn< T > )( ( idOrParent as unknown ) as ParentID< T >, - childID, + childID ); } @@ -471,23 +536,25 @@ export class ModelRepository< T extends ModelRepositoryParams > implements public update( idOrParent: HasParent< T, ParentID< T >, ModelID >, propertiesOrChildID: HasParent< T, ModelID, UpdateParams< T > >, - properties?: HasParent< T, UpdateParams< T >, never >, + properties?: HasParent< T, UpdateParams< T >, never > ): Promise< ModelClass< T > > { if ( ! this.updateHook ) { - throw new Error( 'The \'update\' operation is not supported on this model.' ); + throw new Error( + "The 'update' operation is not supported on this model." + ); } if ( properties === undefined ) { return ( this.updateHook as UpdateFn< T > )( idOrParent as ModelID, - ( propertiesOrChildID as unknown ) as UpdateParams< T >, + ( propertiesOrChildID as unknown ) as UpdateParams< T > ); } return ( this.updateHook as UpdateChildFn< T > )( ( idOrParent as unknown ) as ParentID< T >, propertiesOrChildID as ModelID, - ( properties as unknown ) as UpdateParams< T >, + ( properties as unknown ) as UpdateParams< T > ); } @@ -500,21 +567,21 @@ export class ModelRepository< T extends ModelRepositoryParams > implements */ public delete( idOrParent: HasParent< T, ParentID< T >, ModelID >, - childID?: HasParent< T, ModelID, never >, + childID?: HasParent< T, ModelID, never > ): Promise< boolean > { if ( ! this.deleteHook ) { - throw new Error( 'The \'delete\' operation is not supported on this model.' ); + throw new Error( + "The 'delete' operation is not supported on this model." + ); } if ( childID === undefined ) { - return ( this.deleteHook as DeleteFn )( - idOrParent as ModelID, - ); + return ( this.deleteHook as DeleteFn )( idOrParent as ModelID ); } return ( this.deleteHook as DeleteChildFn< T > )( ( idOrParent as unknown ) as ParentID< T >, - childID, + childID ); } } diff --git a/packages/js/api/src/framework/model-transformer.ts b/packages/js/api/src/framework/model-transformer.ts index 28556cafc6a..36899debc26 100644 --- a/packages/js/api/src/framework/model-transformer.ts +++ b/packages/js/api/src/framework/model-transformer.ts @@ -45,7 +45,7 @@ export enum TransformationOrder { * A special value reserved for transformations that MUST come after all orders due to * the way that they destroy the property keys or values. */ - VeryLast = 2000000 + VeryLast = 2000000, } /** @@ -67,7 +67,9 @@ export class ModelTransformer< T extends Model > { */ public constructor( transformations: ModelTransformation[] ) { // Ensure that the transformations are sorted by priority. - transformations.sort( ( a, b ) => ( a.fromModelOrder > b.fromModelOrder ) ? 1 : -1 ); + transformations.sort( ( a, b ) => + a.fromModelOrder > b.fromModelOrder ? 1 : -1 + ); this.transformations = transformations; } @@ -87,7 +89,7 @@ export class ModelTransformer< T extends Model > { ( properties: any, transformer: ModelTransformation ) => { return transformer.fromModel( properties ); }, - raw, + raw ); } @@ -104,7 +106,7 @@ export class ModelTransformer< T extends Model > { ( properties: any, transformer: ModelTransformation ) => { return transformer.toModel( properties ); }, - data, + data ); return new modelClass( transformed ); diff --git a/packages/js/api/src/framework/transformations/__tests__/add-property-transformation.spec.ts b/packages/js/api/src/framework/transformations/__tests__/add-property-transformation.spec.ts index 2455376d55b..a3c697503dc 100644 --- a/packages/js/api/src/framework/transformations/__tests__/add-property-transformation.spec.ts +++ b/packages/js/api/src/framework/transformations/__tests__/add-property-transformation.spec.ts @@ -6,51 +6,51 @@ describe( 'AddPropertyTransformation', () => { beforeEach( () => { transformation = new AddPropertyTransformation( { toProperty: 'Test' }, - { fromProperty: 'Test' }, + { fromProperty: 'Test' } ); } ); it( 'should add property when missing', () => { let transformed = transformation.toModel( { id: 1, name: 'Test' } ); - expect( transformed ).toMatchObject( - { - id: 1, - name: 'Test', - toProperty: 'Test', - }, - ); + expect( transformed ).toMatchObject( { + id: 1, + name: 'Test', + toProperty: 'Test', + } ); transformed = transformation.fromModel( { id: 1, name: 'Test' } ); - expect( transformed ).toMatchObject( - { - id: 1, - name: 'Test', - fromProperty: 'Test', - }, - ); + expect( transformed ).toMatchObject( { + id: 1, + name: 'Test', + fromProperty: 'Test', + } ); } ); it( 'should not add property when present', () => { - let transformed = transformation.toModel( { id: 1, name: 'Test', toProperty: 'Existing' } ); + let transformed = transformation.toModel( { + id: 1, + name: 'Test', + toProperty: 'Existing', + } ); - expect( transformed ).toMatchObject( - { - id: 1, - name: 'Test', - toProperty: 'Existing', - }, - ); + expect( transformed ).toMatchObject( { + id: 1, + name: 'Test', + toProperty: 'Existing', + } ); - transformed = transformation.fromModel( { id: 1, name: 'Test', fromProperty: 'Existing' } ); + transformed = transformation.fromModel( { + id: 1, + name: 'Test', + fromProperty: 'Existing', + } ); - expect( transformed ).toMatchObject( - { - id: 1, - name: 'Test', - fromProperty: 'Existing', - }, - ); + expect( transformed ).toMatchObject( { + id: 1, + name: 'Test', + fromProperty: 'Existing', + } ); } ); } ); diff --git a/packages/js/api/src/framework/transformations/__tests__/custom-transformation.spec.ts b/packages/js/api/src/framework/transformations/__tests__/custom-transformation.spec.ts index 1384ae47fe2..91e44bc8ff1 100644 --- a/packages/js/api/src/framework/transformations/__tests__/custom-transformation.spec.ts +++ b/packages/js/api/src/framework/transformations/__tests__/custom-transformation.spec.ts @@ -7,7 +7,9 @@ describe( 'CustomTransformation', () => { const expected = { test: 'Test' }; expect( transformation.toModel( expected ) ).toMatchObject( expected ); - expect( transformation.fromModel( expected ) ).toMatchObject( expected ); + expect( transformation.fromModel( expected ) ).toMatchObject( + expected + ); } ); it( 'should execute hooks', () => { @@ -18,9 +20,13 @@ describe( 'CustomTransformation', () => { const transformation = new CustomTransformation( 0, toHook, fromHook ); - expect( transformation.toModel( { test: 'Test' } ) ).toMatchObject( { toModel: 'Test' } ); + expect( transformation.toModel( { test: 'Test' } ) ).toMatchObject( { + toModel: 'Test', + } ); expect( toHook ).toHaveBeenCalledWith( { test: 'Test' } ); - expect( transformation.fromModel( { test: 'Test' } ) ).toMatchObject( { fromModel: 'Test' } ); + expect( transformation.fromModel( { test: 'Test' } ) ).toMatchObject( { + fromModel: 'Test', + } ); expect( fromHook ).toHaveBeenCalledWith( { test: 'Test' } ); } ); } ); diff --git a/packages/js/api/src/framework/transformations/__tests__/ignore-property-transformation.spec.ts b/packages/js/api/src/framework/transformations/__tests__/ignore-property-transformation.spec.ts index 2e0895e42fd..ef6a3790ef1 100644 --- a/packages/js/api/src/framework/transformations/__tests__/ignore-property-transformation.spec.ts +++ b/packages/js/api/src/framework/transformations/__tests__/ignore-property-transformation.spec.ts @@ -8,22 +8,18 @@ describe( 'IgnorePropertyTransformation', () => { } ); it( 'should remove ignored properties', () => { - let transformed = transformation.fromModel( - { - test: 'Test', - skip: 'Test', - }, - ); + let transformed = transformation.fromModel( { + test: 'Test', + skip: 'Test', + } ); expect( transformed ).toHaveProperty( 'test', 'Test' ); expect( transformed ).not.toHaveProperty( 'skip' ); - transformed = transformation.toModel( - { - test: 'Test', - skip: 'Test', - }, - ); + transformed = transformation.toModel( { + test: 'Test', + skip: 'Test', + } ); expect( transformed ).toHaveProperty( 'test', 'Test' ); expect( transformed ).not.toHaveProperty( 'skip' ); diff --git a/packages/js/api/src/framework/transformations/__tests__/key-change-transformation.spec.ts b/packages/js/api/src/framework/transformations/__tests__/key-change-transformation.spec.ts index 25fd9423dd8..4ab9563ae2b 100644 --- a/packages/js/api/src/framework/transformations/__tests__/key-change-transformation.spec.ts +++ b/packages/js/api/src/framework/transformations/__tests__/key-change-transformation.spec.ts @@ -5,15 +5,15 @@ describe( 'KeyChangeTransformation', () => { let transformation: KeyChangeTransformation< DummyModel >; beforeEach( () => { - transformation = new KeyChangeTransformation< DummyModel >( - { - name: 'new-name', - }, - ); + transformation = new KeyChangeTransformation< DummyModel >( { + name: 'new-name', + } ); } ); it( 'should transform to model', () => { - const transformed = transformation.toModel( { 'new-name': 'Test Name' } ); + const transformed = transformation.toModel( { + 'new-name': 'Test Name', + } ); expect( transformed ).toHaveProperty( 'name', 'Test Name' ); expect( transformed ).not.toHaveProperty( 'new-name' ); diff --git a/packages/js/api/src/framework/transformations/__tests__/model-transformer-transformation.spec.ts b/packages/js/api/src/framework/transformations/__tests__/model-transformer-transformation.spec.ts index 6e5834aaeac..766146917c0 100644 --- a/packages/js/api/src/framework/transformations/__tests__/model-transformer-transformation.spec.ts +++ b/packages/js/api/src/framework/transformations/__tests__/model-transformer-transformation.spec.ts @@ -1,4 +1,4 @@ -import { mocked } from 'ts-jest/utils' +import { mocked } from 'ts-jest/utils'; import { ModelTransformerTransformation } from '../model-transformer-transformation'; import { ModelTransformer } from '../../model-transformer'; import { DummyModel } from '../../../__test_data__/dummy-model'; @@ -14,19 +14,26 @@ describe( 'ModelTransformerTransformation', () => { transformation = new ModelTransformerTransformation< DummyModel >( 'test', DummyModel, - propertyTransformer, + propertyTransformer ); } ); it( 'should execute child transformer', () => { - mocked( propertyTransformer.toModel ).mockReturnValue( { toModel: 'Test' } ); + mocked( propertyTransformer.toModel ).mockReturnValue( { + toModel: 'Test', + } ); let transformed = transformation.toModel( { test: 'Test' } ); expect( transformed ).toMatchObject( { test: { toModel: 'Test' } } ); - expect( propertyTransformer.toModel ).toHaveBeenCalledWith( DummyModel, 'Test' ); + expect( propertyTransformer.toModel ).toHaveBeenCalledWith( + DummyModel, + 'Test' + ); - mocked( propertyTransformer.fromModel ).mockReturnValue( { fromModel: 'Test' } ); + mocked( propertyTransformer.fromModel ).mockReturnValue( { + fromModel: 'Test', + } ); transformed = transformation.fromModel( { test: 'Test' } ); @@ -35,19 +42,35 @@ describe( 'ModelTransformerTransformation', () => { } ); it( 'should execute child transformer on array', () => { - mocked( propertyTransformer.toModel ).mockReturnValue( { toModel: 'Test' } ); + mocked( propertyTransformer.toModel ).mockReturnValue( { + toModel: 'Test', + } ); - let transformed = transformation.toModel( { test: [ 'Test', 'Test2' ] } ); + let transformed = transformation.toModel( { + test: [ 'Test', 'Test2' ], + } ); - expect( transformed ).toMatchObject( { test: [ { toModel: 'Test' }, { toModel: 'Test' } ] } ); - expect( propertyTransformer.toModel ).toHaveBeenCalledWith( DummyModel, 'Test' ); - expect( propertyTransformer.toModel ).toHaveBeenCalledWith( DummyModel, 'Test2' ); + expect( transformed ).toMatchObject( { + test: [ { toModel: 'Test' }, { toModel: 'Test' } ], + } ); + expect( propertyTransformer.toModel ).toHaveBeenCalledWith( + DummyModel, + 'Test' + ); + expect( propertyTransformer.toModel ).toHaveBeenCalledWith( + DummyModel, + 'Test2' + ); - mocked( propertyTransformer.fromModel ).mockReturnValue( { fromModel: 'Test' } ); + mocked( propertyTransformer.fromModel ).mockReturnValue( { + fromModel: 'Test', + } ); transformed = transformation.fromModel( { test: [ 'Test', 'Test2' ] } ); - expect( transformed ).toMatchObject( { test: [ { fromModel: 'Test' }, { fromModel: 'Test' } ] } ); + expect( transformed ).toMatchObject( { + test: [ { fromModel: 'Test' }, { fromModel: 'Test' } ], + } ); expect( propertyTransformer.fromModel ).toHaveBeenCalledWith( 'Test' ); expect( propertyTransformer.fromModel ).toHaveBeenCalledWith( 'Test2' ); } ); diff --git a/packages/js/api/src/framework/transformations/__tests__/property-type-transformation.spec.ts b/packages/js/api/src/framework/transformations/__tests__/property-type-transformation.spec.ts index ca89491b1f7..b5c92a9965b 100644 --- a/packages/js/api/src/framework/transformations/__tests__/property-type-transformation.spec.ts +++ b/packages/js/api/src/framework/transformations/__tests__/property-type-transformation.spec.ts @@ -1,19 +1,20 @@ -import { PropertyType, PropertyTypeTransformation } from '../property-type-transformation'; +import { + PropertyType, + PropertyTypeTransformation, +} from '../property-type-transformation'; describe( 'PropertyTypeTransformation', () => { let transformation: PropertyTypeTransformation; beforeEach( () => { - transformation = new PropertyTypeTransformation( - { - string: PropertyType.String, - integer: PropertyType.Integer, - float: PropertyType.Float, - boolean: PropertyType.Boolean, - date: PropertyType.Date, - callback: ( value: string ) => 'Transformed-' + value, - }, - ); + transformation = new PropertyTypeTransformation( { + string: PropertyType.String, + integer: PropertyType.Integer, + float: PropertyType.Float, + boolean: PropertyType.Boolean, + date: PropertyType.Date, + callback: ( value: string ) => 'Transformed-' + value, + } ); } ); it( 'should convert strings', () => { @@ -57,11 +58,17 @@ describe( 'PropertyTypeTransformation', () => { } ); it( 'should convert dates', () => { - let transformed = transformation.toModel( { date: '2020-11-06T03:11:41.000Z' } ); + let transformed = transformation.toModel( { + date: '2020-11-06T03:11:41.000Z', + } ); - expect( transformed.date ).toStrictEqual( new Date( '2020-11-06T03:11:41.000Z' ) ); + expect( transformed.date ).toStrictEqual( + new Date( '2020-11-06T03:11:41.000Z' ) + ); - transformed = transformation.fromModel( { date: new Date( '2020-11-06T03:11:41.000Z' ) } ); + transformed = transformation.fromModel( { + date: new Date( '2020-11-06T03:11:41.000Z' ), + } ); expect( transformed.date ).toStrictEqual( '2020-11-06T03:11:41.000Z' ); } ); @@ -77,11 +84,15 @@ describe( 'PropertyTypeTransformation', () => { } ); it( 'should convert arrays', () => { - let transformed = transformation.toModel( { integer: [ '100', '200', '300' ] } ); + let transformed = transformation.toModel( { + integer: [ '100', '200', '300' ], + } ); expect( transformed.integer ).toStrictEqual( [ 100, 200, 300 ] ); - transformed = transformation.fromModel( { integer: [ 100, 200, 300 ] } ); + transformed = transformation.fromModel( { + integer: [ 100, 200, 300 ], + } ); expect( transformed.integer ).toStrictEqual( [ '100', '200', '300' ] ); } ); diff --git a/packages/js/api/src/framework/transformations/add-property-transformation.ts b/packages/js/api/src/framework/transformations/add-property-transformation.ts index 89873c1f64b..42fd52a1a2a 100644 --- a/packages/js/api/src/framework/transformations/add-property-transformation.ts +++ b/packages/js/api/src/framework/transformations/add-property-transformation.ts @@ -35,7 +35,10 @@ export class AddPropertyTransformation implements ModelTransformation { * @param {AdditionalProperties} toProperties The properties to add when executing toModel. * @param {AdditionalProperties} fromProperties The properties to add when executing fromModel. */ - public constructor( toProperties: AdditionalProperties, fromProperties: AdditionalProperties ) { + public constructor( + toProperties: AdditionalProperties, + fromProperties: AdditionalProperties + ) { this.toProperties = toProperties; this.fromProperties = fromProperties; } diff --git a/packages/js/api/src/framework/transformations/custom-transformation.ts b/packages/js/api/src/framework/transformations/custom-transformation.ts index 32f9ccc425c..81e34f22e91 100644 --- a/packages/js/api/src/framework/transformations/custom-transformation.ts +++ b/packages/js/api/src/framework/transformations/custom-transformation.ts @@ -41,7 +41,7 @@ export class CustomTransformation implements ModelTransformation { public constructor( order: number, toHook: TransformationCallback | null, - fromHook: TransformationCallback | null, + fromHook: TransformationCallback | null ) { this.fromModelOrder = order; this.toHook = toHook; diff --git a/packages/js/api/src/framework/transformations/key-change-transformation.ts b/packages/js/api/src/framework/transformations/key-change-transformation.ts index 8c2c3b8be6d..7a0c61876c8 100644 --- a/packages/js/api/src/framework/transformations/key-change-transformation.ts +++ b/packages/js/api/src/framework/transformations/key-change-transformation.ts @@ -5,14 +5,17 @@ import { Model } from '../../models'; * @typedef KeyChanges * @alias Object. */ -type KeyChanges< T extends Model > = { readonly [ key in keyof Partial< T > ]: string }; +type KeyChanges< T extends Model > = { + readonly [ key in keyof Partial< T > ]: string; +}; /** * A model transformation that can be used to change property keys between two formats. * This transformation has a very high priority so that it will be executed after all * other transformations to prevent the changed key from causing problems. */ -export class KeyChangeTransformation< T extends Model > implements ModelTransformation { +export class KeyChangeTransformation< T extends Model > +implements ModelTransformation { /** * Ensure that this transformation always happens at the very end since it changes the keys * in the transformed object. diff --git a/packages/js/api/src/framework/transformations/model-transformer-transformation.ts b/packages/js/api/src/framework/transformations/model-transformer-transformation.ts index ba4b5415f74..b18dc5b0d37 100644 --- a/packages/js/api/src/framework/transformations/model-transformer-transformation.ts +++ b/packages/js/api/src/framework/transformations/model-transformer-transformation.ts @@ -1,4 +1,8 @@ -import { ModelTransformation, ModelTransformer, TransformationOrder } from '../model-transformer'; +import { + ModelTransformation, + ModelTransformer, + TransformationOrder, +} from '../model-transformer'; import { Model, ModelConstructor } from '../../models'; /** @@ -6,7 +10,8 @@ import { Model, ModelConstructor } from '../../models'; * * @template T */ -export class ModelTransformerTransformation< T extends Model > implements ModelTransformation { +export class ModelTransformerTransformation< T extends Model > +implements ModelTransformation { public readonly fromModelOrder = TransformationOrder.Normal; /** @@ -42,7 +47,15 @@ export class ModelTransformerTransformation< T extends Model > implements ModelT * @param {ModelTransformer} transformer The transformer we want to apply. * @template T */ - public constructor( property: string, modelClass: ModelConstructor< T >, transformer: ModelTransformer< T > ) { + public constructor( + property: string, + modelClass: ModelConstructor< T >, + transformer: ModelTransformer< T > + ) { + // Developer-friendly error to make sure this doesn't go unnoticed. + if ( property.includes( '_' ) ) { + throw new Error( 'The property must be camelCase' ); + } this.property = property; this.modelClass = modelClass; this.transformer = transformer; @@ -58,7 +71,9 @@ export class ModelTransformerTransformation< T extends Model > implements ModelT const val = properties[ this.property ]; if ( val ) { if ( Array.isArray( val ) ) { - properties[ this.property ] = val.map( ( v ) => this.transformer.fromModel( v ) ); + properties[ this.property ] = val.map( ( v ) => + this.transformer.fromModel( v ) + ); } else { properties[ this.property ] = this.transformer.fromModel( val ); } @@ -77,9 +92,14 @@ export class ModelTransformerTransformation< T extends Model > implements ModelT const val = properties[ this.property ]; if ( val ) { if ( Array.isArray( val ) ) { - properties[ this.property ] = val.map( ( v ) => this.transformer.toModel( this.modelClass, v ) ); + properties[ this.property ] = val.map( ( v ) => + this.transformer.toModel( this.modelClass, v ) + ); } else { - properties[ this.property ] = this.transformer.toModel( this.modelClass, val ); + properties[ this.property ] = this.transformer.toModel( + this.modelClass, + val + ); } } diff --git a/packages/js/api/src/framework/transformations/property-type-transformation.ts b/packages/js/api/src/framework/transformations/property-type-transformation.ts index 893857c723c..02a38859638 100644 --- a/packages/js/api/src/framework/transformations/property-type-transformation.ts +++ b/packages/js/api/src/framework/transformations/property-type-transformation.ts @@ -116,20 +116,29 @@ export class PropertyTypeTransformation implements ModelTransformation { * @return {*} The converted type. * @private */ - private convertTo( value: any, type: PropertyType ): PropertyTypeTypes | PropertyTypeTypes[] { + private convertTo( + value: any, + type: PropertyType + ): PropertyTypeTypes | PropertyTypeTypes[] { if ( Array.isArray( value ) ) { - return value.map( ( v: string ) => this.convertTo( v, type ) as PropertyTypeTypes ); + return value.map( + ( v: string ) => this.convertTo( v, type ) as PropertyTypeTypes + ); } - if ( null === value ) { + if ( value === null ) { return null; } switch ( type ) { - case PropertyType.String: return String( value ); - case PropertyType.Integer: return parseInt( value ); - case PropertyType.Float: return parseFloat( value ); - case PropertyType.Boolean: return Boolean( value ); + case PropertyType.String: + return String( value ); + case PropertyType.Integer: + return parseInt( value ); + case PropertyType.Float: + return parseFloat( value ); + case PropertyType.Boolean: + return Boolean( value ); case PropertyType.Date: return new Date( value ); } @@ -143,12 +152,15 @@ export class PropertyTypeTransformation implements ModelTransformation { * @return {*} The converted type. * @private */ - private convertFrom( value: PropertyTypeTypes | PropertyTypeTypes[], type: PropertyType ): any { + private convertFrom( + value: PropertyTypeTypes | PropertyTypeTypes[], + type: PropertyType + ): any { if ( Array.isArray( value ) ) { return value.map( ( v ) => this.convertFrom( v, type ) ); } - if ( null === value ) { + if ( value === null ) { return null; } diff --git a/packages/js/api/src/http/axios/__tests__/axios-interceptor.spec.ts b/packages/js/api/src/http/axios/__tests__/axios-interceptor.spec.ts index 496e23d4772..71f39d4877f 100644 --- a/packages/js/api/src/http/axios/__tests__/axios-interceptor.spec.ts +++ b/packages/js/api/src/http/axios/__tests__/axios-interceptor.spec.ts @@ -47,6 +47,8 @@ describe( 'AxiosInterceptor', () => { interceptor.start( axiosInstance ); } - await expect( axiosInstance.get( 'http://test.test' ) ).rejects.toBeInstanceOf( Error ); + await expect( + axiosInstance.get( 'http://test.test' ) + ).rejects.toBeInstanceOf( Error ); } ); } ); diff --git a/packages/js/api/src/http/axios/__tests__/axios-oauth-interceptor.spec.ts b/packages/js/api/src/http/axios/__tests__/axios-oauth-interceptor.spec.ts index 8999730e1d2..18c65b0e73a 100644 --- a/packages/js/api/src/http/axios/__tests__/axios-oauth-interceptor.spec.ts +++ b/packages/js/api/src/http/axios/__tests__/axios-oauth-interceptor.spec.ts @@ -12,7 +12,7 @@ describe( 'AxiosOAuthInterceptor', () => { adapter = new MockAdapter( axiosInstance ); apiAuthInterceptor = new AxiosOAuthInterceptor( 'consumer_key', - 'consumer_secret', + 'consumer_secret' ); apiAuthInterceptor.start( axiosInstance ); } ); @@ -39,7 +39,7 @@ describe( 'AxiosOAuthInterceptor', () => { // focus on ensuring that the header looks roughly correct given what we readily know. expect( response.config.headers! ).toHaveProperty( 'Authorization' ); expect( response.config.headers!.Authorization ).toMatch( - /^OAuth oauth_consumer_key="consumer_key".*oauth_signature_method="HMAC-SHA256".*oauth_version="1.0"/, + /^OAuth oauth_consumer_key="consumer_key".*oauth_signature_method="HMAC-SHA256".*oauth_version="1.0"/ ); } ); diff --git a/packages/js/api/src/http/axios/__tests__/axios-response-interceptor.spec.ts b/packages/js/api/src/http/axios/__tests__/axios-response-interceptor.spec.ts index 95e635d3bd8..a668d008aff 100644 --- a/packages/js/api/src/http/axios/__tests__/axios-response-interceptor.spec.ts +++ b/packages/js/api/src/http/axios/__tests__/axios-response-interceptor.spec.ts @@ -20,11 +20,13 @@ describe( 'AxiosResponseInterceptor', () => { } ); it( 'should transform responses into an HTTPResponse', async () => { - adapter.onGet( 'http://test.test' ).reply( - 200, - { test: 'value' }, - { 'content-type': 'application/json' } - ); + adapter + .onGet( 'http://test.test' ) + .reply( + 200, + { test: 'value' }, + { 'content-type': 'application/json' } + ); const response = await axiosInstance.get( 'http://test.test' ); @@ -40,13 +42,17 @@ describe( 'AxiosResponseInterceptor', () => { } ); it( 'should transform error responses into an HTTPResponse', async () => { - adapter.onGet( 'http://test.test' ).reply( - 404, - { code: 'error_code', message: 'value' }, - { 'content-type': 'application/json' } - ); + adapter + .onGet( 'http://test.test' ) + .reply( + 404, + { code: 'error_code', message: 'value' }, + { 'content-type': 'application/json' } + ); - await expect( axiosInstance.get( 'http://test.test' ) ).rejects.toMatchObject( { + await expect( + axiosInstance.get( 'http://test.test' ) + ).rejects.toMatchObject( { statusCode: 404, headers: { 'content-type': 'application/json', @@ -61,8 +67,8 @@ describe( 'AxiosResponseInterceptor', () => { it( 'should bubble non-response errors', async () => { adapter.onGet( 'http://test.test' ).timeout(); - await expect( axiosInstance.get( 'http://test.test' ) ).rejects.toMatchObject( - new Error( 'timeout of 0ms exceeded' ), - ); + await expect( + axiosInstance.get( 'http://test.test' ) + ).rejects.toMatchObject( new Error( 'timeout of 0ms exceeded' ) ); } ); } ); diff --git a/packages/js/api/src/http/axios/__tests__/axios-url-to-query-interceptor.spec.ts b/packages/js/api/src/http/axios/__tests__/axios-url-to-query-interceptor.spec.ts index af797a455f8..450e05b207b 100644 --- a/packages/js/api/src/http/axios/__tests__/axios-url-to-query-interceptor.spec.ts +++ b/packages/js/api/src/http/axios/__tests__/axios-url-to-query-interceptor.spec.ts @@ -20,16 +20,17 @@ describe( 'AxiosURLToQueryInterceptor', () => { } ); it( 'should put path in query string', async () => { - adapter.onGet( - 'http://test.test/', - { params: { test: '/test/route' } } - ).reply( - 200, - { test: 'value' }, - { 'content-type': 'application/json' } - ); + adapter + .onGet( 'http://test.test/', { params: { test: '/test/route' } } ) + .reply( + 200, + { test: 'value' }, + { 'content-type': 'application/json' } + ); - const response = await axiosInstance.get( 'http://test.test/test/route' ); + const response = await axiosInstance.get( + 'http://test.test/test/route' + ); expect( response.status ).toEqual( 200 ); } ); diff --git a/packages/js/api/src/http/axios/__tests__/utils.spec.ts b/packages/js/api/src/http/axios/__tests__/utils.spec.ts index 88affd6d7e3..99bc16100c5 100644 --- a/packages/js/api/src/http/axios/__tests__/utils.spec.ts +++ b/packages/js/api/src/http/axios/__tests__/utils.spec.ts @@ -7,12 +7,18 @@ describe( 'buildURL', () => { } ); it( 'should use url when given absolute', () => { - const url = buildURL( { baseURL: 'http://test.test', url: 'http://override.test' } ); + const url = buildURL( { + baseURL: 'http://test.test', + url: 'http://override.test', + } ); expect( url ).toBe( 'http://override.test' ); } ); it( 'should combine base and url', () => { - const url = buildURL( { baseURL: 'http://test.test', url: 'yes/test' } ); + const url = buildURL( { + baseURL: 'http://test.test', + url: 'yes/test', + } ); expect( url ).toBe( 'http://test.test/yes/test' ); } ); } ); @@ -24,7 +30,10 @@ describe( 'buildURLWithParams', () => { } ); it( 'should append query string', () => { - const url = buildURLWithParams( { baseURL: 'http://test.test', params: { test: 'yes' } } ); + const url = buildURLWithParams( { + baseURL: 'http://test.test', + params: { test: 'yes' }, + } ); expect( url ).toBe( 'http://test.test?test=yes' ); } ); } ); diff --git a/packages/js/api/src/http/axios/axios-client.ts b/packages/js/api/src/http/axios/axios-client.ts index 2c1945939eb..648318baa36 100644 --- a/packages/js/api/src/http/axios/axios-client.ts +++ b/packages/js/api/src/http/axios/axios-client.ts @@ -29,7 +29,10 @@ export class AxiosClient implements HTTPClient { * @param {AxiosRequestConfig} config The request configuration. * @param {AxiosInterceptor[]} extraInterceptors An array of additional interceptors to apply to the client. */ - public constructor( config: AxiosRequestConfig, extraInterceptors: AxiosInterceptor[] = [] ) { + public constructor( + config: AxiosRequestConfig, + extraInterceptors: AxiosInterceptor[] = [] + ) { this.client = axios.create( config ); this.interceptors = extraInterceptors; @@ -52,8 +55,8 @@ export class AxiosClient implements HTTPClient { */ public get< T = any >( path: string, - params?: object, - ): Promise< HTTPResponse< T >> { + params?: object + ): Promise< HTTPResponse< T > > { return this.client.get( path, { params } ); } @@ -66,8 +69,8 @@ export class AxiosClient implements HTTPClient { */ public post< T = any >( path: string, - data?: object, - ): Promise< HTTPResponse< T >> { + data?: object + ): Promise< HTTPResponse< T > > { return this.client.post( path, data ); } @@ -80,8 +83,8 @@ export class AxiosClient implements HTTPClient { */ public put< T = any >( path: string, - data?: object, - ): Promise< HTTPResponse< T >> { + data?: object + ): Promise< HTTPResponse< T > > { return this.client.put( path, data ); } @@ -94,8 +97,8 @@ export class AxiosClient implements HTTPClient { */ public patch< T = any >( path: string, - data?: object, - ): Promise< HTTPResponse< T >> { + data?: object + ): Promise< HTTPResponse< T > > { return this.client.patch( path, data ); } @@ -108,8 +111,8 @@ export class AxiosClient implements HTTPClient { */ public delete< T = any >( path: string, - data?: object, - ): Promise< HTTPResponse< T >> { + data?: object + ): Promise< HTTPResponse< T > > { return this.client.delete( path, { data } ); } } diff --git a/packages/js/api/src/http/axios/axios-interceptor.ts b/packages/js/api/src/http/axios/axios-interceptor.ts index 77f41e17f33..2da42c41ce8 100644 --- a/packages/js/api/src/http/axios/axios-interceptor.ts +++ b/packages/js/api/src/http/axios/axios-interceptor.ts @@ -12,7 +12,7 @@ type ActiveInterceptor = { client: AxiosInstance; requestInterceptorID: number; responseInterceptorID: number; -} +}; /** * A base class for encapsulating the start and stop functionality required by all Axios interceptors. @@ -33,13 +33,17 @@ export abstract class AxiosInterceptor { */ public start( client: AxiosInstance ): void { const requestInterceptorID = client.interceptors.request.use( - ( response ) => this.handleRequest( response ), + ( response ) => this.handleRequest( response ) ); const responseInterceptorID = client.interceptors.response.use( ( response ) => this.onResponseSuccess( response ), - ( error ) => this.onResponseRejected( error ), + ( error ) => this.onResponseRejected( error ) ); - this.activeInterceptors.push( { client, requestInterceptorID, responseInterceptorID } ); + this.activeInterceptors.push( { + client, + requestInterceptorID, + responseInterceptorID, + } ); } /** @@ -51,8 +55,12 @@ export abstract class AxiosInterceptor { for ( let i = this.activeInterceptors.length - 1; i >= 0; --i ) { const active = this.activeInterceptors[ i ]; if ( client === active.client ) { - client.interceptors.request.eject( active.requestInterceptorID ); - client.interceptors.response.eject( active.responseInterceptorID ); + client.interceptors.request.eject( + active.requestInterceptorID + ); + client.interceptors.response.eject( + active.responseInterceptorID + ); this.activeInterceptors.splice( i, 1 ); } } diff --git a/packages/js/api/src/http/axios/axios-oauth-interceptor.ts b/packages/js/api/src/http/axios/axios-oauth-interceptor.ts index f86aa3b9f8b..032778d49d6 100644 --- a/packages/js/api/src/http/axios/axios-oauth-interceptor.ts +++ b/packages/js/api/src/http/axios/axios-oauth-interceptor.ts @@ -32,7 +32,9 @@ export class AxiosOAuthInterceptor extends AxiosInterceptor { }, signature_method: 'HMAC-SHA256', hash_function: ( base: any, key: any ) => { - return createHmac( 'sha256', key ).update( base ).digest( 'base64' ); + return createHmac( 'sha256', key ) + .update( base ) + .digest( 'base64' ); }, } ); } @@ -55,7 +57,7 @@ export class AxiosOAuthInterceptor extends AxiosInterceptor { this.oauth.authorize( { url, method: request.method!, - } ), + } ) ).Authorization; } diff --git a/packages/js/api/src/http/axios/axios-response-interceptor.ts b/packages/js/api/src/http/axios/axios-response-interceptor.ts index 00c94459203..c1c3a5b242c 100644 --- a/packages/js/api/src/http/axios/axios-response-interceptor.ts +++ b/packages/js/api/src/http/axios/axios-response-interceptor.ts @@ -13,7 +13,11 @@ export class AxiosResponseInterceptor extends AxiosInterceptor { * @return {HTTPResponse} The HTTP response. */ protected onResponseSuccess( response: AxiosResponse ): HTTPResponse { - return new HTTPResponse( response.status, response.headers, response.data ); + return new HTTPResponse( + response.status, + response.headers, + response.data + ); } /** @@ -24,7 +28,11 @@ export class AxiosResponseInterceptor extends AxiosInterceptor { protected onResponseRejected( error: any ): never { // Convert HTTP response errors into a form that we can handle them with. if ( error.response ) { - throw new HTTPResponse( error.response.status, error.response.headers, error.response.data ); + throw new HTTPResponse( + error.response.status, + error.response.headers, + error.response.data + ); } throw error; diff --git a/packages/js/api/src/http/axios/utils.ts b/packages/js/api/src/http/axios/utils.ts index 09248825510..dcc8d91ed72 100644 --- a/packages/js/api/src/http/axios/utils.ts +++ b/packages/js/api/src/http/axios/utils.ts @@ -24,5 +24,9 @@ export function buildURL( request: AxiosRequestConfig ): string { * @return {string} The merged URL. */ export function buildURLWithParams( request: AxiosRequestConfig ): string { - return appendParams( buildURL( request ), request.params, request.paramsSerializer ); + return appendParams( + buildURL( request ), + request.params, + request.paramsSerializer + ); } diff --git a/packages/js/api/src/http/http-client-factory.ts b/packages/js/api/src/http/http-client-factory.ts index 82aea878879..ec4550239f1 100644 --- a/packages/js/api/src/http/http-client-factory.ts +++ b/packages/js/api/src/http/http-client-factory.ts @@ -8,23 +8,23 @@ import { AxiosURLToQueryInterceptor } from './axios/axios-url-to-query-intercept * These types describe the shape of the different auth methods our factory supports. */ type OAuthMethod = { - type: 'oauth', - key: string, - secret: string, + type: 'oauth'; + key: string; + secret: string; }; type BasicAuthMethod = { - type: 'basic', - username: string, - password: string, -} + type: 'basic'; + username: string; + password: string; +}; /** * An interface for describing the shape of a client to create using the factory. */ interface BuildParams { - wpURL: string, - useIndexPermalinks?: boolean, - auth?: OAuthMethod | BasicAuthMethod, + wpURL: string; + useIndexPermalinks?: boolean; + auth?: OAuthMethod | BasicAuthMethod; } /** @@ -129,8 +129,8 @@ export class HTTPClientFactory { interceptors.push( new AxiosOAuthInterceptor( this.clientConfig.auth.key, - this.clientConfig.auth.secret, - ), + this.clientConfig.auth.secret + ) ); break; } diff --git a/packages/js/api/src/models/coupons/coupon.ts b/packages/js/api/src/models/coupons/coupon.ts index 20e3f42ee9b..ee3ccbd348f 100644 --- a/packages/js/api/src/models/coupons/coupon.ts +++ b/packages/js/api/src/models/coupons/coupon.ts @@ -9,16 +9,19 @@ import { UpdatesModels, DeletesModels, } from '../../framework'; -import { - CouponUpdateParams, -} from './shared'; +import { CouponUpdateParams } from './shared'; import { ObjectLinks } from '../shared-types'; /** * The parameters embedded in this generic can be used in the ModelRepository in order to give * type-safety in an incredibly granular way. */ -export type CouponRepositoryParams = ModelRepositoryParams< Coupon, never, never, CouponUpdateParams >; +export type CouponRepositoryParams = ModelRepositoryParams< + Coupon, + never, + never, + CouponUpdateParams +>; /** * An interface for creating coupons using the repository. @@ -137,14 +140,14 @@ export class Coupon extends Model { * * @type {ReadonlyArray.} */ - public readonly productIds: Array = []; + public readonly productIds: Array< number > = []; /** * List of Product IDs that the coupon cannot be applied to. * * @type {ReadonlyArray.} */ - public readonly excludedProductIds: Array = []; + public readonly excludedProductIds: Array< number > = []; /** * How many times the coupon can be used. @@ -179,14 +182,14 @@ export class Coupon extends Model { * * @type {ReadonlyArray.} */ - public readonly productCategories: Array = []; + public readonly productCategories: Array< number > = []; /** * List of Category IDs the coupon does not apply to. * * @type {ReadonlyArray.} */ - public readonly excludedProductCategories: Array = []; + public readonly excludedProductCategories: Array< number > = []; /** * Flags if the coupon applies to items on sale. @@ -214,14 +217,14 @@ export class Coupon extends Model { * * @type {ReadonlyArray.} */ - public readonly emailRestrictions: Array = []; + public readonly emailRestrictions: Array< string > = []; /** * List of user IDs (or guest emails) that have used the coupon. * * @type {ReadonlyArray.} */ - public readonly usedBy: Array = []; + public readonly usedBy: Array< string > = []; /** * The coupon's links. @@ -248,7 +251,9 @@ export class Coupon extends Model { * * @param {HTTPClient} httpClient The client for communicating via HTTP. */ - public static restRepository( httpClient: HTTPClient ): ReturnType< typeof couponRESTRepository > { + public static restRepository( + httpClient: HTTPClient + ): ReturnType< typeof couponRESTRepository > { return couponRESTRepository( httpClient ); } } diff --git a/packages/js/api/src/models/coupons/shared/update-params.ts b/packages/js/api/src/models/coupons/shared/update-params.ts index 341d495bb74..3dfc3063a2e 100644 --- a/packages/js/api/src/models/coupons/shared/update-params.ts +++ b/packages/js/api/src/models/coupons/shared/update-params.ts @@ -1,7 +1,23 @@ /** * Coupon properties that can be updated */ -export type CouponUpdateParams = 'code' | 'amount' | 'description' | 'discountType' | 'dateExpires' | 'individualUse' - | 'usageCount' | 'productIds' | 'excludedProductIds' | 'usageLimit' | 'usageLimitPerUser' | 'limitUsageToXItems' - | 'freeShipping' | 'productCategories' | 'excludedProductCategories' | 'excludeSaleItems' | 'minimumAmount' - | 'maximumAmount' | 'emailRestrictions'; +export type CouponUpdateParams = + | 'code' + | 'amount' + | 'description' + | 'discountType' + | 'dateExpires' + | 'individualUse' + | 'usageCount' + | 'productIds' + | 'excludedProductIds' + | 'usageLimit' + | 'usageLimitPerUser' + | 'limitUsageToXItems' + | 'freeShipping' + | 'productCategories' + | 'excludedProductCategories' + | 'excludeSaleItems' + | 'minimumAmount' + | 'maximumAmount' + | 'emailRestrictions'; diff --git a/packages/js/api/src/models/orders/orders.ts b/packages/js/api/src/models/orders/orders.ts index d23c4ca7611..dbb6c3d7f14 100644 --- a/packages/js/api/src/models/orders/orders.ts +++ b/packages/js/api/src/models/orders/orders.ts @@ -9,7 +9,8 @@ import { DeletesModels, } from '../../framework'; import { - OrderAddressUpdateParams, + BillingOrderAddressUpdateParams, + ShippingOrderAddressUpdateParams, OrderCouponUpdateParams, OrderDataUpdateParams, OrderFeeUpdateParams, @@ -19,7 +20,8 @@ import { OrderTaxUpdateParams, OrderTotalUpdateParams, OrderItemMeta, - OrderAddress, + BillingOrderAddress, + ShippingOrderAddress, OrderCouponLine, OrderFeeLine, OrderLineItem, @@ -33,21 +35,27 @@ import { ObjectLinks } from '../shared-types'; /** * The parameters that orders can update. */ -type OrderUpdateParams = OrderAddressUpdateParams - & OrderCouponUpdateParams - & OrderDataUpdateParams - & OrderFeeUpdateParams - & OrderLineItemUpdateParams - & OrderRefundUpdateParams - & OrderShippingUpdateParams - & OrderTaxUpdateParams - & OrderTotalUpdateParams; +type OrderUpdateParams = BillingOrderAddressUpdateParams & + ShippingOrderAddressUpdateParams & + OrderCouponUpdateParams & + OrderDataUpdateParams & + OrderFeeUpdateParams & + OrderLineItemUpdateParams & + OrderRefundUpdateParams & + OrderShippingUpdateParams & + OrderTaxUpdateParams & + OrderTotalUpdateParams; /** * The parameters embedded in this generic can be used in the ModelRepository in order to give * type-safety in an incredibly granular way. */ -export type OrderRepositoryParams = ModelRepositoryParams< Order, never, never, OrderUpdateParams >; +export type OrderRepositoryParams = ModelRepositoryParams< + Order, + never, + never, + OrderUpdateParams +>; /** * An interface for creating orders using the repository. @@ -194,16 +202,16 @@ export class Order extends OrderItemMeta { /** * The billing address. * - * @type {OrderAddress} + * @type {BillingOrderAddress} */ - public readonly billing: OrderAddress | null = null; + public readonly billing: BillingOrderAddress | null = null; /** * The shipping address. * - * @type {OrderAddress} + * @type {ShippingOrderAddress} */ - public readonly shipping: OrderAddress | null = null; + public readonly shipping: ShippingOrderAddress | null = null; /** * Name of the payment method. @@ -363,7 +371,9 @@ export class Order extends OrderItemMeta { * * @param {HTTPClient} httpClient The client for communicating via HTTP. */ - public static restRepository( httpClient: HTTPClient ): ReturnType< typeof orderRESTRepository > { + public static restRepository( + httpClient: HTTPClient + ): ReturnType< typeof orderRESTRepository > { return orderRESTRepository( httpClient ); } } diff --git a/packages/js/api/src/models/orders/shared/classes.ts b/packages/js/api/src/models/orders/shared/classes.ts index 540c543d4ae..fc8e01eda96 100644 --- a/packages/js/api/src/models/orders/shared/classes.ts +++ b/packages/js/api/src/models/orders/shared/classes.ts @@ -1,5 +1,5 @@ import { MetaData } from '../../shared-types'; -import { Model } from '../../model'; +import { Model, ModelID } from '../../model'; import { TaxStatus } from './types'; /** @@ -36,7 +36,7 @@ export class OrderItemTax extends Model { /** * An order address. */ -export class OrderAddress extends Model { +export class ShippingOrderAddress extends Model { /** * The first name of the person in the address. * @@ -56,7 +56,7 @@ export class OrderAddress extends Model { * * @type {string} */ - public readonly companyName: string = ''; + public readonly company: string = ''; /** * The first address line in the address. @@ -98,21 +98,61 @@ export class OrderAddress extends Model { * * @type {string} */ - public readonly countryCode: string = ''; + public readonly country: string = ''; + /** + * Adapter to keep backward compatibility with renamed property. + * + * @type {string|null} + */ + get companyName() { + return this.company; + } + + /** + * Adapter to keep backward compatibility with renamed property. + * + * @type {string|null} + */ + get countryCode() { + return this.country; + } + + /** + * Creates a new order instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< ShippingOrderAddress > ) { + super(); + Object.assign( this, properties ); + } +} + +export class BillingOrderAddress extends ShippingOrderAddress { /** * The email address of the person in the address. * - * @type {string} + * @type {string|null} */ - public readonly email: string = ''; + public readonly email: undefined | string = ''; /** * The phone number of the person in the address. * - * @type {string} + * @type {string|null} */ - public readonly phone: string = ''; + public readonly phone: undefined | string = ''; + + /** + * Creates a new order instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< BillingOrderAddress > ) { + super(); + Object.assign( this, properties ); + } } /** @@ -209,6 +249,16 @@ export class OrderLineItem extends OrderItemMeta { * @type {string|null} */ public readonly parentName: string | null = null; + + /** + * Creates a new order instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< OrderLineItem > ) { + super(); + Object.assign( this, properties ); + } } /** @@ -263,6 +313,16 @@ export class OrderTaxRate extends Model { * @type {number} */ public readonly ratePercent: number = 0; + + /** + * Creates a new order instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< OrderTaxRate > ) { + super(); + Object.assign( this, properties ); + } } /** @@ -279,9 +339,9 @@ export class OrderShippingLine extends OrderItemMeta { /** * The shipping method id. * - * @type {string} + * @type {string|number} */ - public readonly methodId: string = ''; + public readonly methodId: ModelID | undefined; /** * The shipping method instance id. @@ -310,6 +370,16 @@ export class OrderShippingLine extends OrderItemMeta { * @type {ReadonlyArray.} */ public readonly taxes: OrderItemTax[] = []; + + /** + * Creates a new order instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< OrderShippingLine > ) { + super(); + Object.assign( this, properties ); + } } /** @@ -364,6 +434,16 @@ export class OrderFeeLine extends OrderItemMeta { * @type {ReadonlyArray.} */ public readonly taxes: OrderItemTax[] = []; + + /** + * Creates a new order instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< OrderFeeLine > ) { + super(); + Object.assign( this, properties ); + } } /** @@ -390,6 +470,16 @@ export class OrderCouponLine extends OrderItemMeta { * @type {string} */ public readonly discountTax: string = ''; + + /** + * Creates a new order instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< OrderCouponLine > ) { + super(); + Object.assign( this, properties ); + } } /** @@ -409,4 +499,14 @@ export class OrderRefundLine extends Model { * @type {string} */ public readonly total: string = ''; + + /** + * Creates a new order instance with the given properties + * + * @param {Object} properties The properties to set in the object. + */ + public constructor( properties?: Partial< OrderRefundLine > ) { + super(); + Object.assign( this, properties ); + } } diff --git a/packages/js/api/src/models/orders/shared/types.ts b/packages/js/api/src/models/orders/shared/types.ts index adeba0128c4..9e2dcaaa877 100644 --- a/packages/js/api/src/models/orders/shared/types.ts +++ b/packages/js/api/src/models/orders/shared/types.ts @@ -3,8 +3,16 @@ * * @typedef OrderStatus */ -export type OrderStatus = 'pending' | 'processing' | 'complete' | 'on-hold' | 'refunded' - | 'cancelled' | 'failed' | 'trash' | string; +export type OrderStatus = + | 'pending' + | 'processing' + | 'complete' + | 'on-hold' + | 'refunded' + | 'cancelled' + | 'failed' + | 'trash' + | string; /** * An fee's tax status. @@ -16,11 +24,32 @@ export type TaxStatus = 'taxable' | 'none'; /** * Base order properties */ -export type OrderDataUpdateParams = 'id' | 'parentId' | 'status' | 'currency' | 'version' - | 'pricesIncludeTax' | 'discountTotal' | 'discountTax' | 'shippingTotal' | 'shippingTax' - | 'cartTax' | 'customerId' | 'orderKey' | 'paymentMethod' | 'paymentMethodTitle' - | 'transactionId' | 'customerIpAddress' | 'customerUserAgent' | 'createdVia' | 'datePaid' - | 'customerNote' | 'dateCompleted' | 'cartHash' | 'orderNumber' | 'currencySymbol'; +export type OrderDataUpdateParams = + | 'id' + | 'parentId' + | 'status' + | 'currency' + | 'version' + | 'pricesIncludeTax' + | 'discountTotal' + | 'discountTax' + | 'shippingTotal' + | 'shippingTax' + | 'cartTax' + | 'customerId' + | 'orderKey' + | 'paymentMethod' + | 'paymentMethodTitle' + | 'transactionId' + | 'customerIpAddress' + | 'customerUserAgent' + | 'createdVia' + | 'datePaid' + | 'customerNote' + | 'dateCompleted' + | 'cartHash' + | 'orderNumber' + | 'currencySymbol'; /** * Common total properties @@ -28,27 +57,61 @@ export type OrderDataUpdateParams = 'id' | 'parentId' | 'status' | 'currency' | export type OrderTotalUpdateParams = 'total' | 'totalTax'; /** - * Order address properties + * Billing address properties */ -export type OrderAddressUpdateParams = 'firstName' | 'lastName' | 'companyName' | 'address1' - | 'address2' | 'city' | 'state' | 'postCode' | 'countryCode' | 'email' | 'phone'; +export type BillingOrderAddressUpdateParams = + | ShippingOrderAddressUpdateParams + | 'email' + | 'phone'; + +/** + * Shipping address properties + */ +export type ShippingOrderAddressUpdateParams = + | 'firstName' + | 'lastName' + | 'company' + | 'address1' + | 'address2' + | 'city' + | 'state' + | 'postCode' + | 'country'; /** * Line item properties */ -export type OrderLineItemUpdateParams = 'name' | 'ProductId' | 'variationId' | 'quantity' - | 'taxClass' | 'subtotal' | 'subtotalTax' | 'sku' | 'price' | 'parentName'; +export type OrderLineItemUpdateParams = + | 'name' + | 'ProductId' + | 'variationId' + | 'quantity' + | 'taxClass' + | 'subtotal' + | 'subtotalTax' + | 'sku' + | 'price' + | 'parentName'; /** * Tax rate properties */ -export type OrderTaxUpdateParams = 'rateCode' | 'rateId' | 'label' | 'compoundRate' - | 'taxTotal' | 'shippingTaxTotal' | 'ratePercent'; +export type OrderTaxUpdateParams = + | 'rateCode' + | 'rateId' + | 'label' + | 'compoundRate' + | 'taxTotal' + | 'shippingTaxTotal' + | 'ratePercent'; /** * Order shipping properties */ -export type OrderShippingUpdateParams = 'methodTitle' | 'methodId' | 'instanceId'; +export type OrderShippingUpdateParams = + | 'methodTitle' + | 'methodId' + | 'instanceId'; /** * Order fee properties diff --git a/packages/js/api/src/models/products/abstract/common.ts b/packages/js/api/src/models/products/abstract/common.ts index bab3e1dc6b7..425b389f703 100644 --- a/packages/js/api/src/models/products/abstract/common.ts +++ b/packages/js/api/src/models/products/abstract/common.ts @@ -1,10 +1,6 @@ import { AbstractProductData } from './data'; import { ModelID } from '../../model'; -import { - CatalogVisibility, - ProductTerm, - ProductAttribute, -} from '../shared'; +import { CatalogVisibility, ProductTerm, ProductAttribute } from '../shared'; import { ObjectLinks } from '../../shared-types'; /** @@ -33,7 +29,8 @@ export const buildProductURL = ( id: ModelID ) => baseProductURL() + id; * @param {ModelID} id the id of the product. * @return {string} RESTful Url. */ -export const deleteProductURL = ( id: ModelID ) => buildProductURL( id ) + '?force=true'; +export const deleteProductURL = ( id: ModelID ) => + buildProductURL( id ) + '?force=true'; /** * The base for all product types. @@ -100,7 +97,8 @@ export abstract class AbstractProduct extends AbstractProductData { * * @type {CatalogVisibility} */ - public readonly catalogVisibility: CatalogVisibility = CatalogVisibility.Everywhere; + public readonly catalogVisibility: CatalogVisibility = + CatalogVisibility.Everywhere; /** * The count of sales of the product @@ -135,7 +133,7 @@ export abstract class AbstractProduct extends AbstractProductData { * * @type {ReadonlyArray.} */ - public readonly relatedIds: Array = []; + public readonly relatedIds: Array< number > = []; /** * The attributes for the product. diff --git a/packages/js/api/src/models/products/abstract/cross-sell.ts b/packages/js/api/src/models/products/abstract/cross-sell.ts index 7b49de6d7f4..a25c47f2881 100644 --- a/packages/js/api/src/models/products/abstract/cross-sell.ts +++ b/packages/js/api/src/models/products/abstract/cross-sell.ts @@ -9,7 +9,7 @@ abstract class AbstractProductCrossSells extends Model { * * @type {ReadonlyArray.} */ - public readonly crossSellIds: Array = []; + public readonly crossSellIds: Array< number > = []; } export interface IProductCrossSells extends AbstractProductCrossSells {} diff --git a/packages/js/api/src/models/products/abstract/external.ts b/packages/js/api/src/models/products/abstract/external.ts index 2f368500c23..5b7016f2ba4 100644 --- a/packages/js/api/src/models/products/abstract/external.ts +++ b/packages/js/api/src/models/products/abstract/external.ts @@ -9,14 +9,14 @@ abstract class AbstractProductExternal extends Model { * * @type {string} */ - public readonly buttonText: string = '' + public readonly buttonText: string = ''; /** * The product's external URL. * * @type {string} */ - public readonly externalUrl: string = '' + public readonly externalUrl: string = ''; } export interface IProductExternal extends AbstractProductExternal {} diff --git a/packages/js/api/src/models/products/abstract/grouped.ts b/packages/js/api/src/models/products/abstract/grouped.ts index 96241425d15..89f619f8f4a 100644 --- a/packages/js/api/src/models/products/abstract/grouped.ts +++ b/packages/js/api/src/models/products/abstract/grouped.ts @@ -9,7 +9,7 @@ abstract class AbstractProductGrouped extends Model { * * @type {ReadonlyArray.} */ - public readonly groupedProducts: Array = []; + public readonly groupedProducts: Array< number > = []; } export interface IProductGrouped extends AbstractProductGrouped {} diff --git a/packages/js/api/src/models/products/abstract/inventory.ts b/packages/js/api/src/models/products/abstract/inventory.ts index 7fb6dac7a57..5a9c935d2e9 100644 --- a/packages/js/api/src/models/products/abstract/inventory.ts +++ b/packages/js/api/src/models/products/abstract/inventory.ts @@ -1,8 +1,5 @@ import { Model } from '../../model'; -import { - BackorderStatus, - StockStatus, -} from '../shared'; +import { BackorderStatus, StockStatus } from '../shared'; /** * The base for inventory products. diff --git a/packages/js/api/src/models/products/abstract/upsell.ts b/packages/js/api/src/models/products/abstract/upsell.ts index 018662c70ec..7c99f3a2466 100644 --- a/packages/js/api/src/models/products/abstract/upsell.ts +++ b/packages/js/api/src/models/products/abstract/upsell.ts @@ -9,7 +9,7 @@ abstract class AbstractProductUpSells extends Model { * * @type {ReadonlyArray.} */ - public readonly upSellIds: Array = []; + public readonly upSellIds: Array< number > = []; } export interface IProductUpSells extends AbstractProductUpSells {} diff --git a/packages/js/api/src/models/products/external-product.ts b/packages/js/api/src/models/products/external-product.ts index 578f93d4d8e..f56b66b051f 100644 --- a/packages/js/api/src/models/products/external-product.ts +++ b/packages/js/api/src/models/products/external-product.ts @@ -29,17 +29,21 @@ import { /** * The parameters that external products can update. */ -type ExternalProductUpdateParams = ProductCommonUpdateParams - & ProductExternalUpdateParams - & ProductPriceUpdateParams - & ProductSalesTaxUpdateParams - & ProductUpSellUpdateParams; +type ExternalProductUpdateParams = ProductCommonUpdateParams & + ProductExternalUpdateParams & + ProductPriceUpdateParams & + ProductSalesTaxUpdateParams & + ProductUpSellUpdateParams; /** * The parameters embedded in this generic can be used in the ModelRepository in order to give * type-safety in an incredibly granular way. */ -export type ExternalProductRepositoryParams = - ModelRepositoryParams< ExternalProduct, never, ProductSearchParams, ExternalProductUpdateParams >; +export type ExternalProductRepositoryParams = ModelRepositoryParams< + ExternalProduct, + never, + ProductSearchParams, + ExternalProductUpdateParams +>; /** * An interface for listing external products using the repository. @@ -84,17 +88,19 @@ export type DeletesExternalProducts = DeletesModels< ExternalProductRepositoryPa /** * The base for the external product object. */ -export class ExternalProduct extends AbstractProduct implements - IProductCommon, - IProductExternal, - IProductPrice, - IProductSalesTax, - IProductUpSells { +export class ExternalProduct + extends AbstractProduct + implements + IProductCommon, + IProductExternal, + IProductPrice, + IProductSalesTax, + IProductUpSells { /** * @see ./abstracts/external.ts */ - public readonly buttonText: string = '' - public readonly externalUrl: string = '' + public readonly buttonText: string = ''; + public readonly externalUrl: string = ''; /** * @see ./abstracts/price.ts @@ -110,7 +116,7 @@ export class ExternalProduct extends AbstractProduct implements /** * @see ./abstracts/upsell.ts */ - public readonly upSellIds: Array = []; + public readonly upSellIds: Array< number > = []; /** * @see ./abstracts/sales-tax.ts @@ -133,7 +139,9 @@ export class ExternalProduct extends AbstractProduct implements * * @param {HTTPClient} httpClient The client for communicating via HTTP. */ - public static restRepository( httpClient: HTTPClient ): ReturnType< typeof externalProductRESTRepository > { + public static restRepository( + httpClient: HTTPClient + ): ReturnType< typeof externalProductRESTRepository > { return externalProductRESTRepository( httpClient ); } } diff --git a/packages/js/api/src/models/products/grouped-product.ts b/packages/js/api/src/models/products/grouped-product.ts index 9582685e874..b92319324a3 100644 --- a/packages/js/api/src/models/products/grouped-product.ts +++ b/packages/js/api/src/models/products/grouped-product.ts @@ -24,15 +24,19 @@ import { /** * The parameters that Grouped products can update. */ -type GroupedProductUpdateParams = ProductCommonUpdateParams - & ProductGroupedUpdateParams - & ProductUpSellUpdateParams; +type GroupedProductUpdateParams = ProductCommonUpdateParams & + ProductGroupedUpdateParams & + ProductUpSellUpdateParams; /** * The parameters embedded in this generic can be used in the ModelRepository in order to give * type-safety in an incredibly granular way. */ -export type GroupedProductRepositoryParams = - ModelRepositoryParams< GroupedProduct, never, ProductSearchParams, GroupedProductUpdateParams >; +export type GroupedProductRepositoryParams = ModelRepositoryParams< + GroupedProduct, + never, + ProductSearchParams, + GroupedProductUpdateParams +>; /** * An interface for listing Grouped products using the repository. @@ -77,19 +81,18 @@ export type DeletesGroupedProducts = DeletesModels< GroupedProductRepositoryPara /** * The base for the Grouped product object. */ -export class GroupedProduct extends AbstractProduct implements - IProductCommon, - IProductGrouped, - IProductUpSells { +export class GroupedProduct + extends AbstractProduct + implements IProductCommon, IProductGrouped, IProductUpSells { /** * @see ./abstracts/grouped.ts */ - public readonly groupedProducts: Array = []; + public readonly groupedProducts: Array< number > = []; /** * @see ./abstracts/upsell.ts */ - public readonly upSellIds: Array = []; + public readonly upSellIds: Array< number > = []; /** * Creates a new Grouped product instance with the given properties @@ -106,7 +109,9 @@ export class GroupedProduct extends AbstractProduct implements * * @param {HTTPClient} httpClient The client for communicating via HTTP. */ - public static restRepository( httpClient: HTTPClient ): ReturnType< typeof groupedProductRESTRepository > { + public static restRepository( + httpClient: HTTPClient + ): ReturnType< typeof groupedProductRESTRepository > { return groupedProductRESTRepository( httpClient ); } } diff --git a/packages/js/api/src/models/products/shared/enums.ts b/packages/js/api/src/models/products/shared/enums.ts index 4e1a1fc2e76..c62d9855af3 100644 --- a/packages/js/api/src/models/products/shared/enums.ts +++ b/packages/js/api/src/models/products/shared/enums.ts @@ -22,7 +22,7 @@ export enum CatalogVisibility { /** * The product should be hidden everywhere. */ - Hidden = 'hidden' + Hidden = 'hidden', } /** @@ -44,7 +44,7 @@ export enum Taxability { /** * The product and shipping are not taxable. */ - None = 'none' + None = 'none', } /** @@ -66,5 +66,5 @@ export enum BackorderStatus { /** * The product is not allowed to be backordered. */ - NotAllowed = 'no' + NotAllowed = 'no', } diff --git a/packages/js/api/src/models/products/shared/types.ts b/packages/js/api/src/models/products/shared/types.ts index c72b92cffa8..bd2a8a74bc8 100644 --- a/packages/js/api/src/models/products/shared/types.ts +++ b/packages/js/api/src/models/products/shared/types.ts @@ -4,24 +4,47 @@ * @typedef StockStatus * @alias 'instock'|'outofstock'|'onbackorder'|string */ -export type StockStatus = 'instock' | 'outofstock' | 'onbackorder' | string +export type StockStatus = 'instock' | 'outofstock' | 'onbackorder' | string; /** * Base product properties. */ -export type ProductDataUpdateParams = 'created' | 'postStatus' - | 'id' | 'permalink' | 'price' | 'priceHtml' - | 'description' | 'sku' | 'attributes' | 'images' - | 'regularPrice' | 'salePrice' | 'saleStart' | 'saleEnd' - | 'metaData' | 'menuOrder' | 'parentId' | 'links'; +export type ProductDataUpdateParams = + | 'created' + | 'postStatus' + | 'id' + | 'permalink' + | 'price' + | 'priceHtml' + | 'description' + | 'sku' + | 'attributes' + | 'images' + | 'regularPrice' + | 'salePrice' + | 'saleStart' + | 'saleEnd' + | 'metaData' + | 'menuOrder' + | 'parentId' + | 'links'; /** * Properties common to all product types. */ -export type ProductCommonUpdateParams = 'name' | 'slug' | 'shortDescription' - | 'categories' | 'tags' | 'isFeatured' | 'averageRating' | 'numRatings' - | 'catalogVisibility' | 'allowReviews' | 'upsellIds' | 'type' - & ProductDataUpdateParams; +export type ProductCommonUpdateParams = + | 'name' + | 'slug' + | 'shortDescription' + | 'categories' + | 'tags' + | 'isFeatured' + | 'averageRating' + | 'numRatings' + | 'catalogVisibility' + | 'allowReviews' + | 'upsellIds' + | ( 'type' & ProductDataUpdateParams ); /** * Cross sells property. @@ -31,8 +54,13 @@ export type ProductCrossUpdateParams = 'crossSellIds'; /** * Price properties. */ -export type ProductPriceUpdateParams = 'price' | 'priceHtml' | 'regularPrice' - | 'salePrice' | 'saleStart' | 'saleEnd'; +export type ProductPriceUpdateParams = + | 'price' + | 'priceHtml' + | 'regularPrice' + | 'salePrice' + | 'saleStart' + | 'saleEnd'; /** * Upsells property. @@ -52,8 +80,13 @@ export type ProductGroupedUpdateParams = 'groupedProducts'; /** * Properties related to tracking inventory. */ -export type ProductInventoryUpdateParams = 'backorderStatus' | 'canBackorder' | 'trackInventory' - | 'onePerOrder' | 'remainingStock' | 'lowStockThreshold'; +export type ProductInventoryUpdateParams = + | 'backorderStatus' + | 'canBackorder' + | 'trackInventory' + | 'onePerOrder' + | 'remainingStock' + | 'lowStockThreshold'; /** * Properties related to sales tax. @@ -63,14 +96,23 @@ export type ProductSalesTaxUpdateParams = 'taxClass' | 'taxStatus'; /** * Properties related to shipping. */ -export type ProductShippingUpdateParams = 'height' | 'length' | 'weight' | 'width' - | 'shippingClass' | 'shippingClassId'; +export type ProductShippingUpdateParams = + | 'height' + | 'length' + | 'weight' + | 'width' + | 'shippingClass' + | 'shippingClassId'; /** * Properties exclusive to the Simple product type. */ -export type ProductDeliveryUpdateParams = 'daysToDownload' | 'downloadLimit' | 'downloads' - | 'purchaseNote' | 'isVirtual'; +export type ProductDeliveryUpdateParams = + | 'daysToDownload' + | 'downloadLimit' + | 'downloads' + | 'purchaseNote' + | 'isVirtual'; /** * Properties exclusive to the Variable product type. diff --git a/packages/js/api/src/models/products/simple-product.ts b/packages/js/api/src/models/products/simple-product.ts index b774ae84c57..67f754d8f6d 100644 --- a/packages/js/api/src/models/products/simple-product.ts +++ b/packages/js/api/src/models/products/simple-product.ts @@ -38,19 +38,24 @@ import { /** * The parameters that simple products can update. */ -type SimpleProductUpdateParams = ProductDeliveryUpdateParams - & ProductCommonUpdateParams - & ProductCrossUpdateParams - & ProductInventoryUpdateParams - & ProductPriceUpdateParams - & ProductSalesTaxUpdateParams - & ProductShippingUpdateParams - & ProductUpSellUpdateParams; +type SimpleProductUpdateParams = ProductDeliveryUpdateParams & + ProductCommonUpdateParams & + ProductCrossUpdateParams & + ProductInventoryUpdateParams & + ProductPriceUpdateParams & + ProductSalesTaxUpdateParams & + ProductShippingUpdateParams & + ProductUpSellUpdateParams; /** * The parameters embedded in this generic can be used in the ModelRepository in order to give * type-safety in an incredibly granular way. */ -export type SimpleProductRepositoryParams = ModelRepositoryParams< SimpleProduct, never, ProductSearchParams, SimpleProductUpdateParams >; +export type SimpleProductRepositoryParams = ModelRepositoryParams< + SimpleProduct, + never, + ProductSearchParams, + SimpleProductUpdateParams +>; /** * An interface for listing simple products using the repository. @@ -95,24 +100,26 @@ export type DeletesSimpleProducts = DeletesModels< SimpleProductRepositoryParams /** * The base for the simple product object. */ -export class SimpleProduct extends AbstractProduct implements - IProductCommon, - IProductCrossSells, - IProductDelivery, - IProductInventory, - IProductPrice, - IProductSalesTax, - IProductShipping, - IProductUpSells { +export class SimpleProduct + extends AbstractProduct + implements + IProductCommon, + IProductCrossSells, + IProductDelivery, + IProductInventory, + IProductPrice, + IProductSalesTax, + IProductShipping, + IProductUpSells { /** * @see ./abstracts/cross-sells.ts */ - public readonly crossSellIds: Array = []; + public readonly crossSellIds: Array< number > = []; /** * @see ./abstracts/upsell.ts */ - public readonly upSellIds: Array = []; + public readonly upSellIds: Array< number > = []; /** * @see ./abstracts/delivery.ts @@ -180,7 +187,9 @@ export class SimpleProduct extends AbstractProduct implements * * @param {HTTPClient} httpClient The client for communicating via HTTP. */ - public static restRepository( httpClient: HTTPClient ): ReturnType< typeof simpleProductRESTRepository > { + public static restRepository( + httpClient: HTTPClient + ): ReturnType< typeof simpleProductRESTRepository > { return simpleProductRESTRepository( httpClient ); } } diff --git a/packages/js/api/src/models/products/variable-product.ts b/packages/js/api/src/models/products/variable-product.ts index 55346f53b33..801b32cfdca 100644 --- a/packages/js/api/src/models/products/variable-product.ts +++ b/packages/js/api/src/models/products/variable-product.ts @@ -35,19 +35,23 @@ import { /** * The parameters that variable products can update. */ -type VariableProductUpdateParams = ProductVariableUpdateParams - & ProductCommonUpdateParams - & ProductCrossUpdateParams - & ProductInventoryUpdateParams - & ProductSalesTaxUpdateParams - & ProductShippingUpdateParams - & ProductUpSellUpdateParams; +type VariableProductUpdateParams = ProductVariableUpdateParams & + ProductCommonUpdateParams & + ProductCrossUpdateParams & + ProductInventoryUpdateParams & + ProductSalesTaxUpdateParams & + ProductShippingUpdateParams & + ProductUpSellUpdateParams; /** * The parameters embedded in this generic can be used in the ModelRepository in order to give * type-safety in an incredibly granular way. */ -export type VariableProductRepositoryParams = - ModelRepositoryParams< VariableProduct, never, ProductSearchParams, VariableProductUpdateParams >; +export type VariableProductRepositoryParams = ModelRepositoryParams< + VariableProduct, + never, + ProductSearchParams, + VariableProductUpdateParams +>; /** * An interface for listing variable products using the repository. @@ -92,22 +96,24 @@ export type DeletesVariableProducts = DeletesModels< VariableProductRepositoryPa /** * The base for the Variable product object. */ -export class VariableProduct extends AbstractProduct implements - IProductCommon, - IProductCrossSells, - IProductInventory, - IProductSalesTax, - IProductShipping, - IProductUpSells { +export class VariableProduct + extends AbstractProduct + implements + IProductCommon, + IProductCrossSells, + IProductInventory, + IProductSalesTax, + IProductShipping, + IProductUpSells { /** * @see ./abstracts/cross-sells.ts */ - public readonly crossSellIds: Array = []; + public readonly crossSellIds: Array< number > = []; /** * @see ./abstracts/upsell.ts */ - public readonly upSellIds: Array = []; + public readonly upSellIds: Array< number > = []; /** * @see ./abstracts/inventory.ts @@ -151,7 +157,7 @@ export class VariableProduct extends AbstractProduct implements * * @type {ReadonlyArray.} */ - public readonly variations: Array = []; + public readonly variations: Array< number > = []; /** * Creates a new Variable product instance with the given properties @@ -168,7 +174,9 @@ export class VariableProduct extends AbstractProduct implements * * @param {HTTPClient} httpClient The client for communicating via HTTP. */ - public static restRepository( httpClient: HTTPClient ): ReturnType< typeof variableProductRESTRepository > { + public static restRepository( + httpClient: HTTPClient + ): ReturnType< typeof variableProductRESTRepository > { return variableProductRESTRepository( httpClient ); } } diff --git a/packages/js/api/src/models/products/variation.ts b/packages/js/api/src/models/products/variation.ts index 61279b0e524..324a7efe5ef 100644 --- a/packages/js/api/src/models/products/variation.ts +++ b/packages/js/api/src/models/products/variation.ts @@ -36,18 +36,22 @@ import { productVariationRESTRepository } from '../../repositories'; /** * The parameters that product variations can update. */ -type ProductVariationUpdateParams = ProductDataUpdateParams - & ProductDeliveryUpdateParams - & ProductInventoryUpdateParams - & ProductPriceUpdateParams - & ProductSalesTaxUpdateParams - & ProductShippingUpdateParams; +type ProductVariationUpdateParams = ProductDataUpdateParams & + ProductDeliveryUpdateParams & + ProductInventoryUpdateParams & + ProductPriceUpdateParams & + ProductSalesTaxUpdateParams & + ProductShippingUpdateParams; /** * The parameters embedded in this generic can be used in the ModelRepository in order to give * type-safety in an incredibly granular way. */ -export type ProductVariationRepositoryParams = - ModelRepositoryParams< ProductVariation, ModelID, ProductSearchParams, ProductVariationUpdateParams >; +export type ProductVariationRepositoryParams = ModelRepositoryParams< + ProductVariation, + ModelID, + ProductSearchParams, + ProductVariationUpdateParams +>; /** * An interface for listing variable products using the repository. @@ -92,12 +96,14 @@ export type DeletesProductVariations = DeletesChildModels< ProductVariationRepos /** * The base for the product variation object. */ -export class ProductVariation extends AbstractProductData implements - IProductDelivery, - IProductInventory, - IProductPrice, - IProductSalesTax, - IProductShipping { +export class ProductVariation + extends AbstractProductData + implements + IProductDelivery, + IProductInventory, + IProductPrice, + IProductSalesTax, + IProductShipping { /** * @see ./abstracts/delivery.ts */ @@ -114,7 +120,7 @@ export class ProductVariation extends AbstractProductData implements public readonly onePerOrder: boolean = false; public readonly trackInventory: boolean = false; public readonly remainingStock: number = -1; - public readonly stockStatus: StockStatus = '' + public readonly stockStatus: StockStatus = ''; public readonly backorderStatus: BackorderStatus = BackorderStatus.Allowed; public readonly canBackorder: boolean = false; public readonly isOnBackorder: boolean = false; @@ -182,7 +188,9 @@ export class ProductVariation extends AbstractProductData implements * * @param {HTTPClient} httpClient The client for communicating via HTTP. */ - public static restRepository( httpClient: HTTPClient ): ReturnType< typeof productVariationRESTRepository > { + public static restRepository( + httpClient: HTTPClient + ): ReturnType< typeof productVariationRESTRepository > { return productVariationRESTRepository( httpClient ); } } diff --git a/packages/js/api/src/models/settings/setting-group.ts b/packages/js/api/src/models/settings/setting-group.ts index 5fa4b03d857..7702caf2ed7 100644 --- a/packages/js/api/src/models/settings/setting-group.ts +++ b/packages/js/api/src/models/settings/setting-group.ts @@ -57,7 +57,9 @@ export class SettingGroup extends Model { * * @param {HTTPClient} httpClient The client for communicating via HTTP. */ - public static restRepository( httpClient: HTTPClient ): ReturnType< typeof settingGroupRESTRepository > { + public static restRepository( + httpClient: HTTPClient + ): ReturnType< typeof settingGroupRESTRepository > { return settingGroupRESTRepository( httpClient ); } } diff --git a/packages/js/api/src/models/settings/setting.ts b/packages/js/api/src/models/settings/setting.ts index e25c7359541..beef5cf1e7f 100644 --- a/packages/js/api/src/models/settings/setting.ts +++ b/packages/js/api/src/models/settings/setting.ts @@ -12,7 +12,12 @@ import { * The parameters embedded in this generic can be used in the ModelRepository in order to give * type-safety in an incredibly granular way. */ -export type SettingRepositoryParams = ModelRepositoryParams< Setting, ModelID, never, 'value' >; +export type SettingRepositoryParams = ModelRepositoryParams< + Setting, + ModelID, + never, + 'value' +>; /** * An interface for listing settings using the repository. @@ -73,7 +78,7 @@ export class Setting extends Model { * * @type {Object.|null} */ - public readonly options: { [key: string]: string } | undefined; + public readonly options: { [ key: string ]: string } | undefined; /** * The default value for the setting. @@ -104,7 +109,9 @@ export class Setting extends Model { * * @param {HTTPClient} httpClient The client for communicating via HTTP. */ - public static restRepository( httpClient: HTTPClient ): ReturnType< typeof settingRESTRepository > { + public static restRepository( + httpClient: HTTPClient + ): ReturnType< typeof settingRESTRepository > { return settingRESTRepository( httpClient ); } } diff --git a/packages/js/api/src/models/shared-types.ts b/packages/js/api/src/models/shared-types.ts index d041f3c48c8..0b8a05508be 100644 --- a/packages/js/api/src/models/shared-types.ts +++ b/packages/js/api/src/models/shared-types.ts @@ -7,7 +7,9 @@ import { Model } from './model'; * @alias Function. * @template T */ -export type ModelConstructor< T extends Model > = new ( properties: Partial< T > ) => T; +export type ModelConstructor< T extends Model > = new ( + properties: Partial< T > +) => T; /** * A post's status. diff --git a/packages/js/api/src/repositories/rest/__tests__/shared.spec.ts b/packages/js/api/src/repositories/rest/__tests__/shared.spec.ts index b81b56d0a2c..0626f7e541a 100644 --- a/packages/js/api/src/repositories/rest/__tests__/shared.spec.ts +++ b/packages/js/api/src/repositories/rest/__tests__/shared.spec.ts @@ -4,7 +4,8 @@ import { ModelTransformer, ModelRepositoryParams } from '../../../framework'; import { DummyModel } from '../../../__test_data__/dummy-model'; import { restCreate, - restDelete, restDeleteChild, + restDelete, + restDeleteChild, restList, restListChild, restRead, @@ -14,7 +15,12 @@ import { } from '../shared'; import { Model } from '../../../models'; -type DummyModelParams = ModelRepositoryParams< DummyModel, never, { search: string }, 'name' > +type DummyModelParams = ModelRepositoryParams< + DummyModel, + never, + { search: string }, + 'name' +>; class DummyChildModel extends Model { public childName: string = ''; @@ -24,7 +30,12 @@ class DummyChildModel extends Model { Object.assign( this, partial ); } } -type DummyChildParams = ModelRepositoryParams< DummyChildModel, { parent: string }, { childSearch: string }, 'childName' > +type DummyChildParams = ModelRepositoryParams< + DummyChildModel, + { parent: string }, + { childSearch: string }, + 'childName' +>; jest.mock( '../../../framework/model-transformer' ); @@ -44,10 +55,8 @@ describe( 'Shared REST Functions', () => { } ); it( 'restList', async () => { - mocked( mockClient.get ).mockResolvedValue( new HTTPResponse( - 200, - {}, - [ + mocked( mockClient.get ).mockResolvedValue( + new HTTPResponse( 200, {}, [ { id: 'Test-1', label: 'Test 1', @@ -56,27 +65,44 @@ describe( 'Shared REST Functions', () => { id: 'Test-2', label: 'Test 2', }, - ], - ) ); - mocked( mockTransformer.toModel ).mockReturnValue( new DummyModel( { name: 'Test' } ) ); + ] ) + ); + mocked( mockTransformer.toModel ).mockReturnValue( + new DummyModel( { name: 'Test' } ) + ); - const fn = restList< DummyModelParams >( () => 'test-url', DummyModel, mockClient, mockTransformer ); + const fn = restList< DummyModelParams >( + () => 'test-url', + DummyModel, + mockClient, + mockTransformer + ); const result = await fn( { search: 'Test' } ); expect( result ).toHaveLength( 2 ); - expect( result[ 0 ] ).toMatchObject( new DummyModel( { name: 'Test' } ) ); - expect( result[ 1 ] ).toMatchObject( new DummyModel( { name: 'Test' } ) ); - expect( mockClient.get ).toHaveBeenCalledWith( 'test-url', { search: 'Test' } ); - expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { id: 'Test-1', label: 'Test 1' } ); - expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { id: 'Test-2', label: 'Test 2' } ); + expect( result[ 0 ] ).toMatchObject( + new DummyModel( { name: 'Test' } ) + ); + expect( result[ 1 ] ).toMatchObject( + new DummyModel( { name: 'Test' } ) + ); + expect( mockClient.get ).toHaveBeenCalledWith( 'test-url', { + search: 'Test', + } ); + expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { + id: 'Test-1', + label: 'Test 1', + } ); + expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { + id: 'Test-2', + label: 'Test 2', + } ); } ); it( 'restListChildren', async () => { - mocked( mockClient.get ).mockResolvedValue( new HTTPResponse( - 200, - {}, - [ + mocked( mockClient.get ).mockResolvedValue( + new HTTPResponse( 200, {}, [ { id: 'Test-1', label: 'Test 1', @@ -85,152 +111,238 @@ describe( 'Shared REST Functions', () => { id: 'Test-2', label: 'Test 2', }, - ], - ) ); - mocked( mockTransformer.toModel ).mockReturnValue( new DummyChildModel( { name: 'Test' } ) ); + ] ) + ); + mocked( mockTransformer.toModel ).mockReturnValue( + new DummyChildModel( { name: 'Test' } ) + ); const fn = restListChild< DummyChildParams >( ( parent ) => 'test-url-' + parent.parent, DummyChildModel, mockClient, - mockTransformer, + mockTransformer ); const result = await fn( { parent: '123' }, { childSearch: 'Test' } ); expect( result ).toHaveLength( 2 ); - expect( result[ 0 ] ).toMatchObject( new DummyChildModel( { name: 'Test' } ) ); - expect( result[ 1 ] ).toMatchObject( new DummyChildModel( { name: 'Test' } ) ); - expect( mockClient.get ).toHaveBeenCalledWith( 'test-url-123', { childSearch: 'Test' } ); - expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyChildModel, { id: 'Test-1', label: 'Test 1' } ); - expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyChildModel, { id: 'Test-2', label: 'Test 2' } ); + expect( result[ 0 ] ).toMatchObject( + new DummyChildModel( { name: 'Test' } ) + ); + expect( result[ 1 ] ).toMatchObject( + new DummyChildModel( { name: 'Test' } ) + ); + expect( mockClient.get ).toHaveBeenCalledWith( 'test-url-123', { + childSearch: 'Test', + } ); + expect( mockTransformer.toModel ).toHaveBeenCalledWith( + DummyChildModel, + { id: 'Test-1', label: 'Test 1' } + ); + expect( mockTransformer.toModel ).toHaveBeenCalledWith( + DummyChildModel, + { id: 'Test-2', label: 'Test 2' } + ); } ); it( 'restCreate', async () => { - mocked( mockClient.post ).mockResolvedValue( new HTTPResponse( - 200, - {}, - { - id: 'Test-1', - label: 'Test 1', - }, - ) ); - mocked( mockTransformer.fromModel ).mockReturnValue( { name: 'From-Test' } ); - mocked( mockTransformer.toModel ).mockReturnValue( new DummyModel( { name: 'Test' } ) ); + mocked( mockClient.post ).mockResolvedValue( + new HTTPResponse( + 200, + {}, + { + id: 'Test-1', + label: 'Test 1', + } + ) + ); + mocked( mockTransformer.fromModel ).mockReturnValue( { + name: 'From-Test', + } ); + mocked( mockTransformer.toModel ).mockReturnValue( + new DummyModel( { name: 'Test' } ) + ); const fn = restCreate< DummyModelParams >( ( properties ) => 'test-url-' + properties.name, DummyModel, mockClient, - mockTransformer, + mockTransformer ); const result = await fn( { name: 'Test' } ); expect( result ).toMatchObject( new DummyModel( { name: 'Test' } ) ); - expect( mockTransformer.fromModel ).toHaveBeenCalledWith( { name: 'Test' } ); - expect( mockClient.post ).toHaveBeenCalledWith( 'test-url-Test', { name: 'From-Test' } ); - expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { id: 'Test-1', label: 'Test 1' } ); + expect( mockTransformer.fromModel ).toHaveBeenCalledWith( { + name: 'Test', + } ); + expect( mockClient.post ).toHaveBeenCalledWith( 'test-url-Test', { + name: 'From-Test', + } ); + expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { + id: 'Test-1', + label: 'Test 1', + } ); } ); it( 'restRead', async () => { - mocked( mockClient.get ).mockResolvedValue( new HTTPResponse( - 200, - {}, - { - id: 'Test-1', - label: 'Test 1', - }, - ) ); - mocked( mockTransformer.toModel ).mockReturnValue( new DummyModel( { name: 'Test' } ) ); + mocked( mockClient.get ).mockResolvedValue( + new HTTPResponse( + 200, + {}, + { + id: 'Test-1', + label: 'Test 1', + } + ) + ); + mocked( mockTransformer.toModel ).mockReturnValue( + new DummyModel( { name: 'Test' } ) + ); - const fn = restRead< DummyModelParams >( ( id ) => 'test-url-' + id, DummyModel, mockClient, mockTransformer ); + const fn = restRead< DummyModelParams >( + ( id ) => 'test-url-' + id, + DummyModel, + mockClient, + mockTransformer + ); const result = await fn( 123 ); expect( result ).toMatchObject( new DummyModel( { name: 'Test' } ) ); expect( mockClient.get ).toHaveBeenCalledWith( 'test-url-123' ); - expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { id: 'Test-1', label: 'Test 1' } ); + expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { + id: 'Test-1', + label: 'Test 1', + } ); } ); it( 'restReadChildren', async () => { - mocked( mockClient.get ).mockResolvedValue( new HTTPResponse( - 200, - {}, - { - id: 'Test-1', - label: 'Test 1', - }, - ) ); - mocked( mockTransformer.toModel ).mockReturnValue( new DummyChildModel( { name: 'Test' } ) ); + mocked( mockClient.get ).mockResolvedValue( + new HTTPResponse( + 200, + {}, + { + id: 'Test-1', + label: 'Test 1', + } + ) + ); + mocked( mockTransformer.toModel ).mockReturnValue( + new DummyChildModel( { name: 'Test' } ) + ); const fn = restReadChild< DummyChildParams >( ( parent, id ) => 'test-url-' + parent.parent + '-' + id, DummyChildModel, mockClient, - mockTransformer, + mockTransformer ); const result = await fn( { parent: '123' }, 456 ); expect( result ).toMatchObject( new DummyModel( { name: 'Test' } ) ); expect( mockClient.get ).toHaveBeenCalledWith( 'test-url-123-456' ); - expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyChildModel, { id: 'Test-1', label: 'Test 1' } ); + expect( mockTransformer.toModel ).toHaveBeenCalledWith( + DummyChildModel, + { id: 'Test-1', label: 'Test 1' } + ); } ); it( 'restUpdate', async () => { - mocked( mockClient.patch ).mockResolvedValue( new HTTPResponse( - 200, - {}, - { - id: 'Test-1', - label: 'Test 1', - }, - ) ); - mocked( mockTransformer.fromModel ).mockReturnValue( { name: 'From-Test' } ); - mocked( mockTransformer.toModel ).mockReturnValue( new DummyModel( { name: 'Test' } ) ); + mocked( mockClient.patch ).mockResolvedValue( + new HTTPResponse( + 200, + {}, + { + id: 'Test-1', + label: 'Test 1', + } + ) + ); + mocked( mockTransformer.fromModel ).mockReturnValue( { + name: 'From-Test', + } ); + mocked( mockTransformer.toModel ).mockReturnValue( + new DummyModel( { name: 'Test' } ) + ); - const fn = restUpdate< DummyModelParams >( ( id ) => 'test-url-' + id, DummyModel, mockClient, mockTransformer ); + const fn = restUpdate< DummyModelParams >( + ( id ) => 'test-url-' + id, + DummyModel, + mockClient, + mockTransformer + ); const result = await fn( 123, { name: 'Test' } ); expect( result ).toMatchObject( new DummyModel( { name: 'Test' } ) ); - expect( mockTransformer.fromModel ).toHaveBeenCalledWith( { name: 'Test' } ); - expect( mockClient.patch ).toHaveBeenCalledWith( 'test-url-123', { name: 'From-Test' } ); - expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { id: 'Test-1', label: 'Test 1' } ); + expect( mockTransformer.fromModel ).toHaveBeenCalledWith( { + name: 'Test', + } ); + expect( mockClient.patch ).toHaveBeenCalledWith( 'test-url-123', { + name: 'From-Test', + } ); + expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyModel, { + id: 'Test-1', + label: 'Test 1', + } ); } ); it( 'restUpdateChildren', async () => { - mocked( mockClient.patch ).mockResolvedValue( new HTTPResponse( - 200, - {}, - { - id: 'Test-1', - label: 'Test 1', - }, - ) ); - mocked( mockTransformer.fromModel ).mockReturnValue( { name: 'From-Test' } ); - mocked( mockTransformer.toModel ).mockReturnValue( new DummyChildModel( { name: 'Test' } ) ); + mocked( mockClient.patch ).mockResolvedValue( + new HTTPResponse( + 200, + {}, + { + id: 'Test-1', + label: 'Test 1', + } + ) + ); + mocked( mockTransformer.fromModel ).mockReturnValue( { + name: 'From-Test', + } ); + mocked( mockTransformer.toModel ).mockReturnValue( + new DummyChildModel( { name: 'Test' } ) + ); const fn = restUpdateChild< DummyChildParams >( ( parent, id ) => 'test-url-' + parent.parent + '-' + id, DummyChildModel, mockClient, - mockTransformer, + mockTransformer ); - const result = await fn( { parent: '123' }, 456, { childName: 'Test' } ); + const result = await fn( { parent: '123' }, 456, { + childName: 'Test', + } ); - expect( result ).toMatchObject( new DummyChildModel( { name: 'Test' } ) ); - expect( mockTransformer.fromModel ).toHaveBeenCalledWith( { childName: 'Test' } ); - expect( mockClient.patch ).toHaveBeenCalledWith( 'test-url-123-456', { name: 'From-Test' } ); - expect( mockTransformer.toModel ).toHaveBeenCalledWith( DummyChildModel, { id: 'Test-1', label: 'Test 1' } ); + expect( result ).toMatchObject( + new DummyChildModel( { name: 'Test' } ) + ); + expect( mockTransformer.fromModel ).toHaveBeenCalledWith( { + childName: 'Test', + } ); + expect( mockClient.patch ).toHaveBeenCalledWith( 'test-url-123-456', { + name: 'From-Test', + } ); + expect( mockTransformer.toModel ).toHaveBeenCalledWith( + DummyChildModel, + { id: 'Test-1', label: 'Test 1' } + ); } ); it( 'restDelete', async () => { - mocked( mockClient.delete ).mockResolvedValue( new HTTPResponse( 200, {}, {} ) ); + mocked( mockClient.delete ).mockResolvedValue( + new HTTPResponse( 200, {}, {} ) + ); - const fn = restDelete< DummyModelParams >( ( id ) => 'test-url-' + id, mockClient ); + const fn = restDelete< DummyModelParams >( + ( id ) => 'test-url-' + id, + mockClient + ); const result = await fn( 123 ); @@ -239,11 +351,13 @@ describe( 'Shared REST Functions', () => { } ); it( 'restDeleteChildren', async () => { - mocked( mockClient.delete ).mockResolvedValue( new HTTPResponse( 200, {}, {} ) ); + mocked( mockClient.delete ).mockResolvedValue( + new HTTPResponse( 200, {}, {} ) + ); const fn = restDeleteChild< DummyChildParams >( ( parent, id ) => 'test-url-' + parent.parent + '-' + id, - mockClient, + mockClient ); const result = await fn( { parent: '123' }, 456 ); diff --git a/packages/js/api/src/repositories/rest/coupons/coupon.ts b/packages/js/api/src/repositories/rest/coupons/coupon.ts index c794232bb41..f5ce67df933 100644 --- a/packages/js/api/src/repositories/rest/coupons/coupon.ts +++ b/packages/js/api/src/repositories/rest/coupons/coupon.ts @@ -1,7 +1,5 @@ import { HTTPClient } from '../../../http'; -import { - ModelRepository, -} from '../../../framework'; +import { ModelRepository } from '../../../framework'; import { ModelID, Coupon, @@ -34,22 +32,45 @@ import { createCouponTransformer } from './transformer'; * DeletesCoupons * } The created repository. */ -export default function couponRESTRepository( httpClient: HTTPClient ): CreatesCoupons - & ListsCoupons - & ReadsCoupons - & UpdatesCoupons - & DeletesCoupons { +export default function couponRESTRepository( + httpClient: HTTPClient +): CreatesCoupons & + ListsCoupons & + ReadsCoupons & + UpdatesCoupons & + DeletesCoupons { const buildURL = ( id: ModelID ) => '/wc/v3/coupons/' + id; // Using `?force=true` permanently deletes the coupon - const buildDeleteUrl = ( id: ModelID ) => `/wc/v3/coupons/${ id }?force=true`; + const buildDeleteUrl = ( id: ModelID ) => + `/wc/v3/coupons/${ id }?force=true`; const transformer = createCouponTransformer(); return new ModelRepository( - restList< CouponRepositoryParams >( () => '/wc/v3/coupons', Coupon, httpClient, transformer ), - restCreate< CouponRepositoryParams >( () => '/wc/v3/coupons', Coupon, httpClient, transformer ), - restRead< CouponRepositoryParams >( buildURL, Coupon, httpClient, transformer ), - restUpdate< CouponRepositoryParams >( buildURL, Coupon, httpClient, transformer ), - restDelete< CouponRepositoryParams >( buildDeleteUrl, httpClient ), + restList< CouponRepositoryParams >( + () => '/wc/v3/coupons', + Coupon, + httpClient, + transformer + ), + restCreate< CouponRepositoryParams >( + () => '/wc/v3/coupons', + Coupon, + httpClient, + transformer + ), + restRead< CouponRepositoryParams >( + buildURL, + Coupon, + httpClient, + transformer + ), + restUpdate< CouponRepositoryParams >( + buildURL, + Coupon, + httpClient, + transformer + ), + restDelete< CouponRepositoryParams >( buildDeleteUrl, httpClient ) ); } diff --git a/packages/js/api/src/repositories/rest/coupons/transformer.ts b/packages/js/api/src/repositories/rest/coupons/transformer.ts index 8e0c7d85eb9..f79852a08f6 100644 --- a/packages/js/api/src/repositories/rest/coupons/transformer.ts +++ b/packages/js/api/src/repositories/rest/coupons/transformer.ts @@ -14,52 +14,46 @@ import { Coupon } from '../../../models'; * @return {ModelTransformer} The created transformer. */ export function createCouponTransformer(): ModelTransformer< Coupon > { - return new ModelTransformer( - [ - new IgnorePropertyTransformation( [ 'date_created', 'date_modified' ] ), - new PropertyTypeTransformation( - { - code: PropertyType.String, - amount: PropertyType.String, - dateCreated: PropertyType.Date, - dateModified: PropertyType.Date, - discountType: PropertyType.String, - dateExpires: PropertyType.Date, - usageCount: PropertyType.Integer, - individualUse: PropertyType.Boolean, - usageLimit: PropertyType.Integer, - usageLimitPerUser: PropertyType.Integer, - limitUsageToXItems: PropertyType.Integer, - freeShipping: PropertyType.Boolean, - excludeSaleItems: PropertyType.Boolean, - minimumAmount: PropertyType.String, - maximumAmount: PropertyType.String, - }, - ), - new KeyChangeTransformation< Coupon >( - { - dateCreated: 'date_created_gmt', - dateModified: 'date_modified_gmt', - discountType: 'discount_type', - dateExpires: 'date_expires', - usageCount: 'usage_count', - individualUse: 'individual_use', - productIds: 'product_ids', - excludedProductIds: 'excluded_product_ids', - usageLimit: 'usage_limit', - usageLimitPerUser: 'usage_limit_per_user', - limitUsageToXItems: 'limit_usage_to_x_items', - freeShipping: 'free_shipping', - productCategories: 'product_categories', - excludedProductCategories: 'excluded_product_categories', - excludeSaleItems: 'exclude_sale_items', - minimumAmount: 'minimum_amount', - maximumAmount: 'maximum_amount', - emailRestrictions: 'email_restrictions', - usedBy: 'used_by', - links: '_links', - }, - ), - ], - ); + return new ModelTransformer( [ + new IgnorePropertyTransformation( [ 'date_created', 'date_modified' ] ), + new PropertyTypeTransformation( { + code: PropertyType.String, + amount: PropertyType.String, + dateCreated: PropertyType.Date, + dateModified: PropertyType.Date, + discountType: PropertyType.String, + dateExpires: PropertyType.Date, + usageCount: PropertyType.Integer, + individualUse: PropertyType.Boolean, + usageLimit: PropertyType.Integer, + usageLimitPerUser: PropertyType.Integer, + limitUsageToXItems: PropertyType.Integer, + freeShipping: PropertyType.Boolean, + excludeSaleItems: PropertyType.Boolean, + minimumAmount: PropertyType.String, + maximumAmount: PropertyType.String, + } ), + new KeyChangeTransformation< Coupon >( { + dateCreated: 'date_created_gmt', + dateModified: 'date_modified_gmt', + discountType: 'discount_type', + dateExpires: 'date_expires', + usageCount: 'usage_count', + individualUse: 'individual_use', + productIds: 'product_ids', + excludedProductIds: 'excluded_product_ids', + usageLimit: 'usage_limit', + usageLimitPerUser: 'usage_limit_per_user', + limitUsageToXItems: 'limit_usage_to_x_items', + freeShipping: 'free_shipping', + productCategories: 'product_categories', + excludedProductCategories: 'excluded_product_categories', + excludeSaleItems: 'exclude_sale_items', + minimumAmount: 'minimum_amount', + maximumAmount: 'maximum_amount', + emailRestrictions: 'email_restrictions', + usedBy: 'used_by', + links: '_links', + } ), + ] ); } diff --git a/packages/js/api/src/repositories/rest/orders/__tests__/order-transformer.spec.ts b/packages/js/api/src/repositories/rest/orders/__tests__/order-transformer.spec.ts new file mode 100644 index 00000000000..ad63ea8b5ed --- /dev/null +++ b/packages/js/api/src/repositories/rest/orders/__tests__/order-transformer.spec.ts @@ -0,0 +1,392 @@ +import { + Order, + BillingOrderAddress, + ShippingOrderAddress, + OrderLineItem, + OrderShippingLine, + OrderFeeLine, + OrderTaxRate, + OrderCouponLine, + MetaData, + OrderRefundLine, +} from '../../../../models'; + +import { + createBillingAddressTransformer, + createOrderTransformer, + createShippingAddressTransformer, +} from '../transformer'; +/* + This Object is a JSON representation of single Order GET operation from the WooCommerce REST API. + + Developer note: + We use JSON.stringify here to convert an Object into a String, as JavaScript passes Objects + around by reference, and we don't want tests to modify the original `responseOrderJson` variable. + */ +const responseOrderJson = JSON.stringify( { + id: 1218, + parent_id: 0, + status: 'pending', + currency: 'USD', + version: '6.1.0', + prices_include_tax: false, + date_created: '2021-12-01T05:43:38', + date_modified: '2021-12-01T07:31:05', + discount_total: '5.00', + discount_tax: '0.60', + shipping_total: '0.00', + shipping_tax: '0.00', + cart_tax: '6.00', + total: '56.00', + total_tax: '6.00', + customer_id: 0, + order_key: 'wc_order_GFHWVmAiamh0B', + billing: { + first_name: 'Billing First Name', + last_name: 'Billing Last Name', + company: 'Billing Company', + address_1: 'Billing Address 1', + address_2: 'Billing Address 2', + city: 'Billing City', + state: 'Billing State', + postcode: 'Billing Postcode', + country: 'Billing Country', + email: 'Billing Email', + phone: 'Billing Phone', + }, + shipping: { + first_name: 'Shipping First Name', + last_name: 'Shipping Last Name', + company: 'Shipping Company', + address_1: 'Shipping Address 1', + address_2: 'Shipping Address 2', + city: 'Shipping City', + state: 'Shipping State', + postcode: 'Shipping Postcode', + country: 'Shipping Country', + phone: 'Shipping Phone', + }, + payment_method: 'Foo Payment Method', + payment_method_title: 'Foo Payment Method Title', + transaction_id: 'Foo Transaction ID', + customer_ip_address: 'Foo Customer IP Address', + customer_user_agent: 'Foo Customer User Agent', + created_via: 'admin', + customer_note: 'Foo Customer Note', + date_completed: 'Foo Date Completed', + date_paid: 'Foo Date Paid', + cart_hash: 'Foo Cart Hash', + number: '1218', + meta_data: [ + { + id: 123, + key: 'Foo Metadata Key 1', + value: 'Foo Metadata Value 1', + }, + ], + line_items: [ + { + id: 6137, + name: 'Belt', + product_id: 16, + variation_id: 0, + quantity: 1, + tax_class: '', + subtotal: '55.00', + subtotal_tax: '6.60', + total: '50.00', + total_tax: '6.00', + taxes: [ { id: 1, total: '6', subtotal: '6.6' } ], + meta_data: [], + sku: 'woo-belt', + price: 50, + parent_name: null, + }, + ], + tax_lines: [ + { + id: 6139, + rate_code: 'US-TAX-1', + rate_id: 1, + label: 'Tax', + compound: false, + tax_total: '6.00', + shipping_tax_total: '0.00', + rate_percent: 12, + meta_data: [], + }, + ], + shipping_lines: [ + { + id: 123, + method_title: 'Foo Method Title', + method_id: 456, + total: '5.00', + total_taxes: '10.00', + taxes: [ { id: 1, total: '6', subtotal: '6.6' } ], + meta_data: [], + }, + ], + fee_lines: [ + { + id: 123, + name: 'Foo Name', + tax_class: 456, + total: '5.00', + total_taxes: '10.00', + taxes: [ { id: 1, total: '6', subtotal: '6.6' } ], + meta_data: [], + }, + ], + coupon_lines: [ + { + id: 6138, + code: 'save5', + discount: '5', + discount_tax: '0.6', + meta_data: [ + { + id: 57112, + key: 'coupon_data', + value: { + id: 171, + code: 'save5', + amount: '5', + date_created: { + date: '2021-05-19 04:28:31.000000', + timezone_type: 3, + timezone: 'Pacific/Auckland', + }, + date_modified: { + date: '2021-05-19 04:28:31.000000', + timezone_type: 3, + timezone: 'Pacific/Auckland', + }, + date_expires: null, + discount_type: 'fixed_cart', + description: '', + usage_count: 3, + individual_use: false, + product_ids: [], + excluded_product_ids: [], + usage_limit: 0, + usage_limit_per_user: 0, + limit_usage_to_x_items: null, + free_shipping: false, + product_categories: [], + excluded_product_categories: [], + exclude_sale_items: false, + minimum_amount: '', + maximum_amount: '', + email_restrictions: [], + virtual: false, + meta_data: [], + }, + }, + ], + }, + ], + refunds: [ + { + id: 123, + reason: 'Foo Reason', + total: '5.00', + }, + ], + date_created_gmt: '2021-11-30T16:43:38', + date_modified_gmt: '2021-11-30T18:31:05', + date_completed_gmt: null, + date_paid_gmt: null, + currency_symbol: '$', + _links: { + self: [ + { + href: 'http://local.wordpress.test/wp-json/wc/v3/orders/1218', + }, + ], + collection: [ + { + href: 'http://local.wordpress.test/wp-json/wc/v3/orders', + }, + ], + }, +} ); + +describe( 'OrderTransformer', () => { + it( 'should transform an order JSON', () => { + const order = createOrderTransformer().toModel( + Order, + JSON.parse( responseOrderJson ) + ); + const billing = createBillingAddressTransformer().toModel( + BillingOrderAddress, + JSON.parse( responseOrderJson ).billing + ); + const shipping = createShippingAddressTransformer().toModel( + ShippingOrderAddress, + JSON.parse( responseOrderJson ).shipping + ); + + // Order + expect( order ).toBeInstanceOf( Order ); + expect( order.id ).toStrictEqual( 1218 ); + expect( order.parentId ).toStrictEqual( 0 ); + expect( order.status ).toStrictEqual( 'pending' ); + expect( order.currency ).toStrictEqual( 'USD' ); + expect( order.version ).toStrictEqual( '6.1.0' ); + expect( order.pricesIncludeTax ).toStrictEqual( false ); + //expect( model.dateCreated ).toStrictEqual('2021-12-01T05:43:38'); + //expect( model.dateModified ).toStrictEqual('2021-12-01T07:31:05'); + expect( order.discountTotal ).toStrictEqual( '5.00' ); + expect( order.discountTax ).toStrictEqual( '0.60' ); + expect( order.shippingTotal ).toStrictEqual( '0.00' ); + expect( order.shippingTax ).toStrictEqual( '0.00' ); + expect( order.cartTax ).toStrictEqual( '6.00' ); + expect( order.total ).toStrictEqual( '56.00' ); + expect( order.totalTax ).toStrictEqual( '6.00' ); + expect( order.customerId ).toStrictEqual( 0 ); + //expect( model.orderKey ).toStrictEqual('wc_order_GFHWVmAiamh0B'); + expect( order.billing ).toStrictEqual( billing ); + expect( order.shipping ).toStrictEqual( shipping ); + expect( order.paymentMethod ).toStrictEqual( 'Foo Payment Method' ); + //expect( order.paymentMethodTitle ).toStrictEqual('Foo Payment Method Title'); + expect( order.transactionId ).toStrictEqual( 'Foo Transaction ID' ); + //expect( order.customerIpAddress ).toStrictEqual('Foo Customer IP Address'); + //expect( order.customerUserAgent ).toStrictEqual('Foo Customer User Agent'); + //expect( order.createdVia ).toStrictEqual('admin'); + expect( order.customerNote ).toStrictEqual( 'Foo Customer Note' ); + //expect( order.dateCompleted ).toStrictEqual('Foo Date Completed'); + //expect( order.datePaid ).toStrictEqual('Foo Date Paid'); + //expect( order.cartHash ).toStrictEqual('Foo Cart Hash'); + //expect( order.orderNumber ).toStrictEqual('1218'); + + // Billing + expect( billing.firstName ).toStrictEqual( 'Billing First Name' ); + expect( billing.lastName ).toStrictEqual( 'Billing Last Name' ); + expect( billing.company ).toStrictEqual( 'Billing Company' ); + expect( billing.address1 ).toStrictEqual( 'Billing Address 1' ); + expect( billing.address2 ).toStrictEqual( 'Billing Address 2' ); + expect( billing.city ).toStrictEqual( 'Billing City' ); + expect( billing.state ).toStrictEqual( 'Billing State' ); + expect( billing.postCode ).toStrictEqual( 'Billing Postcode' ); + expect( billing.country ).toStrictEqual( 'Billing Country' ); + expect( billing.email ).toStrictEqual( 'Billing Email' ); + expect( billing.phone ).toStrictEqual( 'Billing Phone' ); + expect( + createBillingAddressTransformer().fromModel( billing ) + ).toStrictEqual( JSON.parse( responseOrderJson ).billing ); + + // Shipping + expect( shipping.firstName ).toStrictEqual( 'Shipping First Name' ); + expect( shipping.lastName ).toStrictEqual( 'Shipping Last Name' ); + expect( shipping.company ).toStrictEqual( 'Shipping Company' ); + expect( shipping.address1 ).toStrictEqual( 'Shipping Address 1' ); + expect( shipping.address2 ).toStrictEqual( 'Shipping Address 2' ); + expect( shipping.city ).toStrictEqual( 'Shipping City' ); + expect( shipping.state ).toStrictEqual( 'Shipping State' ); + expect( shipping.postCode ).toStrictEqual( 'Shipping Postcode' ); + expect( shipping.country ).toStrictEqual( 'Shipping Country' ); + + /* + * Shipping Address does not have e-mail or phone fields according to WooCommerce API + * @link https://woocommerce.github.io/woocommerce-rest-api-docs/#order-shipping-properties + */ + //expect(shipping.email).toStrictEqual('Shipping Email'); + //expect(shipping.phone).toStrictEqual('Shipping Phone'); + expect( + createShippingAddressTransformer().fromModel( shipping ) + ).toStrictEqual( JSON.parse( responseOrderJson ).shipping ); + + // Metadata + expect( order.metaData ).toHaveLength( 1 ); + //expect(order.metaData[0]['id']).toStrictEqual(123); + expect( order.metaData[ 0 ].key ).toStrictEqual( 'Foo Metadata Key 1' ); + expect( order.metaData[ 0 ].value ).toStrictEqual( + 'Foo Metadata Value 1' + ); + + // Line Items + expect( order.lineItems ).toHaveLength( 1 ); + expect( order.lineItems[ 0 ] ).toBeInstanceOf( OrderLineItem ); + expect( order.lineItems[ 0 ].id ).toStrictEqual( 6137 ); + expect( order.lineItems[ 0 ].name ).toStrictEqual( 'Belt' ); + expect( order.lineItems[ 0 ].productId ).toStrictEqual( 16 ); + expect( order.lineItems[ 0 ].variationId ).toStrictEqual( 0 ); + expect( order.lineItems[ 0 ].quantity ).toStrictEqual( 1 ); + expect( order.lineItems[ 0 ].taxClass ).toStrictEqual( '' ); + expect( order.lineItems[ 0 ].subtotal ).toStrictEqual( '55.00' ); + expect( order.lineItems[ 0 ].subtotalTax ).toStrictEqual( '6.60' ); + expect( order.lineItems[ 0 ].total ).toStrictEqual( '50.00' ); + expect( order.lineItems[ 0 ].totalTax ).toStrictEqual( '6.00' ); + //expect(order.lineItems[0].taxes).toStrictEqual([ { id: 1, total: '6', subtotal: '6.6' } ]); + expect( order.lineItems[ 0 ].metaData ).toStrictEqual( [] ); + expect( order.lineItems[ 0 ].sku ).toStrictEqual( 'woo-belt' ); + expect( order.lineItems[ 0 ].price ).toStrictEqual( 50 ); + expect( order.lineItems[ 0 ].parentName ).toStrictEqual( null ); + + // Tax Lines + expect( order.taxLines ).toHaveLength( 1 ); + expect( order.taxLines[ 0 ] ).toBeInstanceOf( OrderTaxRate ); + //expect(order.taxLines[0].id).toStrictEqual(6139); + expect( order.taxLines[ 0 ].rateCode ).toStrictEqual( 'US-TAX-1' ); + expect( order.taxLines[ 0 ].rateId ).toStrictEqual( 1 ); + expect( order.taxLines[ 0 ].label ).toStrictEqual( 'Tax' ); + //expect(order.taxLines[0].compound).toStrictEqual(false); + expect( order.taxLines[ 0 ].taxTotal ).toStrictEqual( '6.00' ); + expect( order.taxLines[ 0 ].shippingTaxTotal ).toStrictEqual( '0.00' ); + expect( order.taxLines[ 0 ].ratePercent ).toStrictEqual( 12 ); + //expect(order.taxLines[0].metaData).toStrictEqual([]); + + // Shipping Lines + expect( order.shippingLines ).toHaveLength( 1 ); + expect( order.shippingLines[ 0 ] ).toBeInstanceOf( OrderShippingLine ); + //expect(order.shippingLines[0].id).toStrictEqual(123); + expect( order.shippingLines[ 0 ].methodTitle ).toStrictEqual( + 'Foo Method Title' + ); + expect( order.shippingLines[ 0 ].methodId ).toStrictEqual( 456 ); + expect( order.shippingLines[ 0 ].total ).toStrictEqual( '5.00' ); + //expect(order.shippingLines[0].totalTaxes).toStrictEqual('5.00'); + //expect(order.shippingLines[0].totalTax).toStrictEqual('10.00'); + //expect(order.shippingLines[0].taxes).toStrictEqual([ { id: 1, total: '6', subtotal: '6.6' } ]); + expect( order.shippingLines[ 0 ].metaData ).toStrictEqual( [] ); + + // Fee Lines + expect( order.feeLines ).toHaveLength( 1 ); + expect( order.feeLines[ 0 ] ).toBeInstanceOf( OrderFeeLine ); + //expect(order.feeLines[0].id).toStrictEqual(123); + expect( order.feeLines[ 0 ].name ).toStrictEqual( 'Foo Name' ); + expect( order.feeLines[ 0 ].total ).toStrictEqual( '5.00' ); + //expect(order.feeLines[0].totalTaxes).toStrictEqual('5.00'); + //expect(order.feeLines[0].totalTax).toStrictEqual('10.00'); + //expect(order.feeLines[0].taxes).toStrictEqual([ { id: 1, total: '6', subtotal: '6.6' } ]); + expect( order.feeLines[ 0 ].metaData ).toStrictEqual( [] ); + + // Coupon Lines + expect( order.couponLines ).toHaveLength( 1 ); + expect( order.couponLines[ 0 ] ).toBeInstanceOf( OrderCouponLine ); + //expect(order.couponLines[0].id).toStrictEqual(6138); + expect( order.couponLines[ 0 ].code ).toStrictEqual( 'save5' ); + expect( order.couponLines[ 0 ].discount ).toStrictEqual( '5' ); + expect( order.couponLines[ 0 ].discountTax ).toStrictEqual( '0.6' ); + expect( order.couponLines[ 0 ].metaData ).toHaveLength( 1 ); + expect( order.couponLines[ 0 ].metaData[ 0 ] ).toBeInstanceOf( + MetaData + ); + //expect(order.couponLines[0].metaData[0].id).toStrictEqual(57112); + expect( order.couponLines[ 0 ].metaData[ 0 ].key ).toStrictEqual( + 'coupon_data' + ); + expect( order.couponLines[ 0 ].metaData[ 0 ].value ).toStrictEqual( + JSON.parse( responseOrderJson ).coupon_lines[ 0 ].meta_data[ 0 ] + .value + ); + + // Refunds + expect( order.refunds ).toHaveLength( 1 ); + expect( order.refunds[ 0 ] ).toBeInstanceOf( OrderRefundLine ); + //expect(order.refunds[0].id).toStrictEqual(123); + expect( order.refunds[ 0 ].reason ).toStrictEqual( 'Foo Reason' ); + expect( order.refunds[ 0 ].total ).toStrictEqual( '5.00' ); + } ); +} ); diff --git a/packages/js/api/src/repositories/rest/orders/order.ts b/packages/js/api/src/repositories/rest/orders/order.ts index 76e29525982..897d5c5c29c 100644 --- a/packages/js/api/src/repositories/rest/orders/order.ts +++ b/packages/js/api/src/repositories/rest/orders/order.ts @@ -1,7 +1,5 @@ import { HTTPClient } from '../../../http'; -import { - ModelRepository, -} from '../../../framework'; +import { ModelRepository } from '../../../framework'; import { ModelID, Order, @@ -26,22 +24,41 @@ import { createOrderTransformer } from './transformer'; * * @param {HTTPClient} httpClient The HTTP client for the REST requests to be made using. */ -export default function orderRESTRepository( httpClient: HTTPClient ): CreatesOrders -& ListsOrders -& ReadsOrders -& UpdatesOrders -& DeletesOrders { +export default function orderRESTRepository( + httpClient: HTTPClient +): CreatesOrders & ListsOrders & ReadsOrders & UpdatesOrders & DeletesOrders { const buildURL = ( id: ModelID ) => '/wc/v3/orders/' + id; // Using `?force=true` permanently deletes the order - const buildDeleteUrl = ( id: ModelID ) => `/wc/v3/orders/${ id }?force=true`; + const buildDeleteUrl = ( id: ModelID ) => + `/wc/v3/orders/${ id }?force=true`; const transformer = createOrderTransformer(); return new ModelRepository( - restList< OrderRepositoryParams >( () => '/wc/v3/orders', Order, httpClient, transformer ), - restCreate< OrderRepositoryParams >( () => '/wc/v3/orders', Order, httpClient, transformer ), - restRead< OrderRepositoryParams >( buildURL, Order, httpClient, transformer ), - restUpdate< OrderRepositoryParams >( buildURL, Order, httpClient, transformer ), - restDelete< OrderRepositoryParams >( buildDeleteUrl, httpClient ), + restList< OrderRepositoryParams >( + () => '/wc/v3/orders', + Order, + httpClient, + transformer + ), + restCreate< OrderRepositoryParams >( + () => '/wc/v3/orders', + Order, + httpClient, + transformer + ), + restRead< OrderRepositoryParams >( + buildURL, + Order, + httpClient, + transformer + ), + restUpdate< OrderRepositoryParams >( + buildURL, + Order, + httpClient, + transformer + ), + restDelete< OrderRepositoryParams >( buildDeleteUrl, httpClient ) ); } diff --git a/packages/js/api/src/repositories/rest/orders/transformer.ts b/packages/js/api/src/repositories/rest/orders/transformer.ts index 5cd3c0e2b6f..ecf1bd287c0 100644 --- a/packages/js/api/src/repositories/rest/orders/transformer.ts +++ b/packages/js/api/src/repositories/rest/orders/transformer.ts @@ -8,14 +8,17 @@ import { } from '../../../framework'; import { Order, - OrderAddress, + BillingOrderAddress, + ShippingOrderAddress, OrderCouponLine, OrderFeeLine, OrderLineItem, OrderRefundLine, OrderShippingLine, OrderTaxRate, + MetaData, } from '../../../models'; +import { createMetaDataTransformer } from '../shared'; /** * Creates a transformer for an order object. @@ -23,54 +26,92 @@ import { * @return {ModelTransformer} The created transformer. */ export function createOrderTransformer(): ModelTransformer< Order > { - return new ModelTransformer( - [ - new IgnorePropertyTransformation( [ 'date_created', 'date_modified' ] ), - new ModelTransformerTransformation( 'billing', OrderAddress, createOrderAddressTransformer() ), - new ModelTransformerTransformation( 'tax_lines', OrderTaxRate, createOrderTaxRateTransformer() ), - new ModelTransformerTransformation( 'refunds', OrderRefundLine, createOrderRefundLineTransformer() ), - new ModelTransformerTransformation( 'coupon_lines', OrderCouponLine, createOrdeCouponLineTransformer() ), - new ModelTransformerTransformation( 'fee_lines', OrderFeeLine, createOrderFeeLineTransformer() ), - new ModelTransformerTransformation( 'line_items', OrderLineItem, createOrderLineItemTransformer() ), - new ModelTransformerTransformation( 'shipping_lines', OrderShippingLine, createOrderShippingItemTransformer() ), + return new ModelTransformer( [ + new IgnorePropertyTransformation( [ 'date_created', 'date_modified' ] ), + new ModelTransformerTransformation( + 'billing', + BillingOrderAddress, + createBillingAddressTransformer() + ), + new ModelTransformerTransformation( + 'shipping', + ShippingOrderAddress, + createShippingAddressTransformer() + ), + new ModelTransformerTransformation( + 'taxLines', + OrderTaxRate, + createOrderTaxRateTransformer() + ), + new ModelTransformerTransformation( + 'refunds', + OrderRefundLine, + createOrderRefundLineTransformer() + ), + new ModelTransformerTransformation( + 'couponLines', + OrderCouponLine, + createOrdeCouponLineTransformer() + ), + new ModelTransformerTransformation( + 'feeLines', + OrderFeeLine, + createOrderFeeLineTransformer() + ), + new ModelTransformerTransformation( + 'lineItems', + OrderLineItem, + createOrderLineItemTransformer() + ), + new ModelTransformerTransformation( + 'shippingLines', + OrderShippingLine, + createOrderShippingItemTransformer() + ), + new ModelTransformerTransformation( + 'metaData', + MetaData, + createMetaDataTransformer() + ), - new PropertyTypeTransformation( - { - status: PropertyType.String, - currency: PropertyType.String, - discountTotal: PropertyType.String, - discountTax: PropertyType.String, - shippingTotal: PropertyType.String, - shippingTax: PropertyType.String, - cartTax: PropertyType.String, - total: PropertyType.String, - totalTax: PropertyType.String, - pricesIncludeTax: PropertyType.Boolean, - customerId: PropertyType.Integer, - customerNote: PropertyType.String, - paymentMethod: PropertyType.String, - transactionId: PropertyType.String, - setPaid: PropertyType.Boolean, - }, - ), - new KeyChangeTransformation< Order >( - { - discountTotal: 'discount_total', - discountTax: 'discount_tax', - shippingTotal: 'shipping_total', - shippingTax: 'shipping_tax', - cartTax: 'cart_tax', - totalTax: 'total_tax', - pricesIncludeTax: 'prices_include_tax', - customerId: 'customer_id', - customerNote: 'customer_note', - paymentMethod: 'payment_method', - transactionId: 'transaction_id', - setPaid: 'set_paid', - }, - ), - ], - ); + new PropertyTypeTransformation( { + status: PropertyType.String, + currency: PropertyType.String, + discountTotal: PropertyType.String, + discountTax: PropertyType.String, + shippingTotal: PropertyType.String, + shippingTax: PropertyType.String, + cartTax: PropertyType.String, + total: PropertyType.String, + totalTax: PropertyType.String, + pricesIncludeTax: PropertyType.Boolean, + customerId: PropertyType.Integer, + customerNote: PropertyType.String, + paymentMethod: PropertyType.String, + transactionId: PropertyType.String, + setPaid: PropertyType.Boolean, + } ), + new KeyChangeTransformation< Order >( { + discountTotal: 'discount_total', + discountTax: 'discount_tax', + shippingTotal: 'shipping_total', + shippingTax: 'shipping_tax', + cartTax: 'cart_tax', + totalTax: 'total_tax', + pricesIncludeTax: 'prices_include_tax', + customerId: 'customer_id', + customerNote: 'customer_note', + paymentMethod: 'payment_method', + transactionId: 'transaction_id', + setPaid: 'set_paid', + lineItems: 'line_items', + taxLines: 'tax_lines', + shippingLines: 'shipping_lines', + feeLines: 'fee_lines', + couponLines: 'coupon_lines', + metaData: 'meta_data', + } ), + ] ); } /** @@ -78,33 +119,57 @@ export function createOrderTransformer(): ModelTransformer< Order > { * * @return {ModelTransformer} The created transformer. */ -export function createOrderAddressTransformer(): ModelTransformer< OrderAddress > { - return new ModelTransformer( - [ - new PropertyTypeTransformation( - { - firstName: PropertyType.String, - lastName: PropertyType.String, - company: PropertyType.String, - address1: PropertyType.String, - address2: PropertyType.String, - city: PropertyType.String, - state: PropertyType.String, - postCode: PropertyType.String, - country: PropertyType.String, - }, - ), - new KeyChangeTransformation< OrderAddress >( - { - firstName: 'first_name', - lastName: 'last_name', - address1: 'address_1', - address2: 'address_2', - postCode: 'postcode', - }, - ), - ], - ); +export function createBillingAddressTransformer(): ModelTransformer< BillingOrderAddress > { + return new ModelTransformer( [ + new PropertyTypeTransformation( { + firstName: PropertyType.String, + lastName: PropertyType.String, + company: PropertyType.String, + address1: PropertyType.String, + address2: PropertyType.String, + city: PropertyType.String, + state: PropertyType.String, + postCode: PropertyType.String, + country: PropertyType.String, + phone: PropertyType.String, + email: PropertyType.String, + } ), + new KeyChangeTransformation< BillingOrderAddress >( { + firstName: 'first_name', + lastName: 'last_name', + address1: 'address_1', + address2: 'address_2', + postCode: 'postcode', + } ), + ] ); +} + +/** + * Creates a transformer for an order address object. + * + * @return {ModelTransformer} The created transformer. + */ +export function createShippingAddressTransformer(): ModelTransformer< ShippingOrderAddress > { + return new ModelTransformer( [ + new PropertyTypeTransformation( { + firstName: PropertyType.String, + lastName: PropertyType.String, + company: PropertyType.String, + address1: PropertyType.String, + address2: PropertyType.String, + city: PropertyType.String, + state: PropertyType.String, + postCode: PropertyType.String, + country: PropertyType.String, + } ), + new KeyChangeTransformation< ShippingOrderAddress >( { + firstName: 'first_name', + lastName: 'last_name', + address1: 'address_1', + address2: 'address_2', + postCode: 'postcode', + } ), + ] ); } /** @@ -113,30 +178,25 @@ export function createOrderAddressTransformer(): ModelTransformer< OrderAddress * @return {ModelTransformer} The created transformer. */ function createOrderTaxRateTransformer(): ModelTransformer< OrderTaxRate > { - return new ModelTransformer( - [ - new PropertyTypeTransformation( - { - rateCode: PropertyType.String, - rateId: PropertyType.Integer, - label: PropertyType.String, - compoundRate: PropertyType.Boolean, - taxTotal: PropertyType.String, - shippingTaxTotal: PropertyType.String, - ratePercent: PropertyType.Integer, - }, - ), - new KeyChangeTransformation< OrderTaxRate >( - { - rateCode: 'rate_code', - rateId: 'rate_id', - compoundRate: 'compound', - taxTotal: 'tax_total', - shippingTaxTotal: 'shipping_tax_total', - }, - ), - ], - ); + return new ModelTransformer( [ + new PropertyTypeTransformation( { + rateCode: PropertyType.String, + rateId: PropertyType.Integer, + label: PropertyType.String, + compoundRate: PropertyType.Boolean, + taxTotal: PropertyType.String, + shippingTaxTotal: PropertyType.String, + ratePercent: PropertyType.Integer, + } ), + new KeyChangeTransformation< OrderTaxRate >( { + rateCode: 'rate_code', + ratePercent: 'rate_percent', + rateId: 'rate_id', + compoundRate: 'compound', + taxTotal: 'tax_total', + shippingTaxTotal: 'shipping_tax_total', + } ), + ] ); } /** @@ -145,16 +205,12 @@ function createOrderTaxRateTransformer(): ModelTransformer< OrderTaxRate > { * @return {ModelTransformer} The created transformer. */ function createOrderRefundLineTransformer(): ModelTransformer< OrderRefundLine > { - return new ModelTransformer( - [ - new PropertyTypeTransformation( - { - reason: PropertyType.String, - total: PropertyType.String, - }, - ), - ], - ); + return new ModelTransformer( [ + new PropertyTypeTransformation( { + reason: PropertyType.String, + total: PropertyType.String, + } ), + ] ); } /** @@ -163,22 +219,22 @@ function createOrderRefundLineTransformer(): ModelTransformer< OrderRefundLine > * @return {ModelTransformer} The created transformer. */ function createOrdeCouponLineTransformer(): ModelTransformer< OrderCouponLine > { - return new ModelTransformer( - [ - new PropertyTypeTransformation( - { - code: PropertyType.String, - discount: PropertyType.Integer, - discountTax: PropertyType.String, - }, - ), - new KeyChangeTransformation< OrderCouponLine >( - { - discountTax: 'discount_tax', - }, - ), - ], - ); + return new ModelTransformer( [ + new ModelTransformerTransformation( + 'metaData', + MetaData, + createMetaDataTransformer() + ), + new PropertyTypeTransformation( { + code: PropertyType.String, + discount: PropertyType.String, + discountTax: PropertyType.String, + } ), + new KeyChangeTransformation< OrderCouponLine >( { + discountTax: 'discount_tax', + metaData: 'meta_data', + } ), + ] ); } /** @@ -187,27 +243,25 @@ function createOrdeCouponLineTransformer(): ModelTransformer< OrderCouponLine > * @return {ModelTransformer} The created transformer. */ function createOrderFeeLineTransformer(): ModelTransformer< OrderFeeLine > { - return new ModelTransformer( - [ - new ModelTransformerTransformation( 'taxes', OrderTaxRate, createOrderTaxRateTransformer() ), - new PropertyTypeTransformation( - { - name: PropertyType.String, - taxClass: PropertyType.String, - taxStatus: PropertyType.String, - total: PropertyType.String, - totalTax: PropertyType.String, - }, - ), - new KeyChangeTransformation< OrderFeeLine >( - { - taxClass: 'tax_class', - taxStatus: 'tax_status', - totalTax: 'total_tax', - }, - ), - ], - ); + return new ModelTransformer( [ + new ModelTransformerTransformation( + 'taxes', + OrderTaxRate, + createOrderTaxRateTransformer() + ), + new PropertyTypeTransformation( { + name: PropertyType.String, + taxClass: PropertyType.String, + taxStatus: PropertyType.String, + total: PropertyType.String, + totalTax: PropertyType.String, + } ), + new KeyChangeTransformation< OrderFeeLine >( { + taxClass: 'tax_class', + taxStatus: 'tax_status', + totalTax: 'total_tax', + } ), + ] ); } /** @@ -216,36 +270,34 @@ function createOrderFeeLineTransformer(): ModelTransformer< OrderFeeLine > { * @return {ModelTransformer} The created transformer. */ function createOrderLineItemTransformer(): ModelTransformer< OrderLineItem > { - return new ModelTransformer( - [ - new ModelTransformerTransformation( 'taxes', OrderTaxRate, createOrderTaxRateTransformer() ), - new PropertyTypeTransformation( - { - name: PropertyType.String, - productId: PropertyType.Integer, - variationId: PropertyType.Integer, - quantity: PropertyType.Integer, - taxClass: PropertyType.String, - subtotal: PropertyType.String, - subtotalTax: PropertyType.String, - total: PropertyType.String, - totalTax: PropertyType.String, - sku: PropertyType.String, - price: PropertyType.Integer, - parentName: PropertyType.String, - }, - ), - new KeyChangeTransformation< OrderLineItem >( - { - productId: 'product_id', - variationId: 'variation_id', - taxClass: 'tax_class', - subtotalTax: 'subtotal_tax', - totalTax: 'total_tax', - }, - ), - ], - ); + return new ModelTransformer( [ + new ModelTransformerTransformation( + 'taxes', + OrderTaxRate, + createOrderTaxRateTransformer() + ), + new PropertyTypeTransformation( { + name: PropertyType.String, + productId: PropertyType.Integer, + variationId: PropertyType.Integer, + quantity: PropertyType.Integer, + taxClass: PropertyType.String, + subtotal: PropertyType.String, + subtotalTax: PropertyType.String, + total: PropertyType.String, + totalTax: PropertyType.String, + sku: PropertyType.String, + price: PropertyType.Integer, + parentName: PropertyType.String, + } ), + new KeyChangeTransformation< OrderLineItem >( { + productId: 'product_id', + variationId: 'variation_id', + taxClass: 'tax_class', + subtotalTax: 'subtotal_tax', + totalTax: 'total_tax', + } ), + ] ); } /** @@ -254,24 +306,22 @@ function createOrderLineItemTransformer(): ModelTransformer< OrderLineItem > { * @return {ModelTransformer} The created transformer. */ function createOrderShippingItemTransformer(): ModelTransformer< OrderShippingLine > { - return new ModelTransformer( - [ - new ModelTransformerTransformation( 'taxes', OrderTaxRate, createOrderTaxRateTransformer() ), - new PropertyTypeTransformation( - { - methodTitle: PropertyType.String, - methodId: PropertyType.String, - total: PropertyType.String, - totalTax: PropertyType.String, - }, - ), - new KeyChangeTransformation< OrderShippingLine >( - { - methodTitle: 'method_title', - methodId: 'method_id', - totalTax: 'total_tax', - }, - ), - ], - ); + return new ModelTransformer( [ + new ModelTransformerTransformation( + 'taxes', + OrderTaxRate, + createOrderTaxRateTransformer() + ), + new PropertyTypeTransformation( { + methodTitle: PropertyType.String, + methodId: PropertyType.Integer, + total: PropertyType.String, + totalTax: PropertyType.String, + } ), + new KeyChangeTransformation< OrderShippingLine >( { + methodTitle: 'method_title', + methodId: 'method_id', + totalTax: 'total_tax', + } ), + ] ); } diff --git a/packages/js/api/src/repositories/rest/products/external-product.ts b/packages/js/api/src/repositories/rest/products/external-product.ts index bd78edce17f..0143e6cf354 100644 --- a/packages/js/api/src/repositories/rest/products/external-product.ts +++ b/packages/js/api/src/repositories/rest/products/external-product.ts @@ -39,29 +39,52 @@ import { * DeletesExternalProducts * } The created repository. */ -export function externalProductRESTRepository( httpClient: HTTPClient ): ListsExternalProducts - & CreatesExternalProducts - & ReadsExternalProducts - & UpdatesExternalProducts - & DeletesExternalProducts { +export function externalProductRESTRepository( + httpClient: HTTPClient +): ListsExternalProducts & + CreatesExternalProducts & + ReadsExternalProducts & + UpdatesExternalProducts & + DeletesExternalProducts { const external = createProductExternalTransformation(); const price = createProductPriceTransformation(); const salesTax = createProductSalesTaxTransformation(); const upsells = createProductUpSellsTransformation(); - const transformations = [ - ...external, - ...price, - ...salesTax, - ...upsells, - ]; + const transformations = [ ...external, ...price, ...salesTax, ...upsells ]; - const transformer = createProductTransformer( 'external', transformations ); + const transformer = createProductTransformer< ExternalProduct >( + 'external', + transformations + ); return new ModelRepository( - restList< ExternalProductRepositoryParams >( baseProductURL, ExternalProduct, httpClient, transformer ), - restCreate< ExternalProductRepositoryParams >( baseProductURL, ExternalProduct, httpClient, transformer ), - restRead< ExternalProductRepositoryParams >( buildProductURL, ExternalProduct, httpClient, transformer ), - restUpdate< ExternalProductRepositoryParams >( buildProductURL, ExternalProduct, httpClient, transformer ), - restDelete< ExternalProductRepositoryParams >( deleteProductURL, httpClient ), + restList< ExternalProductRepositoryParams >( + baseProductURL, + ExternalProduct, + httpClient, + transformer + ), + restCreate< ExternalProductRepositoryParams >( + baseProductURL, + ExternalProduct, + httpClient, + transformer + ), + restRead< ExternalProductRepositoryParams >( + buildProductURL, + ExternalProduct, + httpClient, + transformer + ), + restUpdate< ExternalProductRepositoryParams >( + buildProductURL, + ExternalProduct, + httpClient, + transformer + ), + restDelete< ExternalProductRepositoryParams >( + deleteProductURL, + httpClient + ) ); } diff --git a/packages/js/api/src/repositories/rest/products/grouped-product.ts b/packages/js/api/src/repositories/rest/products/grouped-product.ts index c2a9a4dd338..4b7b3c67ac5 100644 --- a/packages/js/api/src/repositories/rest/products/grouped-product.ts +++ b/packages/js/api/src/repositories/rest/products/grouped-product.ts @@ -37,25 +37,50 @@ import { * DeletesGroupedProducts * } The created repository. */ -export function groupedProductRESTRepository( httpClient: HTTPClient ): ListsGroupedProducts - & CreatesGroupedProducts - & ReadsGroupedProducts - & UpdatesGroupedProducts - & DeletesGroupedProducts { +export function groupedProductRESTRepository( + httpClient: HTTPClient +): ListsGroupedProducts & + CreatesGroupedProducts & + ReadsGroupedProducts & + UpdatesGroupedProducts & + DeletesGroupedProducts { const upsells = createProductUpSellsTransformation(); const grouped = createProductGroupedTransformation(); - const transformations = [ - ...upsells, - ...grouped, - ]; + const transformations = [ ...upsells, ...grouped ]; - const transformer = createProductTransformer( 'grouped', transformations ); + const transformer = createProductTransformer< GroupedProduct >( + 'grouped', + transformations + ); return new ModelRepository( - restList< GroupedProductRepositoryParams >( baseProductURL, GroupedProduct, httpClient, transformer ), - restCreate< GroupedProductRepositoryParams >( baseProductURL, GroupedProduct, httpClient, transformer ), - restRead< GroupedProductRepositoryParams >( buildProductURL, GroupedProduct, httpClient, transformer ), - restUpdate< GroupedProductRepositoryParams >( buildProductURL, GroupedProduct, httpClient, transformer ), - restDelete< GroupedProductRepositoryParams >( deleteProductURL, httpClient ), + restList< GroupedProductRepositoryParams >( + baseProductURL, + GroupedProduct, + httpClient, + transformer + ), + restCreate< GroupedProductRepositoryParams >( + baseProductURL, + GroupedProduct, + httpClient, + transformer + ), + restRead< GroupedProductRepositoryParams >( + buildProductURL, + GroupedProduct, + httpClient, + transformer + ), + restUpdate< GroupedProductRepositoryParams >( + buildProductURL, + GroupedProduct, + httpClient, + transformer + ), + restDelete< GroupedProductRepositoryParams >( + deleteProductURL, + httpClient + ) ); } diff --git a/packages/js/api/src/repositories/rest/products/shared.ts b/packages/js/api/src/repositories/rest/products/shared.ts index 9cd81bde891..aef65b570af 100644 --- a/packages/js/api/src/repositories/rest/products/shared.ts +++ b/packages/js/api/src/repositories/rest/products/shared.ts @@ -37,11 +37,9 @@ import { createMetaDataTransformer } from '../shared'; * @return {ModelTransformer} The created transformer. */ function createProductTermTransformer(): ModelTransformer< ProductTerm > { - return new ModelTransformer( - [ - new PropertyTypeTransformation( { id: PropertyType.Integer } ), - ], - ); + return new ModelTransformer( [ + new PropertyTypeTransformation( { id: PropertyType.Integer } ), + ] ); } /** @@ -50,25 +48,19 @@ function createProductTermTransformer(): ModelTransformer< ProductTerm > { * @return {ModelTransformer} The created transformer. */ function createProductAttributeTransformer(): ModelTransformer< ProductAttribute > { - return new ModelTransformer( - [ - new PropertyTypeTransformation( - { - id: PropertyType.Integer, - sortOrder: PropertyType.Integer, - isVisibleOnProductPage: PropertyType.Boolean, - isForVariations: PropertyType.Boolean, - }, - ), - new KeyChangeTransformation< ProductAttribute >( - { - sortOrder: 'position', - isVisibleOnProductPage: 'visible', - isForVariations: 'variation', - }, - ), - ], - ); + return new ModelTransformer( [ + new PropertyTypeTransformation( { + id: PropertyType.Integer, + sortOrder: PropertyType.Integer, + isVisibleOnProductPage: PropertyType.Boolean, + isForVariations: PropertyType.Boolean, + } ), + new KeyChangeTransformation< ProductAttribute >( { + sortOrder: 'position', + isVisibleOnProductPage: 'visible', + isForVariations: 'variation', + } ), + ] ); } /** @@ -77,26 +69,20 @@ function createProductAttributeTransformer(): ModelTransformer< ProductAttribute * @return {ModelTransformer} The created transformer. */ function createProductImageTransformer(): ModelTransformer< ProductImage > { - return new ModelTransformer( - [ - new IgnorePropertyTransformation( [ 'date_created', 'date_modified' ] ), - new PropertyTypeTransformation( - { - id: PropertyType.Integer, - created: PropertyType.Date, - modified: PropertyType.Date, - }, - ), - new KeyChangeTransformation< ProductImage >( - { - created: 'date_created_gmt', - modified: 'date_modified_gmt', - url: 'src', - altText: 'altText', - }, - ), - ], - ); + return new ModelTransformer( [ + new IgnorePropertyTransformation( [ 'date_created', 'date_modified' ] ), + new PropertyTypeTransformation( { + id: PropertyType.Integer, + created: PropertyType.Date, + modified: PropertyType.Date, + } ), + new KeyChangeTransformation< ProductImage >( { + created: 'date_created_gmt', + modified: 'date_modified_gmt', + url: 'src', + altText: 'altText', + } ), + ] ); } /** @@ -105,11 +91,9 @@ function createProductImageTransformer(): ModelTransformer< ProductImage > { * @return {ModelTransformer} The created transformer. */ function createProductDownloadTransformer(): ModelTransformer< ProductDownload > { - return new ModelTransformer( - [ - new KeyChangeTransformation< ProductDownload >( { url: 'file' } ), - ], - ); + return new ModelTransformer( [ + new KeyChangeTransformation< ProductDownload >( { url: 'file' } ), + ] ); } /** @@ -119,43 +103,42 @@ function createProductDownloadTransformer(): ModelTransformer< ProductDownload > * @return {ModelTransformer} The created transformer. */ export function createProductDataTransformer< T extends AbstractProductData >( - transformations?: ModelTransformation[], + transformations?: ModelTransformation[] ): ModelTransformer< T > { if ( ! transformations ) { transformations = []; } transformations.push( - new IgnorePropertyTransformation( - [ - 'date_created', - 'date_modified', - ], + new IgnorePropertyTransformation( [ 'date_created', 'date_modified' ] ), + new ModelTransformerTransformation( + 'images', + ProductImage, + createProductImageTransformer() ), - new ModelTransformerTransformation( 'images', ProductImage, createProductImageTransformer() ), - new ModelTransformerTransformation( 'metaData', MetaData, createMetaDataTransformer() ), - new PropertyTypeTransformation( - { - created: PropertyType.Date, - modified: PropertyType.Date, - isPurchasable: PropertyType.Boolean, - parentId: PropertyType.Integer, - menuOrder: PropertyType.Integer, - permalink: PropertyType.String, - }, - ), - new KeyChangeTransformation< AbstractProductData >( - { - created: 'date_created_gmt', - modified: 'date_modified_gmt', - postStatus: 'status', - isPurchasable: 'purchasable', - metaData: 'meta_data', - parentId: 'parent_id', - menuOrder: 'menu_order', - links: '_links', - }, + new ModelTransformerTransformation( + 'metaData', + MetaData, + createMetaDataTransformer() ), + new PropertyTypeTransformation( { + created: PropertyType.Date, + modified: PropertyType.Date, + isPurchasable: PropertyType.Boolean, + parentId: PropertyType.Integer, + menuOrder: PropertyType.Integer, + permalink: PropertyType.String, + } ), + new KeyChangeTransformation< AbstractProductData >( { + created: 'date_created_gmt', + modified: 'date_modified_gmt', + postStatus: 'status', + isPurchasable: 'purchasable', + metaData: 'meta_data', + parentId: 'parent_id', + menuOrder: 'menu_order', + links: '_links', + } ) ); return new ModelTransformer( transformations ); @@ -170,7 +153,7 @@ export function createProductDataTransformer< T extends AbstractProductData >( */ export function createProductTransformer< T extends AbstractProduct >( type: string, - transformations?: ModelTransformation[], + transformations?: ModelTransformation[] ): ModelTransformer< T > { if ( ! transformations ) { transformations = []; @@ -178,31 +161,39 @@ export function createProductTransformer< T extends AbstractProduct >( transformations.push( new AddPropertyTransformation( {}, { type } ), - new ModelTransformerTransformation( 'categories', ProductTerm, createProductTermTransformer() ), - new ModelTransformerTransformation( 'tags', ProductTerm, createProductTermTransformer() ), - new ModelTransformerTransformation( 'attributes', ProductAttribute, createProductAttributeTransformer() ), - new PropertyTypeTransformation( - { - isFeatured: PropertyType.Boolean, - allowReviews: PropertyType.Boolean, - averageRating: PropertyType.Integer, - numRatings: PropertyType.Integer, - totalSales: PropertyType.Integer, - relatedIds: PropertyType.Integer, - }, + new ModelTransformerTransformation( + 'categories', + ProductTerm, + createProductTermTransformer() ), - new KeyChangeTransformation< AbstractProduct >( - { - shortDescription: 'short_description', - isFeatured: 'featured', - catalogVisibility: 'catalog_visibility', - allowReviews: 'reviews_allowed', - averageRating: 'average_rating', - numRatings: 'rating_count', - totalSales: 'total_sales', - relatedIds: 'related_ids', - }, + new ModelTransformerTransformation( + 'tags', + ProductTerm, + createProductTermTransformer() ), + new ModelTransformerTransformation( + 'attributes', + ProductAttribute, + createProductAttributeTransformer() + ), + new PropertyTypeTransformation( { + isFeatured: PropertyType.Boolean, + allowReviews: PropertyType.Boolean, + averageRating: PropertyType.Integer, + numRatings: PropertyType.Integer, + totalSales: PropertyType.Integer, + relatedIds: PropertyType.Integer, + } ), + new KeyChangeTransformation< AbstractProduct >( { + shortDescription: 'short_description', + isFeatured: 'featured', + catalogVisibility: 'catalog_visibility', + allowReviews: 'reviews_allowed', + averageRating: 'average_rating', + numRatings: 'rating_count', + totalSales: 'total_sales', + relatedIds: 'related_ids', + } ) ); return createProductDataTransformer< T >( transformations ); @@ -213,30 +204,24 @@ export function createProductTransformer< T extends AbstractProduct >( */ export function createProductPriceTransformation(): ModelTransformation[] { const transformations = [ - new IgnorePropertyTransformation( - [ - 'date_on_sale_from', - 'date_on_sale_to', - ], - ), - new PropertyTypeTransformation( - { - onSale: PropertyType.Boolean, - saleStart: PropertyType.Date, - saleEnd: PropertyType.Date, - priceHtml: PropertyType.String, - }, - ), - new KeyChangeTransformation< IProductPrice >( - { - regularPrice: 'regular_price', - onSale: 'on_sale', - salePrice: 'sale_price', - saleStart: 'date_on_sale_from_gmt', - saleEnd: 'date_on_sale_to_gmt', - priceHtml: 'price_html', - }, - ), + new IgnorePropertyTransformation( [ + 'date_on_sale_from', + 'date_on_sale_to', + ] ), + new PropertyTypeTransformation( { + onSale: PropertyType.Boolean, + saleStart: PropertyType.Date, + saleEnd: PropertyType.Date, + priceHtml: PropertyType.String, + } ), + new KeyChangeTransformation< IProductPrice >( { + regularPrice: 'regular_price', + onSale: 'on_sale', + salePrice: 'sale_price', + saleStart: 'date_on_sale_from_gmt', + saleEnd: 'date_on_sale_to_gmt', + priceHtml: 'price_html', + } ), ]; return transformations; @@ -247,16 +232,12 @@ export function createProductPriceTransformation(): ModelTransformation[] { */ export function createProductCrossSellsTransformation(): ModelTransformation[] { const transformations = [ - new PropertyTypeTransformation( - { - crossSellIds: PropertyType.Integer, - }, - ), - new KeyChangeTransformation< IProductCrossSells >( - { - crossSellIds: 'cross_sell_ids', - }, - ), + new PropertyTypeTransformation( { + crossSellIds: PropertyType.Integer, + } ), + new KeyChangeTransformation< IProductCrossSells >( { + crossSellIds: 'cross_sell_ids', + } ), ]; return transformations; @@ -267,16 +248,12 @@ export function createProductCrossSellsTransformation(): ModelTransformation[] { */ export function createProductUpSellsTransformation(): ModelTransformation[] { const transformations = [ - new PropertyTypeTransformation( - { - upSellIds: PropertyType.Integer, - }, - ), - new KeyChangeTransformation< IProductUpSells >( - { - upSellIds: 'upsell_ids', - }, - ), + new PropertyTypeTransformation( { + upSellIds: PropertyType.Integer, + } ), + new KeyChangeTransformation< IProductUpSells >( { + upSellIds: 'upsell_ids', + } ), ]; return transformations; @@ -287,16 +264,12 @@ export function createProductUpSellsTransformation(): ModelTransformation[] { */ export function createProductGroupedTransformation(): ModelTransformation[] { const transformations = [ - new PropertyTypeTransformation( - { - groupedProducts: PropertyType.Integer, - }, - ), - new KeyChangeTransformation< IProductGrouped >( - { - groupedProducts: 'grouped_products', - }, - ), + new PropertyTypeTransformation( { + groupedProducts: PropertyType.Integer, + } ), + new KeyChangeTransformation< IProductGrouped >( { + groupedProducts: 'grouped_products', + } ), ]; return transformations; @@ -307,25 +280,25 @@ export function createProductGroupedTransformation(): ModelTransformation[] { */ export function createProductDeliveryTransformation(): ModelTransformation[] { const transformations = [ - new ModelTransformerTransformation( 'downloads', ProductDownload, createProductDownloadTransformer() ), - new PropertyTypeTransformation( - { - isVirtual: PropertyType.Boolean, - isDownloadable: PropertyType.Boolean, - downloadLimit: PropertyType.Integer, - daysToDownload: PropertyType.Integer, - purchaseNote: PropertyType.String, - }, - ), - new KeyChangeTransformation< IProductDelivery >( - { - isVirtual: 'virtual', - isDownloadable: 'downloadable', - downloadLimit: 'download_limit', - daysToDownload: 'download_expiry', - purchaseNote: 'purchase_note', - }, + new ModelTransformerTransformation( + 'downloads', + ProductDownload, + createProductDownloadTransformer() ), + new PropertyTypeTransformation( { + isVirtual: PropertyType.Boolean, + isDownloadable: PropertyType.Boolean, + downloadLimit: PropertyType.Integer, + daysToDownload: PropertyType.Integer, + purchaseNote: PropertyType.String, + } ), + new KeyChangeTransformation< IProductDelivery >( { + isVirtual: 'virtual', + isDownloadable: 'downloadable', + downloadLimit: 'download_limit', + daysToDownload: 'download_expiry', + purchaseNote: 'purchase_note', + } ), ]; return transformations; @@ -336,30 +309,26 @@ export function createProductDeliveryTransformation(): ModelTransformation[] { */ export function createProductInventoryTransformation(): ModelTransformation[] { const transformations = [ - new PropertyTypeTransformation( - { - trackInventory: PropertyType.Boolean, - remainingStock: PropertyType.Integer, - canBackorder: PropertyType.Boolean, - isOnBackorder: PropertyType.Boolean, - onePerOrder: PropertyType.Boolean, - stockStatus: PropertyType.String, - backOrderStatus: PropertyType.String, - lowStockThreshold: PropertyType.Integer, - }, - ), - new KeyChangeTransformation< IProductInventory >( - { - trackInventory: 'manage_stock', - remainingStock: 'stock_quantity', - stockStatus: 'stock_status', - onePerOrder: 'sold_individually', - backorderStatus: 'backorders', - canBackorder: 'backorders_allowed', - isOnBackorder: 'backordered', - lowStockThreshold: 'low_stock_amount', - }, - ), + new PropertyTypeTransformation( { + trackInventory: PropertyType.Boolean, + remainingStock: PropertyType.Integer, + canBackorder: PropertyType.Boolean, + isOnBackorder: PropertyType.Boolean, + onePerOrder: PropertyType.Boolean, + stockStatus: PropertyType.String, + backOrderStatus: PropertyType.String, + lowStockThreshold: PropertyType.Integer, + } ), + new KeyChangeTransformation< IProductInventory >( { + trackInventory: 'manage_stock', + remainingStock: 'stock_quantity', + stockStatus: 'stock_status', + onePerOrder: 'sold_individually', + backorderStatus: 'backorders', + canBackorder: 'backorders_allowed', + isOnBackorder: 'backordered', + lowStockThreshold: 'low_stock_amount', + } ), ]; return transformations; @@ -370,18 +339,14 @@ export function createProductInventoryTransformation(): ModelTransformation[] { */ export function createProductSalesTaxTransformation(): ModelTransformation[] { const transformations = [ - new PropertyTypeTransformation( - { - taxClass: PropertyType.String, - taxStatus: PropertyType.String, - }, - ), - new KeyChangeTransformation< IProductSalesTax >( - { - taxStatus: 'tax_status', - taxClass: 'tax_class', - }, - ), + new PropertyTypeTransformation( { + taxClass: PropertyType.String, + taxStatus: PropertyType.String, + } ), + new KeyChangeTransformation< IProductSalesTax >( { + taxStatus: 'tax_status', + taxClass: 'tax_class', + } ), ]; return transformations; @@ -405,9 +370,11 @@ export function createProductShippingTransformation(): ModelTransformation[] { return properties; }, ( properties: any ) => { - if ( properties.hasOwnProperty( 'length ' ) || + if ( + properties.hasOwnProperty( 'length ' ) || properties.hasOwnProperty( 'width' ) || - properties.hasOwnProperty( 'height' ) ) { + properties.hasOwnProperty( 'height' ) + ) { properties.dimensions = { length: properties.length, width: properties.width, @@ -419,25 +386,21 @@ export function createProductShippingTransformation(): ModelTransformation[] { } return properties; - }, - ), - new PropertyTypeTransformation( - { - requiresShipping: PropertyType.Boolean, - isShippingTaxable: PropertyType.Boolean, - shippingClass: PropertyType.String, - shippingClassId: PropertyType.Integer, - weight: PropertyType.String, - }, - ), - new KeyChangeTransformation< IProductShipping >( - { - requiresShipping: 'shipping_required', - isShippingTaxable: 'shipping_taxable', - shippingClass: 'shipping_class', - shippingClassId: 'shipping_class_id', - }, + } ), + new PropertyTypeTransformation( { + requiresShipping: PropertyType.Boolean, + isShippingTaxable: PropertyType.Boolean, + shippingClass: PropertyType.String, + shippingClassId: PropertyType.Integer, + weight: PropertyType.String, + } ), + new KeyChangeTransformation< IProductShipping >( { + requiresShipping: 'shipping_required', + isShippingTaxable: 'shipping_taxable', + shippingClass: 'shipping_class', + shippingClassId: 'shipping_class_id', + } ), ]; return transformations; @@ -448,19 +411,15 @@ export function createProductShippingTransformation(): ModelTransformation[] { */ export function createProductVariableTransformation(): ModelTransformation[] { const transformations = [ - new PropertyTypeTransformation( - { - id: PropertyType.Integer, - name: PropertyType.String, - option: PropertyType.String, - variations: PropertyType.Integer, - }, - ), - new KeyChangeTransformation< VariableProduct >( - { - defaultAttributes: 'default_attributes', - }, - ), + new PropertyTypeTransformation( { + id: PropertyType.Integer, + name: PropertyType.String, + option: PropertyType.String, + variations: PropertyType.Integer, + } ), + new KeyChangeTransformation< VariableProduct >( { + defaultAttributes: 'default_attributes', + } ), ]; return transformations; @@ -471,18 +430,14 @@ export function createProductVariableTransformation(): ModelTransformation[] { */ export function createProductExternalTransformation(): ModelTransformation[] { const transformations = [ - new PropertyTypeTransformation( - { - buttonText: PropertyType.String, - externalUrl: PropertyType.String, - }, - ), - new KeyChangeTransformation< IProductExternal >( - { - buttonText: 'button_text', - externalUrl: 'external_url', - }, - ), + new PropertyTypeTransformation( { + buttonText: PropertyType.String, + externalUrl: PropertyType.String, + } ), + new KeyChangeTransformation< IProductExternal >( { + buttonText: 'button_text', + externalUrl: 'external_url', + } ), ]; return transformations; diff --git a/packages/js/api/src/repositories/rest/products/simple-product.ts b/packages/js/api/src/repositories/rest/products/simple-product.ts index fb83634b643..67280508238 100644 --- a/packages/js/api/src/repositories/rest/products/simple-product.ts +++ b/packages/js/api/src/repositories/rest/products/simple-product.ts @@ -42,11 +42,13 @@ import { * DeletesSimpleProducts * } The created repository. */ -export function simpleProductRESTRepository( httpClient: HTTPClient ): ListsSimpleProducts - & CreatesSimpleProducts - & ReadsSimpleProducts - & UpdatesSimpleProducts - & DeletesSimpleProducts { +export function simpleProductRESTRepository( + httpClient: HTTPClient +): ListsSimpleProducts & + CreatesSimpleProducts & + ReadsSimpleProducts & + UpdatesSimpleProducts & + DeletesSimpleProducts { const crossSells = createProductCrossSellsTransformation(); const delivery = createProductDeliveryTransformation(); const inventory = createProductInventoryTransformation(); @@ -64,13 +66,39 @@ export function simpleProductRESTRepository( httpClient: HTTPClient ): ListsSimp ...upsells, ]; - const transformer = createProductTransformer( 'simple', transformations ); + const transformer = createProductTransformer< SimpleProduct >( + 'simple', + transformations + ); return new ModelRepository( - restList< SimpleProductRepositoryParams >( baseProductURL, SimpleProduct, httpClient, transformer ), - restCreate< SimpleProductRepositoryParams >( baseProductURL, SimpleProduct, httpClient, transformer ), - restRead< SimpleProductRepositoryParams >( buildProductURL, SimpleProduct, httpClient, transformer ), - restUpdate< SimpleProductRepositoryParams >( buildProductURL, SimpleProduct, httpClient, transformer ), - restDelete< SimpleProductRepositoryParams >( deleteProductURL, httpClient ), + restList< SimpleProductRepositoryParams >( + baseProductURL, + SimpleProduct, + httpClient, + transformer + ), + restCreate< SimpleProductRepositoryParams >( + baseProductURL, + SimpleProduct, + httpClient, + transformer + ), + restRead< SimpleProductRepositoryParams >( + buildProductURL, + SimpleProduct, + httpClient, + transformer + ), + restUpdate< SimpleProductRepositoryParams >( + buildProductURL, + SimpleProduct, + httpClient, + transformer + ), + restDelete< SimpleProductRepositoryParams >( + deleteProductURL, + httpClient + ) ); } diff --git a/packages/js/api/src/repositories/rest/products/variable-product.ts b/packages/js/api/src/repositories/rest/products/variable-product.ts index 2868ebfbeca..d040dcd5b9b 100644 --- a/packages/js/api/src/repositories/rest/products/variable-product.ts +++ b/packages/js/api/src/repositories/rest/products/variable-product.ts @@ -41,11 +41,13 @@ import { * DeletesVariableProducts * } The created repository. */ -export function variableProductRESTRepository( httpClient: HTTPClient ): ListsVariableProducts - & CreatesVariableProducts - & ReadsVariableProducts - & UpdatesVariableProducts - & DeletesVariableProducts { +export function variableProductRESTRepository( + httpClient: HTTPClient +): ListsVariableProducts & + CreatesVariableProducts & + ReadsVariableProducts & + UpdatesVariableProducts & + DeletesVariableProducts { const crossSells = createProductCrossSellsTransformation(); const inventory = createProductInventoryTransformation(); const salesTax = createProductSalesTaxTransformation(); @@ -61,13 +63,39 @@ export function variableProductRESTRepository( httpClient: HTTPClient ): ListsVa ...variable, ]; - const transformer = createProductTransformer( 'variable', transformations ); + const transformer = createProductTransformer< VariableProduct >( + 'variable', + transformations + ); return new ModelRepository( - restList< VariableProductRepositoryParams >( baseProductURL, VariableProduct, httpClient, transformer ), - restCreate< VariableProductRepositoryParams >( baseProductURL, VariableProduct, httpClient, transformer ), - restRead< VariableProductRepositoryParams >( buildProductURL, VariableProduct, httpClient, transformer ), - restUpdate< VariableProductRepositoryParams >( buildProductURL, VariableProduct, httpClient, transformer ), - restDelete< VariableProductRepositoryParams >( deleteProductURL, httpClient ), + restList< VariableProductRepositoryParams >( + baseProductURL, + VariableProduct, + httpClient, + transformer + ), + restCreate< VariableProductRepositoryParams >( + baseProductURL, + VariableProduct, + httpClient, + transformer + ), + restRead< VariableProductRepositoryParams >( + buildProductURL, + VariableProduct, + httpClient, + transformer + ), + restUpdate< VariableProductRepositoryParams >( + buildProductURL, + VariableProduct, + httpClient, + transformer + ), + restDelete< VariableProductRepositoryParams >( + deleteProductURL, + httpClient + ) ); } diff --git a/packages/js/api/src/repositories/rest/products/variation.ts b/packages/js/api/src/repositories/rest/products/variation.ts index 896d6f1bc8f..c47a56be807 100644 --- a/packages/js/api/src/repositories/rest/products/variation.ts +++ b/packages/js/api/src/repositories/rest/products/variation.ts @@ -39,14 +39,19 @@ import { * DeletesProductVariations * } The created repository. */ -export function productVariationRESTRepository( httpClient: HTTPClient ): ListsProductVariations - & CreatesProductVariations - & ReadsProductVariations - & UpdatesProductVariations - & DeletesProductVariations { - const buildURL = ( parent: ModelID ) => buildProductURL( parent ) + '/variations/'; - const buildChildURL = ( parent: ModelID, id: ModelID ) => buildURL( parent ) + id; - const buildDeleteURL = ( parent: ModelID, id: ModelID ) => buildChildURL( parent, id ) + '?force=true'; +export function productVariationRESTRepository( + httpClient: HTTPClient +): ListsProductVariations & + CreatesProductVariations & + ReadsProductVariations & + UpdatesProductVariations & + DeletesProductVariations { + const buildURL = ( parent: ModelID ) => + buildProductURL( parent ) + '/variations/'; + const buildChildURL = ( parent: ModelID, id: ModelID ) => + buildURL( parent ) + id; + const buildDeleteURL = ( parent: ModelID, id: ModelID ) => + buildChildURL( parent, id ) + '?force=true'; const delivery = createProductDeliveryTransformation(); const inventory = createProductInventoryTransformation(); @@ -61,13 +66,38 @@ export function productVariationRESTRepository( httpClient: HTTPClient ): ListsP ...shipping, ]; - const transformer = createProductDataTransformer( transformations ); + const transformer = createProductDataTransformer< ProductVariation >( + transformations + ); return new ModelRepository( - restListChild< ProductVariationRepositoryParams >( buildURL, ProductVariation, httpClient, transformer ), - restCreateChild< ProductVariationRepositoryParams >( buildURL, ProductVariation, httpClient, transformer ), - restReadChild< ProductVariationRepositoryParams >( buildChildURL, ProductVariation, httpClient, transformer ), - restUpdateChild< ProductVariationRepositoryParams >( buildChildURL, ProductVariation, httpClient, transformer ), - restDeleteChild< ProductVariationRepositoryParams >( buildDeleteURL, httpClient ), + restListChild< ProductVariationRepositoryParams >( + buildURL, + ProductVariation, + httpClient, + transformer + ), + restCreateChild< ProductVariationRepositoryParams >( + buildURL, + ProductVariation, + httpClient, + transformer + ), + restReadChild< ProductVariationRepositoryParams >( + buildChildURL, + ProductVariation, + httpClient, + transformer + ), + restUpdateChild< ProductVariationRepositoryParams >( + buildChildURL, + ProductVariation, + httpClient, + transformer + ), + restDeleteChild< ProductVariationRepositoryParams >( + buildDeleteURL, + httpClient + ) ); } diff --git a/packages/js/api/src/repositories/rest/settings/index.ts b/packages/js/api/src/repositories/rest/settings/index.ts index 239f09f18ac..e16e50b89dd 100644 --- a/packages/js/api/src/repositories/rest/settings/index.ts +++ b/packages/js/api/src/repositories/rest/settings/index.ts @@ -1,7 +1,4 @@ import settingRESTRepository from './setting'; import settingGroupRESTRepository from './setting-group'; -export { - settingRESTRepository, - settingGroupRESTRepository, -}; +export { settingRESTRepository, settingGroupRESTRepository }; diff --git a/packages/js/api/src/repositories/rest/settings/setting-group.ts b/packages/js/api/src/repositories/rest/settings/setting-group.ts index 27b5eef387f..90de65305d5 100644 --- a/packages/js/api/src/repositories/rest/settings/setting-group.ts +++ b/packages/js/api/src/repositories/rest/settings/setting-group.ts @@ -12,11 +12,11 @@ import { import { restList } from '../shared'; function createTransformer(): ModelTransformer< SettingGroup > { - return new ModelTransformer( - [ - new KeyChangeTransformation< SettingGroup >( { parentID: 'parent_id' } ), - ], - ); + return new ModelTransformer( [ + new KeyChangeTransformation< SettingGroup >( { + parentID: 'parent_id', + } ), + ] ); } /** @@ -25,14 +25,21 @@ function createTransformer(): ModelTransformer< SettingGroup > { * @param {HTTPClient} httpClient The HTTP client for the REST requests to be made using. * @return {ListsSettingGroups} The created repository. */ -export default function settingGroupRESTRepository( httpClient: HTTPClient ): ListsSettingGroups { +export default function settingGroupRESTRepository( + httpClient: HTTPClient +): ListsSettingGroups { const transformer = createTransformer(); return new ModelRepository( - restList< SettingGroupRepositoryParams >( () => '/wc/v3/settings', SettingGroup, httpClient, transformer ), - null, + restList< SettingGroupRepositoryParams >( + () => '/wc/v3/settings', + SettingGroup, + httpClient, + transformer + ), null, null, null, + null ); } diff --git a/packages/js/api/src/repositories/rest/settings/setting.ts b/packages/js/api/src/repositories/rest/settings/setting.ts index ebc500cc9f9..8c26bf420df 100644 --- a/packages/js/api/src/repositories/rest/settings/setting.ts +++ b/packages/js/api/src/repositories/rest/settings/setting.ts @@ -24,15 +24,35 @@ function createTransformer(): ModelTransformer< Setting > { * @param {HTTPClient} httpClient The HTTP client for the REST requests to be made using. * @return {ListsSettings|ReadsSettings|UpdatesSettings} The created repository. */ -export default function settingRESTRepository( httpClient: HTTPClient ): ListsSettings & ReadsSettings & UpdatesSettings { - const buildURL = ( parent: ParentID< SettingRepositoryParams >, id: ModelID ) => '/wc/v3/settings/' + parent + '/' + id; +export default function settingRESTRepository( + httpClient: HTTPClient +): ListsSettings & ReadsSettings & UpdatesSettings { + const buildURL = ( + parent: ParentID< SettingRepositoryParams >, + id: ModelID + ) => '/wc/v3/settings/' + parent + '/' + id; const transformer = createTransformer(); return new ModelRepository( - restListChild< SettingRepositoryParams >( ( parent ) => '/wc/v3/settings/' + parent, Setting, httpClient, transformer ), - null, - restReadChild< SettingRepositoryParams >( buildURL, Setting, httpClient, transformer ), - restUpdateChild< SettingRepositoryParams >( buildURL, Setting, httpClient, transformer ), + restListChild< SettingRepositoryParams >( + ( parent ) => '/wc/v3/settings/' + parent, + Setting, + httpClient, + transformer + ), null, + restReadChild< SettingRepositoryParams >( + buildURL, + Setting, + httpClient, + transformer + ), + restUpdateChild< SettingRepositoryParams >( + buildURL, + Setting, + httpClient, + transformer + ), + null ); } diff --git a/packages/js/api/src/repositories/rest/shared.ts b/packages/js/api/src/repositories/rest/shared.ts index 3c4e976d177..7b03a1053da 100644 --- a/packages/js/api/src/repositories/rest/shared.ts +++ b/packages/js/api/src/repositories/rest/shared.ts @@ -20,11 +20,7 @@ import { // @ts-ignore ModelParentID, } from '../../framework'; -import { - ModelID, - MetaData, - ModelConstructor, -} from '../../models'; +import { ModelID, MetaData, ModelConstructor } from '../../models'; /** * Creates a new transformer for metadata models. @@ -32,17 +28,13 @@ import { * @return {ModelTransformer} The created transformer. */ export function createMetaDataTransformer(): ModelTransformer< MetaData > { - return new ModelTransformer( - [ - new IgnorePropertyTransformation( [ 'id' ] ), - new KeyChangeTransformation< MetaData >( - { - displayKey: 'display_key', - displayValue: 'display_value', - }, - ), - ], - ); + return new ModelTransformer( [ + new IgnorePropertyTransformation( [ 'id' ] ), + new KeyChangeTransformation< MetaData >( { + displayKey: 'display_key', + displayValue: 'display_value', + } ), + ] ); } /** @@ -52,7 +44,11 @@ export function createMetaDataTransformer(): ModelTransformer< MetaData > { * @param {ModelID} [id] The ID of the model we're dealing with if used for the request. * @return {string} The URL to make the request to. */ -type BuildURLFn< T extends ( 'list' | 'general' ) = 'general' > = [ T ] extends [ 'list' ] ? () => string : ( id: ModelID ) => string; +type BuildURLFn< T extends 'list' | 'general' = 'general' > = [ T ] extends [ + 'list' +] + ? () => string + : ( id: ModelID ) => string; /** * A callback to build a URL for a request. @@ -63,7 +59,10 @@ type BuildURLFn< T extends ( 'list' | 'general' ) = 'general' > = [ T ] extends * @return {string} The URL to make the request to. * @template {ModelParentID} P */ -type BuildURLWithParentFn< P extends ModelRepositoryParams, T extends ( 'list' | 'general' ) = 'general' > = [ T ] extends [ 'list' ] +type BuildURLWithParentFn< + P extends ModelRepositoryParams, + T extends 'list' | 'general' = 'general' +> = [ T ] extends [ 'list' ] ? ( parent: ParentID< P > ) => string : ( parent: ParentID< P >, id: ModelID ) => string; @@ -80,7 +79,7 @@ export function restList< T extends ModelRepositoryParams >( buildURL: HasParent< T, never, BuildURLFn< 'list' > >, modelClass: ModelConstructor< ModelClass< T > >, httpClient: HTTPClient, - transformer: ModelTransformer< ModelClass< T > >, + transformer: ModelTransformer< ModelClass< T > > ): ListFn< T > { return async ( params ) => { const response = await httpClient.get( buildURL(), params ); @@ -107,7 +106,7 @@ export function restListChild< T extends ModelRepositoryParams >( buildURL: HasParent< T, BuildURLWithParentFn< T, 'list' >, never >, modelClass: ModelConstructor< ModelClass< T > >, httpClient: HTTPClient, - transformer: ModelTransformer< ModelClass< T > >, + transformer: ModelTransformer< ModelClass< T > > ): ListChildFn< T > { return async ( parent, params ) => { const response = await httpClient.get( buildURL( parent ), params ); @@ -134,15 +133,17 @@ export function restCreate< T extends ModelRepositoryParams >( buildURL: ( properties: Partial< ModelClass< T > > ) => string, modelClass: ModelConstructor< ModelClass< T > >, httpClient: HTTPClient, - transformer: ModelTransformer< ModelClass< T > >, + transformer: ModelTransformer< ModelClass< T > > ): CreateFn< T > { return async ( properties ) => { const response = await httpClient.post( buildURL( properties ), - transformer.fromModel( properties ), + transformer.fromModel( properties ) ); - return Promise.resolve( transformer.toModel( modelClass, response.data ) ); + return Promise.resolve( + transformer.toModel( modelClass, response.data ) + ); }; } @@ -156,18 +157,23 @@ export function restCreate< T extends ModelRepositoryParams >( * @return {CreateChildFn} The callback for the repository. */ export function restCreateChild< T extends ModelRepositoryParams >( - buildURL: ( parent: ParentID< T >, properties: Partial< ModelClass< T > > ) => string, + buildURL: ( + parent: ParentID< T >, + properties: Partial< ModelClass< T > > + ) => string, modelClass: ModelConstructor< ModelClass< T > >, httpClient: HTTPClient, - transformer: ModelTransformer< ModelClass< T > >, + transformer: ModelTransformer< ModelClass< T > > ): CreateChildFn< T > { return async ( parent, properties ) => { const response = await httpClient.post( buildURL( parent, properties ), - transformer.fromModel( properties ), + transformer.fromModel( properties ) ); - return Promise.resolve( transformer.toModel( modelClass, response.data ) ); + return Promise.resolve( + transformer.toModel( modelClass, response.data ) + ); }; } @@ -184,11 +190,13 @@ export function restRead< T extends ModelRepositoryParams >( buildURL: HasParent< T, never, BuildURLFn >, modelClass: ModelConstructor< ModelClass< T > >, httpClient: HTTPClient, - transformer: ModelTransformer< ModelClass< T > >, + transformer: ModelTransformer< ModelClass< T > > ): ReadFn< T > { return async ( id ) => { const response = await httpClient.get( buildURL( id ) ); - return Promise.resolve( transformer.toModel( modelClass, response.data ) ); + return Promise.resolve( + transformer.toModel( modelClass, response.data ) + ); }; } @@ -205,11 +213,13 @@ export function restReadChild< T extends ModelRepositoryParams >( buildURL: HasParent< T, BuildURLWithParentFn< T >, never >, modelClass: ModelConstructor< ModelClass< T > >, httpClient: HTTPClient, - transformer: ModelTransformer< ModelClass< T > >, + transformer: ModelTransformer< ModelClass< T > > ): ReadChildFn< T > { return async ( parent, id ) => { const response = await httpClient.get( buildURL( parent, id ) ); - return Promise.resolve( transformer.toModel( modelClass, response.data ) ); + return Promise.resolve( + transformer.toModel( modelClass, response.data ) + ); }; } @@ -226,15 +236,17 @@ export function restUpdate< T extends ModelRepositoryParams >( buildURL: HasParent< T, never, BuildURLFn >, modelClass: ModelConstructor< ModelClass< T > >, httpClient: HTTPClient, - transformer: ModelTransformer< ModelClass< T > >, + transformer: ModelTransformer< ModelClass< T > > ): UpdateFn< T > { return async ( id, params ) => { const response = await httpClient.patch( buildURL( id ), - transformer.fromModel( params as any ), + transformer.fromModel( params as any ) ); - return Promise.resolve( transformer.toModel( modelClass, response.data ) ); + return Promise.resolve( + transformer.toModel( modelClass, response.data ) + ); }; } @@ -251,15 +263,17 @@ export function restUpdateChild< T extends ModelRepositoryParams >( buildURL: HasParent< T, BuildURLWithParentFn< T >, never >, modelClass: ModelConstructor< ModelClass< T > >, httpClient: HTTPClient, - transformer: ModelTransformer< ModelClass< T > >, + transformer: ModelTransformer< ModelClass< T > > ): UpdateChildFn< T > { return async ( parent, id, params ) => { const response = await httpClient.patch( buildURL( parent, id ), - transformer.fromModel( params as any ), + transformer.fromModel( params as any ) ); - return Promise.resolve( transformer.toModel( modelClass, response.data ) ); + return Promise.resolve( + transformer.toModel( modelClass, response.data ) + ); }; } @@ -272,7 +286,7 @@ export function restUpdateChild< T extends ModelRepositoryParams >( */ export function restDelete< T extends ModelRepositoryParams >( buildURL: HasParent< T, never, BuildURLFn >, - httpClient: HTTPClient, + httpClient: HTTPClient ): DeleteFn { return ( id ) => { return httpClient.delete( buildURL( id ) ).then( () => true ); @@ -288,7 +302,7 @@ export function restDelete< T extends ModelRepositoryParams >( */ export function restDeleteChild< T extends ModelRepositoryParams >( buildURL: HasParent< T, BuildURLWithParentFn< T >, never >, - httpClient: HTTPClient, + httpClient: HTTPClient ): DeleteChildFn< T > { return ( parent, id ) => { return httpClient.delete( buildURL( parent, id ) ).then( () => true ); diff --git a/packages/js/api/src/services/__tests__/setting-service.spec.ts b/packages/js/api/src/services/__tests__/setting-service.spec.ts index ba73b849709..c598c825e4a 100644 --- a/packages/js/api/src/services/__tests__/setting-service.spec.ts +++ b/packages/js/api/src/services/__tests__/setting-service.spec.ts @@ -18,15 +18,35 @@ describe( 'SettingService', () => { 'line2', 'New York', 'US:NY', - '12345', + '12345' ); expect( result ).toBeTruthy(); expect( repository.update ).toHaveBeenCalledTimes( 5 ); - expect( repository.update ).toHaveBeenCalledWith( 'general', 'woocommerce_store_address', { value: 'line1' } ); - expect( repository.update ).toHaveBeenCalledWith( 'general', 'woocommerce_store_address_2', { value: 'line2' } ); - expect( repository.update ).toHaveBeenCalledWith( 'general', 'woocommerce_store_city', { value: 'New York' } ); - expect( repository.update ).toHaveBeenCalledWith( 'general', 'woocommerce_default_country', { value: 'US:NY' } ); - expect( repository.update ).toHaveBeenCalledWith( 'general', 'woocommerce_store_postcode', { value: '12345' } ); + expect( repository.update ).toHaveBeenCalledWith( + 'general', + 'woocommerce_store_address', + { value: 'line1' } + ); + expect( repository.update ).toHaveBeenCalledWith( + 'general', + 'woocommerce_store_address_2', + { value: 'line2' } + ); + expect( repository.update ).toHaveBeenCalledWith( + 'general', + 'woocommerce_store_city', + { value: 'New York' } + ); + expect( repository.update ).toHaveBeenCalledWith( + 'general', + 'woocommerce_default_country', + { value: 'US:NY' } + ); + expect( repository.update ).toHaveBeenCalledWith( + 'general', + 'woocommerce_store_postcode', + { value: '12345' } + ); } ); } ); diff --git a/packages/js/api/src/services/setting-service.ts b/packages/js/api/src/services/setting-service.ts index 4c8df9ac1ee..70d0d51e434 100644 --- a/packages/js/api/src/services/setting-service.ts +++ b/packages/js/api/src/services/setting-service.ts @@ -31,14 +31,40 @@ export class SettingService { * @param {string} postCode The postal code. * @return {Promise.} Resolves to true if all of the settings are updated. */ - public updateStoreAddress( address1: string, address2: string, city: string, country: string, postCode: string ): Promise< boolean > { + public updateStoreAddress( + address1: string, + address2: string, + city: string, + country: string, + postCode: string + ): Promise< boolean > { const promises: Promise< Setting >[] = []; - promises.push( this.repository.update( 'general', 'woocommerce_store_address', { value: address1 } ) ); - promises.push( this.repository.update( 'general', 'woocommerce_store_address_2', { value: address2 } ) ); - promises.push( this.repository.update( 'general', 'woocommerce_store_city', { value: city } ) ); - promises.push( this.repository.update( 'general', 'woocommerce_default_country', { value: country } ) ); - promises.push( this.repository.update( 'general', 'woocommerce_store_postcode', { value: postCode } ) ); + promises.push( + this.repository.update( 'general', 'woocommerce_store_address', { + value: address1, + } ) + ); + promises.push( + this.repository.update( 'general', 'woocommerce_store_address_2', { + value: address2, + } ) + ); + promises.push( + this.repository.update( 'general', 'woocommerce_store_city', { + value: city, + } ) + ); + promises.push( + this.repository.update( 'general', 'woocommerce_default_country', { + value: country, + } ) + ); + promises.push( + this.repository.update( 'general', 'woocommerce_store_postcode', { + value: postCode, + } ) + ); return Promise.all( promises ).then( () => true ); } diff --git a/packages/js/bin/get-babel-config.js b/packages/js/bin/get-babel-config.js deleted file mode 100644 index e79bc306d07..00000000000 --- a/packages/js/bin/get-babel-config.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * External dependencies - */ -const { get, map } = require( 'lodash' ); -const babel = require( '@babel/core' ); - -/** - * WordPress dependencies - */ -const { options: babelDefaultConfig } = babel.loadPartialConfig( { - configFile: '@wordpress/babel-preset-default', -} ); -const plugins = babelDefaultConfig.plugins; -if ( ! process.env.SKIP_JSX_PRAGMA_TRANSFORM ) { - plugins.push( [ '@wordpress/babel-plugin-import-jsx-pragma', { - scopeVariable: 'createElement', - source: '@wordpress/element', - isDefault: false, - } ] ); -} - -const overrideOptions = ( target, targetName, options ) => { - if ( get( target, [ 'file', 'request' ] ) === targetName ) { - return [ targetName, Object.assign( - {}, - target.options, - options - ) ]; - } - return target; -}; - -const babelConfigs = { - main: Object.assign( - {}, - babelDefaultConfig, - { - plugins, - presets: map( - babelDefaultConfig.presets, - ( preset ) => overrideOptions( preset, '@babel/preset-env', { - modules: 'commonjs', - } ) - ), - } - ), - module: Object.assign( - {}, - babelDefaultConfig, - { - plugins: map( - plugins, - ( plugin ) => overrideOptions( plugin, '@babel/plugin-transform-runtime', { - useESModules: true, - } ) - ), - presets: map( - babelDefaultConfig.presets, - ( preset ) => overrideOptions( preset, '@babel/preset-env', { - modules: false, - } ) - ), - } - ), -}; - -function getBabelConfig( environment ) { - return babelConfigs[ environment ]; -} - -module.exports = getBabelConfig; diff --git a/packages/js/components/.eslintrc.js b/packages/js/components/.eslintrc.js new file mode 100644 index 00000000000..f740ae8d831 --- /dev/null +++ b/packages/js/components/.eslintrc.js @@ -0,0 +1,19 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, + overrides: [ + { + files: [ + '**/stories/*.js', + '**/stories/*.jsx', + '**/docs/example.js', + ], + rules: { + 'import/no-unresolved': [ + 'warn', + { ignore: [ '@woocommerce/components' ] }, + ], + }, + }, + ], +}; diff --git a/packages/js/components/.npmrc b/packages/js/components/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/components/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/components/CHANGELOG.md b/packages/js/components/CHANGELOG.md new file mode 100644 index 00000000000..065094f6063 --- /dev/null +++ b/packages/js/components/CHANGELOG.md @@ -0,0 +1,322 @@ +# Unreleased + +- Fix documentation for `TableCard` component +- Update dependency `@wordpress/hooks` to ^3.5.0 +- Update dependency `@wordpress/icons` to ^8.1.0 +- Add `className` prop for Pill component. #32605 +- Update `StepperProps` prop types. #32712 + +# 10.0.0 +- Replace deprecated wp.compose.withState with wp.element.useState. #8338 +- Add missing dependencies. #8349 +- Update all js packages with minor/patch version changes. #8392 +- Add moment-timezone to package.json. #6483 +## Breaking changes + +- Refactor the `onFilterChange` method in the `AdvancedFilters` component. #8459 + - change: `onFilterChange( index, property, value, shouldResetValue = false );` to `onFilterChange( index, { property, value, shouldResetValue = false } )`; +# 9.0.0 + +- Update line-height of SelectControl label to avoid truncated descenders in some typefaces and zoom levels. #8186 +- Made @woocommerce/components/Stepper a Typescript file. #8286 +- Added Typescript type declarations to build for @woocommerce/components #8282 +## Breaking changes + +- Update dependencies to support react 17. #8305 +- Drop support for IE11. #8305 + +# 8.2.0 + +- Fix usage of Wordpress DatePicker component in `DatePicker`. #7982 +- Fix select-control component label/value alignment. #8045 +- Fix clicking the error message opens the dropdown. #8094 +- Fix misaligned "Rows per page" dropdown. #8113 +- Add `labelPositionToLeft` prop to the `OrderStatus` component. #8121 +- Remove dev dependency `@woocommerce/wc-admin-settings`. #8057 +- Fix incorrect screen reader text generated for data points on charts table. #8181 +- Grow and center buttons in all WooCommerce ellipsis menu popover containers. #8168 +- Added random IDs to SVG checkmarks in stepper component #8222 + +# 8.1.1 + +- Fixed warnings when using AdvancedFilters component. #7704 +- Add `autoComplete` prop to the `SelectControl` component. #7497 +- Fix calendar not being dismissed when clicking outside. #7714 + +# 8.1.0 + +- Fix a bug in the deprecated callback handlers of Form component. #7356 +- Fix a bug in the `` component where values were retained when switching between rules #7423 +- Add `hidden` legend position to `Chart`. #7378 +- Update aligning `Table` fields with the fallback on isNumeric. #7431 + +# 8.0.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 +- Remove deprecated Card, Count and Gravatar components. #7293 +- Add TableSummaryPlaceholder to support skeleton loading. #7294 + +# 7.1.0 + +- Add rowKey prop to Table and TableCard component. #7196 +- AdvancedFilters: Create workable defaults for Reports that don't have them #7186 +- Filters: On update respect all other queries, not just persistedQueries #7155 +- Fix non-string query prop warning in SelectControl component. #7046 +- Fix WordPress 5.8 compatibility UI fixes #7255 +- Revert Card component removal #7167. +- Update DynamicForm, adding initial config memoization. #7256 +- Update package dependencies + +# 7.0.0 + +- Fix style regression with the Chart header. #7002 +- Fix styling of the advanced filter operator selection. #7005 +- Remove the use of Dashicons and replace with @wordpress/icons or gridicons #7020 +- Add tree shaking support to this package. #7034 +- Deprecate the Gravatar component. #7716 +- Remove useFilters from the package. #7117 +- Deprecate SegmentedSelection, it will be removed in the next major release. #7118 +- Deprecate the Count component, with plan to remove in next major version. #7115 +- Remove the long deprecated Card component (use Card from `@wordpress/components` instead). #7114 +- Add `` component. #7017 +- Remove support for IE11. #7112 + +# 6.2.0 + +- Fix `autocompleter` for custom Search in `CompareFilter` #6911 +- SelectControl: automatically scroll to selected options when list is displayed. #6906 +- SelectControl: no longer auto selects on rendering list. #6906 +- Make `Search` accept synchronous `autocompleter.options`. #6884 +- SelectControl: fix display of multiple selections without inline tags. #6862 +- Add depreciation notice for the current list. #6787 +- Force `` form elements id to be unique. #6871 +- Add `controlId` and `name` props to ``. #6871 +- Minor styling tweaks and fixes to ``. #6871 +- Fix `autocompleter` for custom Search in `FilterPicker` #6880 +- Remove `woocommerce/experimental` dependency. #6986 + +# 6.1.2 + +- Update dependencies. + +# 6.1.1 + +- Update dependencies. + +# 6.1.0 + +- Make pagination buttons height and width consistent. #6725 +- Add optional `children` prop to ``. #6748 +- Add `@woocommerce/experimental`, `md5` and `dompurify` as dependencies. #6804 + +# 6.0.0 + +- Change styling of ``. +- Remove the `showCount` prop from ``. Count will always be displayed if any of those props is not undefined/null: `countLabel` and `item.count`. +- Fix alignment of `` count bubble in newest versions of `@wordpress/components`. +- `` no longer has different styles when it's used inside a panel. Those styles are available now with the `isCompact` prop. +- Support custom attributes in ``. +- Add product attributes support to ``. +- Allow single-selection support to ``. +- Improve handling of `multiple` and `inlineTags` in ``. +- Deprecate use of `` in favor of the `` component in `@wordpress/components`. +- Fixing screen reader text being undefined for report `` +- Update `` to use checkbox and radio inputs. +- Fix so the onChange value type always matches the selected type. #6594 + +## Breaking changes + +- Move Lodash to a peer dependency. + +# 5.1.2 + +- Update dependencies. + +# 5.1.1 + +- Update dependencies. + +# 5.1.0 + +- Fix default value for `
` component `onQueryChange` prop. +- Deprecate our bespoke component `useFilters` in favor of using the WordPress variety `withFilters`. +- Fix screen reader text in ``. +- Add `` component to ``. +- Fix internal dependencies for ``. +- Add full response to `` callbacks `onError` and `onComplete`. + +# 5.0.0 + +- Added `` component. +- Added `` component. +- Style form components for WordPress 5.3. +- Fix CompareFilter options format (key prop vs. id). +- Fix styling of `` component "clear all" button. +- Add state classes to `` component. +- Fix `` example code. +- Add `` component for installation of plugins. +- Removed use of `IconButton` in favor of `Button` component. +- Add custom autocompleter support to `` component. +- Fix `` component to allow clicking anywhere on options in list to select. +- Add support for `` component item tags and link types. +- Add `` and `` components to Storybook. +- Add `` component. +- Add `key` prop to `` component items. +- Remove unused `ref` from ``. + +## Breaking Changes + +- Removed `SplitButton` because its not being used. + +# 4.0.0 + +## Breaking Changes + +- Added a new `` component. +- Changed the `` `description` prop to `content` and allowed content nodes to be passed in addition to strings. +- Removed the `` component. + +### Decouple wcSettings from published packages (#3001) + +- `AdvancedFilters` component now receives `siteLocale` as a prop. +- `ReportsFilters` component now receives `siteLocale` as a prop. +- `NumberFilter` component now receives `currencySymbol` and `symbolPosition` as props. +- `AdvancedFilters` and `ReportsFilters` receive `currency` as a prop, it is required and must be an instance of the new `Currency` object exported by `@woocommerce/currency` +- `Chart` receives `currency` as a prop. +- Add `storeDate` prop to `` and `` components. +- `AdvancedFilters` and `ReportFilters` now receive a required `storeDate` prop as a means to pass down date initialization values from client. +- The `href` prop in the `` component must now receive the full url instead of relative. + +## Other Changes + +- Renamed the `` component to ``. +- Added `isSearchable` prop to `` to allow simple select dropdowns. +- Removed WC-Admin specific actions from `` component. +- Export the `` component. +- Add `` component. +- Require `currency` prop in `` component. +- Remove call to `getAdminLink()` inside the `` component. +- Explicitly import component styles from `@wordpress/base-styles` (#3292) +- Update various dependencies + +# 3.2.0 + +- AdvancedFilters component: fire `onAdvancedFilterAction` for match changes. +- TableCard component: add `onSearch` and `onSort` function props. +- Add new component `` for displaying interactive list items. +- Fix z-index issue in `` empty message. +- Added a new `` component. +- Added a new `` component. +- SearchListItem component: fix long count values being cut-off in IE11. +- Add `disabled` prop to CompareButton, Search, and TableCard components. +- Table component: add empty table display. + +# 3.1.0 + +- Added support for a `countLabel` prop on `SearchListItem` to allow custom counts. + +# 3.0.0 + +- and got a `disabled` prop. +- TableCard component: new `onPageChange` prop. +- TableCard now has a `defaultOrder` parameter to specify default sort column sort order. +- Pagination no longer considers `0` a valid input and triggers `onPageChange` on the input blur event. +- Tweaks to SummaryListPlaceholder height in order to better match SummaryNumber. +- EllipsisMenu component (breaking change): Remove `children` prop in favor of a render prop `renderContent` so that function arguments `isOpen`, `onToggle`, and `onClose` can be passed down. +- Chart has a new prop named `yBelow1Format` which overrides the `yFormat` for values between -1 and 1 (not included). +- Add a `totals` prop to Chart component that allows overwriting the total values shown in the legend. +- Add new component `` for showing a list of steps and progress. +- Add new `` component. +- Card component: updated default Muriel design. +- Card component: new `description` prop. +- Card component: new `isInactive` prop. +- DateRangeFilterPicker (breaking change): Introduced `onRangeSelect` prop and remove `path` prop better control. +- Update license to GPL-3.0-or-later. + +# 2.0.0 + +- Chart legend component now uses withInstanceId HOC so the ids used in several HTML elements are unique. +- Chart component now accepts data with negative values. +- Chart component: new prop `filterParam` used to detect selected items in the current query. If there are, they will be displayed in the chart even if their values are 0. +- Expand search results and allow searching when input is refocused in autocompleter. +- Animation Slider: Remove `focusOnChange` in favor of `onExited` so consumers can pass a function to be executed after a transition has finished. +- SearchListControl: Add `onSearch` callback prop to let the parent component know about search changes. +- Calendar: Expose `isInvalidDate` prop to `DatePicker` to indicated invalid days that are not selectable. +- Calendar: Expose `isInvalidDate` prop to `DateRange` and remove the `invalidDays` prop. +- Bump dependency versions. + +# 1.6.0 + +- Chart component: new props `emptyMessage` and `baseValue`. When an empty message is provided, it will be displayed on top of the chart if there are no values different than `baseValue`. +- Chart component: remove d3-array dependency. +- Chart component: fix display when there is no data. +- Chart component: change chart type query parameter to `chartType`. +- Chart component: add `screenReaderFormat` prop that will be used to format dates for screen reader labels. +- Bug fix for `` returning N/A instead of zero. +- Add new component: SearchListControl for displaying and filtering a selectable list of items. + +# 1.5.0 + +- Improves display of charts where all values are 0. +- Fix X-axis labels in hourly bar charts. +- New `` prop named `showClearButton`, that will display a 'Clear' button when the search box contains one or more tags. +- Number of selectable chart elements is now limited to 5. +- Color scale logic for charts with lots of items has been fixed. +- Update `@woocommerce/navigation` to v2.0.0 +- Bug fix for `` returning N/A instead of zero. +- In `` use backspace key to remove tags from the search box. + +# 1.4.2 + +- Add emoji-flags dependency + +# 1.4.1 + +- Chart component: format numbers and prices using store currency settings. +- Make `href`/linking optional in SummaryNumber. +- Fix SummaryNumber example code. + +# 1.4.0 + +- Add download log ip address autocompleter to search component +- Add order number autocompleter to search component +- Add order number, username, and IP address filters to the downloads report. +- Added `interactive` prop for `d3chart/legend` to signal if legend items are clickable or not. +- Fix for undefined ref in `d3chart/legend`. +- Added three news props to ``: + - `interactiveLegend`: whether legend items are clickable or not. Defaults to true. + - `legendPosition`: can be `top`, `side` or `bottom`. If not specified, it's calculated based on `mode` and viewport width. + - `showHeaderControls`: whether the header controls must be visible. Defaults to true. +- `getColor()` method in chart utils now requires `keys` and `colorScheme` to be passed as separate params. +- Fix to avoid duplicated Y-axis ticks when the Y max value was 0. +- Remove decimals from Y-axis when displaying currencies. +- Fix date formatting on charts in Safari. + +# 1.3.0 + +- Update `
` to use header keys to denote which columns are shown +- Add `onColumnsChange` property to `
` which is called when columns are shown/hidden +- Add country autocompleter to search component +- Add customer email autocompleter to search component +- Add customer username autocompleter to search component +- Adding new `` component. +- Added new `showDatePicker` prop to `` component which allows to use the filters component without the date picker. +- Added new taxes and customers autocompleters, and added support for using them within ``. +- Bug fix for `` returning N/A instead of zero. +- Bug fix for screen reader label IDs in `
` header. +- Added new component ``. + +# 1.2.0 + +- Update `Search` to exclude already-selected items +- Fix incorrectly loaded `proptype-validator` +- Update focus style on `SummaryNumber` +- Remove prefixes from order statuses + +# 1.1.0 + +- Add `interpolate-components` as an explicit dependency, fixes issue with +- Update `` usage to match core component updates +- Chart component: Add `chartMode` prop to control display mode +- Add Taxes autocompleter to Search +- Improve test coverage with new tests diff --git a/packages/js/components/README.md b/packages/js/components/README.md new file mode 100644 index 00000000000..1e82e0f932a --- /dev/null +++ b/packages/js/components/README.md @@ -0,0 +1,34 @@ +# Components + +This packages includes a library of components that can be used to create pages in the WooCommerce dashboard and reports pages. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/components --save +``` + +View [the full Component documentation](https://woocommerce.github.io/woocommerce-admin/#/components/) for usage information. + +## Usage + +```jsx +/** + * Woocommerce dependencies + */ +import { Card } from '@woocommerce/components'; + +export default function MyCard() { + return ( + +

Your stuff in a Card.

+
+ ); +} +``` + +Many components include CSS to add style, you will need to add in order to appear correctly. Within WooCommerce, add the `wc-components` stylesheet as a dependency of your plugin's stylesheet. See [wp_enqueue_style documentation](https://developer.wordpress.org/reference/functions/wp_enqueue_style/#parameters) for how to specify dependencies. + +In non-WordPress projects, link to the `build-style/card/style.css` file directly, it is located at `node_modules/@woocommerce/components/build-style//style.css`. diff --git a/packages/js/components/jest.config.json b/packages/js/components/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/components/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/components/package.json b/packages/js/components/package.json new file mode 100644 index 00000000000..646fa4ffec3 --- /dev/null +++ b/packages/js/components/package.json @@ -0,0 +1,133 @@ +{ + "name": "@woocommerce/components", + "version": "10.0.0", + "description": "UI components for WooCommerce.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "components" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/components/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "sideEffects": [ + "build-style/**", + "src/**/*.scss" + ], + "types": "build-types", + "dependencies": { + "@automattic/interpolate-components": "^1.2.0", + "@woocommerce/csv-export": "workspace:*", + "@woocommerce/currency": "workspace:*", + "@woocommerce/data": "workspace:*", + "@woocommerce/date": "workspace:*", + "@woocommerce/navigation": "workspace:*", + "@wordpress/api-fetch": "^6.0.1", + "@wordpress/components": "^19.5.0", + "@wordpress/compose": "^5.1.2", + "@wordpress/date": "^4.3.1", + "@wordpress/deprecated": "^3.3.1", + "@wordpress/dom": "^3.3.2", + "@wordpress/element": "^4.1.1", + "@wordpress/hooks": "^3.5.0", + "@wordpress/html-entities": "^3.3.1", + "@wordpress/i18n": "^4.3.1", + "@wordpress/icons": "^8.1.0", + "@wordpress/keycodes": "^3.3.1", + "@wordpress/url": "^3.4.1", + "@wordpress/viewport": "^4.1.2", + "classnames": "^2.3.1", + "core-js": "^3.21.1", + "d3-axis": "^1.0.12", + "d3-format": "^1.4.5", + "d3-scale": "^2.2.2", + "d3-scale-chromatic": "^1.5.0", + "d3-selection": "^1.4.2", + "d3-shape": "^1.3.7", + "d3-time-format": "^2.3.0", + "dompurify": "^2.3.6", + "emoji-flags": "^1.3.0", + "gridicons": "^3.4.0", + "memoize-one": "^5.2.1", + "moment": "^2.29.1", + "moment-timezone": "^0.5.34", + "prop-types": "^15.8.1", + "react-dates": "^17.2.0", + "react-router-dom": "^5.3.0", + "react-transition-group": "^4.4.2" + }, + "peerDependencies": { + "@wordpress/data": "^6.2.1", + "lodash": "^4.17.0", + "react": "^17.0.0", + "react-dom": "^17.0.0" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@babel/runtime": "^7.17.2", + "@storybook/addon-actions": "^6.4.0", + "@storybook/addon-console": "^1.2.3", + "@storybook/addon-controls": "^6.4.19", + "@storybook/addon-docs": "^6.4.19", + "@storybook/addon-knobs": "^6.4.0", + "@storybook/addon-links": "^6.4.19", + "@storybook/addons": "^6.4.0", + "@storybook/api": "^6.4.0", + "@storybook/components": "^6.4.0", + "@storybook/core-events": "^6.4.0", + "@storybook/react": "^6.4.19", + "@storybook/theming": "^6.4.0", + "@testing-library/dom": "^8.11.3", + "@testing-library/jest-dom": "^5.16.2", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^13.5.0", + "@woocommerce/style-build": "workspace:*", + "@wordpress/browserslist-config": "^4.1.1", + "@wordpress/eslint-plugin": "^11.0.0", + "@wordpress/scripts": "^12.6.1", + "concurrently": "^7.0.0", + "css-loader": "^3.6.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "postcss-loader": "^3.0.0", + "rimraf": "^3.0.2", + "sass-loader": "^10.2.1", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2", + "webpack": "^5.70.0", + "webpack-cli": "^3.3.12" + }, + "scripts": { + "build": "pnpm run build:js && pnpm run build:css", + "build:js": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "build:css": "webpack", + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "lint": "eslint src --ext=js,ts,tsx", + "prepack": "pnpm run clean && pnpm run build", + "start": "concurrently \"tsc --build ./tsconfig.json --watch\" \"webpack --watch\"", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test:update-snapshots": "pnpm run test:nobuild -- --updateSnapshot", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/components/project.json b/packages/js/components/project.json new file mode 100644 index 00000000000..d83cd3b0481 --- /dev/null +++ b/packages/js/components/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/components", + "sourceRoot": "packages/js/components/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/components" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/components/src/abbreviated-card/README.md b/packages/js/components/src/abbreviated-card/README.md new file mode 100644 index 00000000000..3f78e6b7329 --- /dev/null +++ b/packages/js/components/src/abbreviated-card/README.md @@ -0,0 +1,28 @@ +# AbbreviatedCard + +Use `AbbreviatedCard` to display an abbreviated card element. + +## Usage + +```jsx +import { Icon, box } from '@wordpress/icons'; + + } + onClick={ () => alert( 'Abbreviated card clicked' ) } +> + Content +; +``` + +### Props + +| Name | Type | Default | Description | +| ----------- | --------- | ------- | -------------------------------------------------------------------------------- | +| `children` | ReactNode | `null` | (required) The children inside the abbreviated card, rendered in the `component` | +| `className` | String | `null` | Additional CSS classes | +| `href` | String | `null` | (required) The resource to link to | +| `icon` | Element | `null` | (required) The element used to represent the icon for this card | +| `onClick` | Function | `null` | On click handler called when the component is clicked | +| `type` | String | `null` | Type of link | diff --git a/packages/js/components/src/abbreviated-card/index.js b/packages/js/components/src/abbreviated-card/index.js new file mode 100644 index 00000000000..2c02b7a55b6 --- /dev/null +++ b/packages/js/components/src/abbreviated-card/index.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { Card, CardBody, Icon } from '@wordpress/components'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import Link from '../link'; + +const AbbreviatedCard = ( { + children, + className, + href, + icon, + onClick, + type, +} ) => { + return ( + + + +
+ +
+
+ { children } +
+ +
+
+ ); +}; + +AbbreviatedCard.propTypes = { + /** + * The Abbreviated Card content. + */ + children: PropTypes.node.isRequired, + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * The resource to link to. + */ + href: PropTypes.string.isRequired, + /** + * Icon for the Abbreviated Card. + */ + icon: PropTypes.element.isRequired, + /** + * Called when the card is clicked. + */ + onClick: PropTypes.func, + /** + * Type of link. + */ + type: PropTypes.oneOf( [ 'wp-admin', 'wc-admin', 'external' ] ), +}; + +export default AbbreviatedCard; diff --git a/packages/js/components/src/abbreviated-card/stories/index.js b/packages/js/components/src/abbreviated-card/stories/index.js new file mode 100644 index 00000000000..d02fd06a55e --- /dev/null +++ b/packages/js/components/src/abbreviated-card/stories/index.js @@ -0,0 +1,20 @@ +/** + * External dependencies + */ +import { page } from '@wordpress/icons'; +/** + * Internal dependencies + */ +import AbbreviatedCard from '..'; + +export const Basic = () => ( + +

Title

+

Abbreviated card content

+
+); + +export default { + title: 'WooCommerce Admin/components/AbbreviatedCard', + component: AbbreviatedCard, +}; diff --git a/packages/js/components/src/abbreviated-card/style.scss b/packages/js/components/src/abbreviated-card/style.scss new file mode 100644 index 00000000000..317006cd145 --- /dev/null +++ b/packages/js/components/src/abbreviated-card/style.scss @@ -0,0 +1,17 @@ +.woocommerce-abbreviated-card { + a { + display: flex; + flex-direction: row; + border-bottom: 1px solid $gray-200; + text-decoration: none; + } + + &__icon { + display: flex; + align-items: center; + padding: $gap $gap-large; + svg { + fill: var(--wp-admin-theme-color); + } + } +} diff --git a/packages/js/components/src/abbreviated-card/test/__snapshots__/index.js.snap b/packages/js/components/src/abbreviated-card/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000..4d273e3095f --- /dev/null +++ b/packages/js/components/src/abbreviated-card/test/__snapshots__/index.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AbbreviatedCard it renders correctly 1`] = ` +
+
+ + +`; diff --git a/packages/js/components/src/abbreviated-card/test/index.js b/packages/js/components/src/abbreviated-card/test/index.js new file mode 100644 index 00000000000..7e295a4de1c --- /dev/null +++ b/packages/js/components/src/abbreviated-card/test/index.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import AbbreviatedCard from '../'; + +describe( 'AbbreviatedCard', () => { + test( 'it renders correctly', () => { + const { container, getByText } = render( + icon
} href="#"> +

Abbreviated card content

+ + ); + expect( container ).toMatchSnapshot(); + + // should have correct content + expect( getByText( 'Abbreviated card content' ) ).toBeInTheDocument(); + + // should have correct class + expect( + container.getElementsByClassName( 'woocommerce-abbreviated-card' ) + ).toHaveLength( 1 ); + } ); +} ); diff --git a/packages/js/components/src/advanced-filters/README.md b/packages/js/components/src/advanced-filters/README.md new file mode 100644 index 00000000000..72da82a2994 --- /dev/null +++ b/packages/js/components/src/advanced-filters/README.md @@ -0,0 +1,196 @@ +# Advanced Filters + +Displays a configurable set of filters which can modify query parameters. Display, behavior, and types of filters can be designated by a configuration object. + +## Usage + +Below is a config example complete with translation strings. Advanced filters makes use of [interpolateComponents](https://github.com/Automattic/interpolate-components#readme) to organize sentence structure, resulting in a filter visually represented as a sentence fragment in any language. + +```js +const config = { + title: __( + // A sentence describing filters for Orders + // See screen shot for context: https://cloudup.com/cSsUY9VeCVJ + 'Orders Match {{select /}} Filters', + 'woocommerce' + ), + filters: { + status: { + labels: { + add: __( 'Order Status', 'woocommerce' ), + remove: __( 'Remove order status filter', 'woocommerce' ), + rule: __( + 'Select an order status filter match', + 'woocommerce' + ), + // A sentence describing an Order Status filter + // See screen shot for context: https://cloudup.com/cSsUY9VeCVJ + title: __( + 'Order Status {{rule /}} {{filter /}}', + 'woocommerce' + ), + filter: __( 'Select an order status', 'woocommerce' ), + }, + rules: [ + { + value: 'is', + // Sentence fragment, logical, "Is" + // Refers to searching for orders matching a chosen order status + // Screenshot for context: https://cloudup.com/cSsUY9VeCVJ + label: _x( 'Is', 'order status', 'woocommerce' ), + }, + { + value: 'is_not', + // Sentence fragment, logical, "Is Not" + // Refers to searching for orders that don't match a chosen order status + // Screenshot for context: https://cloudup.com/cSsUY9VeCVJ + label: _x( 'Is Not', 'order status', 'woocommerce' ), + }, + ], + input: { + component: 'SelectControl', + options: Object.keys( orderStatuses ).map( ( key ) => ( { + value: key, + label: orderStatuses[ key ], + } ) ), + }, + allowMultiple: false, // Set to true to allow multiple instances of this filter. + }, + }, +}; +``` + +When filters are applied, the query string will be modified using a combination of rule names and selected filter values. + +Taking the above configuration as an example, applying the filter will result in a query parameter like `status_is=pending` or `status_is_not=cancelled`. + +### Props + +| Name | Type | Default | Description | +| ------------------------ | -------- | --------- | ---------------------------------------------------------------------------------- | +| `config` | Object | `null` | (required) The configuration object required to render filters. See example above. | +| `path` | String | `null` | (required) Name of this filter, used in translations. | +| `query` | Object | `null` | The query string represented in object form. | +| `onAdvancedFilterAction` | Function | `null` | Function to be called after an advanced filter action has been taken. | +| `siteLocale` | string | `'en_US'` | The siteLocale for the site. | +| `currency` | Object | `null` | (required) The currency instance for the site (@woocommerce/currency). | + +## Input Components + +### SelectControl + +Render a select component with options. + +```js +const config = { + ..., + filters: { + fruit: { + input: { + component: 'SelectControl', + options: [ + { label: 'Apples', key: 'apples' }, + { label: 'Oranges', key: 'oranges' }, + { label: 'Bananas', key: 'bananas' }, + { label: 'Cherries', key: 'cherries' }, + ], + }, + }, + }, +}; +``` + +`options`: An array of objects with `key` and `label` properties. + +### Search + +Render an input for users to search and select using an autocomplete. + +```js +const config = { + ..., + filters: { + product: { + input: { + component: 'Search', + type: 'products', + getLabels: getRequestByIdString( NAMESPACE + 'products', product => ( { + id: product.id, + label: product.name, + } ) ), + }, + }, + }, +}; +``` + +`type`: A string Autocompleter type used by the [Search Component](https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/components/src/search). +`getLabels`: A function returning a Promise resolving to an array of objects with `id` and `label` properties. + +### Date + +Renders an input or two inputs allowing a user to filter based on a date value or range of values. + +```js +const config = { + ..., + filters: { + registered: { + rules: [ + { + value: 'before', + label: __( 'Before', 'woocommerce' ), + }, + { + value: 'after', + label: __( 'After', 'woocommerce' ), + }, + { + value: 'between', + label: __( 'Between', 'woocommerce' ), + }, + ], + input: { + component: 'Date', + }, + }, + }, +}; +``` + +### Numeric Value + +Renders an input or two inputs allowing a user to filter based on a numeric value or range of values. Can also render inputs for currency values. + +Valid rule values are `after`, `before`, and `between`. Use any combination you'd like. + +```js +const config = { + ..., + filters: { + quantity: { + rules: [ + { + value: 'lessthan', + label: __( 'Less Than', 'woocommerce' ), + }, + { + value: 'morethan', + label: __( 'More Than', 'woocommerce' ), + }, + { + value: 'between', + label: __( 'Between', 'woocommerce' ), + }, + ], + input: { + component: 'Number', + }, + }, + }, +}; +``` + +Valid rule values are `lessthan`, `morethan`, and `between`. Use any combination you'd like. + +Specify `input.type` as `'currency'` if you'd like to render currency inputs, which respects store currency locale. diff --git a/packages/js/components/src/advanced-filters/attribute-filter.js b/packages/js/components/src/advanced-filters/attribute-filter.js new file mode 100644 index 00000000000..bc88b4a1ff9 --- /dev/null +++ b/packages/js/components/src/advanced-filters/attribute-filter.js @@ -0,0 +1,304 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { SelectControl as Select, Spinner } from '@wordpress/components'; +import interpolateComponents from '@automattic/interpolate-components'; +import classnames from 'classnames'; +import { + createElement, + Fragment, + useEffect, + useState, +} from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Search from '../search'; +import SelectControl from '../select-control'; +import { textContent } from './utils'; + +const getScreenReaderText = ( { + attributeTerms, + config, + filter, + selectedAttribute, + selectedAttributeTerm, +} ) => { + if ( + ! attributeTerms || + attributeTerms.length === 0 || + ! selectedAttribute || + selectedAttribute.length === 0 || + selectedAttributeTerm === '' + ) { + return ''; + } + + const rule = Array.isArray( config.rules ) + ? config.rules.find( + ( configRule ) => configRule.value === filter.rule + ) || {} + : {}; + + const attributeName = selectedAttribute[ 0 ].label; + const termObject = attributeTerms.find( + ( { key } ) => key === selectedAttributeTerm + ); + const attributeTerm = termObject && termObject.label; + + if ( ! attributeName || ! attributeTerm ) { + return ''; + } + + const filterStr = interpolateComponents( { + /* eslint-disable-next-line max-len */ + /* translators: Sentence fragment describing a product attribute match. Example: "Color Is Not Blue" - attribute = Color, equals = Is Not, value = Blue */ + mixedString: __( + '{{attribute /}} {{equals /}} {{value /}}', + 'woocommerce' + ), + components: { + attribute: { attributeName }, + equals: { rule.label }, + value: { attributeTerm }, + }, + } ); + + return textContent( + interpolateComponents( { + mixedString: config.labels.title, + components: { + filter: { filterStr }, + rule: , + title: , + }, + } ) + ); +}; + +const AttributeFilter = ( props ) => { + const { className, config, filter, isEnglish, onFilterChange } = props; + const { rule, value } = filter; + const { labels, rules } = config; + + const [ selectedAttribute, setSelectedAttribute ] = useState( [] ); + + // Set selected attribute from filter value (in query string). + useEffect( () => { + if ( + ! selectedAttribute.length && + Array.isArray( value ) && + value[ 0 ] + ) { + apiFetch( { + path: `/wc-analytics/products/attributes/${ value[ 0 ] }`, + } ) + .then( ( { id, name } ) => [ + { + key: id.toString(), + label: name, + }, + ] ) + .then( setSelectedAttribute ); + } + }, [ value, selectedAttribute ] ); + + const [ attributeTerms, setAttributeTerms ] = useState( [] ); + + // Fetch all product attributes on mount. + useEffect( () => { + if ( ! selectedAttribute.length ) { + return; + } + setAttributeTerms( false ); + apiFetch( { + path: `/wc-analytics/products/attributes/${ selectedAttribute[ 0 ].key }/terms?per_page=100`, + } ) + .then( ( terms ) => + terms.map( ( { id, name } ) => ( { + key: id.toString(), + label: name, + } ) ) + ) + .then( setAttributeTerms ); + }, [ selectedAttribute ] ); + + const [ selectedAttributeTerm, setSelectedAttributeTerm ] = useState( + Array.isArray( value ) ? value[ 1 ] || '' : '' + ); + + const screenReaderText = getScreenReaderText( { + attributeTerms, + config, + filter, + selectedAttribute, + selectedAttributeTerm, + } ); + + /*eslint-disable jsx-a11y/no-noninteractive-tabindex*/ + return ( +
+ { labels.add || '' } +
+ { interpolateComponents( { + mixedString: labels.title, + components: { + title: , + rule: ( + + { error && ( + + { error } + + ) } + +

+ { error || describedBy } +

+
+ ); +}; + +DateInput.propTypes = { + disabled: PropTypes.bool, + value: PropTypes.string, + onChange: PropTypes.func.isRequired, + dateFormat: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + describedBy: PropTypes.string.isRequired, + error: PropTypes.string, + errorPosition: PropTypes.string, + onFocus: PropTypes.func, + onBlur: PropTypes.func, + onKeyDown: PropTypes.func, +}; + +DateInput.defaultProps = { + disabled: false, + onFocus: () => {}, + onBlur: () => {}, + errorPosition: 'bottom center', + onKeyDown: noop, +}; + +export default DateInput; diff --git a/packages/js/components/src/calendar/phrases.js b/packages/js/components/src/calendar/phrases.js new file mode 100644 index 00000000000..15148c28b0e --- /dev/null +++ b/packages/js/components/src/calendar/phrases.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +export default { + calendarLabel: __( 'Calendar', 'woocommerce' ), + closeDatePicker: __( 'Close', 'woocommerce' ), + focusStartDate: __( + 'Interact with the calendar and select start and end dates.', + 'woocommerce' + ), + clearDate: __( 'Clear Date', 'woocommerce' ), + clearDates: __( 'Clear Dates', 'woocommerce' ), + jumpToPrevMonth: __( + 'Move backward to switch to the previous month.', + 'woocommerce' + ), + jumpToNextMonth: __( + 'Move forward to switch to the next month.', + 'woocommerce' + ), + enterKey: __( 'Enter key', 'woocommerce' ), + leftArrowRightArrow: __( 'Right and left arrow keys', 'woocommerce' ), + upArrowDownArrow: __( 'up and down arrow keys', 'woocommerce' ), + pageUpPageDown: __( 'page up and page down keys', 'woocommerce' ), + homeEnd: __( 'Home and end keys', 'woocommerce' ), + escape: __( 'Escape key', 'woocommerce' ), + questionMark: __( 'Question mark', 'woocommerce' ), + selectFocusedDate: __( 'Select the date in focus.', 'woocommerce' ), + moveFocusByOneDay: __( + 'Move backward (left) and forward (right) by one day.', + 'woocommerce' + ), + moveFocusByOneWeek: __( + 'Move backward (up) and forward (down) by one week.', + 'woocommerce' + ), + moveFocusByOneMonth: __( 'Switch months.', 'woocommerce' ), + moveFocustoStartAndEndOfWeek: __( + 'Go to the first or last day of a week.', + 'woocommerce' + ), + returnFocusToInput: __( 'Return to the date input field.', 'woocommerce' ), + keyboardNavigationInstructions: __( + 'Press the down arrow key to interact with the calendar and select a date.', + 'woocommerce' + ), + chooseAvailableStartDate: ( { date } ) => + sprintf( __( 'Select %s as a start date.', 'woocommerce' ), date ), + chooseAvailableEndDate: ( { date } ) => + sprintf( __( 'Select %s as an end date.', 'woocommerce' ), date ), + chooseAvailableDate: ( { date } ) => date, + dateIsUnavailable: ( { date } ) => + sprintf( __( '%s is not selectable.', 'woocommerce' ), date ), + dateIsSelected: ( { date } ) => + sprintf( __( 'Selected. %s', 'woocommerce' ), date ), +}; diff --git a/packages/js/components/src/calendar/stories/date-picker.js b/packages/js/components/src/calendar/stories/date-picker.js new file mode 100644 index 00000000000..c40a28ca10b --- /dev/null +++ b/packages/js/components/src/calendar/stories/date-picker.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import { DatePicker, H, Section } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; +const dateFormat = 'MM/DD/YYYY'; + +const DatePickerExample = () => { + const [ state, setState ] = useState( { + after: null, + afterText: '', + before: null, + beforeText: '', + afterError: null, + beforeError: null, + focusedInput: 'startDate', + } ); + const { after, afterText, afterError } = state; + + function onDatePickerUpdate( { date, text, error } ) { + setState( { + ...state, + after: date, + afterText: text, + afterError: error, + } ); + } + + return ( +
+ Date Picker +
+ moment( date ).day() === 1 } + /> +
+
+ ); +}; + +export const Basic = () => ; + +export default { + title: 'WooCommerce Admin/components/calendar/DatePicker', + component: DatePicker, +}; diff --git a/packages/js/components/src/calendar/stories/date-range.js b/packages/js/components/src/calendar/stories/date-range.js new file mode 100644 index 00000000000..75be254edbf --- /dev/null +++ b/packages/js/components/src/calendar/stories/date-range.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import { DateRange, H, Section } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; + +const dateFormat = 'MM/DD/YYYY'; + +const DateRangeExample = () => { + const [ state, setState ] = useState( { + after: null, + afterText: '', + before: null, + beforeText: '', + afterError: null, + beforeError: null, + focusedInput: 'startDate', + } ); + + const { after, afterText, before, beforeText, focusedInput } = state; + + function onRangeUpdate( update ) { + setState( { + ...state, + ...update, + } ); + } + + return ( + <> + Date range picker +
+ + moment().isBefore( moment( date ), 'date' ) + } + /> +
+ + ); +}; + +export const Basic = () => ; + +export default { + title: 'WooCommerce Admin/components/calendar/DateRange', + component: DateRange, +}; diff --git a/packages/js/components/src/calendar/style.scss b/packages/js/components/src/calendar/style.scss new file mode 100644 index 00000000000..666d98abb86 --- /dev/null +++ b/packages/js/components/src/calendar/style.scss @@ -0,0 +1,228 @@ +.woocommerce-calendar { + width: 100%; + background-color: $gray-100; + border-top: 1px solid $gray-400; + height: 396px; + + &.is-mobile { + height: 100%; + } +} + +.woocommerce-calendar__react-dates { + width: 100%; + overflow-x: hidden; + + .DayPicker { + margin: 0 auto; + } + + .CalendarMonth_table { + margin-top: 10px; + } + + .CalendarDay__selected_span { + background: var(--wp-admin-theme-color); + border: 1px solid $gray-400; + + &:hover { + background: var(--wp-admin-theme-color-darker-10); + border: 1px solid $gray-100; + } + } + + .CalendarDay__selected { + background: var(--wp-admin-theme-color-darker-20); + border: 1px solid $gray-400; + + &:hover { + background: var(--wp-admin-theme-color-darker-10); + border: 1px solid $gray-100; + } + } + + .CalendarDay__hovered_span { + background: var(--wp-admin-theme-color-darker-10); + border: 1px solid $gray-100; + color: $studio-white; + + &:hover { + color: $studio-white; + background: var(--wp-admin-theme-color); + } + } + + .CalendarDay__blocked_out_of_range { + color: $gray-400; + } + + .DayPicker_transitionContainer, + .CalendarMonthGrid, + .CalendarMonth, + .DayPicker { + background-color: $gray-100; + } + + .DayPicker_weekHeader_li { + color: $gray-700; + } + + .DayPickerNavigation_button { + &:focus { + outline: 2px solid #bfe7f3; + } + } + + // Make exceptions for wp Core DatePicker. + &.is-core-datepicker { + .components-datetime__date { + padding-left: 0; + } + + .CalendarDay__default { + background-color: transparent; + } + + .CalendarDay__selected { + background: $studio-woocommerce-purple-70; + border: none; + } + } +} + +.woocommerce-calendar__inputs { + padding: 1em; + width: 100%; + max-width: 500px; + display: grid; + grid-template-columns: 43% 14% 43%; + margin: 0 auto; + + .components-base-control { + margin: 0; + } +} + +.woocommerce-calendar__inputs-to { + display: flex; + align-items: center; + justify-content: center; + grid-column-start: 2; +} + +.woocommerce-calendar__input { + position: relative; + + .calendar-icon { + position: absolute; + top: 50%; + transform: translateY(-50%); + left: 7px; + + path { + fill: $gray-700; + } + } + + &:first-child { + grid-column-start: 1; + } + + &:last-child { + grid-column-start: 3; + } + + &.is-empty { + .calendar-icon path { + fill: $gray-700; + } + } + + &.is-error { + .calendar-icon path { + fill: $error-red; + } + + .woocommerce-calendar__input-text { + border: 1px solid $error-red; + box-shadow: inset 0 0 8px $error-red; + + &:focus { + box-shadow: inset 0 0 8px $error-red, + 0 0 6px rgba(30, 140, 190, 0.8); + } + } + } + + .woocommerce-calendar__input-text { + color: $gray-700; + border-radius: 3px; + padding: 10px 10px 10px 30px; + width: 100%; + @include font-size( 13 ); + + &::placeholder { + color: $gray-700; + } + } +} + +.woocommerce-filters-date__content { + &.is-mobile + .woocommerce-calendar__input-error + .components-popover__content { + height: initial; + } +} + +.woocommerce-calendar__input-error { + display: none; + + .is-error .woocommerce-calendar__input-text:focus + span & { + /* rtl:begin:ignore */ + display: block; + left: 50% !important; + position: absolute; + top: auto !important; + /* rtl:end:ignore */ + } + + .components-popover__content { + background-color: $gray-700; + color: $studio-white; + padding: 0.5em; + border: none; + } + + &.components-popover { + .components-popover__content { + min-width: 100px; + width: 100px; + text-align: center; + } + + &:not(.no-arrow):not(.is-mobile).is-bottom::before { + border-bottom-color: $gray-700; + z-index: 1; + top: -6px; + } + + &:not(.no-arrow):not(.is-mobile).is-top::after { + border-top-color: $gray-700; + z-index: 1; + top: 0; + } + } +} + +.woocommerce-calendar__date-picker-title { + @include font-size( 12 ); + font-weight: 100; + text-transform: uppercase; + text-align: center; + color: $gray-700; + width: 100%; + margin: 0; + padding: 1em; + background-color: $studio-white; +} diff --git a/packages/js/components/src/chart/README.md b/packages/js/components/src/chart/README.md new file mode 100644 index 00000000000..28082ca574a --- /dev/null +++ b/packages/js/components/src/chart/README.md @@ -0,0 +1,121 @@ +Chart +=== + +A chart container using d3, to display timeseries data with an interactive legend. + +## Usage + +```jsx +const data = [ + { + date: '2018-05-30T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 21599, + }, + Sunglasses: { + label: 'Sunglasses', + value: 38537, + }, + Cap: { + label: 'Cap', + value: 106010, + }, + }, + { + date: '2018-05-31T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 14205, + }, + Sunglasses: { + label: 'Sunglasses', + value: 24721, + }, + Cap: { + label: 'Cap', + value: 70131, + }, + }, + { + date: '2018-06-01T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 10581, + }, + Sunglasses: { + label: 'Sunglasses', + value: 19991, + }, + Cap: { + label: 'Cap', + value: 53552, + }, + }, + { + date: '2018-06-02T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 9250, + }, + Sunglasses: { + label: 'Sunglasses', + value: 16072, + }, + Cap: { + label: 'Cap', + value: 47821, + }, + }, +]; + + +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`allowedIntervals` | Array | `null` | Allowed intervals to show in a dropdown +`baseValue` | Number | `0` | Base chart value. If no data value is different than the baseValue, the `emptyMessage` will be displayed if provided +`chartType` | One of: 'bar', 'line' | `'line'` | Chart type of either `line` or `bar` +`data` | Array | `[]` | An array of data +`dateParser` | String | `'%Y-%m-%dT%H:%M:%S'` | Format to parse dates into d3 time format +`emptyMessage` | String | `null` | The message to be displayed if there is no data to render. If no message is provided, nothing will be displayed +`filterParam` | String | `null` | Name of the param used to filter items. If specified, it will be used, in combination with query, to detect which elements are being used by the current filter and must be displayed even if their value is 0 +`itemsLabel` | String | `null` | Label describing the legend items +`mode` | One of: 'item-comparison', 'time-comparison' | `'time-comparison'` | `item-comparison` (default) or `time-comparison`, this is used to generate correct ARIA properties +`path` | String | `null` | Current path +`query` | Object | `null` | The query string represented in object form +`interactiveLegend` | Boolean | `true` | Whether the legend items can be activated/deactivated +`interval` | One of: 'hour', 'day', 'week', 'month', 'quarter', 'year' | `'day'` | Interval specification (hourly, daily, weekly etc) +`intervalData` | Object | `null` | Information about the currently selected interval, and set of allowed intervals for the chart. See `getIntervalsForQuery` +`isRequesting` | Boolean | `false` | Render a chart placeholder to signify an in-flight data request +`legendPosition` | One of: 'bottom', 'side', 'top', 'hidden' | `null` | Position the legend must be displayed in. If it's not defined, it's calculated depending on the viewport width and the mode +`legendTotals` | Object | `null` | Values to overwrite the legend totals. If not defined, the sum of all line values will be used +`screenReaderFormat` | One of type: string, func | `'%B %-d, %Y'` | A datetime formatting string or overriding function to format the screen reader labels +`showHeaderControls` | Boolean | `true` | Wether header UI controls must be displayed +`title` | String | `null` | A title describing this chart +`tooltipLabelFormat` | One of type: string, func | `'%B %-d, %Y'` | A datetime formatting string or overriding function to format the tooltip label +`tooltipValueFormat` | One of type: string, func | `','` | A number formatting string or function to format the value displayed in the tooltips +`tooltipTitle` | String | `null` | A string to use as a title for the tooltip. Takes preference over `tooltipLabelFormat` +`valueType` | String | `null` | What type of data is to be displayed? Number, Average, String? +`xFormat` | String | `'%d'` | A datetime formatting string, passed to d3TimeFormat +`x2Format` | String | `'%b %Y'` | A datetime formatting string, passed to d3TimeFormat +`yBelow1Format` | String | `null` | A number formatting string, passed to d3Format +`yFormat` | String | `null` | A number formatting string, passed to d3Format +`currency` | Object | `{}` | An object with currency properties for usage in the chart. The following properties are expected: `decimal`, `symbol`, `symbolPosition`, `thousands`. This is passed to d3Format. + +## Overriding chart colors + +Char colors can be overridden by hooking into the filter `woocommerce_admin_chart_item_color`. For example: + +```js +const colorScales = [ + "#0A2F51", + "#0E4D64", + "#137177", + "#188977", +]; +addFilter( 'woocommerce_admin_chart_item_color', 'example', ( index, key, orderedKeys ) => colorScales[index] ); +``` diff --git a/packages/js/components/src/chart/constants.js b/packages/js/components/src/chart/constants.js new file mode 100644 index 00000000000..d7daea2a880 --- /dev/null +++ b/packages/js/components/src/chart/constants.js @@ -0,0 +1,16 @@ +// This is the max number of items that can be selected/shown on a chart at one time. +// If this number changes, the color scale also needs to be adjusted. +export const selectionLimit = 10; +export const colorScales = [ + [], + [ 0.5 ], + [ 0.333, 0.667 ], + [ 0.2, 0.5, 0.8 ], + [ 0.12, 0.375, 0.625, 0.88 ], + [ 0, 0.25, 0.5, 0.75, 1 ], + [ 0, 0.2, 0.4, 0.6, 0.8, 1 ], + [ 0, 0.16, 0.32, 0.48, 0.64, 0.8, 1 ], + [ 0, 0.14, 0.28, 0.42, 0.56, 0.7, 0.84, 1 ], + [ 0, 0.12, 0.24, 0.36, 0.48, 0.6, 0.72, 0.84, 1 ], + [ 0, 0.11, 0.22, 0.33, 0.44, 0.55, 0.66, 0.77, 0.88, 1 ], +]; diff --git a/packages/js/components/src/chart/d3chart/chart.js b/packages/js/components/src/chart/d3chart/chart.js new file mode 100644 index 00000000000..f0a60137802 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/chart.js @@ -0,0 +1,392 @@ +/** + * External dependencies + */ +import { createElement, Component, createRef } from '@wordpress/element'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { timeFormat as d3TimeFormat } from 'd3-time-format'; + +/** + * Internal dependencies + */ +import D3Base from './d3base'; +import { + getOrderedKeys, + getUniqueDates, + getFormatter, + isDataEmpty, +} from './utils/index'; +import { + getXScale, + getXGroupScale, + getXLineScale, + getYScale, + getYScaleLimits, +} from './utils/scales'; +import { drawAxis } from './utils/axis'; +import { drawBars } from './utils/bar-chart'; +import { drawLines } from './utils/line-chart'; +import { getColor } from './utils/color'; +import ChartTooltip from './utils/tooltip'; +import { selectionLimit } from '../constants'; + +const isRTL = () => document.documentElement.dir === 'rtl'; + +/** + * A simple D3 line and bar chart component for timeseries data in React. + */ +class D3Chart extends Component { + constructor( props ) { + super( props ); + this.drawChart = this.drawChart.bind( this ); + this.getParams = this.getParams.bind( this ); + this.tooltipRef = createRef(); + } + + getFormatParams() { + const { + screenReaderFormat, + xFormat, + x2Format, + yFormat, + yBelow1Format, + } = this.props; + + return { + screenReaderFormat: getFormatter( + screenReaderFormat, + d3TimeFormat + ), + xFormat: getFormatter( xFormat, d3TimeFormat ), + x2Format: getFormatter( x2Format, d3TimeFormat ), + yBelow1Format: getFormatter( yBelow1Format ), + yFormat: getFormatter( yFormat ), + }; + } + + getScaleParams( uniqueDates ) { + const { data, height, orderedKeys, chartType } = this.props; + const margin = this.getMargin(); + + const adjHeight = height - margin.top - margin.bottom; + const adjWidth = this.getWidth() - margin.left - margin.right; + const { upper: yMax, lower: yMin, step } = getYScaleLimits( data ); + const yScale = getYScale( adjHeight, yMin, yMax ); + + if ( chartType === 'line' ) { + return { + step, + xScale: getXLineScale( uniqueDates, adjWidth ), + yMax, + yMin, + yScale, + }; + } + + const compact = this.shouldBeCompact(); + const xScale = getXScale( uniqueDates, adjWidth, compact ); + + return { + step, + xGroupScale: getXGroupScale( orderedKeys, xScale, compact ), + xScale, + yMax, + yMin, + yScale, + }; + } + + getParams( uniqueDates ) { + const { + chartType, + colorScheme, + data, + interval, + mode, + orderedKeys, + } = this.props; + const newOrderedKeys = orderedKeys || getOrderedKeys( data ); + const visibleKeys = newOrderedKeys.filter( ( key ) => key.visible ); + const colorKeys = + newOrderedKeys.length > selectionLimit + ? visibleKeys + : newOrderedKeys; + + return { + getColor: getColor( colorKeys, colorScheme ), + interval, + mode, + chartType, + uniqueDates, + visibleKeys, + }; + } + + createTooltip( chart, getColorFunction, visibleKeys ) { + const { + tooltipLabelFormat, + tooltipPosition, + tooltipTitle, + tooltipValueFormat, + } = this.props; + + const tooltip = new ChartTooltip(); + tooltip.ref = this.tooltipRef.current; + tooltip.chart = chart; + tooltip.position = tooltipPosition; + tooltip.title = tooltipTitle; + tooltip.labelFormat = getFormatter( tooltipLabelFormat, d3TimeFormat ); + tooltip.valueFormat = getFormatter( tooltipValueFormat ); + tooltip.visibleKeys = visibleKeys; + tooltip.getColor = getColorFunction; + this.tooltip = tooltip; + } + + drawChart( node ) { + const { data, dateParser, chartType } = this.props; + const margin = this.getMargin(); + const uniqueDates = getUniqueDates( data, dateParser ); + const formats = this.getFormatParams(); + const params = this.getParams( uniqueDates ); + const scales = this.getScaleParams( uniqueDates ); + + const g = node + .attr( 'id', 'chart' ) + .append( 'g' ) + .attr( + 'transform', + `translate(${ margin.left }, ${ margin.top })` + ); + + this.createTooltip( g.node(), params.getColor, params.visibleKeys ); + + drawAxis( g, params, scales, formats, margin, isRTL() ); + // eslint-disable-next-line no-unused-expressions + chartType === 'line' && + drawLines( g, data, params, scales, formats, this.tooltip ); + // eslint-disable-next-line no-unused-expressions + chartType === 'bar' && + drawBars( g, data, params, scales, formats, this.tooltip ); + } + + shouldBeCompact() { + const { data, chartType, width } = this.props; + + if ( chartType !== 'bar' ) { + return false; + } + const margin = this.getMargin(); + const widthWithoutMargins = width - margin.left - margin.right; + const columnsPerDate = + data && data.length ? Object.keys( data[ 0 ] ).length - 1 : 0; + const minimumWideWidth = data.length * ( columnsPerDate + 1 ); + + return widthWithoutMargins < minimumWideWidth; + } + + getMargin() { + const { margin } = this.props; + + if ( isRTL() ) { + return { + bottom: margin.bottom, + left: margin.right, + right: margin.left, + top: margin.top, + }; + } + + return margin; + } + + getWidth() { + const { data, chartType, width } = this.props; + if ( chartType !== 'bar' ) { + return width; + } + const margin = this.getMargin(); + const columnsPerDate = + data && data.length ? Object.keys( data[ 0 ] ).length - 1 : 0; + const minimumWidth = this.shouldBeCompact() + ? data.length * columnsPerDate + : data.length * ( columnsPerDate + 1 ); + + return Math.max( width, minimumWidth + margin.left + margin.right ); + } + + getEmptyMessage() { + const { baseValue, data, emptyMessage } = this.props; + + if ( emptyMessage && isDataEmpty( data, baseValue ) ) { + return ( +
{ emptyMessage }
+ ); + } + } + + render() { + const { className, data, height, orderedKeys, chartType } = this.props; + const computedWidth = this.getWidth(); + return ( +
+ { this.getEmptyMessage() } +
+ +
+ ); + } +} + +D3Chart.propTypes = { + /** + * Base chart value. If no data value is different than the baseValue, the + * `emptyMessage` will be displayed if provided. + */ + baseValue: PropTypes.number, + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * A chromatic color function to be passed down to d3. + */ + colorScheme: PropTypes.func, + /** + * An array of data. + */ + data: PropTypes.array.isRequired, + /** + * Format to parse dates into d3 time format + */ + dateParser: PropTypes.string.isRequired, + /** + * The message to be displayed if there is no data to render. If no message is provided, + * nothing will be displayed. + */ + emptyMessage: PropTypes.string, + /** + * Height of the `svg`. + */ + height: PropTypes.number, + /** + * Interval specification (hourly, daily, weekly etc.) + */ + interval: PropTypes.oneOf( [ + 'hour', + 'day', + 'week', + 'month', + 'quarter', + 'year', + ] ), + /** + * Margins for axis and chart padding. + */ + margin: PropTypes.shape( { + bottom: PropTypes.number, + left: PropTypes.number, + right: PropTypes.number, + top: PropTypes.number, + } ), + /** + * `items-comparison` (default) or `time-comparison`, this is used to generate correct + * ARIA properties. + */ + mode: PropTypes.oneOf( [ 'item-comparison', 'time-comparison' ] ), + /** + * A datetime formatting string or overriding function to format the screen reader labels. + */ + screenReaderFormat: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.func, + ] ), + /** + * The list of labels for this chart. + */ + orderedKeys: PropTypes.array, + /** + * A datetime formatting string or overriding function to format the tooltip label. + */ + tooltipLabelFormat: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.func, + ] ), + /** + * A number formatting string or function to format the value displayed in the tooltips. + */ + tooltipValueFormat: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.func, + ] ), + /** + * The position where to render the tooltip can be `over` the chart or `below` the chart. + */ + tooltipPosition: PropTypes.oneOf( [ 'below', 'over' ] ), + /** + * A string to use as a title for the tooltip. Takes preference over `tooltipFormat`. + */ + tooltipTitle: PropTypes.string, + /** + * Chart type of either `line` or `bar`. + */ + chartType: PropTypes.oneOf( [ 'bar', 'line' ] ), + /** + * Width of the `svg`. + */ + width: PropTypes.number, + /** + * A datetime formatting string or function, passed to d3TimeFormat. + */ + xFormat: PropTypes.oneOfType( [ PropTypes.string, PropTypes.func ] ), + /** + * A datetime formatting string or function, passed to d3TimeFormat. + */ + x2Format: PropTypes.oneOfType( [ PropTypes.string, PropTypes.func ] ), + /** + * A number formatting string or function for numbers between -1 and 1, passed to d3Format. + * If missing, `yFormat` will be used. + */ + yBelow1Format: PropTypes.oneOfType( [ PropTypes.string, PropTypes.func ] ), + /** + * A number formatting string or function, passed to d3Format. + */ + yFormat: PropTypes.oneOfType( [ PropTypes.string, PropTypes.func ] ), +}; + +D3Chart.defaultProps = { + baseValue: 0, + data: [], + dateParser: '%Y-%m-%dT%H:%M:%S', + height: 200, + margin: { + bottom: 30, + left: 40, + right: 0, + top: 20, + }, + mode: 'time-comparison', + screenReaderFormat: '%B %-d, %Y', + tooltipPosition: 'over', + tooltipLabelFormat: '%B %-d, %Y', + tooltipValueFormat: ',', + chartType: 'line', + width: 600, + xFormat: '%Y-%m-%d', + x2Format: '', + yBelow1Format: '.3~f', + yFormat: '.3~s', +}; + +export default D3Chart; diff --git a/packages/js/components/src/chart/d3chart/d3base/README.md b/packages/js/components/src/chart/d3chart/d3base/README.md new file mode 100644 index 00000000000..7c6a227cdc1 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/d3base/README.md @@ -0,0 +1,23 @@ +# D3 Base Component + +Integrate React Lifecyle methods with d3.js charts. + +### Base Component Responsibilities + +* Create and manage mounting and unmounting parent `div` and `svg` +* Handle resize events, resulting re-renders, and event listeners +* Handle re-renders as a result of new props + +## Props + +### className +{ string } A class to be applied to the parent `div` + +### getParams( node ) +{ function } A function returning an object containing required properties for drawing a chart. This object is created before re-render, making it an ideal place for calculating scales and other props or user input based properties. +* `svg` { node } The parent `div`. Useful for calculating available widths + +### drawChart( svg, params ) +{ function } draw the chart +* `svg` { node } Base element +* `params` { Object } Properties created by the `getParams` function \ No newline at end of file diff --git a/packages/js/components/src/chart/d3chart/d3base/index.js b/packages/js/components/src/chart/d3chart/d3base/index.js new file mode 100644 index 00000000000..672bcebcbc8 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/d3base/index.js @@ -0,0 +1,110 @@ +/** + * External dependencies + */ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import { createElement, Component, createRef } from '@wordpress/element'; +import { isEqual, throttle } from 'lodash'; +import { select as d3Select } from 'd3-selection'; + +/** + * Provides foundation to use D3 within React. + * + * React is responsible for determining when a chart should be updated (e.g. whenever data changes or the browser is + * resized), while D3 is responsible for the actual rendering of the chart (which is performed via DOM operations that + * happen outside of React's control). + * + * This component makes use of new lifecycle methods that come with React 16.3. Thus, while this component (i.e. the + * container of the chart) is rendered during the 'render phase' the chart itself is only rendered during the 'commit + * phase' (i.e. in 'componentDidMount' and 'componentDidUpdate' methods). + */ +export default class D3Base extends Component { + constructor( props ) { + super( props ); + + this.chartRef = createRef(); + } + + componentDidMount() { + this.drawUpdatedChart(); + } + + shouldComponentUpdate( nextProps ) { + return ( + this.props.className !== nextProps.className || + ! isEqual( this.props.data, nextProps.data ) || + ! isEqual( this.props.orderedKeys, nextProps.orderedKeys ) || + this.props.drawChart !== nextProps.drawChart || + this.props.height !== nextProps.height || + this.props.chartType !== nextProps.chartType || + this.props.width !== nextProps.width + ); + } + + componentDidUpdate() { + this.drawUpdatedChart(); + } + + componentWillUnmount() { + this.deleteChart(); + } + + delayedScroll() { + const { tooltip } = this.props; + return throttle( () => { + // eslint-disable-next-line no-unused-expressions + tooltip && tooltip.hide(); + }, 300 ); + } + + deleteChart() { + d3Select( this.chartRef.current ).selectAll( 'svg' ).remove(); + } + + /** + * Renders the chart, or triggers a rendering by updating the list of params. + */ + drawUpdatedChart() { + const { drawChart } = this.props; + const svg = this.getContainer(); + drawChart( svg ); + } + + getContainer() { + const { className, height, width } = this.props; + + this.deleteChart(); + + const svg = d3Select( this.chartRef.current ) + .append( 'svg' ) + .attr( 'viewBox', `0 0 ${ width } ${ height }` ) + .attr( 'height', height ) + .attr( 'width', width ) + .attr( 'preserveAspectRatio', 'xMidYMid meet' ); + + if ( className ) { + svg.attr( 'class', `${ className }__viewbox` ); + } + + return svg.append( 'g' ); + } + + render() { + const { className } = this.props; + return ( +
+ ); + } +} + +D3Base.propTypes = { + className: PropTypes.string, + data: PropTypes.array, + orderedKeys: PropTypes.array, // required to detect changes in data + tooltip: PropTypes.object, + chartType: PropTypes.string, +}; diff --git a/packages/js/components/src/chart/d3chart/d3base/style.scss b/packages/js/components/src/chart/d3chart/d3base/style.scss new file mode 100644 index 00000000000..c1237982b7b --- /dev/null +++ b/packages/js/components/src/chart/d3chart/d3base/style.scss @@ -0,0 +1,10 @@ + + +.d3-base { + background: transparent; + overflow-x: auto; + overflow-y: hidden; + position: relative; + width: 100%; + height: 100%; +} diff --git a/packages/js/components/src/chart/d3chart/d3base/test/index.js b/packages/js/components/src/chart/d3chart/d3base/test/index.js new file mode 100644 index 00000000000..bef413a9fac --- /dev/null +++ b/packages/js/components/src/chart/d3chart/d3base/test/index.js @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { noop } from 'lodash'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import D3Base from '../index'; + +describe( 'D3base', () => { + test( 'should have d3Base class', () => { + const { container } = render( ); + expect( container.getElementsByClassName( 'd3-base' ) ).toHaveLength( + 1 + ); + } ); + + test( 'should render an svg', () => { + const { container } = render( + + ); + expect( container.getElementsByTagName( 'svg' ) ).toHaveLength( 1 ); + } ); + + test( 'should render a result of the drawChart prop', () => { + const drawChart = ( svg ) => { + return svg.append( 'circle' ); + }; + const { container } = render( + + ); + expect( container.getElementsByTagName( 'circle' ) ).toHaveLength( 1 ); + } ); +} ); diff --git a/packages/js/components/src/chart/d3chart/example.md b/packages/js/components/src/chart/d3chart/example.md new file mode 100644 index 00000000000..0b84570aabe --- /dev/null +++ b/packages/js/components/src/chart/d3chart/example.md @@ -0,0 +1,36 @@ +```jsx +import { D3Chart, D3Legend } from 'react-d3-chart'; + +const data = [ + { + date: '2018-05-30T00:00:00', + Hoodie: { value: 21599 }, + Sunglasses: { value: 38537 }, + Cap: { value: 106010 }, + }, + { + date: '2018-05-31T00:00:00', + Hoodie: { value: 14205 }, + Sunglasses: { value: 24721 }, + Cap: { value: 70131 }, + }, + { + date: '2018-06-01T00:00:00', + Hoodie: { value: 10581 }, + Sunglasses: { value: 19991 }, + Cap: { value: 53552 }, + }, + { + date: '2018-06-02T00:00:00', + Hoodie: { value: 9250 }, + Sunglasses: { value: 16072 }, + Cap: { value: 47821 }, + }, +]; + +const MyChart = () => ( +
+ +
+); +``` diff --git a/packages/js/components/src/chart/d3chart/index.js b/packages/js/components/src/chart/d3chart/index.js new file mode 100644 index 00000000000..b8b0f3d5a8a --- /dev/null +++ b/packages/js/components/src/chart/d3chart/index.js @@ -0,0 +1,3 @@ +export { default as D3Chart } from './chart'; +export { default as D3Legend } from './legend'; +export { default as D3Base } from './d3base'; diff --git a/packages/js/components/src/chart/d3chart/legend.js b/packages/js/components/src/chart/d3chart/legend.js new file mode 100644 index 00000000000..a17ecdb55e6 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/legend.js @@ -0,0 +1,226 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import classNames from 'classnames'; +import { createElement, Component, createRef } from '@wordpress/element'; +import { withInstanceId } from '@wordpress/compose'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import { getFormatter } from './utils/index'; +import { getColor } from './utils/color'; +import { selectionLimit } from '../constants'; + +/** + * A legend specifically designed for the WooCommerce admin charts. + */ +class D3Legend extends Component { + constructor() { + super(); + + this.listRef = createRef(); + + this.state = { + isScrollable: false, + }; + } + + componentDidMount() { + this.updateListScroll(); + window.addEventListener( 'resize', this.updateListScroll ); + } + + componentWillUnmount() { + window.removeEventListener( 'resize', this.updateListScroll ); + } + + updateListScroll() { + if ( ! this || ! this.listRef ) { + return; + } + const list = this.listRef.current; + const scrolledToEnd = + list.scrollHeight - list.scrollTop <= list.offsetHeight; + this.setState( { + isScrollable: ! scrolledToEnd, + } ); + } + + render() { + const { + colorScheme, + data, + handleLegendHover, + handleLegendToggle, + interactive, + legendDirection, + legendValueFormat, + instanceId, + totalLabel, + } = this.props; + const { isScrollable } = this.state; + const visibleData = data.filter( ( key ) => key.visible ); + const numberOfRowsVisible = visibleData.length; + const showTotalLabel = + legendDirection === 'column' && + data.length > selectionLimit && + totalLabel; + + const keys = data.length > selectionLimit ? visibleData : data; + + return ( +
+
    + { data.map( ( row ) => ( +
  • + +
  • + ) ) } +
+ { showTotalLabel && ( +
+ { totalLabel } +
+ ) } +
+ ); + } +} + +D3Legend.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * A chromatic color function to be passed down to d3. + */ + colorScheme: PropTypes.func, + /** + * An array of `orderedKeys`. + */ + data: PropTypes.array.isRequired, + /** + * Handles `onClick` event. + */ + handleLegendToggle: PropTypes.func, + /** + * Handles `onMouseEnter`/`onMouseLeave` events. + */ + handleLegendHover: PropTypes.func, + /** + * Determines whether or not you can click on the legend + */ + interactive: PropTypes.bool, + /** + * Display legend items as a `row` or `column` inside a flex-box. + */ + legendDirection: PropTypes.oneOf( [ 'row', 'column' ] ), + /** + * A number formatting string or function to format the value displayed in the legend. + */ + legendValueFormat: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.func, + ] ), + /** + * Label to describe the legend items. It will be displayed in the legend of + * comparison charts when there are many. + */ + totalLabel: PropTypes.string, + // from withInstanceId + instanceId: PropTypes.number, +}; + +D3Legend.defaultProps = { + interactive: true, + legendDirection: 'row', + legendValueFormat: ',', +}; + +export default withInstanceId( D3Legend ); diff --git a/packages/js/components/src/chart/d3chart/legend.scss b/packages/js/components/src/chart/d3chart/legend.scss new file mode 100644 index 00000000000..03be297b911 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/legend.scss @@ -0,0 +1,227 @@ +.woocommerce-legend { + &.has-total { + padding-bottom: 50px; + position: relative; + } + + &.woocommerce-legend__direction-column { + border-right: 1px solid $gray-400; + min-width: 320px; + + .woocommerce-chart__footer & { + border-right: none; + } + } + + &.woocommerce-legend__direction-row { + flex-grow: 1; + flex-direction: row; + } +} + +.woocommerce-legend__list { + color: $wp-admin-sidebar; + display: flex; + height: 100%; + margin: 0; + flex-flow: row wrap; + flex-wrap: wrap; + + .woocommerce-legend__direction-column & { + flex-direction: column; + height: 300px; + overflow: auto; + + .woocommerce-chart__footer & { + border-top: 1px solid $gray-400; + height: 100%; + max-height: none; + min-height: none; + } + } + + .has-total.woocommerce-legend__direction-column & { + height: 250px; + + .woocommerce-chart__footer & { + height: auto; + max-height: 220px; + min-height: none; + } + } + + .woocommerce-legend__direction-row & { + flex-direction: row; + } +} + +.woocommerce-legend__item { + & > button { + display: flex; + justify-content: center; + align-items: center; + background-color: $studio-white; + color: $gray-700; + cursor: pointer; + display: inline-flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-between; + width: 100%; + border: none; + padding: 0; + + .woocommerce-legend__item-container { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + position: relative; + padding: 3px 0 3px 24px; + font-size: 13px; + user-select: none; + width: 100%; + + &:hover { + input { + ~ .woocommerce-legend__item-checkmark { + background-color: $gray-200; + } + } + } + + .woocommerce-legend__item-checkmark { + border: 1px solid $gray-400; + position: absolute; + top: 4px; + left: 0; + height: 16px; + width: 16px; + background-color: $studio-white; + + &::after { + content: ''; + position: absolute; + display: none; + } + + &.woocommerce-legend__item-checkmark-checked { + background-color: currentColor; + border-color: currentColor; + + &::after { + display: block; + left: 5px; + top: 2px; + width: 3px; + height: 6px; + border: solid $studio-white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); + + /*!rtl:ignore*/ + .rtl & { + transform: rotate(45deg) scaleX(-1); + } + } + } + } + + .woocommerce-legend__item-total { + margin-left: auto; + font-weight: bold; + } + } + + &:focus { + outline: none; + + .woocommerce-legend__item-container { + .woocommerce-legend__item-checkmark { + outline: 2px solid $gray-400; + } + } + } + + &:hover { + background-color: $gray-100; + } + } + + .woocommerce-legend__direction-column & { + margin: 0; + padding: 0; + + & > button { + min-height: 36px; + padding: 0 17px; + text-align: left; + } + + &:first-child { + margin-top: $gap-small; + } + + &:last-child::after { + content: ''; + display: block; + height: $gap-small; + width: 100%; + } + } + + .woocommerce-legend__direction-row & { + padding: 0; + margin: 0; + flex: 1 0 20%; + max-width: 338px; + + & > button { + padding: 0 17px; + + .woocommerce-legend__item-container { + height: 50px; + align-items: center; + + .woocommerce-legend__item-checkmark { + top: 17px; + } + + .woocommerce-legend__item-title { + margin-right: 17px; + } + } + } + } +} + +.woocommerce-legend__total { + align-items: center; + background: $studio-white; + border-top: 1px solid $gray-400; + bottom: 0; + color: $gray-700; + display: flex; + height: 50px; + justify-content: center; + left: 0; + position: absolute; + right: 0; + text-transform: uppercase; + + &::before { + background: linear-gradient(180deg, rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.2)); + bottom: 100%; + content: ''; + height: 20px; + left: 0; + opacity: 0; + pointer-events: none; + position: absolute; + right: 0; + transition: opacity 0.3s; + } + + .is-scrollable &::before { + opacity: 1; + } +} diff --git a/packages/js/components/src/chart/d3chart/style.scss b/packages/js/components/src/chart/d3chart/style.scss new file mode 100644 index 00000000000..4eb8d42451f --- /dev/null +++ b/packages/js/components/src/chart/d3chart/style.scss @@ -0,0 +1,167 @@ +/** + * Internal Dependencies + */ +@import './legend.scss'; + +.woocommerce-chart__body-row .d3-chart__container { + width: calc(100% - 320px); +} + +.d3-chart__container { + position: relative; + width: 100%; + + svg { + overflow: visible; + } + + .d3-chart__empty-message { + align-items: center; + bottom: 0; + color: $gray-700; + display: flex; + @include font-size( 18 ); + font-weight: bold; + justify-content: center; + left: 0; + line-height: 1.5; + margin: 0 auto; + max-width: 50%; + padding-bottom: 48px; + pointer-events: none; + position: absolute; + right: 0; + top: 0; + text-align: center; + + @include breakpoint( '<782px' ) { + @include font-size( 13 ); + } + } + + .d3-chart__tooltip { + border: 1px solid $gray-400; + position: absolute; + display: flex; + min-width: 324px; + height: auto; + background-color: $studio-white; + text-align: left; + padding: 17px; + box-shadow: 0 3px 20px 0 rgba(18, 24, 30, 0.1), + 0 1px 3px 0 rgba(18, 24, 30, 0.1); + flex-direction: column; + flex-wrap: nowrap; + justify-content: flex-start; + pointer-events: none; + visibility: hidden; + z-index: 1; + + @include breakpoint( '<600px' ) { + min-width: auto; + width: calc(100% - #{$gap-large * 2}); + } + + h4 { + text-align: left; + line-height: 18px; + width: 100%; + text-transform: uppercase; + font-size: 11px; + color: $gray-700; + margin-top: 0; + } + + ul { + list-style: none; + margin-bottom: 2px; + margin-top: 2px; + font-size: 14px; + + li { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + align-items: center; + + &.key-row { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + + .key-container { + width: 100%; + min-width: 100px; + + .key-color { + display: inline-block; + width: 16px; + height: 16px; + margin-right: 8px; + } + .key-key { + margin-right: 6px; + } + } + .key-value { + font-weight: 600; + } + } + } + } + } + .bargroup { + &rect { + shape-rendering: crispEdges; + } + } + .grid { + .tick { + line { + stroke: $gray-100; + stroke-width: 1; + shape-rendering: crispEdges; + } + + &:first-child { + line { + stroke: $gray-700; + } + } + } + } + .grid.with-positive-ticks .tick:last-child line { + opacity: 0; + } + .tick { + padding-top: 10px; + stroke-width: 1; + } + .y-axis { + text-anchor: start; + &.tick { + &text { + fill: $gray-700; + } + } + } + .y-axis, + .axis-month { + .tick text { + font-size: 10px; + } + } + + .focus-grid { + line { + stroke: rgba(0, 0, 0, 0.1); + stroke-width: 1px; + } + } + + .barfocus { + fill: rgba(0, 0, 0, 0.1); + } +} diff --git a/packages/js/components/src/chart/d3chart/test/legend.js b/packages/js/components/src/chart/d3chart/test/legend.js new file mode 100644 index 00000000000..52f009af7c5 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/test/legend.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import D3Legend from '../legend'; + +const colorScheme = jest.fn(); +const data = [ + { + key: 'lorem', + label: 'Lorem', + visible: true, + total: 100, + }, + { + key: 'ipsum', + label: 'Ipsum', + visible: true, + total: 100, + }, +]; + +describe( 'Legend', () => { + test( 'renders toggles for each dataset', () => { + const { getByRole } = render( + + ); + + expect( getByRole( 'checkbox', { name: /Lorem/ } ) ).toBeEnabled(); + expect( getByRole( 'checkbox', { name: /Ipsum/ } ) ).toBeEnabled(); + } ); + + test( 'should prevent toggling off of last active dataset', () => { + const dataset = { ...data }; + // Leave only the first dataset active. + dataset[ 1 ].visible = false; + + const { getByRole } = render( + + ); + + expect( getByRole( 'checkbox', { name: /Lorem/ } ) ).toBeDisabled(); + expect( getByRole( 'checkbox', { name: /Ipsum/ } ) ).toBeEnabled(); + } ); +} ); diff --git a/packages/js/components/src/chart/d3chart/utils/axis-x.js b/packages/js/components/src/chart/d3chart/utils/axis-x.js new file mode 100644 index 00000000000..b1b6308ead2 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/axis-x.js @@ -0,0 +1,265 @@ +/** + * External dependencies + */ +import { axisBottom as d3AxisBottom } from 'd3-axis'; +import moment from 'moment'; + +/** + * Internal dependencies + */ +import { smallBreak, wideBreak } from './breakpoints'; + +const dayTicksThreshold = 63; +const weekTicksThreshold = 9; +const mediumBreak = 1130; +const smallPoints = 7; +const mediumPoints = 12; +const largePoints = 16; +const mostPoints = 31; + +/** + * Calculate the maximum number of ticks allowed in the x-axis based on the width and mode of the chart + * + * @param {number} width - calculated page width + * @param {string} mode - item-comparison or time-comparison + * @return {number} number of x-axis ticks based on width and chart mode + */ +const calculateMaxXTicks = ( width, mode ) => { + if ( width < smallBreak ) { + return smallPoints; + } else if ( width >= smallBreak && width <= mediumBreak ) { + return mediumPoints; + } else if ( width > mediumBreak && width <= wideBreak ) { + if ( mode === 'time-comparison' ) { + return largePoints; + } else if ( mode === 'item-comparison' ) { + return mediumPoints; + } + } else if ( width > wideBreak ) { + if ( mode === 'time-comparison' ) { + return mostPoints; + } else if ( mode === 'item-comparison' ) { + return largePoints; + } + } + + return largePoints; +}; + +/** + * Filter out irrelevant dates so only the first date of each month is kept. + * + * @param {Array} dates - string dates. + * @return {Array} Filtered dates. + */ +const getFirstDatePerMonth = ( dates ) => { + return dates.filter( + ( date, i ) => + i === 0 || + moment( date ).toDate().getMonth() !== + moment( dates[ i - 1 ] ) + .toDate() + .getMonth() + ); +}; + +/** + * Given an array of dates, returns true if the first and last one belong to the same day. + * + * @param {Array} dates - an array of dates + * @return {boolean} whether the first and last date are different hours from the same date. + */ +const areDatesInTheSameDay = ( dates ) => { + const firstDate = moment( dates[ 0 ] ).toDate(); + const lastDate = moment( dates[ dates.length - 1 ] ).toDate(); + return ( + firstDate.getDate() === lastDate.getDate() && + firstDate.getMonth() === lastDate.getMonth() && + firstDate.getFullYear() === lastDate.getFullYear() + ); +}; + +/** + * Describes `smallestFactor` + * + * @param {number} inputNum - any double or integer + * @return {number} smallest factor of num + */ +const getFactors = ( inputNum ) => { + const numFactors = []; + for ( let i = 1; i <= Math.floor( Math.sqrt( inputNum ) ); i++ ) { + if ( inputNum % i === 0 ) { + numFactors.push( i ); + // eslint-disable-next-line no-unused-expressions + inputNum / i !== i && numFactors.push( inputNum / i ); + } + } + numFactors.sort( ( x, y ) => x - y ); // numeric sort + + return numFactors; +}; + +/** + * Calculates the increment factor between ticks so there aren't more than maxTicks. + * + * @param {Array} uniqueDates - all the unique dates from the input data for the chart + * @param {number} maxTicks - maximum number of ticks that can be displayed in the x-axis + * @return {number} x-axis ticks increment factor + */ +const calculateXTicksIncrementFactor = ( uniqueDates, maxTicks ) => { + let factors = []; + let i = 1; + // First we get all the factors of the length of the uniqueDates array + // if the number is a prime number or near prime (with 3 factors) then we + // step down by 1 integer and try again. + while ( factors.length <= 3 ) { + factors = getFactors( uniqueDates.length - i ); + i += 1; + } + + return factors.find( ( f ) => uniqueDates.length / f < maxTicks ); +}; + +/** + * Get x-axis ticks given the unique dates and the increment factor. + * + * @param {Array} uniqueDates - all the unique dates from the input data for the chart + * @param {number} incrementFactor - increment factor for the visible ticks. + * @return {Array} Ticks for the x-axis. + */ +const getXTicksFromIncrementFactor = ( uniqueDates, incrementFactor ) => { + const ticks = []; + + for ( let idx = 0; idx < uniqueDates.length; idx = idx + incrementFactor ) { + ticks.push( uniqueDates[ idx ] ); + } + + // If the first date is missing from the ticks array, add it back in. + if ( ticks[ 0 ] !== uniqueDates[ 0 ] ) { + ticks.unshift( uniqueDates[ 0 ] ); + } + + return ticks; +}; + +/** + * Returns ticks for the x-axis. + * + * @param {Array} uniqueDates - all the unique dates from the input data for the chart + * @param {number} width - calculated page width + * @param {string} mode - item-comparison or time-comparison + * @param {string} interval - string of the interval used in the graph (hour, day, week...) + * @return {number} number of x-axis ticks based on width and chart mode + */ +export const getXTicks = ( uniqueDates, width, mode, interval ) => { + const maxTicks = calculateMaxXTicks( width, mode ); + + if ( + ( uniqueDates.length >= dayTicksThreshold && interval === 'day' ) || + ( uniqueDates.length >= weekTicksThreshold && interval === 'week' ) + ) { + uniqueDates = getFirstDatePerMonth( uniqueDates ); + } + if ( + uniqueDates.length <= maxTicks || + ( interval === 'hour' && + areDatesInTheSameDay( uniqueDates ) && + width > smallBreak ) + ) { + return uniqueDates; + } + + const incrementFactor = calculateXTicksIncrementFactor( + uniqueDates, + maxTicks + ); + + return getXTicksFromIncrementFactor( uniqueDates, incrementFactor ); +}; + +/** + * Compares 2 strings and returns a list of words that are unique from s2 + * + * @param {string} s1 - base string to compare against + * @param {string} s2 - string to compare against the base string + * @param {string|Object} splitChar - character or RegExp to use to deliminate words + * @return {Array} of unique words that appear in s2 but not in s1, the base string + */ +export const compareStrings = ( + s1, + s2, + splitChar = new RegExp( [ ' |,' ], 'g' ) +) => { + const string1 = s1.split( splitChar ); + const string2 = s2.split( splitChar ); + const diff = new Array(); + const long = s1.length > s2.length ? string1 : string2; + for ( let x = 0; x < long.length; x++ ) { + // eslint-disable-next-line no-unused-expressions + string1[ x ] !== string2[ x ] && diff.push( string2[ x ] ); + } + return diff; +}; + +const removeDuplicateDates = ( d, i, ticks, formatter ) => { + const monthDate = moment( d ).toDate(); + let prevMonth = i !== 0 ? ticks[ i - 1 ] : ticks[ i ]; + prevMonth = + prevMonth instanceof Date ? prevMonth : moment( prevMonth ).toDate(); + return i === 0 + ? formatter( monthDate ) + : compareStrings( formatter( prevMonth ), formatter( monthDate ) ).join( + ' ' + ); +}; + +export const drawXAxis = ( node, params, scales, formats ) => { + const height = scales.yScale.range()[ 0 ]; + let ticks = getXTicks( + params.uniqueDates, + scales.xScale.range()[ 1 ], + params.mode, + params.interval + ); + if ( params.chartType === 'line' ) { + ticks = ticks.map( ( d ) => moment( d ).toDate() ); + } + + node.append( 'g' ) + .attr( 'class', 'axis' ) + .attr( 'aria-hidden', 'true' ) + .attr( 'transform', `translate(0, ${ height })` ) + .call( + d3AxisBottom( scales.xScale ) + .tickValues( ticks ) + .tickFormat( ( d, i ) => + params.interval === 'hour' + ? formats.xFormat( + d instanceof Date ? d : moment( d ).toDate() + ) + : removeDuplicateDates( d, i, ticks, formats.xFormat ) + ) + ); + + node.append( 'g' ) + .attr( 'class', 'axis axis-month' ) + .attr( 'aria-hidden', 'true' ) + .attr( 'transform', `translate(0, ${ height + 14 })` ) + .call( + d3AxisBottom( scales.xScale ) + .tickValues( ticks ) + .tickFormat( ( d, i ) => + removeDuplicateDates( d, i, ticks, formats.x2Format ) + ) + ); + + node.append( 'g' ) + .attr( 'class', 'pipes' ) + .attr( 'transform', `translate(0, ${ height })` ) + .call( + d3AxisBottom( scales.xScale ) + .tickValues( ticks ) + .tickSize( 5 ) + .tickFormat( '' ) + ); +}; diff --git a/packages/js/components/src/chart/d3chart/utils/axis-y.js b/packages/js/components/src/chart/d3chart/utils/axis-y.js new file mode 100644 index 00000000000..b50979b2ff9 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/axis-y.js @@ -0,0 +1,88 @@ +/** + * External dependencies + */ +import { axisLeft as d3AxisLeft } from 'd3-axis'; + +const calculateYGridValues = ( numberOfTicks, limit, roundValues ) => { + const grids = []; + + for ( let i = 0; i < numberOfTicks; i++ ) { + const val = ( ( i + 1 ) / numberOfTicks ) * limit; + const rVal = roundValues ? Math.round( val ) : val; + if ( grids[ grids.length - 1 ] !== rVal ) { + grids.push( rVal ); + } + } + + return grids; +}; + +const getNegativeYGrids = ( yMin, step ) => { + if ( yMin >= 0 ) { + return []; + } + + const numberOfTicks = Math.ceil( -yMin / step ); + return calculateYGridValues( numberOfTicks, yMin, yMin < -1 ); +}; + +const getPositiveYGrids = ( yMax, step ) => { + if ( yMax <= 0 ) { + return []; + } + + const numberOfTicks = Math.ceil( yMax / step ); + return calculateYGridValues( numberOfTicks, yMax, yMax > 1 ); +}; + +export const getYGrids = ( yMin, yMax, step ) => { + return [ + 0, + ...getNegativeYGrids( yMin, step ), + ...getPositiveYGrids( yMax, step ), + ]; +}; + +export const drawYAxis = ( node, scales, formats, margin, isRTL ) => { + const yGrids = getYGrids( + scales.yScale.domain()[ 0 ], + scales.yScale.domain()[ 1 ], + scales.step + ); + const width = scales.xScale.range()[ 1 ]; + const xPosition = isRTL + ? width + margin.left + margin.right / 2 - 15 + : -margin.left / 2 - 15; + + const withPositiveValuesClass = + scales.yMin >= 0 || scales.yMax > 0 ? ' with-positive-ticks' : ''; + node.append( 'g' ) + .attr( 'class', 'grid' + withPositiveValuesClass ) + .attr( 'transform', `translate(-${ margin.left }, 0)` ) + .call( + d3AxisLeft( scales.yScale ) + .tickValues( yGrids ) + .tickSize( -width - margin.left - margin.right ) + .tickFormat( '' ) + ); + + node.append( 'g' ) + .attr( 'class', 'axis y-axis' ) + .attr( 'aria-hidden', 'true' ) + .attr( 'transform', 'translate(' + xPosition + ', 12)' ) + .attr( 'text-anchor', 'start' ) + .call( + d3AxisLeft( scales.yScale ) + .tickValues( + scales.yMax === 0 && scales.yMin === 0 + ? [ yGrids[ 0 ] ] + : yGrids + ) + .tickFormat( ( d ) => { + if ( d > -1 && d < 1 && formats.yBelow1Format ) { + return formats.yBelow1Format( d ); + } + return formats.yFormat( d ); + } ) + ); +}; diff --git a/packages/js/components/src/chart/d3chart/utils/axis.js b/packages/js/components/src/chart/d3chart/utils/axis.js new file mode 100644 index 00000000000..ebbce3f9939 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/axis.js @@ -0,0 +1,13 @@ +/** + * Internal dependencies + */ +import { drawXAxis } from './axis-x'; +import { drawYAxis } from './axis-y'; + +export const drawAxis = ( node, params, scales, formats, margin, isRTL ) => { + drawXAxis( node, params, scales, formats ); + drawYAxis( node, scales, formats, margin, isRTL ); + + node.selectAll( '.domain' ).remove(); + node.selectAll( '.axis .tick line' ).remove(); +}; diff --git a/packages/js/components/src/chart/d3chart/utils/bar-chart.js b/packages/js/components/src/chart/d3chart/utils/bar-chart.js new file mode 100644 index 00000000000..e4f1993d0b5 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/bar-chart.js @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import { get } from 'lodash'; +import { event as d3Event } from 'd3-selection'; +import moment from 'moment'; + +export const drawBars = ( node, data, params, scales, formats, tooltip ) => { + const height = scales.yScale.range()[ 0 ]; + const barGroup = node + .append( 'g' ) + .attr( 'class', 'bars' ) + .selectAll( 'g' ) + .data( data ) + .enter() + .append( 'g' ) + .attr( + 'transform', + ( d ) => `translate(${ scales.xScale( d.date ) }, 0)` + ) + .attr( 'class', 'bargroup' ) + .attr( 'role', 'region' ) + .attr( 'aria-label', ( d ) => + params.mode === 'item-comparison' + ? formats.screenReaderFormat( + d.date instanceof Date + ? d.date + : moment( d.date ).toDate() + ) + : null + ); + + barGroup + .append( 'rect' ) + .attr( 'class', 'barfocus' ) + .attr( 'x', 0 ) + .attr( 'y', 0 ) + .attr( 'width', scales.xGroupScale.range()[ 1 ] ) + .attr( 'height', height ) + .attr( 'opacity', '0' ) + .on( 'mouseover', ( d, i, nodes ) => { + tooltip.show( + data.find( ( e ) => e.date === d.date ), + d3Event.target, + nodes[ i ].parentNode + ); + } ) + .on( 'mouseout', () => tooltip.hide() ); + + const basePosition = scales.yScale( 0 ); + barGroup + .selectAll( '.bar' ) + .data( ( d ) => + params.visibleKeys.map( ( row ) => ( { + key: row.key, + focus: row.focus, + value: get( d, [ row.key, 'value' ], 0 ), + label: row.label, + visible: row.visible, + date: d.date, + } ) ) + ) + .enter() + .append( 'rect' ) + .attr( 'class', 'bar' ) + .attr( 'x', ( d ) => scales.xGroupScale( d.key ) ) + .attr( 'y', ( d ) => + Math.min( basePosition, scales.yScale( d.value ) ) + ) + .attr( 'width', scales.xGroupScale.bandwidth() ) + .attr( 'height', ( d ) => + Math.abs( basePosition - scales.yScale( d.value ) ) + ) + .attr( 'fill', ( d ) => params.getColor( d.key ) ) + .attr( 'pointer-events', 'none' ) + .attr( 'tabindex', '0' ) + .attr( 'aria-label', ( d ) => { + let label = d.label || d.key; + if ( params.mode === 'time-comparison' ) { + const dayData = data.find( ( e ) => e.date === d.date ); + label = formats.screenReaderFormat( + moment( dayData[ d.key ].labelDate ).toDate() + ); + } + return `${ label } ${ tooltip.valueFormat( d.value ) }`; + } ) + .style( 'opacity', ( d ) => { + const opacity = d.focus ? 1 : 0.1; + return d.visible ? opacity : 0; + } ) + .on( 'focus', ( d, i, nodes ) => { + const targetNode = + d.value > 0 ? d3Event.target : d3Event.target.parentNode; + tooltip.show( + data.find( ( e ) => e.date === d.date ), + targetNode, + nodes[ i ].parentNode + ); + } ) + .on( 'blur', () => tooltip.hide() ); +}; diff --git a/packages/js/components/src/chart/d3chart/utils/breakpoints.js b/packages/js/components/src/chart/d3chart/utils/breakpoints.js new file mode 100644 index 00000000000..94e7ee9f9e0 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/breakpoints.js @@ -0,0 +1,2 @@ +export const smallBreak = 783; +export const wideBreak = 1365; diff --git a/packages/js/components/src/chart/d3chart/utils/color.js b/packages/js/components/src/chart/d3chart/utils/color.js new file mode 100644 index 00000000000..8820d25526a --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/color.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { findIndex } from 'lodash'; +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { colorScales, selectionLimit } from '../../constants'; + +export const getColor = ( orderedKeys, colorScheme ) => ( key ) => { + const len = + orderedKeys.length > selectionLimit + ? selectionLimit + : orderedKeys.length; + const idx = findIndex( orderedKeys, ( d ) => d.key === key ); + + /** + * Color to be used for a chart item. + * + * @filter woocommerce_admin_chart_item_color + * @example + * addFilter( + * 'woocommerce_admin_chart_item_color', + * 'example', + * ( idx ) => { + * const colorScales = [ + * "#0A2F51", + * "#0E4D64", + * "#137177", + * "#188977", + * ]; + * return colorScales[ idx ] || false; + * }); + * + */ + const color = applyFilters( + 'woocommerce_admin_chart_item_color', + idx, + key, + orderedKeys + ); + + if ( color && color.toString().startsWith( '#' ) ) { + return color; + } + + const keyValue = idx <= selectionLimit - 1 ? colorScales[ len ][ idx ] : 0; + return colorScheme( keyValue ); +}; diff --git a/packages/js/components/src/chart/d3chart/utils/index.js b/packages/js/components/src/chart/d3chart/utils/index.js new file mode 100644 index 00000000000..99742b77f36 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/index.js @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import { isNil } from 'lodash'; +import { format as d3Format } from 'd3-format'; +import { utcParse as d3UTCParse } from 'd3-time-format'; + +/** + * Allows an overriding formatter or defaults to d3Format or d3TimeFormat + * + * @param {string|Function} format - either a format string for the D3 formatters or an overriding fomatting method + * @param {Function} formatter - default d3Format or another formatting method, which accepts the string `format` + * @return {Function} to be used to format an input given the format and formatter + */ +export const getFormatter = ( format, formatter = d3Format ) => + typeof format === 'function' ? format : formatter( format ); + +/** + * Returns an array of unique keys contained in the data. + * + * @param {Array} data - The chart component's `data` prop. + * @return {Array} Array of unique keys. + */ +export const getUniqueKeys = ( data ) => { + const keys = new Set( + data.reduce( ( acc, curr ) => acc.concat( Object.keys( curr ) ), [] ) + ); + + return [ ...keys ].filter( ( key ) => key !== 'date' ); +}; + +/** + * Describes `getOrderedKeys` + * + * @param {Array} data - The chart component's `data` prop. + * @return {Array} Array of unique category keys ordered by cumulative total value + */ +export const getOrderedKeys = ( data ) => { + const keys = getUniqueKeys( data ); + + return keys + .map( ( key ) => ( { + key, + focus: true, + total: data.reduce( ( a, c ) => a + c[ key ].value, 0 ), + visible: true, + } ) ) + .sort( ( a, b ) => b.total - a.total ); +}; + +/** + * Describes `getUniqueDates` + * + * @param {Array} data - the chart component's `data` prop. + * @param {string} dateParser - D3 time format + * @return {Array} an array of unique date values sorted from earliest to latest + */ +export const getUniqueDates = ( data, dateParser ) => { + const parseDate = d3UTCParse( dateParser ); + const dates = new Set( data.map( ( d ) => d.date ) ); + return [ ...dates ].sort( ( a, b ) => parseDate( a ) - parseDate( b ) ); +}; + +/** + * Check whether data is empty. + * + * @param {Array} data - the chart component's `data` prop. + * @param {number} baseValue - base value to test data values against. + * @return {boolean} `false` if there was at least one data value different than + * the baseValue. + */ +export const isDataEmpty = ( data, baseValue = 0 ) => { + for ( let i = 0; i < data.length; i++ ) { + for ( const [ key, item ] of Object.entries( data[ i ] ) ) { + if ( + key !== 'date' && + ! isNil( item.value ) && + item.value !== baseValue + ) { + return false; + } + } + } + + return true; +}; diff --git a/packages/js/components/src/chart/d3chart/utils/line-chart.js b/packages/js/components/src/chart/d3chart/utils/line-chart.js new file mode 100644 index 00000000000..dd508eca683 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/line-chart.js @@ -0,0 +1,247 @@ +/** + * External dependencies + */ +import { event as d3Event } from 'd3-selection'; +import { line as d3Line } from 'd3-shape'; +import moment from 'moment'; +import { first, get } from 'lodash'; + +/** + * Internal dependencies + */ +import { smallBreak, wideBreak } from './breakpoints'; + +/** + * Describes getDateSpaces + * + * @param {Array} data - The chart component's `data` prop. + * @param {Array} uniqueDates - from `getUniqueDates` + * @param {Array} visibleKeys - visible keys from the input data for the chart + * @param {number} width - calculated width of the charting space + * @param {Function} xScale - from `getXLineScale` + * @return {Array} that includes the date, start (x position) and width to mode the mouseover rectangles + */ +export const getDateSpaces = ( + data, + uniqueDates, + visibleKeys, + width, + xScale +) => { + const reversedKeys = visibleKeys.slice().reverse(); + + return uniqueDates.map( ( d, i ) => { + const datapoints = first( data.filter( ( item ) => item.date === d ) ); + const xNow = xScale( moment( d ).toDate() ); + const xPrev = + i >= 1 + ? xScale( moment( uniqueDates[ i - 1 ] ).toDate() ) + : xScale( moment( uniqueDates[ 0 ] ).toDate() ); + const xNext = + i < uniqueDates.length - 1 + ? xScale( moment( uniqueDates[ i + 1 ] ).toDate() ) + : xScale( + moment( uniqueDates[ uniqueDates.length - 1 ] ).toDate() + ); + let xWidth = i === 0 ? xNext - xNow : xNow - xPrev; + const xStart = i === 0 ? 0 : xNow - xWidth / 2; + xWidth = i === 0 || i === uniqueDates.length - 1 ? xWidth / 2 : xWidth; + return { + date: d, + start: uniqueDates.length > 1 ? xStart : 0, + width: uniqueDates.length > 1 ? xWidth : width, + values: reversedKeys + .map( ( { key } ) => { + const datapoint = datapoints[ key ]; + if ( ! datapoint ) { + return null; + } + return { + key, + value: datapoint.value, + date: d, + }; + } ) + .filter( Boolean ), + }; + } ); +}; + +/** + * Describes getLine + * + * @param {Function} xScale - from `getXLineScale`. + * @param {Function} yScale - from `getYScale`. + * @return {Function} the D3 line function for plotting all category values + */ +export const getLine = ( xScale, yScale ) => + d3Line() + .x( ( d ) => xScale( moment( d.date ).toDate() ) ) + .y( ( d ) => yScale( d.value ) ); + +/** + * Describes `getLineData` + * + * @param {Array} data - The chart component's `data` prop. + * @param {Array} orderedKeys - from `getOrderedKeys`. + * @return {Array} an array objects with a category `key` and an array of `values` with `date` and `value` properties + */ +export const getLineData = ( data, orderedKeys ) => + orderedKeys.map( ( row ) => ( { + key: row.key, + focus: row.focus, + visible: row.visible, + label: row.label, + values: data.map( ( d ) => ( { + // To have the same X-axis scale, we use the same dates for all lines. + date: d.date, + // To have actual date for the screenReader, we need to use label date. + labelDate: d[ row.key ].labelDate, + focus: row.focus, + value: get( d, [ row.key, 'value' ], 0 ), + visible: row.visible, + } ) ), + } ) ); + +export const drawLines = ( node, data, params, scales, formats, tooltip ) => { + const height = scales.yScale.range()[ 0 ]; + const width = scales.xScale.range()[ 1 ]; + const line = getLine( scales.xScale, scales.yScale ); + const lineData = getLineData( data, params.visibleKeys ); + const series = node + .append( 'g' ) + .attr( 'class', 'lines' ) + .selectAll( '.line-g' ) + .data( lineData.filter( ( d ) => d.visible ).reverse() ) + .enter() + .append( 'g' ) + .attr( 'class', 'line-g' ) + .attr( 'role', 'region' ) + .attr( 'aria-label', ( d ) => d.label || d.key ); + const dateSpaces = getDateSpaces( + data, + params.uniqueDates, + params.visibleKeys, + width, + scales.xScale + ); + + let lineStroke = + width <= wideBreak || params.uniqueDates.length > 50 ? 2 : 3; + lineStroke = width <= smallBreak ? 1.25 : lineStroke; + const dotRadius = width <= wideBreak ? 4 : 6; + + // eslint-disable-next-line no-unused-expressions + params.uniqueDates.length > 1 && + series + .append( 'path' ) + .attr( 'fill', 'none' ) + .attr( 'stroke-width', lineStroke ) + .attr( 'stroke-linejoin', 'round' ) + .attr( 'stroke-linecap', 'round' ) + .attr( 'stroke', ( d ) => params.getColor( d.key ) ) + .style( 'opacity', ( d ) => { + const opacity = d.focus ? 1 : 0.1; + return d.visible ? opacity : 0; + } ) + .attr( 'd', ( d ) => line( d.values ) ); + + const minDataPointSpacing = 36; + // eslint-disable-next-line no-unused-expressions + width / params.uniqueDates.length > minDataPointSpacing && + series + .selectAll( 'circle' ) + .data( ( d, i ) => + d.values.map( ( row ) => ( { + ...row, + i, + visible: d.visible, + key: d.key, + } ) ) + ) + .enter() + .append( 'circle' ) + .attr( 'r', dotRadius ) + .attr( 'fill', ( d ) => params.getColor( d.key ) ) + .attr( 'stroke', '#fff' ) + .attr( 'stroke-width', lineStroke + 1 ) + .style( 'opacity', ( d ) => { + const opacity = d.focus ? 1 : 0.1; + return d.visible ? opacity : 0; + } ) + .attr( 'cx', ( d ) => scales.xScale( moment( d.date ).toDate() ) ) + .attr( 'cy', ( d ) => scales.yScale( d.value ) ) + .attr( 'tabindex', '0' ) + .attr( 'role', 'graphics-symbol' ) + .attr( 'aria-label', ( d ) => { + const label = formats.screenReaderFormat( + d.labelDate instanceof Date + ? d.labelDate + : moment( d.labelDate ).toDate() + ); + return `${ label } ${ tooltip.valueFormat( d.value ) }`; + } ) + .on( 'focus', ( d, i, nodes ) => { + tooltip.show( + data.find( ( e ) => e.date === d.date ), + nodes[ i ].parentNode, + d3Event.target + ); + } ) + .on( 'blur', () => tooltip.hide() ); + + const focus = node + .append( 'g' ) + .attr( 'class', 'focusspaces' ) + .selectAll( '.focus' ) + .data( dateSpaces ) + .enter() + .append( 'g' ) + .attr( 'class', 'focus' ); + + const focusGrid = focus + .append( 'g' ) + .attr( 'class', 'focus-grid' ) + .attr( 'opacity', '0' ); + + focusGrid + .append( 'line' ) + .attr( 'x1', ( d ) => scales.xScale( moment( d.date ).toDate() ) ) + .attr( 'y1', 0 ) + .attr( 'x2', ( d ) => scales.xScale( moment( d.date ).toDate() ) ) + .attr( 'y2', height ); + + focusGrid + .selectAll( 'circle' ) + .data( ( d ) => d.values ) + .enter() + .append( 'circle' ) + .attr( 'r', dotRadius + 2 ) + .attr( 'fill', ( d ) => params.getColor( d.key ) ) + .attr( 'stroke', '#fff' ) + .attr( 'stroke-width', lineStroke + 2 ) + .attr( 'cx', ( d ) => scales.xScale( moment( d.date ).toDate() ) ) + .attr( 'cy', ( d ) => scales.yScale( d.value ) ); + + focus + .append( 'rect' ) + .attr( 'class', 'focus-g' ) + .attr( 'x', ( d ) => d.start ) + .attr( 'y', 0 ) + .attr( 'width', ( d ) => d.width ) + .attr( 'height', height ) + .attr( 'opacity', 0 ) + .on( 'mouseover', ( d, i, nodes ) => { + const isTooltipLeftAligned = + ( i === 0 || i === dateSpaces.length - 1 ) && + params.uniqueDates.length > 1; + const elementWidthRatio = isTooltipLeftAligned ? 0 : 0.5; + tooltip.show( + data.find( ( e ) => e.date === d.date ), + d3Event.target, + nodes[ i ].parentNode, + elementWidthRatio + ); + } ) + .on( 'mouseout', () => tooltip.hide() ); +}; diff --git a/packages/js/components/src/chart/d3chart/utils/scales.js b/packages/js/components/src/chart/d3chart/utils/scales.js new file mode 100644 index 00000000000..dc5d2fd4e42 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/scales.js @@ -0,0 +1,152 @@ +/** + * External dependencies + */ +import { + scaleBand as d3ScaleBand, + scaleLinear as d3ScaleLinear, + scaleTime as d3ScaleTime, +} from 'd3-scale'; +import moment from 'moment'; + +/** + * Describes getXScale + * + * @param {Array} uniqueDates - from `getUniqueDates` + * @param {number} width - calculated width of the charting space + * @param {boolean} compact - whether the chart must be compact (without padding + between days) + * @return {Function} a D3 scale of the dates + */ +export const getXScale = ( uniqueDates, width, compact = false ) => + d3ScaleBand() + .domain( uniqueDates ) + .range( [ 0, width ] ) + .paddingInner( compact ? 0 : 0.1 ); + +/** + * Describes getXGroupScale + * + * @param {Array} orderedKeys - from `getOrderedKeys` + * @param {Function} xScale - from `getXScale` + * @param {boolean} compact - whether the chart must be compact (without padding + between days) + * @return {Function} a D3 scale for each category within the xScale range + */ +export const getXGroupScale = ( orderedKeys, xScale, compact = false ) => + d3ScaleBand() + .domain( + orderedKeys.filter( ( d ) => d.visible ).map( ( d ) => d.key ) + ) + .rangeRound( [ 0, xScale.bandwidth() ] ) + .padding( compact ? 0 : 0.07 ); + +/** + * Describes getXLineScale + * + * @param {Array} uniqueDates - from `getUniqueDates` + * @param {number} width - calculated width of the charting space + * @return {Function} a D3 scaletime for each date + */ +export const getXLineScale = ( uniqueDates, width ) => + d3ScaleTime() + .domain( [ + moment( uniqueDates[ 0 ], 'YYYY-MM-DD HH:mm' ).toDate(), + moment( + uniqueDates[ uniqueDates.length - 1 ], + 'YYYY-MM-DD HH:mm' + ).toDate(), + ] ) + .rangeRound( [ 0, width ] ); + +const getYValueLimits = ( data ) => { + let maxYValue = Number.NEGATIVE_INFINITY; + let minYValue = Number.POSITIVE_INFINITY; + data.forEach( ( d ) => { + for ( const [ key, item ] of Object.entries( d ) ) { + if ( + key !== 'date' && + Number.isFinite( item.value ) && + item.value > maxYValue + ) { + maxYValue = item.value; + } + if ( + key !== 'date' && + Number.isFinite( item.value ) && + item.value < minYValue + ) { + minYValue = item.value; + } + } + } ); + + return { upper: maxYValue, lower: minYValue }; +}; + +export const calculateStep = ( minValue, maxValue ) => { + if ( ! Number.isFinite( minValue ) || ! Number.isFinite( maxValue ) ) { + return 1; + } + + if ( maxValue === 0 && minValue === 0 ) { + return 1 / 3; + } + + const maxAbsValue = Math.max( -minValue, maxValue ); + const maxLimit = ( 4 / 3 ) * maxAbsValue; + const pow3Y = + // eslint-disable-next-line no-bitwise + Math.pow( 10, ( ( Math.log( maxLimit ) * Math.LOG10E + 1 ) | 0 ) - 2 ) * + 3; + const step = ( Math.ceil( maxLimit / pow3Y ) * pow3Y ) / 3; + + if ( maxValue < 1 && minValue > -1 ) { + return Math.round( step * 4 ) / 4; + } + + return Math.ceil( step ); +}; + +/** + * Returns the lower and upper limits of the Y scale and the calculated step to use in the axis, rounding + * them to the nearest thousand, ten-thousand, million etc. In case it is a decimal number, ceils it. + * + * @param {Array} data - The chart component's `data` prop. + * @return {Object} Object containing the `lower` and `upper` limits and a `step` value. + */ +export const getYScaleLimits = ( data ) => { + const { lower: minValue, upper: maxValue } = getYValueLimits( data ); + const step = calculateStep( minValue, maxValue ); + const limits = { lower: 0, upper: 0, step }; + + if ( Number.isFinite( minValue ) || minValue < 0 ) { + limits.lower = Math.floor( minValue / step ) * step; + if ( limits.lower === minValue && minValue !== 0 ) { + limits.lower -= step; + } + } + if ( Number.isFinite( maxValue ) || maxValue > 0 ) { + limits.upper = Math.ceil( maxValue / step ) * step; + if ( limits.upper === maxValue && maxValue !== 0 ) { + limits.upper += step; + } + } + + return limits; +}; + +/** + * Describes getYScale + * + * @param {number} height - calculated height of the charting space + * @param {number} yMin - minimum y value + * @param {number} yMax - maximum y value + * @return {Function} the D3 linear scale from 0 to the value from `getYMax` + */ +export const getYScale = ( height, yMin, yMax ) => + d3ScaleLinear() + .domain( [ + Math.min( yMin, 0 ), + yMax === 0 && yMin === 0 ? 1 : Math.max( yMax, 0 ), + ] ) + .rangeRound( [ height, 0 ] ); diff --git a/packages/js/components/src/chart/d3chart/utils/test/axis-x.js b/packages/js/components/src/chart/d3chart/utils/test/axis-x.js new file mode 100644 index 00000000000..f85aee2e666 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/test/axis-x.js @@ -0,0 +1,257 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { compareStrings, getXTicks } from '../axis-x'; + +describe( 'getXTicks', () => { + describe( 'interval=day', () => { + const uniqueDates = [ + '2018-01-01T00:00:00', + '2018-01-02T00:00:00', + '2018-01-03T00:00:00', + '2018-01-04T00:00:00', + '2018-01-05T00:00:00', + '2018-01-06T00:00:00', + '2018-01-07T00:00:00', + '2018-01-08T00:00:00', + '2018-01-09T00:00:00', + '2018-01-10T00:00:00', + '2018-01-11T00:00:00', + '2018-01-12T00:00:00', + '2018-01-13T00:00:00', + '2018-01-14T00:00:00', + '2018-01-15T00:00:00', + '2018-01-16T00:00:00', + '2018-01-17T00:00:00', + '2018-01-18T00:00:00', + '2018-01-19T00:00:00', + '2018-01-20T00:00:00', + '2018-01-21T00:00:00', + '2018-01-22T00:00:00', + '2018-01-23T00:00:00', + '2018-01-24T00:00:00', + '2018-01-25T00:00:00', + '2018-01-26T00:00:00', + '2018-01-27T00:00:00', + '2018-01-28T00:00:00', + '2018-01-29T00:00:00', + '2018-01-30T00:00:00', + '2018-01-31T00:00:00', + ]; + + it( 'returns a subset of the uniqueDates as ticks depending on the width', () => { + const width = 0; + const mode = 'item-comparison'; + const interval = 'day'; + const expectedXTicks = [ + '2018-01-01T00:00:00', + '2018-01-06T00:00:00', + '2018-01-11T00:00:00', + '2018-01-16T00:00:00', + '2018-01-21T00:00:00', + '2018-01-26T00:00:00', + '2018-01-31T00:00:00', + ]; + + const xTicks = getXTicks( uniqueDates, width, mode, interval ); + + expect( xTicks ).toEqual( expectedXTicks ); + } ); + + it( 'returns a tick for the first date of each month when the list of uniqueDates exceeds the threshold', () => { + const width = 0; + const mode = 'item-comparison'; + const interval = 'day'; + const extendedUniqueDates = [ + '2018-02-01T00:00:00', + '2018-02-02T00:00:00', + '2018-02-03T00:00:00', + '2018-02-04T00:00:00', + '2018-02-05T00:00:00', + '2018-02-06T00:00:00', + '2018-02-07T00:00:00', + '2018-02-08T00:00:00', + '2018-02-09T00:00:00', + '2018-02-10T00:00:00', + '2018-02-11T00:00:00', + '2018-02-12T00:00:00', + '2018-02-13T00:00:00', + '2018-02-14T00:00:00', + '2018-02-15T00:00:00', + '2018-02-16T00:00:00', + '2018-02-17T00:00:00', + '2018-02-18T00:00:00', + '2018-02-19T00:00:00', + '2018-02-20T00:00:00', + '2018-02-21T00:00:00', + '2018-02-22T00:00:00', + '2018-02-23T00:00:00', + '2018-02-24T00:00:00', + '2018-02-25T00:00:00', + '2018-02-26T00:00:00', + '2018-02-27T00:00:00', + '2018-02-28T00:00:00', + '2018-03-01T00:00:00', + '2018-03-02T00:00:00', + '2018-03-03T00:00:00', + '2018-03-04T00:00:00', + '2018-03-05T00:00:00', + '2018-03-06T00:00:00', + '2018-03-07T00:00:00', + '2018-03-08T00:00:00', + '2018-03-09T00:00:00', + '2018-03-10T00:00:00', + '2018-03-11T00:00:00', + '2018-03-12T00:00:00', + '2018-03-13T00:00:00', + '2018-03-14T00:00:00', + '2018-03-15T00:00:00', + '2018-03-16T00:00:00', + '2018-03-17T00:00:00', + '2018-03-18T00:00:00', + '2018-03-19T00:00:00', + '2018-03-20T00:00:00', + '2018-03-21T00:00:00', + '2018-03-22T00:00:00', + '2018-03-23T00:00:00', + '2018-03-24T00:00:00', + '2018-03-25T00:00:00', + '2018-03-26T00:00:00', + '2018-03-27T00:00:00', + '2018-03-28T00:00:00', + '2018-03-29T00:00:00', + '2018-03-30T00:00:00', + '2018-03-31T00:00:00', + '2018-04-01T00:00:00', + '2018-04-02T00:00:00', + '2018-04-03T00:00:00', + '2018-04-04T00:00:00', + '2018-04-05T00:00:00', + '2018-04-06T00:00:00', + '2018-04-07T00:00:00', + '2018-04-08T00:00:00', + ]; + const expectedXTicks = [ + '2018-01-01T00:00:00', + '2018-02-01T00:00:00', + '2018-03-01T00:00:00', + '2018-04-01T00:00:00', + ]; + + const xTicks = getXTicks( + uniqueDates.concat( extendedUniqueDates ), + width, + mode, + interval + ); + + expect( xTicks ).toEqual( expectedXTicks ); + } ); + } ); + + describe( 'interval=hour', () => { + const uniqueDates = [ + '2018-01-02T00:00:00', + '2018-01-02T01:00:00', + '2018-01-02T02:00:00', + '2018-01-02T03:00:00', + '2018-01-02T04:00:00', + '2018-01-02T05:00:00', + '2018-01-02T06:00:00', + '2018-01-02T07:00:00', + '2018-01-02T08:00:00', + '2018-01-02T09:00:00', + '2018-01-02T10:00:00', + '2018-01-02T11:00:00', + '2018-01-02T12:00:00', + '2018-01-02T13:00:00', + '2018-01-02T14:00:00', + '2018-01-02T15:00:00', + '2018-01-02T16:00:00', + '2018-01-02T17:00:00', + '2018-01-02T18:00:00', + '2018-01-02T19:00:00', + '2018-01-02T20:00:00', + '2018-01-02T21:00:00', + '2018-01-02T22:00:00', + '2018-01-02T23:00:00', + ]; + + it( "doesn't return a tick for each unique date when width is not big enough", () => { + const width = 0; + const mode = 'item-comparison'; + const interval = 'hour'; + const expectedXTicks = [ + '2018-01-02T00:00:00', + '2018-01-02T11:00:00', + '2018-01-02T22:00:00', + ]; + + const xTicks = getXTicks( uniqueDates, width, mode, interval ); + + expect( xTicks ).toEqual( expectedXTicks ); + } ); + + it( "doesn't return a tick for each unique date when all dates don't belong to the same day", () => { + const width = 9999; + const mode = 'item-comparison'; + const interval = 'hour'; + const expectedXTicks = [ + '2018-01-01T23:00:00', + '2018-01-02T01:00:00', + '2018-01-02T03:00:00', + '2018-01-02T05:00:00', + '2018-01-02T07:00:00', + '2018-01-02T09:00:00', + '2018-01-02T11:00:00', + '2018-01-02T13:00:00', + '2018-01-02T15:00:00', + '2018-01-02T17:00:00', + '2018-01-02T19:00:00', + '2018-01-02T21:00:00', + '2018-01-02T23:00:00', + ]; + + const xTicks = getXTicks( + [ '2018-01-01T23:00:00' ].concat( uniqueDates ), + width, + mode, + interval + ); + + expect( xTicks ).toEqual( expectedXTicks ); + } ); + + it( 'returns a tick for each unique date when all dates are from the same day and width is big enough', () => { + const width = 9999; + const mode = 'item-comparison'; + const interval = 'hour'; + const expectedXTicks = uniqueDates; + + const xTicks = getXTicks( uniqueDates, width, mode, interval ); + + expect( xTicks ).toEqual( expectedXTicks ); + } ); + } ); +} ); + +describe( 'compareStrings', () => { + it( 'return an array of unique words from s2 that dont appear in base string', () => { + expect( compareStrings( 'Jul 2018', 'Aug 2018' ).join( ' ' ) ).toEqual( + 'Aug' + ); + expect( compareStrings( 'Jul 2017', 'Aug 2018' ).join( ' ' ) ).toEqual( + 'Aug 2018' + ); + expect( compareStrings( 'Jul 2017', 'Jul 2018' ).join( ' ' ) ).toEqual( + '2018' + ); + expect( + compareStrings( 'Jul, 2018', 'Aug, 2018' ).join( ' ' ) + ).toEqual( 'Aug' ); + } ); +} ); diff --git a/packages/js/components/src/chart/d3chart/utils/test/axis-y.js b/packages/js/components/src/chart/d3chart/utils/test/axis-y.js new file mode 100644 index 00000000000..c8a663cb5af --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/test/axis-y.js @@ -0,0 +1,106 @@ +/** + * External dependencies + */ + +/** + * Internal dependencies + */ +import { getYGrids } from '../axis-y'; + +describe( 'getYGrids', () => { + it( 'returns a single 0 when yMax and yMin are 0', () => { + expect( getYGrids( 0, 0, 0 ) ).toEqual( [ 0 ] ); + } ); + + describe( 'positive charts', () => { + it( 'returns decimal values when yMax is <= 1 and yMin is 0', () => { + expect( getYGrids( 0, 1, 0.3333333333333333 ) ).toEqual( [ + 0, + 0.3333333333333333, + 0.6666666666666666, + 1, + ] ); + } ); + + it( 'returns decimal values when yMax and yMin are <= 1', () => { + expect( getYGrids( 1, 1, 0.3333333333333333 ) ).toEqual( [ + 0, + 0.3333333333333333, + 0.6666666666666666, + 1, + ] ); + } ); + + it( "doesn't return decimal values when yMax is > 1", () => { + expect( getYGrids( 0, 2, 1 ) ).toEqual( [ 0, 1, 2 ] ); + } ); + + it( 'returns up to four values when yMax is a big number', () => { + expect( getYGrids( 0, 12000, 4000 ) ).toEqual( [ + 0, + 4000, + 8000, + 12000, + ] ); + } ); + } ); + + describe( 'negative charts', () => { + it( 'returns decimal values when yMin is >= -1 and yMax is 0', () => { + expect( getYGrids( -1, 0, 0.3333333333333333 ) ).toEqual( [ + 0, + -0.3333333333333333, + -0.6666666666666666, + -1, + ] ); + } ); + + it( 'returns decimal values when yMax and yMin are >= -1', () => { + expect( getYGrids( -1, -1, 0.3333333333333333 ) ).toEqual( [ + 0, + -0.3333333333333333, + -0.6666666666666666, + -1, + ] ); + } ); + + it( "doesn't return decimal values when yMin is < -1", () => { + expect( getYGrids( -2, 0, 1 ) ).toEqual( [ 0, -1, -2 ] ); + } ); + + it( 'returns up to four values when yMin is a big negative number', () => { + expect( getYGrids( -12000, 0, 4000 ) ).toEqual( [ + 0, + -4000, + -8000, + -12000, + ] ); + } ); + } ); + + describe( 'positive & negative charts', () => { + it( 'returns decimal values when yMax is <= 1 and yMin is 0', () => { + expect( getYGrids( -1, 1, 0.5 ) ).toEqual( [ + 0, + -0.5, + -1, + 0.5, + 1, + ] ); + } ); + + it( "doesn't return decimal values when yMax is > 1", () => { + expect( getYGrids( -2, 2, 1 ) ).toEqual( [ 0, -1, -2, 1, 2 ] ); + } ); + + it( 'returns up to six values when yMax is a big number', () => { + expect( getYGrids( -12000, 12000, 6000 ) ).toEqual( [ + 0, + -6000, + -12000, + 6000, + 12000, + ] ); + } ); + } ); +} ); diff --git a/packages/js/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-dates.js b/packages/js/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-dates.js new file mode 100644 index 00000000000..ee9007e42a8 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-dates.js @@ -0,0 +1,8 @@ +export default [ + '2018-05-30T00:00:00', + '2018-05-31T00:00:00', + '2018-06-01T00:00:00', + '2018-06-02T00:00:00', + '2018-06-03T00:00:00', + '2018-06-04T00:00:00', +]; diff --git a/packages/js/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-keys.js b/packages/js/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-keys.js new file mode 100644 index 00000000000..bb62d15b56c --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/test/fixtures/dummy-ordered-keys.js @@ -0,0 +1,32 @@ +export default [ + { + key: 'Cap', + focus: true, + visible: true, + total: 34513697, + }, + { + key: 'T-Shirt', + focus: true, + visible: true, + total: 14762281, + }, + { + key: 'Sunglasses', + focus: true, + visible: true, + total: 12430349, + }, + { + key: 'Polo', + focus: true, + visible: true, + total: 8712807, + }, + { + key: 'Hoodie', + focus: true, + visible: true, + total: 6968764, + }, +]; diff --git a/packages/js/components/src/chart/d3chart/utils/test/fixtures/dummy-orders.js b/packages/js/components/src/chart/d3chart/utils/test/fixtures/dummy-orders.js new file mode 100644 index 00000000000..086645400d5 --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/test/fixtures/dummy-orders.js @@ -0,0 +1,50 @@ +export default [ + { + date: '2018-05-30T00:00:00', + Polo: { value: 2704659 }, + 'T-Shirt': { value: 4499890 }, + Hoodie: { value: 2159981 }, + Sunglasses: { value: 3853788 }, + Cap: { value: 10604510 }, + }, + { + date: '2018-05-31T00:00:00', + Polo: { value: 2027307 }, + 'T-Shirt': { value: 3277946 }, + Hoodie: { value: 1420518 }, + Sunglasses: { value: 2454721 }, + Cap: { value: 7017731 }, + }, + { + date: '2018-06-01T00:00:00', + Polo: { value: 1208495 }, + 'T-Shirt': { value: 2141490 }, + Hoodie: { value: 1058031 }, + Sunglasses: { value: 1999120 }, + Cap: { value: 5355235 }, + }, + { + date: '2018-06-02T00:00:00', + Polo: { value: 1140516 }, + 'T-Shirt': { value: 1938695 }, + Hoodie: { value: 925060 }, + Sunglasses: { value: 1607297 }, + Cap: { value: 4782119 }, + }, + { + date: '2018-06-03T00:00:00', + Polo: { value: 894368 }, + 'T-Shirt': { value: 1558919 }, + Hoodie: { value: 725973 }, + Sunglasses: { value: 1311479 }, + Cap: { value: 3596343 }, + }, + { + date: '2018-06-04T00:00:00', + Polo: { value: 737462 }, + 'T-Shirt': { value: 1345341 }, + Hoodie: { value: 679201 }, + Sunglasses: { value: 1203944 }, + Cap: { value: 3157759 }, + }, +]; diff --git a/packages/js/components/src/chart/d3chart/utils/test/index.js b/packages/js/components/src/chart/d3chart/utils/test/index.js new file mode 100644 index 00000000000..791591885dc --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/test/index.js @@ -0,0 +1,87 @@ +/** + * External dependencies + */ +import { utcParse as d3UTCParse } from 'd3-time-format'; + +/** + * Internal dependencies + */ +import dummyOrders from './fixtures/dummy-orders'; +import orderedKeys from './fixtures/dummy-ordered-keys'; +import { getOrderedKeys, isDataEmpty } from '../index'; + +const parseDate = d3UTCParse( '%Y-%m-%dT%H:%M:%S' ); +const testOrderedKeys = getOrderedKeys( dummyOrders ); + +describe( 'parseDate', () => { + it( 'correctly parse date in the expected format', () => { + const testDate = parseDate( '2018-06-30T00:00:00' ); + const expectedDate = new Date( Date.UTC( 2018, 5, 30 ) ); + expect( testDate.getTime() ).toEqual( expectedDate.getTime() ); + } ); +} ); + +describe( 'getOrderedKeys', () => { + it( 'returns an array of keys order by value from largest to smallest', () => { + expect( testOrderedKeys ).toEqual( orderedKeys ); + } ); +} ); + +describe( 'isDataEmpty', () => { + it( 'should return true when all data values are 0 and no baseValue is provided', () => { + const data = [ + { + lorem: { + value: 0, + }, + ipsum: { + value: 0, + }, + }, + ]; + expect( isDataEmpty( data ) ).toBeTruthy(); + } ); + + it( 'should return true when all data values match the base value', () => { + const data = [ + { + lorem: { + value: 100, + }, + ipsum: { + value: 100, + }, + }, + ]; + expect( isDataEmpty( data, 100 ) ).toBeTruthy(); + } ); + + it( "should return false if at least one data values doesn't match the base value", () => { + const data = [ + { + lorem: { + value: 100, + }, + ipsum: { + value: 0, + }, + }, + ]; + expect( isDataEmpty( data, 100 ) ).toBeFalsy(); + } ); + + it( 'should return true when all data values match the base value or are null/undefined', () => { + const data = [ + { + lorem: { + value: 100, + }, + ipsum: { + value: null, + }, + dolor: {}, + }, + ]; + expect( isDataEmpty( data, 100 ) ).toBeTruthy(); + } ); +} ); diff --git a/packages/js/components/src/chart/d3chart/utils/test/line-chart.js b/packages/js/components/src/chart/d3chart/utils/test/line-chart.js new file mode 100644 index 00000000000..88c7cf31f1c --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/test/line-chart.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import { utcParse as d3UTCParse } from 'd3-time-format'; + +/** + * Internal dependencies + */ +import dummyOrders from './fixtures/dummy-orders'; +import orderedDates from './fixtures/dummy-ordered-dates'; +import orderedKeys from './fixtures/dummy-ordered-keys'; +import { getOrderedKeys, getUniqueDates } from '../index'; +import { getDateSpaces, getLineData } from '../line-chart'; +import { getXLineScale } from '../scales'; + +const parseDate = d3UTCParse( '%Y-%m-%dT%H:%M:%S' ); +const testOrderedKeys = getOrderedKeys( dummyOrders ); +const testLineData = getLineData( dummyOrders, testOrderedKeys ); +const testUniqueDates = getUniqueDates( dummyOrders, '%Y-%m-%dT%H:%M:%S' ); +const testXLineScale = getXLineScale( testUniqueDates, 100 ); + +describe( 'getDateSpaces', () => { + it( 'return an array used to space out the mouseover rectangles, used for tooltips', () => { + const visibleKeys = testOrderedKeys.slice(); + const testDateSpaces = getDateSpaces( + dummyOrders, + testUniqueDates, + visibleKeys, + 100, + testXLineScale + ); + expect( testDateSpaces[ 0 ].date ).toEqual( '2018-05-30T00:00:00' ); + expect( testDateSpaces[ 0 ].start ).toEqual( 0 ); + expect( testDateSpaces[ 0 ].width ).toEqual( 10 ); + expect( testDateSpaces[ 3 ].date ).toEqual( '2018-06-02T00:00:00' ); + expect( testDateSpaces[ 3 ].start ).toEqual( 50 ); + expect( testDateSpaces[ 3 ].width ).toEqual( 20 ); + expect( testDateSpaces[ testDateSpaces.length - 1 ].date ).toEqual( + '2018-06-04T00:00:00' + ); + expect( testDateSpaces[ testDateSpaces.length - 1 ].start ).toEqual( + 90 + ); + expect( testDateSpaces[ testDateSpaces.length - 1 ].width ).toEqual( + 10 + ); + } ); +} ); + +describe( 'getLineData', () => { + it( 'returns a sorted array of objects with category key', () => { + expect( testLineData ).toBeInstanceOf( Array ); + expect( testLineData ).toHaveLength( 5 ); + expect( testLineData.map( ( d ) => d.key ) ).toEqual( + orderedKeys.map( ( d ) => d.key ) + ); + } ); + + testLineData.forEach( ( d ) => { + it( 'ensure a key and that the values property is an array', () => { + expect( d ).toHaveProperty( 'key' ); + expect( d ).toHaveProperty( 'values' ); + expect( d.values ).toBeInstanceOf( Array ); + } ); + + it( 'ensure all unique dates exist in values array', () => { + const rowDates = d.values.map( ( row ) => row.date ); + expect( rowDates ).toEqual( orderedDates ); + } ); + + d.values.forEach( ( row ) => { + it( 'ensure a date property and that the values property is an array with date (parseable) and value properties', () => { + expect( row ).toHaveProperty( 'date' ); + expect( row ).toHaveProperty( 'value' ); + expect( parseDate( row.date ) ).not.toBeNull(); + expect( typeof row.date ).toBe( 'string' ); + expect( typeof row.value ).toBe( 'number' ); + } ); + } ); + } ); +} ); diff --git a/packages/js/components/src/chart/d3chart/utils/test/scales.js b/packages/js/components/src/chart/d3chart/utils/test/scales.js new file mode 100644 index 00000000000..cc2e9e9161d --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/test/scales.js @@ -0,0 +1,182 @@ +/** + * External dependencies + */ +import { scaleBand, scaleLinear, scaleTime } from 'd3-scale'; + +/** + * Internal dependencies + */ +import dummyOrders from './fixtures/dummy-orders'; +import { getOrderedKeys, getUniqueDates } from '../index'; +import { + calculateStep, + getXGroupScale, + getXScale, + getXLineScale, + getYScaleLimits, + getYScale, +} from '../scales'; + +jest.mock( 'd3-scale', () => ( { + ...jest.requireActual( 'd3-scale' ), + scaleBand: jest.fn().mockReturnValue( { + bandwidth: jest.fn().mockReturnThis(), + domain: jest.fn().mockReturnThis(), + padding: jest.fn().mockReturnThis(), + paddingInner: jest.fn().mockReturnThis(), + range: jest.fn().mockReturnThis(), + rangeRound: jest.fn().mockReturnThis(), + } ), + scaleLinear: jest.fn().mockReturnValue( { + domain: jest.fn().mockReturnThis(), + rangeRound: jest.fn().mockReturnThis(), + } ), + scaleTime: jest.fn().mockReturnValue( { + domain: jest.fn().mockReturnThis(), + rangeRound: jest.fn().mockReturnThis(), + } ), +} ) ); + +const testOrderedKeys = getOrderedKeys( dummyOrders ); + +describe( 'X scales', () => { + const testUniqueDates = getUniqueDates( dummyOrders, '%Y-%m-%dT%H:%M:%S' ); + + describe( 'getXScale', () => { + it( 'creates band scale with correct parameters', () => { + getXScale( testUniqueDates, 100 ); + + expect( scaleBand().domain ).toHaveBeenLastCalledWith( + testUniqueDates + ); + expect( scaleBand().range ).toHaveBeenLastCalledWith( [ 0, 100 ] ); + expect( scaleBand().paddingInner ).toHaveBeenLastCalledWith( 0.1 ); + } ); + + it( "creates band scale with correct paddingInner parameter when it's in compact mode", () => { + getXScale( testUniqueDates, 100, true ); + + expect( scaleBand().paddingInner ).toHaveBeenLastCalledWith( 0 ); + } ); + } ); + + describe( 'getXGroupScale', () => { + const testXScale = getXScale( testUniqueDates, 100 ); + + it( 'creates band scale with correct parameters', () => { + getXGroupScale( testOrderedKeys, testXScale ); + const filteredOrderedKeys = [ + 'Cap', + 'T-Shirt', + 'Sunglasses', + 'Polo', + 'Hoodie', + ]; + + expect( scaleBand().domain ).toHaveBeenLastCalledWith( + filteredOrderedKeys + ); + expect( scaleBand().range ).toHaveBeenLastCalledWith( [ 0, 100 ] ); + expect( scaleBand().padding ).toHaveBeenLastCalledWith( 0.07 ); + } ); + + it( "creates band scale with correct padding parameter when it's in compact mode", () => { + getXGroupScale( testOrderedKeys, testXScale, true ); + + expect( scaleBand().padding ).toHaveBeenLastCalledWith( 0 ); + } ); + } ); + + describe( 'getXLineScale', () => { + it( 'creates time scale with correct parameters', () => { + getXLineScale( testUniqueDates, 100 ); + + expect( scaleTime().domain ).toHaveBeenLastCalledWith( [ + new Date( '2018-05-30T00:00:00' ), + new Date( '2018-06-04T00:00:00' ), + ] ); + expect( scaleTime().rangeRound ).toHaveBeenLastCalledWith( [ + 0, + 100, + ] ); + } ); + } ); +} ); + +describe( 'Y scales', () => { + describe( 'calculateStep', () => { + it( 'returns 1 when arguments are invalid', () => { + expect( calculateStep() ).toEqual( 1 ); + } ); + + it( 'returns 1/3 when max and min values are 0', () => { + expect( calculateStep( 0, 0 ) ).toEqual( 1 / 3 ); + } ); + + it( 'returns decimals for scales below 1', () => { + expect( calculateStep( 0, 0.5 ) ).toEqual( 0.25 ); + } ); + + it( 'returns integers for scales over 1', () => { + expect( calculateStep( 0, 100 ) ).toEqual( 50 ); + } ); + + it( 'returns positive values for negative scales', () => { + expect( calculateStep( -100, 0 ) ).toEqual( 50 ); + } ); + } ); + + describe( 'getYScaleLimits', () => { + it( 'calculate the correct y value limits', () => { + expect( getYScaleLimits( dummyOrders ) ).toEqual( { + lower: 0, + upper: 15000000, + step: 5000000, + } ); + } ); + + it( 'return defaults if there is no line data', () => { + expect( getYScaleLimits( [] ) ).toEqual( { + lower: 0, + upper: 0, + step: 1, + } ); + } ); + } ); + + describe( 'getYScale', () => { + it( 'creates positive linear scale with correct parameters', () => { + getYScale( 100, 0, 15000000 ); + + expect( scaleLinear().domain ).toHaveBeenLastCalledWith( [ + 0, + 15000000, + ] ); + expect( scaleLinear().rangeRound ).toHaveBeenLastCalledWith( [ + 100, + 0, + ] ); + } ); + + it( 'creates negative linear scale with correct parameters', () => { + getYScale( 100, -15000000, 0 ); + + expect( scaleLinear().domain ).toHaveBeenLastCalledWith( [ + -15000000, + 0, + ] ); + expect( scaleLinear().rangeRound ).toHaveBeenLastCalledWith( [ + 100, + 0, + ] ); + } ); + + it( 'avoids the domain starting and ending at the same point when yMin, yMax are 0', () => { + getYScale( 100, 0, 0 ); + + const args = scaleLinear().domain.mock.calls; + const lastArgs = args[ args.length - 1 ][ 0 ]; + expect( lastArgs[ 0 ] ).toBeLessThan( lastArgs[ 1 ] ); + } ); + } ); +} ); diff --git a/packages/js/components/src/chart/d3chart/utils/tooltip.js b/packages/js/components/src/chart/d3chart/utils/tooltip.js new file mode 100644 index 00000000000..c5f5ca1c6fc --- /dev/null +++ b/packages/js/components/src/chart/d3chart/utils/tooltip.js @@ -0,0 +1,165 @@ +/** + * External dependencies + */ +import { select as d3Select } from 'd3-selection'; +import moment from 'moment'; + +class ChartTooltip { + constructor() { + this.ref = null; + this.chart = null; + this.position = ''; + this.title = ''; + this.labelFormat = ''; + this.valueFormat = ''; + this.visibleKeys = ''; + this.getColor = null; + this.margin = 24; + } + + calculateXPosition( elementCoords, chartCoords, elementWidthRatio ) { + const tooltipSize = this.ref.getBoundingClientRect(); + const d3BaseCoords = this.ref.parentNode + .querySelector( '.d3-base' ) + .getBoundingClientRect(); + const leftMargin = Math.max( d3BaseCoords.left, chartCoords.left ); + + if ( this.position === 'below' ) { + return Math.max( + this.margin, + Math.min( + elementCoords.left + + elementCoords.width * 0.5 - + tooltipSize.width / 2 - + leftMargin, + d3BaseCoords.width - tooltipSize.width - this.margin + ) + ); + } + + const xPosition = + elementCoords.left + + elementCoords.width * elementWidthRatio + + this.margin - + leftMargin; + + if ( + xPosition + tooltipSize.width + this.margin > + d3BaseCoords.width + ) { + return Math.max( + this.margin, + elementCoords.left + + elementCoords.width * ( 1 - elementWidthRatio ) - + tooltipSize.width - + this.margin - + leftMargin + ); + } + + return xPosition; + } + + calculateYPosition( elementCoords, chartCoords ) { + if ( this.position === 'below' ) { + return chartCoords.height; + } + + const tooltipSize = this.ref.getBoundingClientRect(); + const yPosition = elementCoords.top + this.margin - chartCoords.top; + if ( + yPosition + tooltipSize.height + this.margin > + chartCoords.height + ) { + return Math.max( + 0, + elementCoords.top - + tooltipSize.height - + this.margin - + chartCoords.top + ); + } + + return yPosition; + } + + calculatePosition( element, elementWidthRatio = 1 ) { + const elementCoords = element.getBoundingClientRect(); + const chartCoords = this.chart.getBoundingClientRect(); + + if ( this.position === 'below' ) { + elementWidthRatio = 0; + } + + return { + x: this.calculateXPosition( + elementCoords, + chartCoords, + elementWidthRatio + ), + y: this.calculateYPosition( elementCoords, chartCoords ), + }; + } + + hide() { + d3Select( this.chart ) + .selectAll( '.barfocus, .focus-grid' ) + .attr( 'opacity', '0' ); + d3Select( this.ref ).style( 'visibility', 'hidden' ); + } + + getTooltipRowLabel( d, row ) { + if ( d[ row.key ].labelDate ) { + return this.labelFormat( + moment( d[ row.key ].labelDate ).toDate() + ); + } + return row.label || row.key; + } + + show( d, triggerElement, parentNode, elementWidthRatio = 1 ) { + if ( ! this.visibleKeys.length ) { + return; + } + d3Select( parentNode ) + .select( '.focus-grid, .barfocus' ) + .attr( 'opacity', '1' ); + const position = this.calculatePosition( + triggerElement, + elementWidthRatio + ); + + const keys = this.visibleKeys.map( + ( row ) => ` +
  • +
    + + + ${ this.getTooltipRowLabel( d, row ) } +
    + ${ this.valueFormat( d[ row.key ].value ) } +
  • + ` + ); + + const tooltipTitle = this.title + ? this.title + : this.labelFormat( moment( d.date ).toDate() ); + + d3Select( this.ref ) + .style( 'left', position.x + 'px' ) + .style( 'top', position.y + 'px' ) + .style( 'visibility', 'visible' ).html( ` +
    +

    ${ tooltipTitle }

    +
      + ${ keys.join( '' ) } +
    +
    + ` ); + } +} + +export default ChartTooltip; diff --git a/packages/js/components/src/chart/index.js b/packages/js/components/src/chart/index.js new file mode 100644 index 00000000000..c5afe925f08 --- /dev/null +++ b/packages/js/components/src/chart/index.js @@ -0,0 +1,658 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import classNames from 'classnames'; +import { + createElement, + Component, + createRef, + Fragment, +} from '@wordpress/element'; +import { formatDefaultLocale as d3FormatDefaultLocale } from 'd3-format'; +import { isEqual, partial, without } from 'lodash'; +import LineGraphIcon from 'gridicons/dist/line-graph'; +import StatsAltIcon from 'gridicons/dist/stats-alt'; +import { Button, NavigableMenu, SelectControl } from '@wordpress/components'; +import { interpolateViridis as d3InterpolateViridis } from 'd3-scale-chromatic'; +import memoize from 'memoize-one'; +import PropTypes from 'prop-types'; +import { withViewportMatch } from '@wordpress/viewport'; +import { sanitize } from 'dompurify'; +import { getIdsFromQuery, updateQueryString } from '@woocommerce/navigation'; + +/** + * Internal dependencies + */ +import ChartPlaceholder from './placeholder'; +import { H, Section } from '../section'; +import { D3Chart, D3Legend } from './d3chart'; +import { getUniqueKeys } from './d3chart/utils/index'; +import { selectionLimit } from './constants'; + +function getD3CurrencyFormat( symbol, position ) { + switch ( position ) { + case 'left_space': + return [ symbol + ' ', '' ]; + case 'right': + return [ '', symbol ]; + case 'right_space': + return [ '', ' ' + symbol ]; + case 'left': + default: + return [ symbol, '' ]; + } +} + +/** + * A chart container using d3, to display timeseries data with an interactive legend. + */ +class Chart extends Component { + constructor( props ) { + super( props ); + this.chartBodyRef = createRef(); + const dataKeys = this.getDataKeys(); + this.state = { + focusedKeys: [], + visibleKeys: dataKeys.slice( 0, selectionLimit ), + width: 0, + }; + this.prevDataKeys = dataKeys.sort(); + this.handleTypeToggle = this.handleTypeToggle.bind( this ); + this.handleLegendToggle = this.handleLegendToggle.bind( this ); + this.handleLegendHover = this.handleLegendHover.bind( this ); + this.updateDimensions = this.updateDimensions.bind( this ); + this.getVisibleData = memoize( this.getVisibleData ); + this.getOrderedKeys = memoize( this.getOrderedKeys ); + this.setInterval = this.setInterval.bind( this ); + } + + getDataKeys() { + const { data, filterParam, mode, query } = this.props; + if ( mode === 'item-comparison' ) { + const selectedIds = filterParam + ? getIdsFromQuery( query[ filterParam ] ) + : []; + return this.getOrderedKeys( [], [], selectedIds ).map( + ( orderedItem ) => orderedItem.key + ); + } + return getUniqueKeys( data ); + } + + componentDidUpdate() { + const { data } = this.props; + if ( ! data || ! data.length ) { + return; + } + const uniqueKeys = getUniqueKeys( data ).sort(); + + if ( ! isEqual( uniqueKeys, this.prevDataKeys ) ) { + const dataKeys = this.getDataKeys(); + this.prevDataKeys = uniqueKeys; + /* eslint-disable react/no-did-update-set-state */ + this.setState( { + visibleKeys: dataKeys.slice( 0, selectionLimit ), + } ); + /* eslint-enable react/no-did-update-set-state */ + } + } + + componentDidMount() { + this.updateDimensions(); + this.setD3DefaultFormat(); + window.addEventListener( 'resize', this.updateDimensions ); + } + + componentWillUnmount() { + window.removeEventListener( 'resize', this.updateDimensions ); + } + + setD3DefaultFormat() { + const { + symbol: currencySymbol, + symbolPosition, + decimalSeparator: decimal, + thousandSeparator: thousands, + } = this.props.currency; + d3FormatDefaultLocale( { + decimal, + thousands, + grouping: [ 3 ], + currency: getD3CurrencyFormat( currencySymbol, symbolPosition ), + } ); + } + + getOrderedKeys( focusedKeys, visibleKeys, selectedIds = [] ) { + const { data, legendTotals, mode } = this.props; + if ( ! data || data.length === 0 ) { + return []; + } + + const uniqueKeys = data.reduce( ( accum, curr ) => { + Object.entries( curr ).forEach( ( [ key, value ] ) => { + if ( key !== 'date' && ! accum[ key ] ) { + accum[ key ] = value.label; + } + } ); + return accum; + }, {} ); + + const updatedKeys = Object.entries( uniqueKeys ).map( + ( [ key, label ] ) => { + label = sanitize( label, { ALLOWED_TAGS: [] } ); + return { + focus: + focusedKeys.length === 0 || focusedKeys.includes( key ), + key, + label, + total: + legendTotals && + typeof legendTotals[ key ] !== 'undefined' + ? legendTotals[ key ] + : data.reduce( ( a, c ) => a + c[ key ].value, 0 ), + visible: visibleKeys.includes( key ), + }; + } + ); + + if ( mode === 'item-comparison' ) { + return updatedKeys + .sort( ( a, b ) => b.total - a.total ) + .filter( + ( key ) => + key.total > 0 || + selectedIds.includes( parseInt( key.key, 10 ) ) + ); + } + + return updatedKeys; + } + + handleTypeToggle( chartType ) { + if ( this.props.chartType !== chartType ) { + const { path, query } = this.props; + updateQueryString( { chartType }, path, query ); + } + } + + handleLegendToggle( event ) { + const { interactiveLegend } = this.props; + if ( ! interactiveLegend ) { + return; + } + const key = event.currentTarget.id.split( '_' ).pop(); + const { focusedKeys, visibleKeys } = this.state; + if ( visibleKeys.includes( key ) ) { + this.setState( { + focusedKeys: without( focusedKeys, key ), + visibleKeys: without( visibleKeys, key ), + } ); + } else { + this.setState( { + focusedKeys: focusedKeys.concat( [ key ] ), + visibleKeys: visibleKeys.concat( [ key ] ), + } ); + } + } + + handleLegendHover( event ) { + if ( event.type === 'mouseleave' || event.type === 'blur' ) { + this.setState( { + focusedKeys: [], + } ); + } else if ( event.type === 'mouseenter' || event.type === 'focus' ) { + const key = event.currentTarget.id.split( '__' ).pop(); + this.setState( { + focusedKeys: [ key ], + } ); + } + } + + updateDimensions() { + this.setState( { + width: this.chartBodyRef.current.offsetWidth, + } ); + } + + getVisibleData( data, orderedKeys ) { + const visibleKeys = orderedKeys.filter( ( d ) => d.visible ); + return data.map( ( d ) => { + const newRow = { date: d.date }; + visibleKeys.forEach( ( row ) => { + newRow[ row.key ] = d[ row.key ]; + } ); + return newRow; + } ); + } + + setInterval( interval ) { + const { path, query } = this.props; + updateQueryString( { interval }, path, query ); + } + + renderIntervalSelector() { + const { interval, allowedIntervals } = this.props; + if ( ! allowedIntervals || allowedIntervals.length < 1 ) { + return null; + } + + const intervalLabels = { + hour: __( 'By hour', 'woocommerce' ), + day: __( 'By day', 'woocommerce' ), + week: __( 'By week', 'woocommerce' ), + month: __( 'By month', 'woocommerce' ), + quarter: __( 'By quarter', 'woocommerce' ), + year: __( 'By year', 'woocommerce' ), + }; + + return ( +
    + ( { + value: allowedInterval, + label: intervalLabels[ allowedInterval ], + } ) ) } + onChange={ this.setInterval } + /> +
    + ); + } + + getChartHeight() { + const { isViewportLarge, isViewportMobile } = this.props; + + if ( isViewportMobile ) { + return 180; + } + + if ( isViewportLarge ) { + return 300; + } + + return 220; + } + + getLegendPosition() { + const { legendPosition, mode, isViewportWide } = this.props; + if ( legendPosition ) { + return legendPosition; + } + if ( isViewportWide && mode === 'time-comparison' ) { + return 'top'; + } + if ( isViewportWide && mode === 'item-comparison' ) { + return 'side'; + } + return 'bottom'; + } + + render() { + const { focusedKeys, visibleKeys, width } = this.state; + const { + baseValue, + chartType, + data, + dateParser, + emptyMessage, + filterParam, + interactiveLegend, + interval, + isRequesting, + isViewportLarge, + itemsLabel, + mode, + query, + screenReaderFormat, + showHeaderControls, + title, + tooltipLabelFormat, + tooltipValueFormat, + tooltipTitle, + valueType, + xFormat, + x2Format, + yBelow1Format, + yFormat, + } = this.props; + const selectedIds = filterParam + ? getIdsFromQuery( query[ filterParam ] ) + : []; + const orderedKeys = this.getOrderedKeys( + focusedKeys, + visibleKeys, + selectedIds + ); + + const visibleData = isRequesting + ? null + : this.getVisibleData( data, orderedKeys ); + + const legendPosition = this.getLegendPosition(); + const legendDirection = legendPosition === 'top' ? 'row' : 'column'; + const chartDirection = legendPosition === 'side' ? 'row' : 'column'; + + const chartHeight = this.getChartHeight(); + const legend = + legendPosition !== 'hidden' && isRequesting ? null : ( + + ); + const margin = { + bottom: 50, + left: 80, + right: 30, + top: 0, + }; + + let d3chartYFormat = yFormat; + let d3chartYBelow1Format = yBelow1Format; + if ( ! yFormat ) { + switch ( valueType ) { + case 'average': + d3chartYFormat = ',.0f'; + break; + case 'currency': + d3chartYFormat = '$.3~s'; + d3chartYBelow1Format = '$.3~f'; + break; + case 'number': + d3chartYFormat = ',.0f'; + break; + } + } + return ( +
    + { showHeaderControls && ( +
    + { title } + { legendPosition === 'top' && legend } + { this.renderIntervalSelector() } + + + + +
    + ) } +
    +
    + { legendPosition === 'side' && legend } + { isRequesting && ( + + + { __( + 'Your requested data is loading', + 'woocommerce' + ) } + + + + ) } + { ! isRequesting && width > 0 && ( + + ) } +
    + { legendPosition === 'bottom' && ( +
    + { legend } +
    + ) } +
    +
    + ); + } +} + +Chart.propTypes = { + /** + * Allowed intervals to show in a dropdown. + */ + allowedIntervals: PropTypes.array, + /** + * Base chart value. If no data value is different than the baseValue, the + * `emptyMessage` will be displayed if provided. + */ + baseValue: PropTypes.number, + /** + * Chart type of either `line` or `bar`. + */ + chartType: PropTypes.oneOf( [ 'bar', 'line' ] ), + /** + * An array of data. + */ + data: PropTypes.array.isRequired, + /** + * Format to parse dates into d3 time format + */ + dateParser: PropTypes.string.isRequired, + /** + * The message to be displayed if there is no data to render. If no message is provided, + * nothing will be displayed. + */ + emptyMessage: PropTypes.string, + /** + * Name of the param used to filter items. If specified, it will be used, in combination + * with query, to detect which elements are being used by the current filter and must be + * displayed even if their value is 0. + */ + filterParam: PropTypes.string, + /** + * Label describing the legend items. + */ + itemsLabel: PropTypes.string, + /** + * `item-comparison` (default) or `time-comparison`, this is used to generate correct + * ARIA properties. + */ + mode: PropTypes.oneOf( [ 'item-comparison', 'time-comparison' ] ), + /** + * Current path + */ + path: PropTypes.string, + /** + * The query string represented in object form + */ + query: PropTypes.object, + /** + * Whether the legend items can be activated/deactivated. + */ + interactiveLegend: PropTypes.bool, + /** + * Interval specification (hourly, daily, weekly etc). + */ + interval: PropTypes.oneOf( [ + 'hour', + 'day', + 'week', + 'month', + 'quarter', + 'year', + ] ), + /** + * Information about the currently selected interval, and set of allowed intervals for the chart. See `getIntervalsForQuery`. + */ + intervalData: PropTypes.object, + /** + * Render a chart placeholder to signify an in-flight data request. + */ + isRequesting: PropTypes.bool, + /** + * Position the legend must be displayed in. If it's not defined, it's calculated + * depending on the viewport width and the mode. + */ + legendPosition: PropTypes.oneOf( [ 'bottom', 'side', 'top', 'hidden' ] ), + /** + * Values to overwrite the legend totals. If not defined, the sum of all line values will be used. + */ + legendTotals: PropTypes.object, + /** + * A datetime formatting string or overriding function to format the screen reader labels. + */ + screenReaderFormat: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.func, + ] ), + /** + * Wether header UI controls must be displayed. + */ + showHeaderControls: PropTypes.bool, + /** + * A title describing this chart. + */ + title: PropTypes.string, + /** + * A datetime formatting string or overriding function to format the tooltip label. + */ + tooltipLabelFormat: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.func, + ] ), + /** + * A number formatting string or function to format the value displayed in the tooltips. + */ + tooltipValueFormat: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.func, + ] ), + /** + * A string to use as a title for the tooltip. Takes preference over `tooltipLabelFormat`. + */ + tooltipTitle: PropTypes.string, + /** + * What type of data is to be displayed? Number, Average, String? + */ + valueType: PropTypes.string, + /** + * A datetime formatting string, passed to d3TimeFormat. + */ + xFormat: PropTypes.string, + /** + * A datetime formatting string, passed to d3TimeFormat. + */ + x2Format: PropTypes.string, + /** + * A number formatting string, passed to d3Format. + */ + yBelow1Format: PropTypes.string, + /** + * A number formatting string, passed to d3Format. + */ + yFormat: PropTypes.string, + /** + * A currency object passed to d3Format. + */ + currency: PropTypes.object, +}; + +Chart.defaultProps = { + baseValue: 0, + chartType: 'line', + data: [], + dateParser: '%Y-%m-%dT%H:%M:%S', + interactiveLegend: true, + interval: 'day', + isRequesting: false, + mode: 'time-comparison', + screenReaderFormat: '%B %-d, %Y', + showHeaderControls: true, + tooltipLabelFormat: '%B %-d, %Y', + tooltipValueFormat: ',', + xFormat: '%d', + x2Format: '%b %Y', + currency: { + symbol: '$', + symbolPosition: 'left', + decimalSeparator: '.', + thousandSeparator: ',', + }, +}; + +export default withViewportMatch( { + isViewportMobile: '< medium', + isViewportLarge: '>= large', + isViewportWide: '>= wide', +} )( Chart ); diff --git a/packages/js/components/src/chart/placeholder.js b/packages/js/components/src/chart/placeholder.js new file mode 100644 index 00000000000..9f52b27513d --- /dev/null +++ b/packages/js/components/src/chart/placeholder.js @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import PropTypes from 'prop-types'; +import { Spinner } from '@wordpress/components'; + +/** + * `ChartPlaceholder` displays a large loading indiciator for use in place of a `Chart` while data is loading. + */ +class ChartPlaceholder extends Component { + render() { + const { height } = this.props; + + return ( + + ); + } +} + +ChartPlaceholder.propTypes = { + height: PropTypes.number, +}; + +ChartPlaceholder.defaultProps = { + height: 0, +}; + +export default ChartPlaceholder; diff --git a/packages/js/components/src/chart/stories/index.js b/packages/js/components/src/chart/stories/index.js new file mode 100644 index 00000000000..369058198b9 --- /dev/null +++ b/packages/js/components/src/chart/stories/index.js @@ -0,0 +1,197 @@ +/** + * Internal dependencies + */ +import Chart from '../'; + +const data = [ + { + date: '2018-05-30T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 21599, + }, + Sunglasses: { + label: 'Sunglasses', + value: 38537, + }, + Cap: { + label: 'Cap', + value: 106010, + }, + Tshirt: { + label: 'Tshirt', + value: 26784, + }, + Jeans: { + label: 'Jeans', + value: 35645, + }, + Headphones: { + label: 'Headphones', + value: 19500, + }, + Lamp: { + label: 'Lamp', + value: 21599, + }, + Socks: { + label: 'Socks', + value: 32572, + }, + Mug: { + label: 'Mug', + value: 10991, + }, + Case: { + label: 'Case', + value: 35537, + }, + }, + { + date: '2018-05-31T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 14205, + }, + Sunglasses: { + label: 'Sunglasses', + value: 24721, + }, + Cap: { + label: 'Cap', + value: 70131, + }, + Tshirt: { + label: 'Tshirt', + value: 16784, + }, + Jeans: { + label: 'Jeans', + value: 25645, + }, + Headphones: { + label: 'Headphones', + value: 39500, + }, + Lamp: { + label: 'Lamp', + value: 15599, + }, + Socks: { + label: 'Socks', + value: 27572, + }, + Mug: { + label: 'Mug', + value: 110991, + }, + Case: { + label: 'Case', + value: 21537, + }, + }, + { + date: '2018-06-01T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 10581, + }, + Sunglasses: { + label: 'Sunglasses', + value: 19991, + }, + Cap: { + label: 'Cap', + value: 53552, + }, + Tshirt: { + label: 'Tshirt', + value: 41784, + }, + Jeans: { + label: 'Jeans', + value: 17645, + }, + Headphones: { + label: 'Headphones', + value: 22500, + }, + Lamp: { + label: 'Lamp', + value: 25599, + }, + Socks: { + label: 'Socks', + value: 14572, + }, + Mug: { + label: 'Mug', + value: 20991, + }, + Case: { + label: 'Case', + value: 11537, + }, + }, + { + date: '2018-06-02T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 9250, + }, + Sunglasses: { + label: 'Sunglasses', + value: 16072, + }, + Cap: { + label: 'Cap', + value: 47821, + }, + Tshirt: { + label: 'Tshirt', + value: 18784, + }, + Jeans: { + label: 'Jeans', + value: 29645, + }, + Headphones: { + label: 'Headphones', + value: 24500, + }, + Lamp: { + label: 'Lamp', + value: 18599, + }, + Socks: { + label: 'Socks', + value: 23572, + }, + Mug: { + label: 'Mug', + value: 20991, + }, + Case: { + label: 'Case', + value: 16537, + }, + }, +]; + +export default { + title: 'WooCommerce Admin/components/Chart', + component: Chart, + args: { + legendPosition: undefined, + }, + argTypes: { + legendPosition: { + control: { type: 'select' }, + options: [ undefined, 'bottom', 'side', 'top', 'hidden' ], + }, + }, +}; + +export const Default = ( { legendPosition } ) => ( + +); diff --git a/packages/js/components/src/chart/style.scss b/packages/js/components/src/chart/style.scss new file mode 100644 index 00000000000..c316cec928f --- /dev/null +++ b/packages/js/components/src/chart/style.scss @@ -0,0 +1,134 @@ +.woocommerce-chart { + margin-top: -$gap; + margin-bottom: $gap-large; + background: $studio-white; + border: 1px solid $table-border; + border-top: 0; + @include breakpoint( '<782px' ) { + margin-left: -16px; + margin-right: -16px; + margin-bottom: $gap-small; + border-left: none; + border-right: none; + width: auto; + } + + .woocommerce-chart__header { + min-height: 50px; + border-bottom: 1px solid $table-border; + display: flex; + flex-flow: row wrap; + justify-content: space-between; + align-items: center; + width: 100%; + + .woocommerce-chart__title { + margin-left: $gap-large; + margin-right: $gap; + } + } + + .woocommerce-chart__body { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + width: 100%; + + &.woocommerce-chart__body-column { + flex-direction: column; + } + } + + .woocommerce-chart__footer { + width: 100%; + } +} + +.woocommerce-chart-placeholder { + @include placeholder(); + padding: 0; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + .components-spinner { + margin: 0; + } +} + +.woocommerce-chart__interval-select { + align-items: start; + border-right: 1px solid $table-border; + display: flex; + flex-direction: column; + justify-content: center; + margin: 0 0 0 auto; + min-height: 50px; + padding: 8px $gap 0 $gap; + + @include breakpoint( '<960px' ) { + width: 100%; + order: 1; + margin-top: -8px; + margin-left: 0; + padding-left: math.div($gap, 2); + border-right: 0; + min-height: 0; + } + + #wpbody & .components-select-control__input { + @include font-size( 13 ); + border: 0; + box-shadow: none; + + .components-input-control__backdrop { + display: none; + } + + &:not(:disabled):not([aria-disabled='true']):focus { + @include button-style__focus-active(); + + & ~ .components-input-control__backdrop { + display: block; + } + } + + & ~ .components-input-control__backdrop { + display: none; + } + } +} + +.woocommerce-chart__types { + padding: 0 8px; + white-space: nowrap; +} + +.woocommerce-chart__type-button { + background: transparent !important; + + &.components-button { + color: $table-border; + display: inline-flex; + padding: 8px; + + &.woocommerce-chart__type-button-selected { + color: $gray-700; + } + } +} + +@include breakpoint( '<960px' ) { + .woocommerce-summary + .woocommerce-chart { + .woocommerce-chart__title { + display: none; + } + + .woocommerce-chart__interval-select { + width: auto; + order: 0; + margin-top: 0; + } + } +} diff --git a/packages/js/components/src/chart/test/legend.js b/packages/js/components/src/chart/test/legend.js new file mode 100644 index 00000000000..673c11b9bc5 --- /dev/null +++ b/packages/js/components/src/chart/test/legend.js @@ -0,0 +1,87 @@ +/** + * @jest-environment jsdom + */ +/** + * External dependencies + */ +import { render, within } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Chart from '../'; + +jest.mock( '../d3chart', () => ( { + D3Legend: jest.fn().mockReturnValue( '[D3Legend]' ), +} ) ); + +const data = [ + { + date: '2018-05-30T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 21599, + }, + Sunglasses: { + label: 'Sunglasses', + value: 38537, + }, + Cap: { + label: 'Cap', + value: 106010, + }, + }, + { + date: '2018-05-31T00:00:00', + Hoodie: { + label: 'Hoodie', + value: 14205, + }, + Sunglasses: { + label: 'Sunglasses', + value: 24721, + }, + Cap: { + label: 'Cap', + value: 70131, + }, + }, +]; + +describe( 'Chart', () => { + test( ' should not render any legend', () => { + const { queryByText } = render( + + ); + expect( queryByText( '[D3Legend]' ) ).not.toBeInTheDocument(); + } ); + + test( ' should render the legend at the bottom', () => { + const { container } = render( + + ); + const footer = container.querySelector( '.woocommerce-chart__footer' ); + expect( + within( footer ).queryByText( '[D3Legend]' ) + ).toBeInTheDocument(); + } ); + + test( ' should render the legend at the side', () => { + const { container } = render( + + ); + const body = container.querySelector( '.woocommerce-chart__body' ); + expect( + within( body ).queryByText( '[D3Legend]' ) + ).toBeInTheDocument(); + } ); + + test( ' should render the legend at the top', () => { + const { container } = render( + + ); + const top = container.querySelector( '.woocommerce-chart__header' ); + expect( within( top ).queryByText( '[D3Legend]' ) ).toBeInTheDocument(); + } ); +} ); diff --git a/packages/js/components/src/compare-filter/README.md b/packages/js/components/src/compare-filter/README.md new file mode 100644 index 00000000000..bb3d1372a83 --- /dev/null +++ b/packages/js/components/src/compare-filter/README.md @@ -0,0 +1,36 @@ +CompareFilter +=== + +Displays a card + search used to filter results as a comparison between objects. + +## Usage + +```jsx +const path = ''; // from React Router +const getLabels = () => Promise.resolve( [] ); +const labels = { + helpText: 'Select at least two products to compare', + placeholder: 'Search for products to compare', + title: 'Compare Products', + update: 'Compare', +}; + + +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`getLabels` | Function | `null` | (required) Function used to fetch object labels via an API request, returns a Promise +`labels` | Object | `{}` | Object of localized labels +`param` | String | `null` | (required) The parameter to use in the querystring +`path` | String | `null` | (required) The `path` parameter supplied by React-Router +`query` | Object | `{}` | The query string represented in object form +`type` | String | `null` | (required) Which type of autocompleter should be used in the Search diff --git a/packages/js/components/src/compare-filter/button.js b/packages/js/components/src/compare-filter/button.js new file mode 100644 index 00000000000..2586a48d1a1 --- /dev/null +++ b/packages/js/components/src/compare-filter/button.js @@ -0,0 +1,79 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import { Button, Tooltip } from '@wordpress/components'; +import { createElement } from '@wordpress/element'; + +/** + * A button used when comparing items, if `count` is less than 2 a hoverable tooltip is added with `helpText`. + * + * @param {Object} props + * @param {string} props.className + * @param {number} props.count + * @param {Node} props.children + * @param {boolean} props.disabled + * @param {string} props.helpText + * @param {Function} props.onClick + * @return {Object} - + */ +const CompareButton = ( { + className, + count, + children, + disabled, + helpText, + onClick, +} ) => + ! disabled && count < 2 ? ( + + + + + + ) : ( + + ); + +CompareButton.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * The count of items selected. + */ + count: PropTypes.number.isRequired, + /** + * The button content. + */ + children: PropTypes.node.isRequired, + /** + * Text displayed when hovering over a disabled button. + */ + helpText: PropTypes.string.isRequired, + /** + * The function called when the button is clicked. + */ + onClick: PropTypes.func.isRequired, + /** + * Whether the control is disabled or not. + */ + disabled: PropTypes.bool, +}; + +export default CompareButton; diff --git a/packages/js/components/src/compare-filter/index.js b/packages/js/components/src/compare-filter/index.js new file mode 100644 index 00000000000..92e0a41025b --- /dev/null +++ b/packages/js/components/src/compare-filter/index.js @@ -0,0 +1,185 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createElement, Component } from '@wordpress/element'; +import { + Button, + Card, + CardBody, + CardFooter, + CardHeader, +} from '@wordpress/components'; +import { isEqual, isFunction } from 'lodash'; +import PropTypes from 'prop-types'; +import { getIdsFromQuery, updateQueryString } from '@woocommerce/navigation'; + +/** + * Internal dependencies + */ +import CompareButton from './button'; +import Search from '../search'; +import { Text } from '../experimental'; + +export { default as CompareButton } from './button'; + +/** + * Displays a card + search used to filter results as a comparison between objects. + */ +export class CompareFilter extends Component { + constructor( { getLabels, param, query } ) { + super( ...arguments ); + this.state = { + selected: [], + }; + this.clearQuery = this.clearQuery.bind( this ); + this.updateQuery = this.updateQuery.bind( this ); + this.updateLabels = this.updateLabels.bind( this ); + this.onButtonClicked = this.onButtonClicked.bind( this ); + if ( query[ param ] ) { + getLabels( query[ param ], query ).then( this.updateLabels ); + } + } + + componentDidUpdate( + { param: prevParam, query: prevQuery }, + { selected: prevSelected } + ) { + const { getLabels, param, query } = this.props; + const { selected } = this.state; + if ( + prevParam !== param || + ( prevSelected.length > 0 && selected.length === 0 ) + ) { + this.clearQuery(); + return; + } + + const prevIds = getIdsFromQuery( prevQuery[ param ] ); + const currentIds = getIdsFromQuery( query[ param ] ); + if ( ! isEqual( prevIds.sort(), currentIds.sort() ) ) { + getLabels( query[ param ], query ).then( this.updateLabels ); + } + } + + clearQuery() { + const { param, path, query } = this.props; + + this.setState( { + selected: [], + } ); + + updateQueryString( { [ param ]: undefined }, path, query ); + } + + updateLabels( selected ) { + this.setState( { selected } ); + } + + updateQuery() { + const { param, path, query } = this.props; + const { selected } = this.state; + const idList = selected.map( ( p ) => p.key ); + updateQueryString( { [ param ]: idList.join( ',' ) }, path, query ); + } + + onButtonClicked( e ) { + this.updateQuery( e ); + if ( isFunction( this.props.onClick ) ) { + this.props.onClick( e ); + } + } + + render() { + const { labels, type, autocompleter } = this.props; + const { selected } = this.state; + return ( + + + + { labels.title } + + + + { + this.setState( { selected: value } ); + } } + /> + + + + { labels.update } + + { selected.length > 0 && ( + + ) } + + + ); + } +} + +CompareFilter.propTypes = { + /** + * Function used to fetch object labels via an API request, returns a Promise. + */ + getLabels: PropTypes.func.isRequired, + /** + * Object of localized labels. + */ + labels: PropTypes.shape( { + /** + * Label for the search placeholder. + */ + placeholder: PropTypes.string, + /** + * Label for the card title. + */ + title: PropTypes.string, + /** + * Label for button which updates the URL/report. + */ + update: PropTypes.string, + } ), + /** + * The parameter to use in the querystring. + */ + param: PropTypes.string.isRequired, + /** + * The `path` parameter supplied by React-Router + */ + path: PropTypes.string.isRequired, + /** + * The query string represented in object form + */ + query: PropTypes.object, + /** + * Which type of autocompleter should be used in the Search + */ + type: PropTypes.string.isRequired, + /** + * The custom autocompleter to be forwarded to the `Search` component. + */ + autocompleter: PropTypes.object, +}; + +CompareFilter.defaultProps = { + labels: {}, + query: {}, +}; diff --git a/packages/js/components/src/compare-filter/stories/index.js b/packages/js/components/src/compare-filter/stories/index.js new file mode 100644 index 00000000000..bb837027de6 --- /dev/null +++ b/packages/js/components/src/compare-filter/stories/index.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { CompareFilter } from '../'; + +const query = {}; +const compareFilter = { + type: 'products', + param: 'product', + getLabels() { + return Promise.resolve( [] ); + }, + labels: { + helpText: 'Select at least two products to compare', + placeholder: 'Search for products to compare', + title: 'Compare Products', + update: 'Compare', + }, +}; + +export const Basic = ( { + path = new URL( document.location ).searchParams.get( 'path' ), +} ) => ; + +export default { + title: 'WooCommerce Admin/components/CompareFilter', + component: CompareFilter, +}; diff --git a/packages/js/components/src/compare-filter/test/compare-filter.js b/packages/js/components/src/compare-filter/test/compare-filter.js new file mode 100644 index 00000000000..2980de4f4cd --- /dev/null +++ b/packages/js/components/src/compare-filter/test/compare-filter.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Basic } from '../stories/index'; +import { CompareFilter } from '../index'; +import Search from '../../search'; +import productAutocompleter from '../../search/autocompleters/product'; +// Due to Jest implementation we cannot mock it only for specific tests. +// If your test requires non-mocked Search, move them to another test file. +jest.mock( '../../search' ); +Search.mockName( 'Search' ); + +describe( 'CompareFilter', () => { + let props; + beforeEach( () => { + props = { + path: '/foo/bar', + type: 'products', + param: 'product', + getLabels() { + return Promise.resolve( [] ); + }, + labels: { + helpText: 'Select at least two to compare', + placeholder: 'Search for things to compare', + title: 'Compare Things', + update: 'Compare', + }, + }; + } ); + it( 'should render the example from the storybook', () => { + const path = '/story/woocommerce-admin-components-comparefilter--basic'; + + expect( function () { + render( ); + } ).not.toThrow(); + } ); + + it( 'should forward the `type` prop the Search component', () => { + props.type = 'custom'; + + render( ); + + // Check that Search component received the prop, without checking its behavior/internals/implementation details. + expect( Search ).toHaveBeenLastCalledWith( + expect.objectContaining( { + type: 'custom', + } ), + expect.anything() + ); + } ); + it( 'should forward the `autocompleter` prop the Search component', () => { + props.autocompleter = productAutocompleter; + + render( ); + + // Check that Search component received the prop, without checking its behavior/internals/implementation details. + expect( Search ).toHaveBeenLastCalledWith( + expect.objectContaining( { + autocompleter: productAutocompleter, + } ), + expect.anything() + ); + } ); +} ); diff --git a/packages/js/components/src/date-range-filter-picker/README.md b/packages/js/components/src/date-range-filter-picker/README.md new file mode 100644 index 00000000000..846b43f792b --- /dev/null +++ b/packages/js/components/src/date-range-filter-picker/README.md @@ -0,0 +1,69 @@ +Date Range Picker +=== + +Select a range of dates or single dates + +## Usage + +```jsx +import { + getDateParamsFromQuery, + getCurrentDates, + isoDateFormat, + loadLocaleData, +} from '@woocommerce/date'; + +/** + * External dependencies + */ +import { partialRight } from 'lodash'; + +const query = {}; + +// Fetch locale from store settings and load for date functions. +const localeSettings = { + userLocale: 'fr_FR', + weekdaysShort: [ 'dim', 'lun', 'mar', 'mer', 'jeu', 'ven', 'sam' ], +}; +loadLocaleData( localeSettings ); + +const defaultDateRange = 'period=month&compare=previous_year'; +const storeGetDateParamsFromQuery = partialRight( getDateParamsFromQuery, defaultDateRange ); +const storeGetCurrentDates = partialRight( getCurrentDates, defaultDateRange ); +const { period, compare, before, after } = storeGetDateParamsFromQuery( query ); +const { primary: primaryDate, secondary: secondaryDate } = storeGetCurrentDates( query ); +const dateQuery = { + period, + compare, + before, + after, + primaryDate, + secondaryDate, +}; + + {} } + dateQuery={ dateQuery } + isoDateFormat={ isoDateFormat } +/> +``` + +### Props + +Name | Type | Default | Description +------- | -------- | ------- | --- +`isDateFormat` | string | `null` | (required) ISO date format string +`onRangeSelect` | Function | `null` | Callback called when selection is made +`dateQuery` | object | `null` | (required) Date initialization object + +## URL as the source of truth + +The Date Range Picker reads parameters from the URL querystring and updates them by creating a link to reflect newly selected parameters, which is rendered as the "Update" button. + +URL Parameter | Default | Possible Values +--- | --- | --- +`period` | `today` | `today`, `yesterday`, `week`, `last_week`, `month`, `last_month`, `quarter`, `last_quarter`, `year`, `last_year`, `custom` +`compare` | `previous_period` | `previous_period`, `previous_year` +`start` | none | start date for custom periods `2018-04-15`. [ISO 8601 format](https://en.wikipedia.org/wiki/ISO_8601) +`end` | none | end date for custom periods `2018-04-15`. [ISO 8601 format](https://en.wikipedia.org/wiki/ISO_8601) diff --git a/packages/js/components/src/date-range-filter-picker/compare-periods.js b/packages/js/components/src/date-range-filter-picker/compare-periods.js new file mode 100644 index 00000000000..6d653d0cc8c --- /dev/null +++ b/packages/js/components/src/date-range-filter-picker/compare-periods.js @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createElement, Component } from '@wordpress/element'; +import PropTypes from 'prop-types'; + +import { periods } from '@woocommerce/date'; + +/** + * Internal dependencies + */ +import SegmentedSelection from '../segmented-selection'; + +class ComparePeriods extends Component { + render() { + const { onSelect, compare } = this.props; + return ( + + ); + } +} + +ComparePeriods.propTypes = { + onSelect: PropTypes.func.isRequired, + compare: PropTypes.string, +}; + +export default ComparePeriods; diff --git a/packages/js/components/src/date-range-filter-picker/content.js b/packages/js/components/src/date-range-filter-picker/content.js new file mode 100644 index 00000000000..cffa56861f1 --- /dev/null +++ b/packages/js/components/src/date-range-filter-picker/content.js @@ -0,0 +1,201 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { + createElement, + Component, + createRef, + Fragment, +} from '@wordpress/element'; +import { TabPanel, Button } from '@wordpress/components'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import moment from 'moment'; + +/** + * Internal dependencies + */ +import ComparePeriods from './compare-periods'; +import DateRange from '../calendar/date-range'; +import { H, Section } from '../section'; +import PresetPeriods from './preset-periods'; + +class DatePickerContent extends Component { + constructor() { + super(); + this.onTabSelect = this.onTabSelect.bind( this ); + this.controlsRef = createRef(); + } + onTabSelect( tab ) { + const { onUpdate, period } = this.props; + + /** + * If the period is `custom` and the user switches tabs to view the presets, + * then a preset should be selected. This logic selects the default, otherwise + * `custom` value for period will result in no selection. + */ + if ( tab === 'period' && period === 'custom' ) { + onUpdate( { period: 'today' } ); + } + } + + isFutureDate( dateString ) { + return moment().isBefore( moment( dateString ), 'day' ); + } + + render() { + const { + period, + compare, + after, + before, + onUpdate, + onClose, + onSelect, + isValidSelection, + resetCustomValues, + focusedInput, + afterText, + beforeText, + afterError, + beforeError, + shortDateFormat, + } = this.props; + return ( +
    + + { __( 'Select date range and comparison', 'woocommerce' ) } + +
    + + { __( 'select a date range', 'woocommerce' ) } + + + { ( selected ) => ( + + { selected.name === 'period' && ( + + ) } + { selected.name === 'custom' && ( + + ) } +
    + + { __( 'compare to', 'woocommerce' ) } + + +
    + { selected.name === 'custom' && ( + + ) } + { isValidSelection( selected.name ) ? ( + + ) : ( + + ) } +
    +
    +
    + ) } +
    +
    +
    + ); + } +} + +DatePickerContent.propTypes = { + period: PropTypes.string.isRequired, + compare: PropTypes.string.isRequired, + onUpdate: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + onSelect: PropTypes.func.isRequired, + resetCustomValues: PropTypes.func.isRequired, + focusedInput: PropTypes.string, + afterText: PropTypes.string, + beforeText: PropTypes.string, + afterError: PropTypes.string, + beforeError: PropTypes.string, + shortDateFormat: PropTypes.string.isRequired, +}; + +export default DatePickerContent; diff --git a/packages/js/components/src/date-range-filter-picker/index.js b/packages/js/components/src/date-range-filter-picker/index.js new file mode 100644 index 00000000000..6dccc05c0f6 --- /dev/null +++ b/packages/js/components/src/date-range-filter-picker/index.js @@ -0,0 +1,202 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Dropdown } from '@wordpress/components'; +import PropTypes from 'prop-types'; +import { withViewportMatch } from '@wordpress/viewport'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import DatePickerContent from './content'; +import DropdownButton from '../dropdown-button'; + +const shortDateFormat = __( 'MM/DD/YYYY', 'woocommerce' ); + +/** + * Select a range of dates or single dates. + */ +class DateRangeFilterPicker extends Component { + constructor( props ) { + super( props ); + this.state = this.getResetState(); + + this.update = this.update.bind( this ); + this.onSelect = this.onSelect.bind( this ); + this.isValidSelection = this.isValidSelection.bind( this ); + this.resetCustomValues = this.resetCustomValues.bind( this ); + } + + formatDate( date, format ) { + if ( + date && + date._isAMomentObject && + typeof date.format === 'function' + ) { + return date.format( format ); + } + + return ''; + } + + getResetState() { + const { period, compare, before, after } = this.props.dateQuery; + + return { + period, + compare, + before, + after, + focusedInput: 'startDate', + afterText: this.formatDate( after, shortDateFormat ), + beforeText: this.formatDate( before, shortDateFormat ), + afterError: null, + beforeError: null, + }; + } + + update( update ) { + this.setState( update ); + } + + onSelect( selectedTab, onClose ) { + const { isoDateFormat, onRangeSelect } = this.props; + return ( event ) => { + const { period, compare, after, before } = this.state; + const data = { + period: selectedTab === 'custom' ? 'custom' : period, + compare, + }; + if ( selectedTab === 'custom' ) { + data.after = this.formatDate( after, isoDateFormat ); + data.before = this.formatDate( before, isoDateFormat ); + } else { + data.after = undefined; + data.before = undefined; + } + onRangeSelect( data ); + onClose( event ); + }; + } + + getButtonLabel() { + const { primaryDate, secondaryDate } = this.props.dateQuery; + return [ + `${ primaryDate.label } (${ primaryDate.range })`, + `${ __( 'vs.', 'woocommerce' ) } ${ secondaryDate.label } (${ + secondaryDate.range + })`, + ]; + } + + isValidSelection( selectedTab ) { + const { compare, after, before } = this.state; + if ( selectedTab === 'custom' ) { + return compare && after && before; + } + return true; + } + + resetCustomValues() { + this.setState( { + after: null, + before: null, + focusedInput: 'startDate', + afterText: '', + beforeText: '', + afterError: null, + beforeError: null, + } ); + } + + render() { + const { + period, + compare, + after, + before, + focusedInput, + afterText, + beforeText, + afterError, + beforeError, + } = this.state; + + const { isViewportMobile } = this.props; + const contentClasses = classnames( + 'woocommerce-filters-date__content', + { + 'is-mobile': isViewportMobile, + } + ); + return ( +
    + + { __( 'Date range', 'woocommerce' ) }: + + ( + + ) } + renderContent={ ( { onClose } ) => ( + + ) } + /> +
    + ); + } +} + +DateRangeFilterPicker.propTypes = { + /** + * Callback called when selection is made. + */ + onRangeSelect: PropTypes.func.isRequired, + /** + * The date query string represented in object form. + */ + dateQuery: PropTypes.shape( { + period: PropTypes.string.isRequired, + compare: PropTypes.string.isRequired, + before: PropTypes.object, + after: PropTypes.object, + primaryDate: PropTypes.shape( { + label: PropTypes.string.isRequired, + range: PropTypes.string.isRequired, + } ).isRequired, + secondaryDate: PropTypes.shape( { + label: PropTypes.string.isRequired, + range: PropTypes.string.isRequired, + } ).isRequired, + } ).isRequired, +}; + +export default withViewportMatch( { + isViewportMobile: '< medium', +} )( DateRangeFilterPicker ); diff --git a/packages/js/components/src/date-range-filter-picker/preset-periods.js b/packages/js/components/src/date-range-filter-picker/preset-periods.js new file mode 100644 index 00000000000..a2c629196c9 --- /dev/null +++ b/packages/js/components/src/date-range-filter-picker/preset-periods.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createElement, Component } from '@wordpress/element'; +import { filter } from 'lodash'; +import PropTypes from 'prop-types'; + +import { presetValues } from '@woocommerce/date'; + +/** + * Internal dependencies + */ +import SegmentedSelection from '../segmented-selection'; + +class PresetPeriods extends Component { + render() { + const { onSelect, period } = this.props; + return ( + preset.value !== 'custom' + ) } + selected={ period } + onSelect={ onSelect } + name="period" + legend={ __( 'select a preset period', 'woocommerce' ) } + /> + ); + } +} + +PresetPeriods.propTypes = { + onSelect: PropTypes.func.isRequired, + period: PropTypes.string, +}; + +export default PresetPeriods; diff --git a/packages/js/components/src/date-range-filter-picker/stories/index.js b/packages/js/components/src/date-range-filter-picker/stories/index.js new file mode 100644 index 00000000000..8035c15bb82 --- /dev/null +++ b/packages/js/components/src/date-range-filter-picker/stories/index.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { DateRangeFilterPicker } from '@woocommerce/components'; +import { + getDateParamsFromQuery, + getCurrentDates, + isoDateFormat, +} from '@woocommerce/date'; + +/** + * External dependencies + */ +import { partialRight } from 'lodash'; + +const query = {}; + +const defaultDateRange = 'period=month&compare=previous_year'; +const storeGetDateParamsFromQuery = partialRight( + getDateParamsFromQuery, + defaultDateRange +); +const storeGetCurrentDates = partialRight( getCurrentDates, defaultDateRange ); +const { period, compare, before, after } = storeGetDateParamsFromQuery( query ); +const { primary: primaryDate, secondary: secondaryDate } = storeGetCurrentDates( + query +); +const dateQuery = { + period, + compare, + before, + after, + primaryDate, + secondaryDate, +}; + +export const Basic = () => ( + {} } + dateQuery={ dateQuery } + isoDateFormat={ isoDateFormat } + /> +); + +export default { + title: 'WooCommerce Admin/components/DateRangeFilterPicker', + component: DateRangeFilterPicker, +}; diff --git a/packages/js/components/src/date-range-filter-picker/style.scss b/packages/js/components/src/date-range-filter-picker/style.scss new file mode 100644 index 00000000000..382cadd9e57 --- /dev/null +++ b/packages/js/components/src/date-range-filter-picker/style.scss @@ -0,0 +1,84 @@ +.woocommerce-filters-date__content { + &.is-mobile { + .components-popover__header { + border: none; + height: 0; + } + + .components-popover__close { + transform: translateY(22px); + } + + .components-tab-panel__tab-content { + height: calc(100% - 46px); + overflow: auto; + } + } + + &.components-dropdown__content .components-popover__content > div { + padding: 0; + } +} + +.woocommerce-filters-date__tabs { + height: calc(100% - 42px); + border-top: 1px solid $gray-400; + + .components-tab-panel__tabs { + display: flex; + justify-content: space-between; + + .components-button { + display: block; + text-align: center; + width: 50%; + } + } + + .components-tab-panel__tab-content { + display: flex; + flex-direction: column; + align-items: center; + } + + @include set-grid-item-position( 2, 2 ); +} + +.woocommerce-filters-date__text { + @include font-size( 12 ); + font-weight: 100; + text-transform: uppercase; + text-align: center; + color: $gray-700; + width: 100%; + margin: 0; + padding: 1em; + background-color: $studio-white; +} + +.woocommerce-filters-date__content-controls { + display: flex; + flex-direction: column; + width: 100%; + align-items: center; + padding-bottom: 1em; + background-color: $studio-white; + + &.is-custom { + border-top: 1px solid $gray-400; + } +} + +.woocommerce-filters-date__button-group { + padding-top: 1em; + display: flex; + justify-content: center; + width: 100%; + + .woocommerce-filters-date__button { + justify-content: center; + width: 40%; + height: 34px; + margin: 0 $gap-small; + } +} diff --git a/packages/js/components/src/date/README.md b/packages/js/components/src/date/README.md new file mode 100644 index 00000000000..f949df6cc4f --- /dev/null +++ b/packages/js/components/src/date/README.md @@ -0,0 +1,19 @@ +Date +=== + +Use the `Date` component to display accessible dates or times. + +## Usage + +```jsx + +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`date` | One of type: string, object | `null` | (required) Date to use in the component +`machineFormat` | String | `'Y-m-d H:i:s'` | Date format used in the `datetime` prop of the `time` element +`screenReaderFormat` | String | `'F j, Y'` | Date format used for screen readers +`visibleFormat` | String | `'Y-m-d'` | Date format displayed in the page diff --git a/packages/js/components/src/date/index.js b/packages/js/components/src/date/index.js new file mode 100644 index 00000000000..e67a986039f --- /dev/null +++ b/packages/js/components/src/date/index.js @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { format as formatDate } from '@wordpress/date'; +import { createElement } from '@wordpress/element'; + +/** + * Use the `Date` component to display accessible dates or times. + * + * @param {Object} props + * @param {Object} props.date + * @param {string} props.machineFormat + * @param {string} props.screenReaderFormat + * @param {string} props.visibleFormat + * @return {Object} - + */ +const Date = ( { date, machineFormat, screenReaderFormat, visibleFormat } ) => { + return ( + + ); +}; + +Date.propTypes = { + /** + * Date to use in the component. + */ + date: PropTypes.oneOfType( [ PropTypes.string, PropTypes.object ] ) + .isRequired, + /** + * Date format used in the `datetime` prop of the `time` element. + */ + machineFormat: PropTypes.string, + /** + * Date format used for screen readers. + */ + screenReaderFormat: PropTypes.string, + /** + * Date format displayed in the page. + */ + visibleFormat: PropTypes.string, +}; + +Date.defaultProps = { + machineFormat: 'Y-m-d H:i:s', + screenReaderFormat: 'F j, Y', + visibleFormat: 'Y-m-d', +}; + +export default Date; diff --git a/packages/js/components/src/date/stories/index.js b/packages/js/components/src/date/stories/index.js new file mode 100644 index 00000000000..fcc680dd95d --- /dev/null +++ b/packages/js/components/src/date/stories/index.js @@ -0,0 +1,11 @@ +/** + * External dependencies + */ +import { Date } from '@woocommerce/components'; + +export const Basic = () => ; + +export default { + title: 'WooCommerce Admin/components/Date', + component: Date, +}; diff --git a/packages/js/components/src/date/test/__snapshots__/index.js.snap b/packages/js/components/src/date/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000..718b383d38b --- /dev/null +++ b/packages/js/components/src/date/test/__snapshots__/index.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Date should respect formats from props 1`] = ` +
    + +
    +`; + +exports[`Date should use fallback formats 1`] = ` +
    + +
    +`; diff --git a/packages/js/components/src/date/test/index.js b/packages/js/components/src/date/test/index.js new file mode 100644 index 00000000000..9aa16fc08c4 --- /dev/null +++ b/packages/js/components/src/date/test/index.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Date from '../'; + +describe( 'Date', () => { + test( 'should use fallback formats', () => { + const { container } = render( ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'should respect formats from props', () => { + const { container } = render( + + ); + expect( container ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/js/components/src/dropdown-button/README.md b/packages/js/components/src/dropdown-button/README.md new file mode 100644 index 00000000000..d8340e4e1eb --- /dev/null +++ b/packages/js/components/src/dropdown-button/README.md @@ -0,0 +1,28 @@ +DropdownButton +=== + +A button useful for a launcher of a dropdown component. The button is 100% width of its container and displays single or multiple lines rendered as `` elments. + +## Usage + +```jsx + ( + + ) } + renderContent={ () => ( +

    Dropdown content here

    + ) } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`labels` | Array | `null` | (required) An array of elements to be rendered as the content of the button +`isOpen` | Boolean | `null` | Boolean describing if the dropdown in open or not diff --git a/packages/js/components/src/dropdown-button/index.js b/packages/js/components/src/dropdown-button/index.js new file mode 100644 index 00000000000..98994af8faa --- /dev/null +++ b/packages/js/components/src/dropdown-button/index.js @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import { Button } from '@wordpress/components'; +import { createElement } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +/** + * A button useful for a launcher of a dropdown component. The button is 100% width of its container and displays + * single or multiple lines rendered as `` elments. + * + * @param {Object} props Props passed to component. + * @return {Object} - + */ +const DropdownButton = ( props ) => { + const { labels, isOpen, ...otherProps } = props; + const buttonClasses = classnames( 'woocommerce-dropdown-button', { + 'is-open': isOpen, + 'is-multi-line': labels.length > 1, + } ); + return ( + + ); +}; + +DropdownButton.propTypes = { + /** + * An array of elements to be rendered as the content of the button. + */ + labels: PropTypes.array.isRequired, + /** + * Boolean describing if the dropdown in open or not. + */ + isOpen: PropTypes.bool, +}; + +export default DropdownButton; diff --git a/packages/js/components/src/dropdown-button/stories/index.js b/packages/js/components/src/dropdown-button/stories/index.js new file mode 100644 index 00000000000..2f50937f12d --- /dev/null +++ b/packages/js/components/src/dropdown-button/stories/index.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import { Dropdown } from '@wordpress/components'; +import { DropdownButton } from '@woocommerce/components'; + +export const Basic = () => ( + ( + + ) } + renderContent={ () =>

    Dropdown content here

    } + /> +); + +export default { + title: 'WooCommerce Admin/components/DropdownButton', + component: DropdownButton, +}; diff --git a/packages/js/components/src/dropdown-button/style.scss b/packages/js/components/src/dropdown-button/style.scss new file mode 100644 index 00000000000..d254a68dddc --- /dev/null +++ b/packages/js/components/src/dropdown-button/style.scss @@ -0,0 +1,83 @@ +.woocommerce-page .woocommerce-dropdown-button { + background-color: $studio-white; + position: relative; + border: 1px solid $gray-700; + color: $gray-900; + border-radius: 4px; + padding: 0 40px 0 0; + width: 100%; + height: auto; + + &::after { + content: ''; + background: $gray-900; + mask: url(data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2220%22%20height%3D%2220%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22M5%206l5%205%205-5%202%201-7%207-7-7%202-1z%22%20%2F%3E%3C%2Fsvg%3E) + no-repeat right 0 top 55%; + position: absolute; + right: 14px; + width: 32px; + height: 48px; + } + + &.is-open { + &::after { + transform: translateX(12px) translateY(2px) rotate(180deg); + } + } + + &:hover, + &:active, + &.is-open { + color: var(--wp-admin-theme-color); + &::after { + background: var(--wp-admin-theme-color); + } + } + + &.is-multi-line .woocommerce-dropdown-button__labels { + flex-direction: column; + } + + &:not(:focus):not(.is-open) { + border-color: $gray-700; + } +} + +.woocommerce-dropdown-button__labels { + text-align: left; + padding: 8px 12px; + min-height: 48px; + display: flex; + align-items: center; + width: 100%; + justify-content: space-around; + + @include breakpoint( '<400px' ) { + min-height: 46px; + } + + span { + width: 100%; + text-align: left; + + &:last-child { + @include font-size( 12 ); + margin: 0; + } + + &:first-child { + @include font-size( 13 ); + font-weight: 600; + } + + @include breakpoint( '<400px' ) { + &:last-child { + @include font-size( 10 ); + } + + &:first-child { + @include font-size( 12 ); + } + } + } +} diff --git a/packages/js/components/src/dropdown-button/test/__snapshots__/index.js.snap b/packages/js/components/src/dropdown-button/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000..79f6f9aa533 --- /dev/null +++ b/packages/js/components/src/dropdown-button/test/__snapshots__/index.js.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DropdownButton it renders correctly 1`] = ` +Object { + "asFragment": [Function], + "baseElement": + +
    +
    +
    + +
    + , + "container":
    + +
    , + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/js/components/src/dropdown-button/test/index.js b/packages/js/components/src/dropdown-button/test/index.js new file mode 100644 index 00000000000..52e7518cd9f --- /dev/null +++ b/packages/js/components/src/dropdown-button/test/index.js @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import DropdownButton from '../'; + +describe( 'DropdownButton', () => { + test( 'it renders correctly', () => { + const component = render( ); + expect( component ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/js/components/src/dynamic-form/README.md b/packages/js/components/src/dynamic-form/README.md new file mode 100644 index 00000000000..7b0d1629f08 --- /dev/null +++ b/packages/js/components/src/dynamic-form/README.md @@ -0,0 +1,42 @@ +# DynamicForm + +A component to handle form state and provide input helper props. + +## Usage + +```jsx +const initialValues = { firstName: '' }; + + { + setSubmitted( values ); + } } + isBusy={ false } + onChange={ () => {} } + validate={ () => ( {} ) } + submitLabel="Submit" +/>; +``` + +### Props + +| Name | Type | Default | Description | +| ------------- | -------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fields` | {} or [] | [] | An object to describe the structure and types of all fields, matching the structure returned by the [Settings API](https://woocommerce.com/document/settings-api/) | +| `isBusy` | Boolean | false | Boolean indicating busy state of submit button | +| `onSubmit` | Function | `noop` | Function to call when a form is submitted with valid fields | +| `onChange` | Function | `noop` | Function to call when any values on the form are changed | +| `validate` | Function | `noop` | A function that is passed a list of all values and should return an `errors` object with error response | +| `submitLabel` | String | "Proceed" | Label for submit button. | + +### Fields structure + +Please reference the [WordPress settings API documentation](https://woocommerce.com/document/settings-api/) to better understand the structure expected for the fields property. This component accepts the object returned via the `settings` property when querying a gateway via the API, or simply the array provided by `Object.values(settings)`. + +### Currently Supported Types + +- Text +- Password +- Checkbox +- Select diff --git a/packages/js/components/src/dynamic-form/dynamic-form.tsx b/packages/js/components/src/dynamic-form/dynamic-form.tsx new file mode 100644 index 00000000000..e0dc14391d6 --- /dev/null +++ b/packages/js/components/src/dynamic-form/dynamic-form.tsx @@ -0,0 +1,122 @@ +/** + * External dependencies + */ +import { createElement, useMemo } from '@wordpress/element'; +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { Form } from '../index'; +import { + TextField, + PasswordField, + CheckboxField, + SelectField, +} from './field-types'; + +import { Field, FormInputProps } from './types'; + +type DynamicFormProps = { + fields: Field[] | { [ key: string ]: Field }; + validate: ( values: Record< string, string > ) => Record< string, string >; + isBusy?: boolean; + onSubmit?: ( values: Record< string, string > ) => void; + onChange?: ( + value: Record< string, string >, + values: Record< string, string >[], + result: boolean + ) => void; + submitLabel?: string; +}; + +const fieldTypeMap = { + text: TextField, + password: PasswordField, + checkbox: CheckboxField, + select: SelectField, + default: TextField, +}; + +const getInitialConfigValues = ( fields: Field[] ) => + fields.reduce( + ( data, field ) => ( { + ...data, + [ field.id ]: + field.type === 'checkbox' ? field.value === 'yes' : field.value, + } ), + {} + ); + +export const DynamicForm: React.FC< DynamicFormProps > = ( { + fields: baseFields = [], + isBusy = false, + onSubmit = () => {}, + onChange = () => {}, + validate = () => ( {} ), + submitLabel = __( 'Proceed', 'woocommerce' ), +} ) => { + // Support accepting fields in the format provided by the API (object), but transform to Array + const fields = + baseFields instanceof Array ? baseFields : Object.values( baseFields ); + + const initialValues = useMemo( () => getInitialConfigValues( fields ), [ + fields, + ] ); + + return ( +
    + { ( { + getInputProps, + handleSubmit, + }: { + getInputProps: ( name: string ) => FormInputProps; + handleSubmit: () => void; + } ) => { + return ( +
    + { fields.map( ( field ) => { + if ( + field.type && + ! ( field.type in fieldTypeMap ) + ) { + /* eslint-disable no-console */ + console.warn( + `Field type of ${ field.type } not current supported in DynamicForm component` + ); + /* eslint-enable no-console */ + return null; + } + + const Control = + fieldTypeMap[ field.type || 'default' ]; + return ( + + ); + } ) } + + +
    + ); + } } + + ); +}; diff --git a/packages/js/components/src/dynamic-form/field-types/field-checkbox.tsx b/packages/js/components/src/dynamic-form/field-types/field-checkbox.tsx new file mode 100644 index 00000000000..4e4d3b2ba1e --- /dev/null +++ b/packages/js/components/src/dynamic-form/field-types/field-checkbox.tsx @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { CheckboxControl } from '@wordpress/components'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ControlProps } from '../types'; + +export const CheckboxField: React.FC< ControlProps > = ( { + field, + onChange, + ...props +} ) => { + const { label, description } = field; + + return ( + onChange( val ) } + title={ description } + label={ label } + { ...props } + /> + ); +}; diff --git a/packages/js/components/src/dynamic-form/field-types/field-password.tsx b/packages/js/components/src/dynamic-form/field-types/field-password.tsx new file mode 100644 index 00000000000..f3ccee3ff97 --- /dev/null +++ b/packages/js/components/src/dynamic-form/field-types/field-password.tsx @@ -0,0 +1,14 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { TextField } from './field-text'; +import { ControlProps } from '../types'; + +export const PasswordField: React.FC< ControlProps > = ( props ) => { + return ; +}; diff --git a/packages/js/components/src/dynamic-form/field-types/field-select.tsx b/packages/js/components/src/dynamic-form/field-types/field-select.tsx new file mode 100644 index 00000000000..735672435c3 --- /dev/null +++ b/packages/js/components/src/dynamic-form/field-types/field-select.tsx @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { createElement, useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { SelectControl } from '../../index'; +import { ControlProps } from '../types'; + +type SelectControlOption = { + key: string; + label: string; + value: { id: string }; +}; + +const transformOptions = ( options: Record< string, string > ) => + Object.entries( options ).map( ( [ key, value ] ) => ( { + key, + label: value, + value: { id: key }, + } ) ); + +export const SelectField: React.FC< ControlProps > = ( { + field, + ...props +} ) => { + const { description, label, options = {} } = field; + + const transformedOptions: SelectControlOption[] = useMemo( + () => transformOptions( options ), + [ options ] + ); + + return ( + + ); +}; diff --git a/packages/js/components/src/dynamic-form/field-types/field-text.tsx b/packages/js/components/src/dynamic-form/field-types/field-text.tsx new file mode 100644 index 00000000000..3587a01b951 --- /dev/null +++ b/packages/js/components/src/dynamic-form/field-types/field-text.tsx @@ -0,0 +1,27 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { TextControl } from '../../index'; +import { ControlProps } from '../types'; + +export const TextField: React.FC< ControlProps & { type?: string } > = ( { + field, + type = 'text', + ...props +} ) => { + const { label, description } = field; + + return ( + + ); +}; diff --git a/packages/js/components/src/dynamic-form/field-types/index.ts b/packages/js/components/src/dynamic-form/field-types/index.ts new file mode 100644 index 00000000000..8a17fb1b44d --- /dev/null +++ b/packages/js/components/src/dynamic-form/field-types/index.ts @@ -0,0 +1,4 @@ +export * from './field-text'; +export * from './field-password'; +export * from './field-checkbox'; +export * from './field-select'; diff --git a/packages/js/components/src/dynamic-form/index.js b/packages/js/components/src/dynamic-form/index.js new file mode 100644 index 00000000000..7bae50b0b6a --- /dev/null +++ b/packages/js/components/src/dynamic-form/index.js @@ -0,0 +1 @@ +export * from './dynamic-form'; diff --git a/packages/js/components/src/dynamic-form/stories/index.js b/packages/js/components/src/dynamic-form/stories/index.js new file mode 100644 index 00000000000..929a0e48cd2 --- /dev/null +++ b/packages/js/components/src/dynamic-form/stories/index.js @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import { DynamicForm } from '@woocommerce/components'; +import { createElement, useState } from '@wordpress/element'; + +const fields = [ + { + id: 'user_name', + label: 'Username', + description: 'This is your username.', + type: 'text', + value: '', + default: '', + tip: 'This is your username.', + placeholder: '', + }, + { + id: 'pass_phrase', + label: 'Passphrase', + description: + '* Required. Needed to ensure the data passed through is secure.', + type: 'password', + value: '', + default: '', + tip: '* Required. Needed to ensure the data passed through is secure.', + placeholder: '', + }, + { + id: 'button_type', + label: 'Button Type', + description: 'Select the button type you would like to show.', + type: 'select', + value: 'buy', + default: 'buy', + tip: 'Select the button type you would like to show.', + placeholder: '', + options: { + default: 'Default', + buy: 'Buy', + donate: 'Donate', + branded: 'Branded', + custom: 'Custom', + }, + }, + { + id: 'checkbox_sample', + label: 'Checkbox style', + description: 'This is an example checkbox field.', + type: 'checkbox', + value: 'no', + default: 'no', + tip: 'This is an example checkbox field.', + placeholder: '', + }, +]; + +const getField = ( fieldId ) => + fields.find( ( field ) => field.id === fieldId ); + +const validate = ( values ) => { + const errors = {}; + + for ( const [ key, value ] of Object.entries( values ) ) { + const field = getField( key ); + + if ( ! ( value || field.type === 'checkbox' ) ) { + errors[ key ] = `Please enter your ${ field.label.toLowerCase() }`; + } + } + + return errors; +}; + +const DynamicExample = () => { + const [ submitted, setSubmitted ] = useState( null ); + return ( + <> + setSubmitted( values ) } + validate={ validate } + /> +

    Submitted:

    +

    { submitted ? JSON.stringify( submitted, null, 3 ) : 'None' }

    + + ); +}; + +export const Basic = () => ; + +export default { + title: 'WooCommerce Admin/components/DynamicForm', + component: DynamicForm, +}; diff --git a/packages/js/components/src/dynamic-form/style.scss b/packages/js/components/src/dynamic-form/style.scss new file mode 100644 index 00000000000..722f4252b7b --- /dev/null +++ b/packages/js/components/src/dynamic-form/style.scss @@ -0,0 +1,13 @@ +.woocommerce-component_dynamic-form { + .components-base-control { + margin-top: $gap; + margin-bottom: $gap; + position: relative; + + &.has-error { + .components-base-control__help { + left: 0 !important; + } + } + } +} diff --git a/packages/js/components/src/dynamic-form/types.ts b/packages/js/components/src/dynamic-form/types.ts new file mode 100644 index 00000000000..702cf9c7aa1 --- /dev/null +++ b/packages/js/components/src/dynamic-form/types.ts @@ -0,0 +1,22 @@ +export type Field = { + id: string; + type: 'text' | 'password' | 'checkbox' | 'select'; + title: string; + label: string; + description?: string; + default?: string; + class?: string; + css?: string; + options?: Record< string, string >; + tip?: string; + value?: string; + placeholder?: string; +}; + +export type FormInputProps = React.InputHTMLAttributes< HTMLInputElement > & { + onChange: ( value: string | boolean ) => void; +}; + +export type ControlProps = FormInputProps & { + field: Field; +}; diff --git a/packages/js/components/src/ellipsis-menu/README.md b/packages/js/components/src/ellipsis-menu/README.md new file mode 100644 index 00000000000..688965b6625 --- /dev/null +++ b/packages/js/components/src/ellipsis-menu/README.md @@ -0,0 +1,97 @@ +EllipsisMenu +=== + +This is a dropdown menu hidden behind a vertical ellipsis icon. When clicked, the inner MenuItems are displayed. + +## Usage + +```jsx + { + return ( +
    + Display stats + setState( { showCustomers: ! showCustomers } ) }> + setState( { showCustomers: ! showCustomers } ) } + /> + + setState( { showOrders: ! showOrders } ) }> + setState( { showOrders: ! showOrders } ) } + /> + + + + +
    + ); + } } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`label` | String | `null` | (required) The label shown when hovering/focusing on the icon button +`renderContent` | Function | `null` | A function returning `MenuTitle`/`MenuItem` components as a render prop. Arguments from Dropdown passed as function arguments + + +MenuItem +=== + +`MenuItem` is used to give the item an accessible wrapper, with the `menuitem` role and added keyboard functionality (`onInvoke`). +`MenuItem`s can also be deemed "clickable", though this is disabled by default because generally the inner component handles +the click event. + +## Usage + +```jsx + + + +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`checked` | Boolean | `null` | Whether the menu item is checked or not. Only relevant for menu items with `isCheckbox` +`children` | ReactNode | `null` | A renderable component (or string) which will be displayed as the content of this item. Generally a `ToggleControl` +`isCheckbox` | Boolean | `false` | Whether the menu item is a checkbox (will render a FormToggle and use the `menuitemcheckbox` role) +`isClickable` | Boolean | `false` | Boolean to control whether the MenuItem should handle the click event. Defaults to false, assuming your child component handles the click event +`onInvoke` | Function | `null` | (required) A function called when this item is activated via keyboard ENTER or SPACE; or when the item is clicked (only if `isClickable` is set) + + +MenuTitle +=== + +`MenuTitle` is another valid Menu child, but this does not have any accessibility attributes associated +(so this should not be used in place of the `EllipsisMenu` prop `label`). + +## Usage + +```jsx +Display stats +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`children` | ReactNode | `null` | A renderable component (or string) which will be displayed as the content of this item diff --git a/packages/js/components/src/ellipsis-menu/index.js b/packages/js/components/src/ellipsis-menu/index.js new file mode 100644 index 00000000000..e5d2e9654f5 --- /dev/null +++ b/packages/js/components/src/ellipsis-menu/index.js @@ -0,0 +1,89 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import classnames from 'classnames'; +import { Button, Dropdown, NavigableMenu } from '@wordpress/components'; +import { Icon } from '@wordpress/icons'; +import Ellipsis from 'gridicons/dist/ellipsis'; +import PropTypes from 'prop-types'; + +/** + * This is a dropdown menu hidden behind a vertical ellipsis icon. When clicked, the inner MenuItems are displayed. + */ +class EllipsisMenu extends Component { + render() { + const { label, renderContent, className } = this.props; + if ( ! renderContent ) { + return null; + } + + const renderEllipsis = ( { onToggle, isOpen } ) => { + const toggleClassname = classnames( + 'woocommerce-ellipsis-menu__toggle', + { + 'is-opened': isOpen, + } + ); + + return ( + + ); + }; + + const renderMenu = ( renderContentArgs ) => ( + + { renderContent( renderContentArgs ) } + + ); + + return ( +
    + +
    + ); + } +} + +EllipsisMenu.propTypes = { + /** + * The label shown when hovering/focusing on the icon button. + */ + label: PropTypes.string.isRequired, + /** + * A function returning `MenuTitle`/`MenuItem` components as a render prop. Arguments from Dropdown passed as function arguments. + */ + renderContent: PropTypes.func, + /** + * Classname to add to ellipsis menu. + */ + className: PropTypes.string, + /** + * Callback function when dropdown button is clicked, it provides the click event. + */ + onToggle: PropTypes.func, +}; + +export default EllipsisMenu; diff --git a/packages/js/components/src/ellipsis-menu/menu-item.js b/packages/js/components/src/ellipsis-menu/menu-item.js new file mode 100644 index 00000000000..cacbc32b68d --- /dev/null +++ b/packages/js/components/src/ellipsis-menu/menu-item.js @@ -0,0 +1,129 @@ +/** + * External dependencies + */ +import { BaseControl, FormToggle } from '@wordpress/components'; +import { createElement, Component, createRef } from '@wordpress/element'; +import { DOWN, ENTER, SPACE, UP } from '@wordpress/keycodes'; +import PropTypes from 'prop-types'; + +/** + * `MenuItem` is used to give the item an accessible wrapper, with the `menuitem` role and added keyboard functionality (`onInvoke`). + * `MenuItem`s can also be deemed "clickable", though this is disabled by default because generally the inner component handles + * the click event. + */ +class MenuItem extends Component { + constructor() { + super( ...arguments ); + this.onClick = this.onClick.bind( this ); + this.onFocusFormToggle = this.onFocusFormToggle.bind( this ); + this.onKeyDown = this.onKeyDown.bind( this ); + this.container = createRef(); + } + + onClick( event ) { + const { isClickable, onInvoke } = this.props; + if ( isClickable ) { + event.preventDefault(); + onInvoke(); + } + } + + onKeyDown( event ) { + if ( event.target.isSameNode( event.currentTarget ) ) { + if ( event.keyCode === ENTER || event.keyCode === SPACE ) { + event.preventDefault(); + this.props.onInvoke(); + } + if ( event.keyCode === UP ) { + event.preventDefault(); + } + if ( event.keyCode === DOWN ) { + event.preventDefault(); + const nextElementToFocus = + event.target.nextSibling || + event.target.parentNode.querySelector( + '.woocommerce-ellipsis-menu__item' + ); + nextElementToFocus.focus(); + } + } + } + + onFocusFormToggle() { + this.container.current.focus(); + } + + render() { + const { checked, children, isCheckbox } = this.props; + + if ( isCheckbox ) { + return ( +
    + + +
    + ); + } + + return ( +
    + { children } +
    + ); + } +} + +MenuItem.propTypes = { + /** + * Whether the menu item is checked or not. Only relevant for menu items with `isCheckbox`. + */ + checked: PropTypes.bool, + /** + * A renderable component (or string) which will be displayed as the content of this item. Generally a `ToggleControl`. + */ + children: PropTypes.node, + /** + * Whether the menu item is a checkbox (will render a FormToggle and use the `menuitemcheckbox` role). + */ + isCheckbox: PropTypes.bool, + /** + * Boolean to control whether the MenuItem should handle the click event. Defaults to false, assuming your child component + * handles the click event. + */ + isClickable: PropTypes.bool, + /** + * A function called when this item is activated via keyboard ENTER or SPACE; or when the item is clicked + * (only if `isClickable` is set). + */ + onInvoke: PropTypes.func.isRequired, +}; + +MenuItem.defaultProps = { + isClickable: false, + isCheckbox: false, +}; + +export default MenuItem; diff --git a/packages/js/components/src/ellipsis-menu/menu-title.js b/packages/js/components/src/ellipsis-menu/menu-title.js new file mode 100644 index 00000000000..135a97298e4 --- /dev/null +++ b/packages/js/components/src/ellipsis-menu/menu-title.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; + +/** + * `MenuTitle` is another valid Menu child, but this does not have any accessibility attributes associated + * (so this should not be used in place of the `EllipsisMenu` prop `label`). + * + * @param {Object} props + * @param {Node} props.children + * @return {Object} - + */ +const MenuTitle = ( { children } ) => { + return
    { children }
    ; +}; + +MenuTitle.propTypes = { + /** + * A renderable component (or string) which will be displayed as the content of this item. + */ + children: PropTypes.node, +}; + +export default MenuTitle; diff --git a/packages/js/components/src/ellipsis-menu/stories/index.js b/packages/js/components/src/ellipsis-menu/stories/index.js new file mode 100644 index 00000000000..fc355a37e9e --- /dev/null +++ b/packages/js/components/src/ellipsis-menu/stories/index.js @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import { Fragment, useState } from '@wordpress/element'; +import { Icon } from '@wordpress/icons'; +import CrossSmall from 'gridicons/dist/cross-small'; +import { EllipsisMenu, MenuItem, MenuTitle } from '@woocommerce/components'; + +const ExampleEllipsisMenu = () => { + const [ { showCustomers, showOrders }, setState ] = useState( { + showCustomers: true, + showOrders: true, + } ); + return ( + ( + + Display stats + + setState( { + showOrders, + showCustomers: ! showCustomers, + } ) + } + > + Show Customers + + + setState( { + showCustomers, + showOrders: ! showOrders, + } ) + } + > + Show Orders + + + } /> + Close Menu + + + ) } + /> + ); +}; + +export const Basic = () => ; + +export default { + title: 'WooCommerce Admin/components/EllipsisMenu', + component: EllipsisMenu, +}; diff --git a/packages/js/components/src/ellipsis-menu/style.scss b/packages/js/components/src/ellipsis-menu/style.scss new file mode 100644 index 00000000000..fb195392144 --- /dev/null +++ b/packages/js/components/src/ellipsis-menu/style.scss @@ -0,0 +1,74 @@ +.woocommerce-ellipsis-menu { + text-align: center; + + .woocommerce-ellipsis-menu__toggle { + justify-content: center; + vertical-align: middle; + width: 24px; + padding: 0; + + .gridicon { + transform: rotate(90deg); + } + } +} + +.woocommerce-ellipsis-menu__popover { + text-align: left; + + &:not(.is-mobile)::before, + &:not(.is-mobile)::after { + margin-left: -16px; + } + + .components-popover__content { + width: 182px; + padding: 2px; + } + + .woocommerce-ellipsis-menu__content { + width: 100%; + + .components-button { + justify-content: center; + width: 100%; + } + } + + .woocommerce-ellipsis-menu__title, + .woocommerce-ellipsis-menu__item { + padding: 4px 12px; + } + + .woocommerce-ellipsis-menu__item { + cursor: pointer; + color: $gray-700; + + &:focus { + box-shadow: inset 0 0 0 1px #6c7781, inset 0 0 0 2px $studio-white; + outline: 2px solid transparent; + outline-offset: -2px; + } + + .components-form-toggle { + margin-right: $gap-smaller; + } + } + + .components-base-control__label, + .woocommerce-ellipsis-menu__title { + color: $gray-900; + padding-top: $gap-smaller; + padding-bottom: $gap-smaller; + @include font-size( 15 ); + margin-bottom: $gap-smallest; + } + + .components-toggle-control .components-base-control__field { + margin: $gap-smallest 0; + } + + .components-base-control { + margin: 0; + } +} diff --git a/packages/js/components/src/ellipsis-menu/test/index.js b/packages/js/components/src/ellipsis-menu/test/index.js new file mode 100644 index 00000000000..7af535fafc0 --- /dev/null +++ b/packages/js/components/src/ellipsis-menu/test/index.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import EllipsisMenu from '../'; + +describe( 'EllipsisMenu', () => { + it( 'adds the passed in classname', () => { + const { container } = render( +
    content
    } + /> + ); + + const menu = container.querySelector( '.custom-classname' ); + expect( menu ).toBeInTheDocument(); + } ); + + it( 'should call onToggle when clicking on the ellipsis', () => { + const onClickMock = jest.fn(); + const { getByTitle } = render( +
    content
    } + /> + ); + + userEvent.click( getByTitle( 'foo' ) ); + expect( onClickMock ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should render content when clicking on the ellipsis', () => { + const { getByTitle, getByText } = render( +
    content
    } + /> + ); + + userEvent.click( getByTitle( 'foo' ) ); + expect( getByText( 'content' ) ).toBeInTheDocument(); + } ); +} ); diff --git a/packages/js/components/src/empty-content/README.md b/packages/js/components/src/empty-content/README.md new file mode 100644 index 00000000000..4e9d63495d5 --- /dev/null +++ b/packages/js/components/src/empty-content/README.md @@ -0,0 +1,33 @@ +EmptyContent +=== + +A component to be used when there is no data to show. +It can be used as an opportunity to provide explanation or guidance to help a user progress. + +## Usage + +```jsx + +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`title` | String | `null` | (required) The title to be displayed +`message` | String | `null` | An additional message to be displayed +`illustration` | String | `'/empty-content.svg'` | The url string of an image path. Prefix with `/` to load an image relative to the plugin directory +`illustrationHeight` | Number | `null` | Height to use for the illustration +`illustrationWidth` | Number | `400` | Width to use for the illustration +`actionLabel` | String | `null` | (required) Label to be used for the primary action button +`actionURL` | String | `null` | URL to be used for the primary action button +`actionCallback` | Function | `null` | Callback to be used for the primary action button +`secondaryActionLabel` | String | `null` | Label to be used for the secondary action button +`secondaryActionURL` | String | `null` | URL to be used for the secondary action button +`secondaryActionCallback` | Function | `null` | Callback to be used for the secondary action button +`className` | String | `null` | Additional CSS classes diff --git a/packages/js/components/src/empty-content/index.js b/packages/js/components/src/empty-content/index.js new file mode 100644 index 00000000000..1d0dbb8cc7e --- /dev/null +++ b/packages/js/components/src/empty-content/index.js @@ -0,0 +1,184 @@ +/** + * External dependencies + */ +import { Button } from '@wordpress/components'; +import { createElement, Component } from '@wordpress/element'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import { H } from '../section'; + +/** + * A component to be used when there is no data to show. + * It can be used as an opportunity to provide explanation or guidance to help a user progress. + */ +class EmptyContent extends Component { + renderIllustration() { + const { + illustrationWidth, + illustrationHeight, + illustration, + } = this.props; + return ( + + ); + } + + renderActionButtons( type ) { + const actionLabel = + type === 'secondary' + ? this.props.secondaryActionLabel + : this.props.actionLabel; + const actionURL = + type === 'secondary' + ? this.props.secondaryActionURL + : this.props.actionURL; + const actionCallback = + type === 'secondary' + ? this.props.secondaryActionCallback + : this.props.actionCallback; + + const isPrimary = type === 'secondary' ? false : true; + + if ( actionURL && actionCallback ) { + return ( + + ); + } else if ( actionURL ) { + return ( + + ); + } else if ( actionCallback ) { + return ( + + ); + } + + return null; + } + + renderActions() { + const { actionLabel, secondaryActionLabel } = this.props; + return ( +
    + { actionLabel && this.renderActionButtons( 'primary' ) } + { secondaryActionLabel && + this.renderActionButtons( 'secondary' ) } +
    + ); + } + + render() { + const { className, title, message, illustration } = this.props; + return ( +
    + { illustration && this.renderIllustration() } + { title ? ( + + { title } + + ) : null } + { message ? ( +

    + { message } +

    + ) : null } + + { this.renderActions() } +
    + ); + } +} + +EmptyContent.propTypes = { + /** + * The title to be displayed. + */ + title: PropTypes.string.isRequired, + /** + * An additional message to be displayed. + */ + message: PropTypes.node, + /** + * The url string of an image path for img src. + */ + illustration: PropTypes.string, + /** + * Height to use for the illustration. + */ + illustrationHeight: PropTypes.number, + /** + * Width to use for the illustration. + */ + illustrationWidth: PropTypes.number, + /** + * Label to be used for the primary action button. + */ + actionLabel: PropTypes.string.isRequired, + /** + * URL to be used for the primary action button. + */ + actionURL: PropTypes.string, + /** + * Callback to be used for the primary action button. + */ + actionCallback: PropTypes.func, + /** + * Label to be used for the secondary action button. + */ + secondaryActionLabel: PropTypes.string, + /** + * URL to be used for the secondary action button. + */ + secondaryActionURL: PropTypes.string, + /** + * Callback to be used for the secondary action button. + */ + secondaryActionCallback: PropTypes.func, + /** + * Additional CSS classes. + */ + className: PropTypes.string, +}; + +EmptyContent.defaultProps = { + // eslint-disable-next-line max-len + illustration: + 'data:image/svg+xml;utf8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"%3E%3Cpath d="M226.153073,88.3099993 L355.380187,301.446227 C363.970299,315.614028 359.448689,334.062961 345.280888,342.653073 C340.591108,345.496544 335.21158,347 329.727115,347 L71.2728854,347 C54.7043429,347 41.2728854,333.568542 41.2728854,317 C41.2728854,311.515534 42.7763415,306.136007 45.6198127,301.446227 L174.846927,88.3099993 C183.437039,74.1421985 201.885972,69.6205881 216.053773,78.2106999 C220.184157,80.7150022 223.64877,84.1796157 226.153073,88.3099993 Z M184.370159,153 L186.899684,255.024156 L213.459691,255.024156 L215.989216,153 L184.370159,153 Z M200.179688,307.722584 C209.770801,307.722584 217.359375,300.450201 217.359375,291.175278 C217.359375,281.900355 209.770801,274.627972 200.179688,274.627972 C190.588574,274.627972 183,281.900355 183,291.175278 C183,300.450201 190.588574,307.722584 200.179688,307.722584 Z" id="Combined-Shape" stroke="%23979797" fill="%2395588A" fill-rule="nonzero"%3E%3C/path%3E%3C/svg%3E', + illustrationWidth: 400, +}; + +export default EmptyContent; diff --git a/packages/js/components/src/empty-content/stories/index.js b/packages/js/components/src/empty-content/stories/index.js new file mode 100644 index 00000000000..5b83383ce48 --- /dev/null +++ b/packages/js/components/src/empty-content/stories/index.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { EmptyContent } from '@woocommerce/components'; + +export const Basic = () => ( + +); + +export default { + title: 'WooCommerce Admin/components/EmptyContent', + component: EmptyContent, +}; diff --git a/packages/js/components/src/empty-content/style.scss b/packages/js/components/src/empty-content/style.scss new file mode 100644 index 00000000000..38981b1f057 --- /dev/null +++ b/packages/js/components/src/empty-content/style.scss @@ -0,0 +1,16 @@ + + +.woocommerce-empty-content { + margin-bottom: $gap; + text-align: center; + + .woocommerce-empty-content__illustration { + max-width: 100%; + } + + .woocommerce-empty-content__actions { + .components-button + .components-button { + margin-left: $gap; + } + } +} diff --git a/packages/js/components/src/experimental.js b/packages/js/components/src/experimental.js new file mode 100644 index 00000000000..f7d25b4cb34 --- /dev/null +++ b/packages/js/components/src/experimental.js @@ -0,0 +1,13 @@ +/** + * External dependencies + */ +import { + __experimentalText, + Text as TextComponent, +} from '@wordpress/components'; + +/** + * Export experimental components within the components package to prevent a circular + * dependency with woocommerce/experimental. Only for internal use. + */ +export const Text = TextComponent || __experimentalText; diff --git a/packages/js/components/src/filter-picker/README.md b/packages/js/components/src/filter-picker/README.md new file mode 100644 index 00000000000..6e29b59e69c --- /dev/null +++ b/packages/js/components/src/filter-picker/README.md @@ -0,0 +1,82 @@ +Filter Picker +=== + +Modify a url query parameter via a dropdown selection of configurable options. This component manipulates the `filter` query parameter. + +## Usage + +```jsx +import { FilterPicker } from '@woocommerce/components'; + +const renderFilterPicker = () => { + const config = { + label: 'Meal', + staticParams: [], + param: 'meal', + showFilters: function showFilters() { + return true; + }, + filters: [ + { label: 'Breakfast', value: 'breakfast' }, + { + label: 'Lunch', + value: 'lunch', + subFilters: [ + { label: 'Meat', value: 'meat', path: [ 'lunch' ] }, + { label: 'Vegan', value: 'vegan', path: [ 'lunch' ] }, + { + label: 'Pescatarian', + value: 'fish', + path: [ 'lunch' ], + subFilters: [ + { label: 'Snapper', value: 'snapper', path: [ 'lunch', 'fish' ] }, + { label: 'Cod', value: 'cod', path: [ 'lunch', 'fish' ] }, + // Specify a custom component to render (Work in Progress) + { + label: 'Other', + value: 'other_fish', + path: [ 'lunch', 'fish' ], + component: 'OtherFish' + }, + ], + }, + ], + }, + { label: 'Dinner', value: 'dinner' }, + ], + }; + + return ; +}; +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`config` | Object | `null` | (required) An array of filters and subFilters to construct the menu +`path` | String | `null` | (required) The `path` parameter supplied by React-Router +`query` | Object | `{}` | The query string represented in object form +`onFilterSelect` | Function | `() => {}` | Function to be called after filter selection + +### `config` structure + +The `config` prop has the following structure: + +- `label`: String - A label above the filter selector. +- `staticParams`: Array - Url parameters to persist when selecting a new filter. +- `param`: String - The url paramter this filter will modify. +- `defaultValue`: String - The default paramter value to use instead of 'all'. +- `showFilters`: Function - Determine if the filter should be shown. Supply a function with the query object as an argument returning a boolean. +- `filters`: Array - Array of filter objects. + +### `filters` structure + +The `filters` prop is an array of filter objects. Each filter object should have the following format: + +- `chartMode`: One of: 'item-comparison', 'time-comparison' +- `component`: String - A custom component used instead of a button, might have special handling for filtering. TBD, not yet implemented. +- `label`: String - The label for this filter. Optional only for custom component filters. +- `path`: String - An array representing the "path" to this filter, if nested. +- `subFilters`: Array - An array of more filter objects that act as "children" to this item. This set of filters is shown if the parent filter is clicked. +- `value`: String - The value for this filter, used to set the `filter` query param when clicked, if there are no `subFilters`. diff --git a/packages/js/components/src/filter-picker/index.js b/packages/js/components/src/filter-picker/index.js new file mode 100644 index 00000000000..29d86d8a2bd --- /dev/null +++ b/packages/js/components/src/filter-picker/index.js @@ -0,0 +1,454 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, Dropdown } from '@wordpress/components'; +import { focus } from '@wordpress/dom'; +import classnames from 'classnames'; +import { createElement, Component } from '@wordpress/element'; +import { find, partial, last, get, includes } from 'lodash'; +import PropTypes from 'prop-types'; +import { Icon, chevronLeft } from '@wordpress/icons'; +import { + flattenFilters, + updateQueryString, + getQueryFromActiveFilters, +} from '@woocommerce/navigation'; + +/** + * Internal dependencies + */ +import AnimationSlider from '../animation-slider'; +import DropdownButton from '../dropdown-button'; +import Search from '../search'; + +export const DEFAULT_FILTER = 'all'; + +/** + * Modify a url query parameter via a dropdown selection of configurable options. + * This component manipulates the `filter` query parameter. + */ +class FilterPicker extends Component { + constructor( props ) { + super( props ); + + const selectedFilter = this.getFilter(); + this.state = { + nav: selectedFilter.path || [], + animate: null, + selectedTag: null, + }; + + this.selectSubFilter = this.selectSubFilter.bind( this ); + this.getVisibleFilters = this.getVisibleFilters.bind( this ); + this.updateSelectedTag = this.updateSelectedTag.bind( this ); + this.onTagChange = this.onTagChange.bind( this ); + this.onContentMount = this.onContentMount.bind( this ); + this.goBack = this.goBack.bind( this ); + + if ( selectedFilter.settings && selectedFilter.settings.getLabels ) { + const { query } = this.props; + const { param: filterParam, getLabels } = selectedFilter.settings; + getLabels( query[ filterParam ], query ).then( + this.updateSelectedTag + ); + } + } + + componentDidUpdate( { query: prevQuery } ) { + const { query: nextQuery, config } = this.props; + if ( prevQuery[ config.param ] !== nextQuery[ [ config.param ] ] ) { + const selectedFilter = this.getFilter(); + if ( selectedFilter && selectedFilter.component === 'Search' ) { + /* eslint-disable react/no-did-update-set-state */ + this.setState( { nav: selectedFilter.path || [] } ); + /* eslint-enable react/no-did-update-set-state */ + const { + param: filterParam, + getLabels, + } = selectedFilter.settings; + getLabels( nextQuery[ filterParam ], nextQuery ).then( + this.updateSelectedTag + ); + } + } + } + + updateSelectedTag( tags ) { + this.setState( { selectedTag: tags[ 0 ] } ); + } + + getFilter( value ) { + const { config, query } = this.props; + const allFilters = flattenFilters( config.filters ); + value = + value || + query[ config.param ] || + config.defaultValue || + DEFAULT_FILTER; + return find( allFilters, { value } ) || {}; + } + + getButtonLabel( selectedFilter ) { + if ( selectedFilter.component === 'Search' ) { + const { selectedTag } = this.state; + return [ + selectedTag && selectedTag.label, + get( selectedFilter, 'settings.labels.button' ), + ]; + } + return selectedFilter ? [ selectedFilter.label ] : []; + } + + getVisibleFilters( filters, nav ) { + if ( nav.length === 0 ) { + return filters; + } + const value = nav[ 0 ]; + const nextFilters = find( filters, { value } ); + return this.getVisibleFilters( + nextFilters && nextFilters.subFilters, + nav.slice( 1 ) + ); + } + + selectSubFilter( value ) { + // Add the value onto the nav path + this.setState( ( prevState ) => ( { + nav: [ ...prevState.nav, value ], + animate: 'left', + } ) ); + } + + goBack() { + // Remove the last item from the nav path + this.setState( ( prevState ) => ( { + nav: prevState.nav.slice( 0, -1 ), + animate: 'right', + } ) ); + } + + getAllFilterParams() { + const { config } = this.props; + const params = []; + const getParam = ( filters ) => { + filters.forEach( ( filter ) => { + if ( + filter.settings && + ! params.includes( filter.settings.param ) + ) { + params.push( filter.settings.param ); + } + if ( filter.subFilters ) { + getParam( filter.subFilters ); + } + } ); + }; + getParam( config.filters ); + return params; + } + + update( value, additionalQueries = {} ) { + const { + path, + query, + config, + onFilterSelect, + advancedFilters, + } = this.props; + let update = { + [ config.param ]: + ( config.defaultValue || DEFAULT_FILTER ) === value + ? undefined + : value, + ...additionalQueries, + }; + // Keep any url parameters as designated by the config + config.staticParams.forEach( ( param ) => { + update[ param ] = query[ param ]; + } ); + + // Remove all of this filter's params not associated witth the update while + // leaving any other params from any other filter an extension may have added. + this.getAllFilterParams().forEach( ( param ) => { + if ( ! update[ param ] ) { + // Explicitly give value of undefined so it can be removed from the query. + update[ param ] = undefined; + } + } ); + + // If the main filter is being set to anything but advanced, remove any advancedFilters. + if ( config.param === 'filter' && value !== 'advanced' ) { + const resetAdvancedFilters = getQueryFromActiveFilters( + [], + query, + advancedFilters.filters || {} + ); + + update = { + ...update, + ...resetAdvancedFilters, + }; + } + updateQueryString( update, path, query ); + onFilterSelect( update ); + } + + onTagChange( filter, onClose, config, tags ) { + const tag = last( tags ); + const { value, settings } = filter; + const { param: filterParam } = settings; + if ( tag ) { + this.update( value, { [ filterParam ]: tag.key } ); + onClose(); + } else { + this.update( config.defaultValue || DEFAULT_FILTER ); + } + this.updateSelectedTag( [ tag ] ); + } + + renderButton( filter, onClose, config ) { + if ( filter.component ) { + const { type, labels, autocompleter } = filter.settings; + const persistedFilter = this.getFilter(); + const selectedTag = + persistedFilter.value === filter.value + ? this.state.selectedTag + : null; + + return ( + + ); + } + + const selectFilter = ( event ) => { + onClose( event ); + this.update( filter.value, filter.query || {} ); + this.setState( { selectedTag: null } ); + }; + + const selectSubFilter = partial( this.selectSubFilter, filter.value ); + const selectedFilter = this.getFilter(); + const buttonIsSelected = + selectedFilter.value === filter.value || + ( selectedFilter.path && + includes( selectedFilter.path, filter.value ) ); + const onClick = ( event ) => { + if ( buttonIsSelected ) { + // Don't navigate if the button is already selected. + onClose( event ); + return; + } + + if ( filter.subFilters ) { + selectSubFilter( event ); + return; + } + + selectFilter( event ); + }; + + return ( + + ); + } + + onContentMount( content ) { + const { nav } = this.state; + const parentFilter = nav.length + ? this.getFilter( nav[ nav.length - 1 ] ) + : false; + const focusableIndex = parentFilter ? 1 : 0; + const focusable = focus.tabbable.find( content )[ focusableIndex ]; + setTimeout( () => { + focusable.focus(); + }, 0 ); + } + + render() { + const { config } = this.props; + const { nav, animate } = this.state; + const visibleFilters = this.getVisibleFilters( config.filters, nav ); + const parentFilter = nav.length + ? this.getFilter( nav[ nav.length - 1 ] ) + : false; + const selectedFilter = this.getFilter(); + return ( +
    + { config.label && ( + + { config.label }: + + ) } + ( + + ) } + renderContent={ ( { onClose } ) => ( + + { () => ( +
      + { parentFilter && ( +
    • + +
    • + ) } + { visibleFilters.map( ( filter ) => ( +
    • + { this.renderButton( + filter, + onClose, + config + ) } +
    • + ) ) } +
    + ) } +
    + ) } + /> +
    + ); + } +} + +FilterPicker.propTypes = { + /** + * An array of filters and subFilters to construct the menu. + */ + config: PropTypes.shape( { + /** + * A label above the filter selector. + */ + label: PropTypes.string, + /** + * Url parameters to persist when selecting a new filter. + */ + staticParams: PropTypes.array.isRequired, + /** + * The url paramter this filter will modify. + */ + param: PropTypes.string.isRequired, + /** + * The default paramter value to use instead of 'all'. + */ + defaultValue: PropTypes.string, + /** + * Determine if the filter should be shown. Supply a function with the query object as an argument returning a boolean. + */ + showFilters: PropTypes.func.isRequired, + /** + * An array of filter a user can select. + */ + filters: PropTypes.arrayOf( + PropTypes.shape( { + /** + * The chart display mode to use for charts displayed when this filter is active. + */ + chartMode: PropTypes.oneOf( [ + 'item-comparison', + 'time-comparison', + ] ), + /** + * A custom component used instead of a button, might have special handling for filtering. TBD, not yet implemented. + */ + component: PropTypes.string, + /** + * The label for this filter. Optional only for custom component filters. + */ + label: PropTypes.string, + /** + * An array representing the "path" to this filter, if nested. + */ + path: PropTypes.string, + /** + * An array of more filter objects that act as "children" to this item. + * This set of filters is shown if the parent filter is clicked. + */ + subFilters: PropTypes.array, + /** + * The value for this filter, used to set the `filter` query param when clicked, if there are no `subFilters`. + */ + value: PropTypes.string.isRequired, + } ) + ), + } ).isRequired, + /** + * The `path` parameter supplied by React-Router. + */ + path: PropTypes.string.isRequired, + /** + * The query string represented in object form. + */ + query: PropTypes.object, + /** + * Function to be called after filter selection. + */ + onFilterSelect: PropTypes.func, + /** + * Advanced Filters configuration object. + */ + advancedFilters: PropTypes.object, +}; + +FilterPicker.defaultProps = { + query: {}, + onFilterSelect: () => {}, +}; + +export default FilterPicker; diff --git a/packages/js/components/src/filter-picker/stories/index.js b/packages/js/components/src/filter-picker/stories/index.js new file mode 100644 index 00000000000..9ea2d877d15 --- /dev/null +++ b/packages/js/components/src/filter-picker/stories/index.js @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import FilterPicker from '../'; + +const query = { + meal: 'breakfast', +}; +const config = { + label: 'Meal', + staticParams: [], + param: 'meal', + showFilters: () => true, + filters: [ + { label: 'Breakfast', value: 'breakfast' }, + { + label: 'Lunch', + value: 'lunch', + subFilters: [ + { label: 'Meat', value: 'meat', path: [ 'lunch' ] }, + { label: 'Vegan', value: 'vegan', path: [ 'lunch' ] }, + { + label: 'Pescatarian', + value: 'fish', + path: [ 'lunch' ], + subFilters: [ + { + label: 'Snapper', + value: 'snapper', + path: [ 'lunch', 'fish' ], + }, + { + label: 'Cod', + value: 'cod', + path: [ 'lunch', 'fish' ], + }, + // Specify a custom component to render (Work in Progress) + { + label: 'Other', + value: 'other_fish', + path: [ 'lunch', 'fish' ], + component: 'OtherFish', + }, + ], + }, + ], + }, + { label: 'Dinner', value: 'dinner' }, + ], +}; + +export const Basic = ( { + path = new URL( document.location ).searchParams.get( 'path' ), +} ) => { + return ; +}; + +export default { + title: 'WooCommerce Admin/components/FilterPicker', + component: FilterPicker, +}; diff --git a/packages/js/components/src/filter-picker/style.scss b/packages/js/components/src/filter-picker/style.scss new file mode 100644 index 00000000000..1b34bd36205 --- /dev/null +++ b/packages/js/components/src/filter-picker/style.scss @@ -0,0 +1,80 @@ +.woocommerce-filters-filter__content { + &.is-mobile { + .components-popover__header-title { + @include font-size( 12 ); + font-weight: 100; + text-transform: uppercase; + text-align: center; + color: $gray-700; + } + + .woocommerce-filters-filter__content-list-item:last-child { + border-bottom: 1px solid $gray-400; + } + } +} + +.woocommerce-filters-filter__content-list { + margin: 0; + width: 100%; + min-width: 100%; +} + +.woocommerce-filters-filter__content-list-item { + border: 1px solid transparent; + border-bottom: 1px solid $gray-400; + margin: 0; + + &:last-child { + border-bottom: 1px solid transparent; + } + + &.is-selected { + .woocommerce-filters-filter__button { + background-color: $studio-white; + + &.components-button:not(:disabled):not([aria-disabled='true']):focus { + background-color: $studio-white; + } + + &::before { + content: ''; + width: 8px; + height: 8px; + background-color: $studio-woocommerce-purple; + position: absolute; + top: 50%; + left: 1em; + transform: translate(50%, -50%); + } + } + } + + .woocommerce-filters-filter__button { + position: relative; + align-items: center; + display: flex; + width: 100%; + padding: 1em 1em 1em 3em; + background-color: $gray-100; + text-align: left; + + &.components-button { + color: $gray-700; + } + + &:hover { + background-color: $gray-200; + color: $gray-700; + } + + &.components-button:not(:disabled):not([aria-disabled='true']):focus { + background-color: $gray-100; + } + + svg { + position: absolute; + left: 1em; + } + } +} diff --git a/packages/js/components/src/filter-picker/test/index.js b/packages/js/components/src/filter-picker/test/index.js new file mode 100644 index 00000000000..62d024f199f --- /dev/null +++ b/packages/js/components/src/filter-picker/test/index.js @@ -0,0 +1,153 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Basic } from '../stories/index'; +import FilterPicker from '../index'; +import Search from '../../search'; +import productAutocompleter from '../../search/autocompleters/product'; +// Due to Jest implementation we cannot mock it only for specific tests. +// If your test requires non-mocked Search, move them to another test file. +jest.mock( '../../search' ); + +describe( 'FilterPicker', () => { + it( 'should render the example from the storybook', async () => { + // Jest and its JSDOM does not allow making extensive use of searchParams used by Basic example. + const path = '/story/woocommerce-admin-components-filterpicker--basic'; + + expect( function () { + render( ); + } ).not.toThrow(); + } ); + describe( "when a config is given with a filter with `component: 'Search'`", () => { + let config; + beforeEach( () => { + config = { + label: 'Show', + staticParams: [], + param: 'product_filter', + showFilters: () => true, + filters: [ + { label: 'All Products', value: 'all' }, + { + component: 'Search', + value: 'select_product', + chartMode: 'item-comparison', + path: 'select_product', + settings: { + type: 'products', + param: 'products', + labels: { + placeholder: 'Type to search for a product', + button: 'Single Product', + }, + }, + }, + ], + }; + } ); + + it( 'should render the Search component', async () => { + const path = '/foo/bar'; + + const { queryAllByRole } = render( + + ); + + // Emulate filter dropdown being opened. + // The main dropdown does not have its role defined, so we need to dig deeper into actual internals. + userEvent.click( queryAllByRole( 'button' )[ 0 ] ); + + // Check that the given component was rendered, without checking its behavior/internals/implementation details. + // + // In vanilla HTML, we would check + // expect( filterPicker.querySelector('woo-search') ).to.be.not.null(); + // expect( filterPicker.querySelector('woo-search') ).to.be.an.instanceof( Search ); + // + // Following will check if it was rendered, not neceserily being visible now. + expect( Search ).toHaveBeenCalled(); + } ); + it( "for a `'custom'` type should forward autocompleter config the Search component", async () => { + const path = '/foo/bar'; + + const customFilterSettings = config.filters[ 1 ].settings; + customFilterSettings.type = 'custom'; + customFilterSettings.autocompleter = productAutocompleter; + + const { queryAllByRole } = render( + + ); + + // Emulate filter dropdown being opened. + // The main dropdown does not have its role defined, so we need to dig deeper into actual internals. + userEvent.click( queryAllByRole( 'button' )[ 0 ] ); + + // Check that the given component was rendered, without checking its behavior/internals/implementation details. + // + // In vanilla HTML, we would check + // expect( filterPicker.querySelector('woo-search') ).to.have.a.property( 'autocompleter', autocompleter ); + // + // Following will check if it was rendered with given props, not neceserily being visible now. + const lastCallArgs = Search.mock.calls.slice( -1 )[ 0 ]; + expect( lastCallArgs[ 0 ] ).toHaveProperty( + 'autocompleter', + productAutocompleter + ); + } ); + } ); + describe( 'getAllFilterParams', () => { + const query = { product_filter: 'select_product' }; + const config = { + label: 'Show', + staticParams: [], + param: 'product_filter', + showFilters: () => true, + filters: [ + { + label: 'Single Product', + value: 'select_product', + chartMode: 'item-comparison', + subFilters: [ + { + component: 'Search', + value: 'single_product', + chartMode: 'item-comparison', + path: [ 'select_product' ], + settings: { + type: 'products', + param: 'param_1', + getLabels: () => {}, + }, + }, + ], + }, + { + label: 'Comparison', + value: 'compare-products', + chartMode: 'item-comparison', + settings: { + type: 'products', + param: 'param_2', + getLabels: () => {}, + onClick: () => {}, + }, + }, + ], + }; + + it( 'should return an array', () => { + const filterPicker = new FilterPicker( { config, query } ); + const allParams = filterPicker.getAllFilterParams(); + + expect( allParams ).toHaveLength( 2 ); + expect( allParams.includes( 'param_1' ) ).toBeTruthy(); + expect( allParams.includes( 'param_2' ) ).toBeTruthy(); + } ); + } ); +} ); diff --git a/packages/js/components/src/filters/README.md b/packages/js/components/src/filters/README.md new file mode 100644 index 00000000000..db2645535eb --- /dev/null +++ b/packages/js/components/src/filters/README.md @@ -0,0 +1,21 @@ +ReportFilters +=== + +Add a collection of report filters to a page. This uses `DatePicker` & `FilterPicker` for the "basic" filters, and `AdvancedFilters` +or a comparison card if "advanced" or "compare" are picked from `FilterPicker`. + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`advancedFilters` | Object | `{}` | Config option passed through to `AdvancedFilters` +`siteLocale` | string| `en_US` | The locale of the site. Passed through to `AdvancedFilters` +`currency` | object | {} | The currency of the site. Passed through to `AdvancedFilters` +`filters` | Array | `[]` | Config option passed through to `FilterPicker` - if not used, `FilterPicker` is not displayed +`path` | String | `null` | (required) The `path` parameter supplied by React-Router +`query` | Object | `{}` | The query string represented in object form +`showDatePicker` | Boolean | `true` | Whether the date picker must be shown +`onDateSelect` | Function | `() => {}` | Function to be called after date selection +`onFilterSelect` | Function | `null` | Function to be called after filter selection +`onAdvancedFilterAction` | Function | `null` | Function to be called after an advanced filter action has been taken +`storeDate` | object | `null` | (required) Date utility function object bound to store settings. diff --git a/packages/js/components/src/filters/index.js b/packages/js/components/src/filters/index.js new file mode 100644 index 00000000000..b6c1edf4f7d --- /dev/null +++ b/packages/js/components/src/filters/index.js @@ -0,0 +1,238 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createElement, Component, Fragment } from '@wordpress/element'; +import { find } from 'lodash'; +import PropTypes from 'prop-types'; +import { updateQueryString } from '@woocommerce/navigation'; +import { getDateParamsFromQuery, getCurrentDates } from '@woocommerce/date'; +import CurrencyFactory from '@woocommerce/currency'; + +/** + * Internal dependencies + */ +import AdvancedFilters from '../advanced-filters'; +import { CompareFilter } from '../compare-filter'; +import DateRangeFilterPicker from '../date-range-filter-picker'; +import FilterPicker from '../filter-picker'; +import { H, Section } from '../section'; + +/** + * Add a collection of report filters to a page. This uses `DatePicker` & `FilterPicker` for the "basic" filters, and `AdvancedFilters` + * or a comparison card if "advanced" or "compare" are picked from `FilterPicker`. + * + * @return {Object} - + */ +class ReportFilters extends Component { + constructor() { + super(); + this.renderCard = this.renderCard.bind( this ); + this.onRangeSelect = this.onRangeSelect.bind( this ); + } + + renderCard( config ) { + const { + siteLocale, + advancedFilters, + query, + path, + onAdvancedFilterAction, + currency, + } = this.props; + const { filters, param } = config; + if ( ! query[ param ] ) { + return null; + } + + if ( query[ param ].indexOf( 'compare' ) === 0 ) { + const filter = find( filters, { value: query[ param ] } ); + if ( ! filter ) { + return null; + } + const { settings = {} } = filter; + return ( +
    + +
    + ); + } + if ( query[ param ] === 'advanced' ) { + return ( +
    + +
    + ); + } + } + + onRangeSelect( data ) { + const { query, path, onDateSelect } = this.props; + updateQueryString( data, path, query ); + onDateSelect( data ); + } + + getDateQuery( query ) { + const { period, compare, before, after } = getDateParamsFromQuery( + query + ); + const { + primary: primaryDate, + secondary: secondaryDate, + } = getCurrentDates( query ); + return { + period, + compare, + before, + after, + primaryDate, + secondaryDate, + }; + } + + render() { + const { + dateQuery, + filters, + query, + path, + showDatePicker, + onFilterSelect, + isoDateFormat, + advancedFilters, + } = this.props; + return ( + + + { __( 'Filters', 'woocommerce' ) } + +
    +
    + { showDatePicker && ( + + ) } + { filters.map( ( config ) => { + if ( config.showFilters( query ) ) { + return ( + + ); + } + return null; + } ) } +
    + { filters.map( this.renderCard ) } +
    +
    + ); + } +} + +ReportFilters.propTypes = { + /** + * The locale of the site (passed through to `AdvancedFilters`) + */ + siteLocale: PropTypes.string, + /** + * Config option passed through to `AdvancedFilters` + */ + advancedFilters: PropTypes.object, + /** + * Config option passed through to `FilterPicker` - if not used, `FilterPicker` is not displayed. + */ + filters: PropTypes.array, + /** + * The `path` parameter supplied by React-Router + */ + path: PropTypes.string.isRequired, + /** + * The query string represented in object form + */ + query: PropTypes.object, + /** + * Whether the date picker must be shown. + */ + showDatePicker: PropTypes.bool, + /** + * Function to be called after date selection. + */ + onDateSelect: PropTypes.func, + /** + * Function to be called after filter selection. + */ + onFilterSelect: PropTypes.func, + /** + * Function to be called after an advanced filter action has been taken. + */ + onAdvancedFilterAction: PropTypes.func, + /** + * The currency formatting instance for the site. + */ + currency: PropTypes.object, + /** + * The date query string represented in object form. + */ + dateQuery: PropTypes.shape( { + period: PropTypes.string.isRequired, + compare: PropTypes.string.isRequired, + before: PropTypes.object, + after: PropTypes.object, + primaryDate: PropTypes.shape( { + label: PropTypes.string.isRequired, + range: PropTypes.string.isRequired, + } ).isRequired, + secondaryDate: PropTypes.shape( { + label: PropTypes.string.isRequired, + range: PropTypes.string.isRequired, + } ), + } ), + /** + * ISO date format string. + */ + isoDateFormat: PropTypes.string, +}; + +ReportFilters.defaultProps = { + siteLocale: 'en_US', + advancedFilters: { + title: '', + filters: {}, + }, + filters: [], + query: {}, + showDatePicker: true, + onDateSelect: () => {}, + currency: CurrencyFactory().getCurrencyConfig(), +}; + +export default ReportFilters; diff --git a/packages/js/components/src/filters/stories/index.js b/packages/js/components/src/filters/stories/index.js new file mode 100644 index 00000000000..30d5fc9d021 --- /dev/null +++ b/packages/js/components/src/filters/stories/index.js @@ -0,0 +1,241 @@ +/** + * External dependencies + */ +import { + AdvancedFilters, + CompareFilter, + H, + ReportFilters, + Section, +} from '@woocommerce/components'; +import { + getDateParamsFromQuery, + getCurrentDates, + isoDateFormat, +} from '@woocommerce/date'; +import { partialRight } from 'lodash'; + +const ORDER_STATUSES = { + cancelled: 'Cancelled', + completed: 'Completed', + failed: 'Failed', + 'on-hold': 'On hold', + pending: 'Pending payment', + processing: 'Processing', + refunded: 'Refunded', +}; + +// Fetch store default date range and compose with date utility functions. +const defaultDateRange = 'period=month&compare=previous_year'; +const storeGetDateParamsFromQuery = partialRight( + getDateParamsFromQuery, + defaultDateRange +); +const storeGetCurrentDates = partialRight( getCurrentDates, defaultDateRange ); + +// Package date utilities for filter picker component. +const storeDate = { + getDateParamsFromQuery: storeGetDateParamsFromQuery, + getCurrentDates: storeGetCurrentDates, + isoDateFormat, +}; + +const siteLocale = 'en_US'; + +const path = ''; +const query = {}; +const filters = [ + { + label: 'Show', + staticParams: [ 'chart' ], + param: 'filter', + showFilters: () => true, + filters: [ + { label: 'All orders', value: 'all' }, + { label: 'Advanced filters', value: 'advanced' }, + ], + }, +]; + +const advancedFilters = { + title: 'Orders Match {{select /}} Filters', + filters: { + status: { + labels: { + add: 'Order Status', + remove: 'Remove order status filter', + rule: 'Select an order status filter match', + title: 'Order Status {{rule /}} {{filter /}}', + filter: 'Select an order status', + }, + rules: [ + { + value: 'is', + label: 'Is', + }, + { + value: 'is_not', + label: 'Is Not', + }, + ], + input: { + component: 'SelectControl', + options: Object.keys( ORDER_STATUSES ).map( ( key ) => ( { + value: key, + label: ORDER_STATUSES[ key ], + } ) ), + }, + }, + product: { + labels: { + add: 'Products', + placeholder: 'Search products', + remove: 'Remove products filter', + rule: 'Select a product filter match', + title: 'Product {{rule /}} {{filter /}}', + filter: 'Select products', + }, + rules: [ + { + value: 'includes', + label: 'Includes', + }, + { + value: 'excludes', + label: 'Excludes', + }, + ], + input: { + component: 'Search', + type: 'products', + getLabels: () => Promise.resolve( [] ), + }, + }, + customer: { + labels: { + add: 'Customer type', + remove: 'Remove customer filter', + rule: 'Select a customer filter match', + title: 'Customer is {{filter /}}', + filter: 'Select a customer type', + }, + input: { + component: 'SelectControl', + options: [ + { value: 'new', label: 'New' }, + { value: 'returning', label: 'Returning' }, + ], + defaultOption: 'new', + }, + }, + quantity: { + labels: { + add: 'Item Quantity', + remove: 'Remove item quantity filter', + rule: 'Select an item quantity filter match', + title: 'Item Quantity is {{rule /}} {{filter /}}', + }, + rules: [ + { + value: 'lessthan', + label: 'Less Than', + }, + { + value: 'morethan', + label: 'More Than', + }, + { + value: 'between', + label: 'Between', + }, + ], + input: { + component: 'Number', + }, + }, + subtotal: { + labels: { + add: 'Subtotal', + remove: 'Remove subtotal filter', + rule: 'Select a subtotal filter match', + title: 'Subtotal is {{rule /}} {{filter /}}', + }, + rules: [ + { + value: 'lessthan', + label: 'Less Than', + }, + { + value: 'morethan', + label: 'More Than', + }, + { + value: 'between', + label: 'Between', + }, + ], + input: { + component: 'Number', + type: 'currency', + }, + }, + }, +}; + +const compareFilter = { + type: 'products', + param: 'product', + getLabels() { + return Promise.resolve( [] ); + }, + labels: { + helpText: 'Select at least two products to compare', + placeholder: 'Search for products to compare', + title: 'Compare Products', + update: 'Compare', + }, +}; + +export const Examples = () => ( +
    + Date picker only +
    + +
    + + Date picker & more filters +
    + +
    + + Advanced filters +
    + +
    + + Compare Filter +
    + +
    +
    +); + +export default { + title: 'WooCommerce Admin/components/ReportFilters', + component: ReportFilters, +}; diff --git a/packages/js/components/src/filters/style.scss b/packages/js/components/src/filters/style.scss new file mode 100644 index 00000000000..1c648799d76 --- /dev/null +++ b/packages/js/components/src/filters/style.scss @@ -0,0 +1,115 @@ +.woocommerce-filters { + .components-base-control__field { + margin-bottom: 0; + } + + @include breakpoint( '<400px' ) { + margin-left: -8px; + margin-right: -8px; + } +} + +.woocommerce-filters__basic-filters { + display: flex; + margin-bottom: $gap-large; + + @include breakpoint( '<1280px' ) { + flex-direction: column; + } + + @include breakpoint( '<782px' ) { + margin-bottom: $gap; + } +} + +.woocommerce-filters__advanced-filters { + .components-card__body { + background-color: $gray-100; + } + + .components-card__footer { + .components-button { + margin-right: $gap; + } + } +} + +.woocommerce-filters-filter { + width: 25%; + padding: 0 $gap-small; + min-height: 82px; + display: flex; + flex-direction: column; + justify-content: flex-end; + + &:first-child { + padding-left: 0; + } + + &:last-child { + padding-right: 0; + } + + @include breakpoint( '<1440px' ) { + width: 33.3%; + } + + @include breakpoint( '<1280px' ) { + width: 50%; + padding: 0; + min-height: 78px; + } + + @include breakpoint( '<782px' ) { + width: 100%; + } +} + +.woocommerce-filters-label { + margin: 7px 0; + display: block; + + @include breakpoint( '<1280px' ) { + margin: 5px 0; + } +} + +.woocommerce-filters-date__content, +.woocommerce-filters-filter__content { + .components-popover__content { + width: 320px; + border: 1px solid $gray-400; + background-color: $studio-white; + } + + .woocommerce-calendar__input-error .components-popover__content { + background-color: $gray-700; + } + + &.is-mobile { + .components-popover__content { + width: 100%; + height: 100%; + border: none; + } + } +} + +.woocommerce-filters-filter__search { + .woocommerce-search__autocomplete-results { + position: static; + } + .woocommerce-search__inline-container { + overflow: hidden; + + &:not(.is-active) { + border: none; + } + } +} + +.woocommerce-filters-advanced__list-item { + .components-base-control + .components-base-control { + margin-bottom: 0; + } +} diff --git a/packages/js/components/src/flag/README.md b/packages/js/components/src/flag/README.md new file mode 100644 index 00000000000..ad848c2021b --- /dev/null +++ b/packages/js/components/src/flag/README.md @@ -0,0 +1,21 @@ +Flag +=== + +Use the `Flag` component to display a country's flag using the operating system's emojis. + + React component. + +## Usage + +```jsx + +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`code` | String | `null` | Two letter, three letter or three digit country code +`order` | Object | `null` | An order can be passed instead of `code` and the code will automatically be pulled from the billing or shipping data +`className` | String | `null` | Additional CSS classes +`size` | Number | `null` | Supply a font size to be applied to the emoji flag diff --git a/packages/js/components/src/flag/index.js b/packages/js/components/src/flag/index.js new file mode 100644 index 00000000000..b5afaa78f35 --- /dev/null +++ b/packages/js/components/src/flag/index.js @@ -0,0 +1,72 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import emojiFlags from 'emoji-flags'; +import { get } from 'lodash'; +import { createElement } from '@wordpress/element'; + +/** + * Use the `Flag` component to display a country's flag using the operating system's emojis. + * + * @param {Object} props + * @param {string} props.code + * @param {Object} props.order + * @param {string} props.className + * @param {string} props.size + * @param {boolean} props.hideFromScreenReader + * @return {Object} - React component. + */ +const Flag = ( { code, order, className, size, hideFromScreenReader } ) => { + const classes = classnames( 'woocommerce-flag', className ); + + let _code = code || 'unknown'; + if ( order && order.shipping && order.shipping.country ) { + _code = order.shipping.country; + } else if ( order && order.billing && order.billing.country ) { + _code = order.billing.country; + } + + const inlineStyles = { + fontSize: size, + }; + + const emoji = get( emojiFlags.countryCode( _code ), 'emoji' ); + + return ( +
    + { emoji && { emoji } } + { ! emoji && ( + + Invalid country flag + + ) } +
    + ); +}; + +Flag.propTypes = { + /** + * Two letter, three letter or three digit country code. + */ + code: PropTypes.string, + /** + * An order can be passed instead of `code` and the code will automatically be pulled from the billing or shipping data. + */ + order: PropTypes.object, + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * Supply a font size to be applied to the emoji flag. + */ + size: PropTypes.number, +}; + +export default Flag; diff --git a/packages/js/components/src/flag/stories/index.js b/packages/js/components/src/flag/stories/index.js new file mode 100644 index 00000000000..0025b83af08 --- /dev/null +++ b/packages/js/components/src/flag/stories/index.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { Flag, H, Section } from '@woocommerce/components'; + +export const Examples = () => ( +
    + Default (inherits parent font size) +
    + +
    + Large +
    + +
    + Invalid Country Code +
    + +
    +
    +); + +export default { + title: 'WooCommerce Admin/components/Flag', + component: Flag, +}; diff --git a/packages/js/components/src/flag/style.scss b/packages/js/components/src/flag/style.scss new file mode 100644 index 00000000000..18b422b596b --- /dev/null +++ b/packages/js/components/src/flag/style.scss @@ -0,0 +1,14 @@ +.woocommerce-flag { + span { + vertical-align: middle; + } + + .woocommerce-flag__fallback { + background: $gray-100; + color: transparent; + width: 24px; + height: 18px; + display: block; + overflow: hidden; + } +} diff --git a/packages/js/components/src/form/README.md b/packages/js/components/src/form/README.md new file mode 100644 index 00000000000..d2da1156ebf --- /dev/null +++ b/packages/js/components/src/form/README.md @@ -0,0 +1,48 @@ +Form +=== + +A form component to handle form state and provide input helper props. + +## Usage + +```jsx +const initialValues = { firstName: '' }; + +
    {} } + initialValues={ initialValues } +> + { ( { + getInputProps, + values, + errors, + handleSubmit, + } ) => ( +
    + + +
    + ) } + +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`children` | * | `null` | A renderable component in which to pass this component's state and helpers. Generally a number of input or other form elements +`errors` | Object | `{}` | Object of all initial errors to store in state +`initialValues` | Object | `{}` | Object key:value pair list of all initial field values +`onSubmit` | Function | `noop` | Function to call when a form is submitted with valid fields +`validate` | Function | `noop` | A function that is passed a list of all values and should return an `errors` object with error response +`touched` | Object | `{}` | This prop helps determine whether or not a field has received focus +`onChange` | Function | `null` | A function that receives the value of the input; called when selected items change, whether added, edited, or removed \ No newline at end of file diff --git a/packages/js/components/src/form/index.js b/packages/js/components/src/form/index.js new file mode 100644 index 00000000000..8c2a0235e79 --- /dev/null +++ b/packages/js/components/src/form/index.js @@ -0,0 +1,212 @@ +/** + * External dependencies + */ +import { cloneElement, Component } from '@wordpress/element'; +import PropTypes from 'prop-types'; +import deprecated from '@wordpress/deprecated'; + +/** + * A form component to handle form state and provide input helper props. + */ +class Form extends Component { + constructor( props ) { + super(); + + this.state = { + values: props.initialValues, + errors: props.errors, + touched: props.touched, + }; + + this.getInputProps = this.getInputProps.bind( this ); + this.handleSubmit = this.handleSubmit.bind( this ); + this.setTouched = this.setTouched.bind( this ); + this.setValue = this.setValue.bind( this ); + } + + componentDidMount() { + this.validate(); + } + + async isValidForm() { + await this.validate(); + return ! Object.keys( this.state.errors ).length; + } + + validate( onValidate = () => {} ) { + const { values } = this.state; + const errors = this.props.validate( values ); + this.setState( { errors }, onValidate ); + } + + setValue( name, value ) { + this.setState( + ( prevState ) => ( { + values: { ...prevState.values, [ name ]: value }, + } ), + () => { + this.validate( () => { + const { onChange, onChangeCallback } = this.props; + + // Note that onChange is a no-op by default so this will never be null + const callback = onChangeCallback || onChange; + + if ( onChangeCallback ) { + deprecated( 'onChangeCallback', { + version: '9.0.0', + alternative: 'onChange', + plugin: '@woocommerce/components', + } ); + } + // onChange keeps track of validity, so needs to + // happen after setting the error state. + callback( + { name, value }, + this.state.values, + ! Object.keys( this.state.errors || {} ).length + ); + } ); + } + ); + } + + setTouched( name, touched = true ) { + this.setState( ( prevState ) => ( { + touched: { ...prevState.touched, [ name ]: touched }, + } ) ); + } + + handleChange( name, value ) { + const { values } = this.state; + + // Handle native events. + if ( value.target ) { + if ( value.target.type === 'checkbox' ) { + this.setValue( name, ! values[ name ] ); + } else { + this.setValue( name, value.target.value ); + } + } else { + this.setValue( name, value ); + } + } + + handleBlur( name ) { + this.setTouched( name ); + } + + async handleSubmit() { + const { values } = this.state; + const { onSubmitCallback, onSubmit } = this.props; + const touched = {}; + Object.keys( values ).map( ( name ) => ( touched[ name ] = true ) ); + this.setState( { touched } ); + + if ( await this.isValidForm() ) { + // Note that onSubmit is a no-op by default so this will never be null + const callback = onSubmitCallback || onSubmit; + + if ( onSubmitCallback ) { + deprecated( 'onSubmitCallback', { + version: '9.0.0', + alternative: 'onSubmit', + plugin: '@woocommerce/components', + } ); + } + + callback( values ); + } + } + + getInputProps( name ) { + const { errors, touched, values } = this.state; + + return { + value: values[ name ], + checked: Boolean( values[ name ] ), + selected: values[ name ], + onChange: ( value ) => this.handleChange( name, value ), + onBlur: () => this.handleBlur( name ), + className: touched[ name ] && errors[ name ] ? 'has-error' : null, + help: touched[ name ] ? errors[ name ] : null, + }; + } + + getStateAndHelpers() { + const { values, errors, touched } = this.state; + + return { + values, + errors, + touched, + setTouched: this.setTouched, + setValue: this.setValue, + handleSubmit: this.handleSubmit, + getInputProps: this.getInputProps, + isValidForm: ! Object.keys( errors ).length, + }; + } + + render() { + const element = this.props.children( this.getStateAndHelpers() ); + return cloneElement( element ); + } +} + +Form.propTypes = { + /** + * A renderable component in which to pass this component's state and helpers. + * Generally a number of input or other form elements. + */ + children: PropTypes.any, + /** + * Object of all initial errors to store in state. + */ + errors: PropTypes.object, + /** + * Object key:value pair list of all initial field values. + */ + initialValues: PropTypes.object.isRequired, + /** + * This prop helps determine whether or not a field has received focus + */ + touched: PropTypes.object, + /** + * Function to call when a form is submitted with valid fields. + * + * @deprecated + */ + onSubmitCallback: PropTypes.func, + /** + * Function to call when a form is submitted with valid fields. + */ + onSubmit: PropTypes.func, + /** + * Function to call when a value changes in the form. + * + * @deprecated + */ + onChangeCallback: PropTypes.func, + /** + * Function to call when a value changes in the form. + */ + onChange: PropTypes.func, + /** + * A function that is passed a list of all values and + * should return an `errors` object with error response. + */ + validate: PropTypes.func, +}; + +Form.defaultProps = { + errors: {}, + initialValues: {}, + onSubmitCallback: null, + onSubmit: () => {}, + onChangeCallback: null, + onChange: () => {}, + touched: {}, + validate: () => {}, +}; + +export default Form; diff --git a/packages/js/components/src/form/stories/index.js b/packages/js/components/src/form/stories/index.js new file mode 100644 index 00000000000..1f29845f46d --- /dev/null +++ b/packages/js/components/src/form/stories/index.js @@ -0,0 +1,95 @@ +/** + * External dependencies + */ +import { + Button, + CheckboxControl, + RadioControl, + SelectControl, + TextControl, +} from '@wordpress/components'; +import { Form } from '@woocommerce/components'; + +const validate = ( values ) => { + const errors = {}; + if ( ! values.firstName ) { + errors.firstName = 'First name is required'; + } + if ( values.lastName.length < 3 ) { + errors.lastName = 'Last name must be at least 3 characters'; + } + return errors; +}; + +// eslint-disable-next-line no-console +const onSubmit = ( values ) => console.log( values ); +const initialValues = { + firstName: '', + lastName: '', + select: '3', + checkbox: true, + radio: '2', +}; + +export const Basic = () => ( +
    + { ( { getInputProps, values, errors, handleSubmit } ) => ( +
    + + + + + + +
    +
    +

    Return data:

    +
    +					Values: { JSON.stringify( values ) }
    +					
    + Errors: { JSON.stringify( errors ) } +
    +
    + ) } + +); + +export default { + title: 'WooCommerce Admin/components/Form', + component: Form, +}; diff --git a/packages/js/components/src/form/test/index.js b/packages/js/components/src/form/test/index.js new file mode 100644 index 00000000000..745f9438603 --- /dev/null +++ b/packages/js/components/src/form/test/index.js @@ -0,0 +1,113 @@ +/** + * External dependencies + */ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Form from '../'; + +describe( 'Form', () => { + it( 'should default to call the deprecated onSubmitCallback if it is provided.', async () => { + const onSubmitCallback = jest.fn().mockName( 'onSubmitCallback' ); + const onSubmit = jest.fn().mockName( 'onSubmit' ); + + const { queryByText } = render( +
    ( {} ) } + > + { ( { handleSubmit } ) => { + return ; + } } + + ); + + userEvent.click( queryByText( 'Submit' ) ); + + await waitFor( () => + expect( onSubmitCallback ).toHaveBeenCalledTimes( 1 ) + ); + await waitFor( () => expect( onSubmit ).not.toHaveBeenCalled() ); + } ); + + it( 'should default to call the deprecated onChangeCallback prop if it is provided.', async () => { + const mockOnChangeCallback = jest.fn(); + const mockOnChange = jest.fn(); + + const { queryByText } = render( +
    ( {} ) } + > + { ( { setValue } ) => { + return ( + + ); + } } + + ); + + userEvent.click( queryByText( 'Change' ) ); + + await waitFor( () => + expect( mockOnChangeCallback ).toHaveBeenCalledTimes( 1 ) + ); + await waitFor( () => expect( mockOnChange ).not.toHaveBeenCalled() ); + } ); + + it( 'should call onSubmit if it is the only prop provided', async () => { + const mockOnSubmit = jest.fn(); + + const { queryByText } = render( +
    ( {} ) }> + { ( { handleSubmit } ) => { + return ; + } } + + ); + + userEvent.click( queryByText( 'Submit' ) ); + + await waitFor( () => + expect( mockOnSubmit ).toHaveBeenCalledTimes( 1 ) + ); + } ); + + it( 'should call onChange if it is the only prop provided', async () => { + const mockOnChange = jest.fn(); + + const { queryByText } = render( +
    ( {} ) }> + { ( { setValue } ) => { + return ( + + ); + } } + + ); + + userEvent.click( queryByText( 'Submit' ) ); + + await waitFor( () => + expect( mockOnChange ).toHaveBeenCalledTimes( 1 ) + ); + } ); +} ); diff --git a/packages/js/components/src/image-upload/README.md b/packages/js/components/src/image-upload/README.md new file mode 100644 index 00000000000..219150c3b68 --- /dev/null +++ b/packages/js/components/src/image-upload/README.md @@ -0,0 +1,18 @@ +ImageUpload +=== + +ImageUpload - Adds an upload area for selecting or uploading an image from the WordPress media gallery. + +## Usage + +```jsx + setState( { url: newImage } ) } /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`image` | Object | `null` | Image information containing media gallery `id` and image `url` +`onChange` | Function | `null` | Function to trigger when the selected image changes +`className` | String | `null` | Additional class name to style the component diff --git a/packages/js/components/src/image-upload/index.js b/packages/js/components/src/image-upload/index.js new file mode 100644 index 00000000000..7c83f3a51f0 --- /dev/null +++ b/packages/js/components/src/image-upload/index.js @@ -0,0 +1,106 @@ +/** + * External dependencies + */ +import { createElement, Component, Fragment } from '@wordpress/element'; +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import classNames from 'classnames'; +import { Icon, upload } from '@wordpress/icons'; + +class ImageUpload extends Component { + constructor() { + super( ...arguments ); + this.state = { + frame: false, + }; + this.openModal = this.openModal.bind( this ); + this.handleImageSelect = this.handleImageSelect.bind( this ); + this.removeImage = this.removeImage.bind( this ); + } + + openModal() { + if ( this.state.frame ) { + this.state.frame.open(); + return; + } + + const frame = wp.media( { + title: __( 'Select or upload image' ), + button: { + text: __( 'Select' ), + }, + library: { + type: 'image', + }, + multiple: false, + } ); + + frame.on( 'select', this.handleImageSelect ); + frame.open(); + + this.setState( { frame } ); + } + + handleImageSelect() { + const { onChange } = this.props; + const attachment = this.state.frame + .state() + .get( 'selection' ) + .first() + .toJSON(); + onChange( attachment ); + } + + removeImage() { + const { onChange } = this.props; + onChange( null ); + } + + render() { + const { className, image } = this.props; + return ( + + { !! image && ( +
    +
    + +
    + +
    + ) } + { ! image && ( +
    + +
    + ) } +
    + ); + } +} + +export default ImageUpload; diff --git a/packages/js/components/src/image-upload/stories/index.js b/packages/js/components/src/image-upload/stories/index.js new file mode 100644 index 00000000000..9226a3e78cd --- /dev/null +++ b/packages/js/components/src/image-upload/stories/index.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import { useState } from '@wordpress/element'; +import { ImageUpload } from '@woocommerce/components'; + +const ImageUploadExample = () => { + const [ image, setImage ] = useState( null ); + + return ( + setImage( _image ) } + /> + ); +}; + +export const Basic = () => ; + +export default { + title: 'WooCommerce Admin/components/ImageUpload', + component: ImageUpload, +}; diff --git a/packages/js/components/src/image-upload/style.scss b/packages/js/components/src/image-upload/style.scss new file mode 100644 index 00000000000..2208816def5 --- /dev/null +++ b/packages/js/components/src/image-upload/style.scss @@ -0,0 +1,20 @@ +.woocommerce-image-upload { + .woocommerce-image-upload__image-preview { + font-size: 16px; + margin-right: 2em; + + img { + max-width: 240px; + height: auto; + } + } + + .woocommerce-image-upload__add-image { + margin: $gap 0; + } + + &.has-image { + display: flex; + align-items: center; + } +} diff --git a/packages/js/components/src/image-upload/test/index.js b/packages/js/components/src/image-upload/test/index.js new file mode 100644 index 00000000000..c29e778c93f --- /dev/null +++ b/packages/js/components/src/image-upload/test/index.js @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ImageUpload from '../index'; + +describe( 'ImageUpload', () => { + describe( 'basic rendering', () => { + it( 'should render an image uploader ready for upload', () => { + const { getByRole, queryByRole } = render( ); + + expect( queryByRole( 'img' ) ).toBeNull(); + expect( + getByRole( 'button', { name: 'Add an image' } ) + ).toBeInTheDocument(); + } ); + + it( 'should render an image uploader prepopulated with an upload', () => { + const image = { + id: 1234, + url: + 'https://upload.wikimedia.org/wikipedia/en/a/a9/Example.jpg', + }; + const { getByRole } = render( ); + + expect( getByRole( 'img' ) ).toHaveAttribute( 'src', image.url ); + expect( + getByRole( 'button', { name: 'Remove image' } ) + ).toBeInTheDocument(); + } ); + } ); +} ); diff --git a/packages/js/components/src/index.js b/packages/js/components/src/index.js new file mode 100644 index 00000000000..1b78e6ec5b0 --- /dev/null +++ b/packages/js/components/src/index.js @@ -0,0 +1,60 @@ +export { default as AbbreviatedCard } from './abbreviated-card'; +export { default as AdvancedFilters } from './advanced-filters'; +export { default as AnimationSlider } from './animation-slider'; +export { default as Chart } from './chart'; +export { default as ChartPlaceholder } from './chart/placeholder'; +export { CompareButton, CompareFilter } from './compare-filter'; +export { default as Date } from './date'; +export { default as DateRangeFilterPicker } from './date-range-filter-picker'; +export { default as DateRange } from './calendar/date-range'; +export { default as DatePicker } from './calendar/date-picker'; +export { default as DropdownButton } from './dropdown-button'; +export { default as EllipsisMenu } from './ellipsis-menu'; +export { default as EmptyContent } from './empty-content'; +export { default as Flag } from './flag'; +export { default as Form } from './form'; +export { default as FilterPicker } from './filter-picker'; +export { H, Section } from './section'; +export { default as ImageUpload } from './image-upload'; +export { default as Link } from './link'; +export { default as List } from './list'; +export { default as MenuItem } from './ellipsis-menu/menu-item'; +export { default as MenuTitle } from './ellipsis-menu/menu-title'; +export { default as OrderStatus } from './order-status'; +export { default as Pagination } from './pagination'; +export { default as Pill } from './pill'; +export { default as Plugins } from './plugins'; +export { default as ProductImage } from './product-image'; +export { default as ProductRating } from './rating/product'; +export { default as Rating } from './rating'; +export { default as ReportFilters } from './filters'; +export { default as ReviewRating } from './rating/review'; +export { default as Search } from './search'; +export { default as SearchListControl } from './search-list-control'; +export { default as SearchListItem } from './search-list-control/item'; +export { default as SectionHeader } from './section-header'; +export { default as SegmentedSelection } from './segmented-selection'; +export { default as SelectControl } from './select-control'; +export { default as ScrollTo } from './scroll-to'; +export { default as Spinner } from './spinner'; +export { default as Stepper } from './stepper'; +export { default as SummaryList } from './summary'; +export { default as SummaryListPlaceholder } from './summary/placeholder'; +export { SummaryNumberPlaceholder } from './summary/placeholder'; +export { default as SummaryNumber } from './summary/number'; +export { default as Table } from './table/table'; +export { default as TableCard } from './table'; +export { default as EmptyTable } from './table/empty'; +export { default as TablePlaceholder } from './table/placeholder'; +export { + default as TableSummary, + TableSummaryPlaceholder, +} from './table/summary'; +export { default as Tag } from './tag'; +export { default as TextControl } from './text-control'; +export { default as TextControlWithAffixes } from './text-control-with-affixes'; +export { default as Timeline } from './timeline'; +export { default as ViewMoreList } from './view-more-list'; +export { default as WebPreview } from './web-preview'; +export { Badge } from './badge'; +export { DynamicForm } from './dynamic-form'; diff --git a/packages/js/components/src/lib/proptype-validator.js b/packages/js/components/src/lib/proptype-validator.js new file mode 100644 index 00000000000..6c8382b80b3 --- /dev/null +++ b/packages/js/components/src/lib/proptype-validator.js @@ -0,0 +1,19 @@ +export function validateComponent( component ) { + return ( props, propName, componentName ) => { + // Not a required prop, we can drop early. + if ( ! props[ propName ] ) { + return; + } + if ( + ! props[ propName ].type || + props[ propName ].type !== component + ) { + return new Error( + `Invalid ${ propName } passed to ${ componentName }. Must be ` + + '`' + + component.name + + '`' + ); + } + }; +} diff --git a/packages/js/components/src/link/README.md b/packages/js/components/src/link/README.md new file mode 100644 index 00000000000..8c1fe40056c --- /dev/null +++ b/packages/js/components/src/link/README.md @@ -0,0 +1,23 @@ +Link +=== + +Use `Link` to create a link to another resource. It accepts a type to automatically +create wp-admin links, wc-admin links, and external links. + +## Usage + +```jsx + + Coupons + +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`href` | String | `null` | (required) The resource to link to +`type` | One of: 'wp-admin', 'wc-admin', 'external' | `'wc-admin'` | Type of link. For wp-admin and wc-admin, the correct prefix is appended diff --git a/packages/js/components/src/link/index.js b/packages/js/components/src/link/index.js new file mode 100644 index 00000000000..50cf32b1064 --- /dev/null +++ b/packages/js/components/src/link/index.js @@ -0,0 +1,77 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { partial } from 'lodash'; +import { createElement } from '@wordpress/element'; +import { getHistory } from '@woocommerce/navigation'; + +/** + * Use `Link` to create a link to another resource. It accepts a type to automatically + * create wp-admin links, wc-admin links, and external links. + */ + +function Link( { children, href, type, ...props } ) { + // @todo Investigate further if we can use directly. + // With React Router 5+, cannot be used outside of the main elements, + // which seems to include components imported from @woocommerce/components. For now, we can use the history object directly. + const wcAdminLinkHandler = ( onClick, event ) => { + // If cmd, ctrl, alt, or shift are used, use default behavior to allow opening in a new tab. + if ( + event.ctrlKey || + event.metaKey || + event.altKey || + event.shiftKey + ) { + return; + } + + event.preventDefault(); + + // If there is an onclick event, execute it. + const onClickResult = onClick ? onClick( event ) : true; + + // Mimic browser behavior and only continue if onClickResult is not explicitly false. + if ( onClickResult === false ) { + return; + } + + getHistory().push( event.target.closest( 'a' ).getAttribute( 'href' ) ); + }; + + const passProps = { + ...props, + 'data-link-type': type, + }; + + if ( type === 'wc-admin' ) { + passProps.onClick = partial( wcAdminLinkHandler, passProps.onClick ); + } + + return ( + + { children } + + ); +} + +Link.propTypes = { + /** + * The resource to link to. + */ + href: PropTypes.string.isRequired, + /** + * Type of link. For wp-admin and wc-admin, the correct prefix is appended. + */ + type: PropTypes.oneOf( [ 'wp-admin', 'wc-admin', 'external' ] ).isRequired, +}; + +Link.defaultProps = { + type: 'wc-admin', +}; + +Link.contextTypes = { + router: PropTypes.object, +}; + +export default Link; diff --git a/packages/js/components/src/link/stories/index.js b/packages/js/components/src/link/stories/index.js new file mode 100644 index 00000000000..b3ca2a03af0 --- /dev/null +++ b/packages/js/components/src/link/stories/index.js @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import { withConsole } from '@storybook/addon-console'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Link from '../'; + +function logLinkClick( event ) { + const a = event.currentTarget; + const logMessage = `[${ a.textContent }](${ a.href }) ${ a.dataset.linkType } link clicked`; + + // eslint-disable-next-line no-console + console.log( logMessage ); + + event.preventDefault(); + return false; +} + +export default { + title: 'WooCommerce Admin/components/Link', + component: Link, + decorators: [ ( storyFn, context ) => withConsole()( storyFn )( context ) ], +}; + +export const External = () => { + return ( + + WooCommerce.com + + ); +}; + +export const WCAdmin = () => { + return ( + + Analytics: Orders + + ); +}; + +export const WPAdmin = () => { + return ( + + New Product + + ); +}; diff --git a/packages/js/components/src/link/test/index.js b/packages/js/components/src/link/test/index.js new file mode 100644 index 00000000000..d5d54b9b42d --- /dev/null +++ b/packages/js/components/src/link/test/index.js @@ -0,0 +1,129 @@ +/** + * External dependencies + */ +import { fireEvent, render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Link from '../index'; + +describe( 'Link', () => { + it( 'should render `external` links', () => { + const { container } = render( + + WooCommerce.com + + ); + + expect( container.firstChild ).toMatchInlineSnapshot( ` + + WooCommerce.com + + ` ); + } ); + + it( 'should render `wp-admin` links', () => { + const { container } = render( + + New Post + + ); + + expect( container.firstChild ).toMatchInlineSnapshot( ` + + New Post + + ` ); + } ); + + it( 'should render `wc-admin` links', () => { + const { container } = render( + + Analytics: Orders + + ); + + expect( container.firstChild ).toMatchInlineSnapshot( ` + + Analytics: Orders + + ` ); + } ); + + it( 'should render links without a type as `wc-admin`', () => { + const { container } = render( + + Analytics: Orders + + ); + + expect( container.firstChild ).toMatchInlineSnapshot( ` + + Analytics: Orders + + ` ); + } ); + + it( 'should allow custom props to be passed through', () => { + const { container } = render( + + WooCommerce.com + + ); + + expect( container.firstChild ).toMatchInlineSnapshot( ` + + WooCommerce.com + + ` ); + } ); + + it( 'should support `onClick`', () => { + // Prevent jsdom "Error: Not implemented: navigation" in test output + const clickHandler = jest.fn( ( event ) => { + event.preventDefault(); + return false; + } ); + + const { container } = render( + + WooCommerce.com + + ); + + fireEvent.click( container.firstChild ); + + expect( clickHandler ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/js/components/src/list/README.md b/packages/js/components/src/list/README.md new file mode 100644 index 00000000000..b80ebc67aab --- /dev/null +++ b/packages/js/components/src/list/README.md @@ -0,0 +1,60 @@ +# List + +List component to display a list of items. + +## Usage + +```jsx +const listItems = [ + { + title: 'List item title', + description: 'List item description text', + }, + { + before: , + title: 'List item with before icon', + description: 'List item description text', + }, + { + before: , + after: , + title: 'List item with before and after icons', + description: 'List item description text', + }, + { + title: 'Clickable list item', + description: 'List item description text', + onClick: () => alert( 'List item clicked' ), + }, +]; + +; +``` + +If you wanted a different format for the individual list item you can pass in a functional child: + +``` + +{ + (item, index) =>
    {item.title}
    +} +
    +``` + +### Props + +| Name | Type | Default | Description | +| ----------- | ------ | ------- | -------------------------------------------- | +| `className` | String | `null` | Additional class name to style the component | +| `items` | Array | `null` | (required) An array of list items | + +`items` structure: + +- `after`: ReactNode - Content displayed after the list item text. +- `before`: ReactNode - Content displayed before the list item text. +- `className`: String - Additional class name to style the list item. +- `description`: String - Description displayed beneath the list item title. +- `href`: String - Href attribute used in a Link wrapped around the item. +- `onClick`: Function - Called when the list item is clicked. +- `target`: String - Target attribute used for Link wrapper. +- `title`: String - Title displayed for the list item. diff --git a/packages/js/components/src/list/index.js b/packages/js/components/src/list/index.js new file mode 100644 index 00000000000..f407327e530 --- /dev/null +++ b/packages/js/components/src/list/index.js @@ -0,0 +1,116 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import deprecated from '@wordpress/deprecated'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import LegacyListItem from './list-item'; + +/** + * List component to display a list of items. + * + * @param {Object} props props for list + */ +function List( props ) { + const { className, items, children } = props; + const listClassName = classnames( 'woocommerce-list', className ); + + deprecated( 'List with items prop is deprecated', { + version: '9.0.0', + hint: + 'See ExperimentalList / ExperimentalListItem for the new API that will replace this component in future versions.', + } ); + + return ( + + { items.map( ( item, index ) => { + const { className: itemClasses, href, key, onClick } = item; + const hasAction = typeof onClick === 'function' || href; + const itemClassName = classnames( + 'woocommerce-list__item', + itemClasses, + { + 'has-action': hasAction, + } + ); + + return ( + +
  • + { children ? ( + children( item, index ) + ) : ( + + ) } +
  • +
    + ); + } ) } +
    + ); +} + +List.propTypes = { + /** + * Additional class name to style the component. + */ + className: PropTypes.string, + /** + * An array of list items. + */ + items: PropTypes.arrayOf( + PropTypes.shape( { + /** + * Content displayed after the list item text. + */ + after: PropTypes.node, + /** + * Content displayed before the list item text. + */ + before: PropTypes.node, + /** + * Additional class name to style the list item. + */ + className: PropTypes.string, + /** + * Content displayed beneath the list item title. + */ + content: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.node, + ] ), + /** + * Href attribute used in a Link wrapped around the item. + */ + href: PropTypes.string, + /** + * Called when the list item is clicked. + */ + onClick: PropTypes.func, + /** + * Target attribute used for Link wrapper. + */ + target: PropTypes.string, + /** + * Title displayed for the list item. + */ + title: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ), + /** + * Unique key for list item. + */ + key: PropTypes.string, + } ) + ), +}; + +export default List; diff --git a/packages/js/components/src/list/list-item.js b/packages/js/components/src/list/list-item.js new file mode 100644 index 00000000000..4c571b21291 --- /dev/null +++ b/packages/js/components/src/list/list-item.js @@ -0,0 +1,122 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { ENTER } from '@wordpress/keycodes'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import Link from '../link'; + +export function handleKeyDown( event, onClick ) { + if ( typeof onClick === 'function' && event.keyCode === ENTER ) { + onClick(); + } +} + +function getItemLinkType( item ) { + const { href, linkType } = item; + + if ( linkType ) { + return linkType; + } + + return href ? 'external' : null; +} + +/** + * List component to display a list of items. + * + * @param {Object} props props for list item + */ +function ListItem( props ) { + const { item } = props; + const { + before, + title, + after, + content, + onClick, + href, + target, + listItemTag, + } = item; + const hasAction = typeof onClick === 'function' || href; + const InnerTag = href ? Link : 'div'; + + const innerTagProps = { + className: 'woocommerce-list__item-inner', + onClick: typeof onClick === 'function' ? onClick : null, + 'aria-disabled': hasAction ? 'false' : null, + tabIndex: hasAction ? '0' : null, + role: hasAction ? 'menuitem' : null, + onKeyDown: ( e ) => ( hasAction ? handleKeyDown( e, onClick ) : null ), + target: href ? target : null, + type: getItemLinkType( item ), + href, + 'data-list-item-tag': listItemTag, + }; + + return ( + + { before && ( +
    { before }
    + ) } +
    + { title } + { content && ( + + { content } + + ) } +
    + { after && ( +
    { after }
    + ) } +
    + ); +} + +ListItem.propTypes = { + /** + * An array of list items. + */ + item: PropTypes.shape( { + /** + * Content displayed after the list item text. + */ + after: PropTypes.node, + /** + * Content displayed before the list item text. + */ + before: PropTypes.node, + /** + * Additional class name to style the list item. + */ + className: PropTypes.string, + /** + * Content displayed beneath the list item title. + */ + content: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ), + /** + * Href attribute used in a Link wrapped around the item. + */ + href: PropTypes.string, + /** + * Called when the list item is clicked. + */ + onClick: PropTypes.func, + /** + * Target attribute used for Link wrapper. + */ + target: PropTypes.string, + /** + * Title displayed for the list item. + */ + title: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ), + } ).isRequired, +}; + +export default ListItem; diff --git a/packages/js/components/src/list/stories/index.js b/packages/js/components/src/list/stories/index.js new file mode 100644 index 00000000000..ee1e67dc11f --- /dev/null +++ b/packages/js/components/src/list/stories/index.js @@ -0,0 +1,179 @@ +/** + * External dependencies + */ +import Gridicon from 'gridicons'; +import { withConsole } from '@storybook/addon-console'; +import { + Title, + Subtitle, + Description, + Primary, + ArgsTable, + Stories, + PRIMARY_STORY, +} from '@storybook/addon-docs'; +import { withLinks } from '@storybook/addon-links'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import List from '../'; +import './style.scss'; + +function logItemClick( event ) { + const a = event.currentTarget; + const itemDescription = a.href + ? `[${ a.textContent }](${ a.href }) ${ a.dataset.linkType }` + : `[${ a.textContent }]`; + const itemTag = a.dataset.listItemTag + ? `'${ a.dataset.listItemTag }'` + : 'not set'; + const logMessage = `[${ itemDescription } item clicked (tag: ${ itemTag })`; + + // eslint-disable-next-line no-console + console.log( logMessage ); + + event.preventDefault(); + return false; +} + +export default { + title: 'WooCommerce Admin/components/List', + component: List, + decorators: [ + ( storyFn, context ) => withConsole()( storyFn )( context ), + withLinks, + ], + parameters: { + docs: { + page: () => ( + <> + + <Subtitle /> + <Description + markdown={ `[deprecated] and will be replaced by + <a + data-sb-kind="woocommerce-admin-experimental-list" + data-sb-story="default" + > + ExperimentalList + </a>` } + /> + <Primary /> + <ArgsTable story={ PRIMARY_STORY } /> + <Stories /> + </> + ), + }, + }, +}; + +export const Default = () => { + const listItems = [ + { + title: 'WooCommerce.com', + href: 'https://woocommerce.com', + onClick: logItemClick, + }, + { + title: 'WordPress.org', + href: 'https://wordpress.org', + onClick: logItemClick, + }, + { + title: 'A list item with no action', + }, + { + title: 'Click me!', + content: 'An alert will be triggered.', + onClick: ( event ) => { + // eslint-disable-next-line no-alert + window.alert( 'List item clicked' ); + return logItemClick( event ); + }, + }, + ]; + + return <List items={ listItems } />; +}; + +Default.storyName = 'Default (deprecated)'; + +export const BeforeAndAfter = () => { + const listItems = [ + { + before: <Gridicon icon="cart" />, + after: <Gridicon icon="chevron-right" />, + title: 'WooCommerce.com', + href: 'https://woocommerce.com', + onClick: logItemClick, + }, + { + before: <Gridicon icon="my-sites" />, + after: <Gridicon icon="chevron-right" />, + title: 'WordPress.org', + href: 'https://wordpress.org', + onClick: logItemClick, + }, + { + before: <Gridicon icon="link-break" />, + title: 'A list item with no action', + description: 'List item description text', + }, + { + before: <Gridicon icon="notice" />, + title: 'Click me!', + content: 'An alert will be triggered.', + onClick: ( event ) => { + // eslint-disable-next-line no-alert + window.alert( 'List item clicked' ); + return logItemClick( event ); + }, + }, + ]; + + return <List items={ listItems } />; +}; + +BeforeAndAfter.storyName = 'Before and after (deprecated)'; + +export const CustomStyleAndTags = () => { + const listItems = [ + { + before: <Gridicon icon="cart" />, + after: <Gridicon icon="chevron-right" />, + title: 'WooCommerce.com', + href: 'https://woocommerce.com', + onClick: logItemClick, + listItemTag: 'woocommerce.com-link', + }, + { + before: <Gridicon icon="my-sites" />, + after: <Gridicon icon="chevron-right" />, + title: 'WordPress.org', + href: 'https://wordpress.org', + onClick: logItemClick, + listItemTag: 'wordpress.org-link', + }, + { + before: <Gridicon icon="link-break" />, + title: 'A list item with no action', + }, + { + before: <Gridicon icon="notice" />, + title: 'Click me!', + content: 'An alert will be triggered.', + onClick: ( event ) => { + // eslint-disable-next-line no-alert + window.alert( 'List item clicked' ); + return logItemClick( event ); + }, + listItemTag: 'click-me', + }, + ]; + + return <List items={ listItems } className="storybook-custom-list" />; +}; + +CustomStyleAndTags.storyName = 'Custom style and tags (deprecated)'; diff --git a/packages/js/components/src/list/stories/style.scss b/packages/js/components/src/list/stories/style.scss new file mode 100644 index 00000000000..8f677d33917 --- /dev/null +++ b/packages/js/components/src/list/stories/style.scss @@ -0,0 +1,21 @@ +.storybook-custom-list { + border: 1px solid $gray-400; + border-radius: 2px; + padding: 0; + + .woocommerce-list__item { + &:not(:first-child) { + border-top: 1px solid $gray-200; + } + + .woocommerce-list__item-before { + .gridicon { + fill: $studio-pink; + } + } + + .woocommerce-list__item-title { + color: $studio-pink; + } + } +} diff --git a/packages/js/components/src/list/style.scss b/packages/js/components/src/list/style.scss new file mode 100644 index 00000000000..44a33b125f1 --- /dev/null +++ b/packages/js/components/src/list/style.scss @@ -0,0 +1,159 @@ +.woocommerce-list { + margin: 0; + padding: 0; +} + +a.woocommerce-list__item { + color: inherit; +} + +.woocommerce-list__item { + display: flex; + align-items: center; + margin-bottom: 0; + text-decoration: none; + + &.has-gutters { + padding: $gap $gap-large; + } + + &.has-action { + cursor: pointer; + } + + &:focus { + box-shadow: inset 0 0 0 1px $studio-wordpress-blue, + inset 0 0 0 2px $studio-white; + } + + &:focus-visible { + box-shadow: none; + } + + // transitions + &:not(.transitions-disabled) { + &.woocommerce-list__item-enter { + opacity: 0; + max-height: 0; + transform: translateX(50%); + } + + &.woocommerce-list__item-enter-active { + opacity: 1; + max-height: 100vh; + transform: translateX(0%); + transition: opacity 500ms, transform 500ms, max-height 500ms; + } + + &.woocommerce-list__item-exit { + opacity: 1; + max-height: 100vh; + transform: translateX(0%); + } + + &.woocommerce-list__item-exit-active { + opacity: 0; + max-height: 0; + transform: translateX(50%); + transition: opacity 500ms, transform 500ms, max-height 500ms; + } + } + + > .woocommerce-list__item-inner { + text-decoration: none; + width: 100%; + display: flex; + align-items: center; + padding: $gap $gap-large; + + &:focus { + box-shadow: inset 0 0 0 1px $studio-wordpress-blue, + inset 0 0 0 2px $studio-white; + } + } + + .woocommerce-list__item-title { + color: $studio-gray-90; + } + + .woocommerce-list__item-content { + margin-top: $gap-smallest; + display: block; + font-size: 14px; + line-height: 20px; + color: #50575d; + } + + .woocommerce-list__item-before { + margin-right: 20px; + display: flex; + align-items: center; + } + + .woocommerce-list__item-after { + margin-left: $gap; + display: flex; + align-items: center; + margin-left: auto; + } + + $chevron-color: $gray-900; + $background-color: $white; + $background-color-hover: $gray-100; + $border-color: $gray-100; + $foreground-color: var(--wp-admin-theme-color); + $foreground-color-hover: var(--wp-admin-theme-color); + + background-color: $background-color; + + &:not(:first-child) { + border-top: 1px solid $border-color; + } + + &:hover { + background-color: $background-color-hover; + + .woocommerce-list__item-title { + color: $foreground-color-hover; + } + + .woocommerce-list__item-before > svg { + fill: $foreground-color-hover; + } + } + + .woocommerce-list__item-title, + .woocommerce-list__item-title > div { + color: $foreground-color; + } + + .woocommerce-list__item-before > svg { + fill: $foreground-color; + } + + .woocommerce-list__item-after > svg { + fill: $chevron-color; + } + + &.is-complete { + .woocommerce-task__icon { + background-color: var(--wp-admin-theme-color); + } + + .woocommerce-list__item-title { + color: $gray-700; + } + + .woocommerce-list__item-content { + display: none; + } + } +} + +.woocommerce-list__item-title { + color: $studio-gray-80; +} + +.woocommerce-list__item-content { + color: $studio-gray-50; +} diff --git a/packages/js/components/src/list/test/index.js b/packages/js/components/src/list/test/index.js new file mode 100644 index 00000000000..d60f0b1a7b5 --- /dev/null +++ b/packages/js/components/src/list/test/index.js @@ -0,0 +1,144 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import List from '../index'; + +describe( 'List', () => { + describe( 'Legacy List', () => { + it( 'should have aria roles for items', () => { + const clickHandler = jest.fn(); + const listItems = [ + { + title: 'WooCommerce.com', + href: 'https://woocommerce.com', + }, + { + title: 'Click me!', + onClick: clickHandler, + }, + ]; + + render( <List items={ listItems } /> ); + + expect( screen.getAllByRole( 'menuitem' ) ).toHaveLength( 2 ); + } ); + + it( 'should support `onClick` for items', () => { + const clickHandler = jest.fn(); + const listItems = [ + { + title: 'WooCommerce.com', + href: 'https://woocommerce.com', + }, + { + title: 'Click me!', + onClick: clickHandler, + }, + ]; + + render( <List items={ listItems } /> ); + + userEvent.click( + screen.getByRole( 'menuitem', { name: 'Click me!' } ) + ); + + expect( clickHandler ).toHaveBeenCalled(); + } ); + + it( 'should set `data-link-type` on items', () => { + const listItems = [ + { + title: 'Add products', + href: '/post-new.php?post_type=product', + linkType: 'wp-admin', + }, + { + title: 'Market my store', + href: '/admin.php?page=wc-admin&path=%2Fmarketing', + linkType: 'wc-admin', + }, + { + title: 'WooCommerce.com', + href: 'https://woocommerce.com', + linkType: 'external', + }, + { + title: 'WordPress.org', + href: 'https://wordpress.org', + }, + ]; + + render( <List items={ listItems } /> ); + + expect( + screen.getByRole( 'menuitem', { name: 'Add products' } ).dataset + .linkType + ).toBe( 'wp-admin' ); + expect( + screen.getByRole( 'menuitem', { name: 'Market my store' } ) + .dataset.linkType + ).toBe( 'wc-admin' ); + expect( + screen.getByRole( 'menuitem', { name: 'WooCommerce.com' } ) + .dataset.linkType + ).toBe( 'external' ); + expect( + screen.getByRole( 'menuitem', { name: 'WordPress.org' } ) + .dataset.linkType + ).toBe( 'external' ); + } ); + + it( 'should set `data-list-item-tag` on items', () => { + const listItems = [ + { + title: 'Add products', + href: '/post-new.php?post_type=product', + linkType: 'wp-admin', + listItemTag: 'add-product', + }, + { + title: 'Market my store', + href: '/admin.php?page=wc-admin&path=%2Fmarketing', + linkType: 'wc-admin', + listItemTag: 'marketing', + }, + { + title: 'WooCommerce.com', + href: 'https://woocommerce.com', + linkType: 'external', + listItemTag: 'woocommerce.com-site', + }, + { + title: 'WordPress.org', + href: 'https://wordpress.org', + }, + ]; + + render( <List items={ listItems } /> ); + + expect( + screen.getByRole( 'menuitem', { name: 'Add products' } ).dataset + .listItemTag + ).toBe( 'add-product' ); + expect( + screen.getByRole( 'menuitem', { name: 'Market my store' } ) + .dataset.listItemTag + ).toBe( 'marketing' ); + expect( + screen.getByRole( 'menuitem', { name: 'WooCommerce.com' } ) + .dataset.listItemTag + ).toBe( 'woocommerce.com-site' ); + expect( + screen.getByRole( 'menuitem', { name: 'WordPress.org' } ) + .dataset.listItemTag + ).toBeUndefined(); + } ); + } ); +} ); diff --git a/packages/js/components/src/order-status/README.md b/packages/js/components/src/order-status/README.md new file mode 100644 index 00000000000..04417062fe3 --- /dev/null +++ b/packages/js/components/src/order-status/README.md @@ -0,0 +1,20 @@ +OrderStatus +=== + +Use `OrderStatus` to display a badge with human-friendly text describing the current order status. + +## Usage + +```jsx +const order = { status: 'processing' }; // Use a real WooCommerce Order here. + +<OrderStatus order={ order } /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`order` | Object | `null` | (required) The order to display a status for. See: https://woocommerce.github.io/woocommerce-rest-api-docs/#order-properties +`className` | String | `null` | Additional CSS classes +`orderStatusMap` | Object | {} | A map of order status to human-friendly label. diff --git a/packages/js/components/src/order-status/index.js b/packages/js/components/src/order-status/index.js new file mode 100644 index 00000000000..d65a03bf235 --- /dev/null +++ b/packages/js/components/src/order-status/index.js @@ -0,0 +1,65 @@ +/** + * External dependencies + */ +import { createElement, Fragment } from '@wordpress/element'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; + +/** + * Use `OrderStatus` to display a badge with human-friendly text describing the current order status. + * + * @param {Object} props + * @param {Object} props.order + * @param {string} props.order.status + * @param {string} props.className + * @param {Object} props.orderStatusMap + * @param {boolean} props.labelPositionToLeft + * @return {Object} - + */ +const OrderStatus = ( { + order: { status }, + className, + orderStatusMap, + labelPositionToLeft = false, +} ) => { + const indicatorClasses = classnames( + 'woocommerce-order-status__indicator', + { + [ 'is-' + status ]: true, + } + ); + const label = orderStatusMap[ status ] || status; + + return ( + <div className={ classnames( 'woocommerce-order-status', className ) }> + { labelPositionToLeft ? ( + <Fragment> + { label } + <span className={ indicatorClasses } /> + </Fragment> + ) : ( + <Fragment> + <span className={ indicatorClasses } /> + { label } + </Fragment> + ) } + </div> + ); +}; + +OrderStatus.propTypes = { + /** + * The order to display a status for. + */ + order: PropTypes.object.isRequired, + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * A map of status to label for order statuses. + */ + orderStatusMap: PropTypes.object, +}; + +export default OrderStatus; diff --git a/packages/js/components/src/order-status/stories/index.js b/packages/js/components/src/order-status/stories/index.js new file mode 100644 index 00000000000..2bfaf7b1c68 --- /dev/null +++ b/packages/js/components/src/order-status/stories/index.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { OrderStatus } from '@woocommerce/components'; + +const orderStatusMap = { + processing: __( 'Processing Order' ), + pending: __( 'Pending Order' ), + completed: __( 'Completed Order' ), +}; + +export const Basic = () => ( + <div> + <OrderStatus + order={ { status: 'processing' } } + orderStatusMap={ orderStatusMap } + /> + <OrderStatus + order={ { status: 'pending' } } + orderStatusMap={ orderStatusMap } + /> + <OrderStatus + order={ { status: 'completed' } } + orderStatusMap={ orderStatusMap } + /> + </div> +); + +export default { + title: 'WooCommerce Admin/components/OrderStatus', + component: OrderStatus, +}; diff --git a/packages/js/components/src/order-status/style.scss b/packages/js/components/src/order-status/style.scss new file mode 100644 index 00000000000..87269f77771 --- /dev/null +++ b/packages/js/components/src/order-status/style.scss @@ -0,0 +1,26 @@ +.woocommerce-order-status { + display: grid; + grid-template-columns: min-content min-content; + grid-gap: $gap-smallest * 2; + align-items: center; +} + +.woocommerce-order-status__indicator { + min-width: 16px; + width: 16px; + height: 16px; + display: block; + background: $gray-400; + border-radius: 50%; + border: 3px solid $gray-100; + + &.is-processing { + background: $valid-green; + border-color: lighten($valid-green, 20%); + } + + &.is-on-hold { + background: $notice-yellow; + border-color: lighten($notice-yellow, 20%); + } +} diff --git a/packages/js/components/src/pagination/README.md b/packages/js/components/src/pagination/README.md new file mode 100644 index 00000000000..e50d0a11f30 --- /dev/null +++ b/packages/js/components/src/pagination/README.md @@ -0,0 +1,31 @@ +Pagination +=== + +Use `Pagination` to allow navigation between pages that represent a collection of items. +The component allows for selecting a new page and items per page options. + +## Usage + +```jsx +<Pagination + page={ 1 } + perPage={ 10 } + total={ 500 } + onPageChange={ ( newPage ) => setState( { page: newPage } ) } + onPerPageChange={ ( newPerPage ) => setState( { perPage: newPerPage } ) } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`page` | Number | `null` | (required) The current page of the collection +`onPageChange` | Function | `noop` | A function to execute when the page is changed +`perPage` | Number | `null` | (required) The amount of results that are being displayed per page +`onPerPageChange` | Function | `noop` | A function to execute when the per page option is changed +`total` | Number | `null` | (required) The total number of results +`className` | String | `null` | Additional classNames +`showPagePicker` | Boolean | `true` | Whether the page picker should be shown. +`showPerPagePicker` | Boolean | `true` | Whether the per page picker should shown. +`showPageArrowsLabel` | Boolean | `true` | Whether the page arrows label should be shown. diff --git a/packages/js/components/src/pagination/index.js b/packages/js/components/src/pagination/index.js new file mode 100644 index 00000000000..c9575623d06 --- /dev/null +++ b/packages/js/components/src/pagination/index.js @@ -0,0 +1,274 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { createElement, Component } from '@wordpress/element'; +import { Button, SelectControl } from '@wordpress/components'; +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import { noop, uniqueId } from 'lodash'; +import { Icon, chevronLeft, chevronRight } from '@wordpress/icons'; + +const PER_PAGE_OPTIONS = [ 25, 50, 75, 100 ]; + +/** + * Use `Pagination` to allow navigation between pages that represent a collection of items. + * The component allows for selecting a new page and items per page options. + */ +class Pagination extends Component { + constructor( props ) { + super( props ); + + this.state = { + inputValue: this.props.page, + }; + + this.previousPage = this.previousPage.bind( this ); + this.nextPage = this.nextPage.bind( this ); + this.onInputChange = this.onInputChange.bind( this ); + this.onInputBlur = this.onInputBlur.bind( this ); + this.perPageChange = this.perPageChange.bind( this ); + this.selectInputValue = this.selectInputValue.bind( this ); + } + + previousPage( event ) { + event.stopPropagation(); + const { page, onPageChange } = this.props; + if ( page - 1 < 1 ) { + return; + } + onPageChange( page - 1, 'previous' ); + } + + nextPage( event ) { + event.stopPropagation(); + const { page, onPageChange } = this.props; + if ( page + 1 > this.pageCount ) { + return; + } + onPageChange( page + 1, 'next' ); + } + + perPageChange( perPage ) { + const { onPerPageChange, onPageChange, total, page } = this.props; + + onPerPageChange( parseInt( perPage, 10 ) ); + const newMaxPage = Math.ceil( total / parseInt( perPage, 10 ) ); + if ( page > newMaxPage ) { + onPageChange( newMaxPage ); + } + } + + onInputChange( event ) { + this.setState( { + inputValue: event.target.value, + } ); + } + + onInputBlur( event ) { + const { onPageChange, page } = this.props; + const newPage = parseInt( event.target.value, 10 ); + + if ( + newPage !== page && + Number.isFinite( newPage ) && + newPage > 0 && + this.pageCount && + this.pageCount >= newPage + ) { + onPageChange( newPage, 'goto' ); + } + } + + selectInputValue( event ) { + event.target.select(); + } + + renderPageArrows() { + const { page, showPageArrowsLabel } = this.props; + + if ( this.pageCount <= 1 ) { + return null; + } + + const previousLinkClass = classNames( 'woocommerce-pagination__link', { + 'is-active': page > 1, + } ); + + const nextLinkClass = classNames( 'woocommerce-pagination__link', { + 'is-active': page < this.pageCount, + } ); + + return ( + <div className="woocommerce-pagination__page-arrows"> + { showPageArrowsLabel && ( + <span + className="woocommerce-pagination__page-arrows-label" + role="status" + aria-live="polite" + > + { sprintf( + __( 'Page %d of %d', 'woocommerce' ), + page, + this.pageCount + ) } + </span> + ) } + <div className="woocommerce-pagination__page-arrows-buttons"> + <Button + className={ previousLinkClass } + disabled={ ! ( page > 1 ) } + onClick={ this.previousPage } + label={ __( 'Previous Page', 'woocommerce' ) } + > + <Icon icon={ chevronLeft } /> + </Button> + <Button + className={ nextLinkClass } + disabled={ ! ( page < this.pageCount ) } + onClick={ this.nextPage } + label={ __( 'Next Page', 'woocommerce' ) } + > + <Icon icon={ chevronRight } /> + </Button> + </div> + </div> + ); + } + + renderPagePicker() { + const { page } = this.props; + const { inputValue } = this.state; + const isError = page < 1 || page > this.pageCount; + const inputClass = classNames( + 'woocommerce-pagination__page-picker-input', + { + 'has-error': isError, + } + ); + + const instanceId = uniqueId( 'woocommerce-pagination-page-picker-' ); + return ( + <div className="woocommerce-pagination__page-picker"> + <label + htmlFor={ instanceId } + className="woocommerce-pagination__page-picker-label" + > + { __( 'Go to page', 'woocommerce' ) } + <input + id={ instanceId } + className={ inputClass } + aria-invalid={ isError } + type="number" + onClick={ this.selectInputValue } + onChange={ this.onInputChange } + onBlur={ this.onInputBlur } + value={ inputValue } + min={ 1 } + max={ this.pageCount } + /> + </label> + </div> + ); + } + + renderPerPagePicker() { + // @todo Replace this with a styleized Select drop-down/control? + const pickerOptions = PER_PAGE_OPTIONS.map( ( option ) => { + return { value: option, label: option }; + } ); + + return ( + <div className="woocommerce-pagination__per-page-picker"> + <SelectControl + label={ __( 'Rows per page', 'woocommerce' ) } + labelPosition="side" + value={ this.props.perPage } + onChange={ this.perPageChange } + options={ pickerOptions } + /> + </div> + ); + } + + render() { + const { + total, + perPage, + className, + showPagePicker, + showPerPagePicker, + } = this.props; + this.pageCount = Math.ceil( total / perPage ); + + const classes = classNames( 'woocommerce-pagination', className ); + + if ( this.pageCount <= 1 ) { + return ( + ( total > PER_PAGE_OPTIONS[ 0 ] && ( + <div className={ classes }> + { this.renderPerPagePicker() } + </div> + ) ) || + null + ); + } + + return ( + <div className={ classes }> + { this.renderPageArrows() } + { showPagePicker && this.renderPagePicker() } + { showPerPagePicker && this.renderPerPagePicker() } + </div> + ); + } +} + +Pagination.propTypes = { + /** + * The current page of the collection. + */ + page: PropTypes.number.isRequired, + /** + * A function to execute when the page is changed. + */ + onPageChange: PropTypes.func, + /** + * The amount of results that are being displayed per page. + */ + perPage: PropTypes.number.isRequired, + /** + * A function to execute when the per page option is changed. + */ + onPerPageChange: PropTypes.func, + /** + * The total number of results. + */ + total: PropTypes.number.isRequired, + /** + * Additional classNames. + */ + className: PropTypes.string, + /** + * Whether the page picker should be rendered. + */ + showPagePicker: PropTypes.bool, + /** + * Whether the perPage picker should be rendered. + */ + showPerPagePicker: PropTypes.bool, + /** + * Whether the page arrows label should be rendered. + */ + showPageArrowsLabel: PropTypes.bool, +}; + +Pagination.defaultProps = { + onPageChange: noop, + onPerPageChange: noop, + showPagePicker: true, + showPerPagePicker: true, + showPageArrowsLabel: true, +}; + +export default Pagination; diff --git a/packages/js/components/src/pagination/stories/index.js b/packages/js/components/src/pagination/stories/index.js new file mode 100644 index 00000000000..094a74e0be2 --- /dev/null +++ b/packages/js/components/src/pagination/stories/index.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ + +import { createElement, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Pagination from '../'; + +export default { + title: 'WooCommerce Admin/components/Pagination', + component: Pagination, + args: { + total: 500, + showPagePicker: true, + showPerPagePicker: true, + showPageArrowsLabel: true, + }, +}; + +export const Default = ( args ) => { + const [ statePage, setPage ] = useState( 2 ); + const [ statePerPage, setPerPage ] = useState( 50 ); + + return ( + <Pagination + page={ statePage } + perPage={ statePerPage } + onPageChange={ ( newPage ) => setPage( newPage ) } + onPerPageChange={ ( newPerPage ) => setPerPage( newPerPage ) } + { ...args } + /> + ); +}; diff --git a/packages/js/components/src/pagination/style.scss b/packages/js/components/src/pagination/style.scss new file mode 100644 index 00000000000..deb55ded018 --- /dev/null +++ b/packages/js/components/src/pagination/style.scss @@ -0,0 +1,110 @@ +.woocommerce-pagination { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: center; + align-items: center; + + @include breakpoint( '<782px' ) { + flex-direction: column; + } + + input { + border-radius: 4px; + } +} + +.woocommerce-pagination__page-arrows { + display: flex; + flex-direction: row; +} + +.woocommerce-pagination__page-arrows-buttons { + display: inline-flex; + align-items: baseline; + border: 1px solid $button-border; + border-radius: 4px; + background: $button; + + .components-button { + color: $wp-admin-sidebar; + height: 30px; + width: 32px; + justify-content: center; + } + + .components-button:not(:disabled):not([aria-disabled='true']):hover { + color: $gray-700; + } + + button:first-child { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + border-right: 2px solid darken($button, 10%); + } + + button:last-child { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + .woocommerce-pagination__link { + padding: 4px; + } +} + +.woocommerce-pagination__page-arrows-label { + margin-top: math.div($spacing, 2); + margin-right: math.div($spacing, 2); +} + +.woocommerce-pagination__page-picker { + margin-left: $spacing; + + @include breakpoint( '<782px' ) { + margin-top: $gap; + margin-left: 0; + } + .woocommerce-pagination__page-picker-input { + margin-left: math.div($spacing, 2); + width: 60px; + height: 34px; + box-shadow: none; + } +} + +.woocommerce-pagination__per-page-picker { + margin-left: $spacing; + + @include breakpoint( '<782px' ) { + margin-top: $gap; + margin-left: 0; + } + + .components-base-control { + margin-bottom: 0; + + .components-base-control__field { + display: flex; + flex-direction: row; + align-items: baseline; + margin-bottom: 0; + } + + .components-select-control__input { + width: 60px; + height: 34px; + box-shadow: none; + } + + .components-base-control__label { + margin-right: math.div($spacing, 2); + } + } +} + +.woocommerce-pagination__page-picker-input.has-error, +.woocommerce-pagination__page-picker-input.has-error:focus { + border-color: $error-red; + box-shadow: 0 0 2px $error-red; +} diff --git a/packages/js/components/src/pill/README.md b/packages/js/components/src/pill/README.md new file mode 100644 index 00000000000..8ab84a23041 --- /dev/null +++ b/packages/js/components/src/pill/README.md @@ -0,0 +1,9 @@ +# Pill + +Use `Pill` to display a pill element containing child content. + +## Usage + +```jsx +<Pill>Content</Pill> +``` diff --git a/packages/js/components/src/pill/index.js b/packages/js/components/src/pill/index.js new file mode 100644 index 00000000000..0b77b075743 --- /dev/null +++ b/packages/js/components/src/pill/index.js @@ -0,0 +1 @@ +export { Pill as default } from './pill'; diff --git a/packages/js/components/src/pill/pill.js b/packages/js/components/src/pill/pill.js new file mode 100644 index 00000000000..e7e81d1635b --- /dev/null +++ b/packages/js/components/src/pill/pill.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import { Text } from '../experimental'; + +export function Pill( { children, className = '' } ) { + return ( + <Text + className={ classnames( 'woocommerce-pill', className ) } + variant="caption" + as="span" + size="12" + lineHeight="16px" + > + { children } + </Text> + ); +} diff --git a/packages/js/components/src/pill/stories/index.js b/packages/js/components/src/pill/stories/index.js new file mode 100644 index 00000000000..37ed71f148d --- /dev/null +++ b/packages/js/components/src/pill/stories/index.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Pill from '../'; + +export default { + title: 'WooCommerce Admin/components/Pill', + component: Pill, +}; + +export function Default() { + return <Pill>Content</Pill>; +} diff --git a/packages/js/components/src/pill/style.scss b/packages/js/components/src/pill/style.scss new file mode 100644 index 00000000000..15b129336a4 --- /dev/null +++ b/packages/js/components/src/pill/style.scss @@ -0,0 +1,11 @@ +.woocommerce-pill { + border: 1px solid $gray-700; + border-radius: 28px; + color: $gray-700; + display: inline-block; + padding: $gap-smallest $gap-smaller; + + @include breakpoint( '<320px' ) { + font-size: 11px; + } +} diff --git a/packages/js/components/src/plugins/README.md b/packages/js/components/src/plugins/README.md new file mode 100644 index 00000000000..6e4fcb08b6b --- /dev/null +++ b/packages/js/components/src/plugins/README.md @@ -0,0 +1,23 @@ +# Plugins + +Use `Plugins` to install and activate a list of plugins. + +## Usage + +```jsx +<Plugins + onComplete={ this.complete } + pluginSlugs={ [ 'jetpack', 'woocommerce-services' ] } +/> +``` + +### Props + +| Name | Type | Default | Description | +| ------------- | -------- | ---------------------------------------- | ------------------------------------------------------------------------- | +| `onComplete` | Function | | Called when the plugin installer is completed | +| `onError` | Function | | Called when the plugin installer completes with an error | +| `onSkip` | Function | `noop` | Called when the plugin installer is skipped | +| `skipText` | String | | Text used for the skip installer button | +| `autoInstall` | Boolean | false | If installation should happen automatically, or require user confirmation | +| `pluginSlugs` | Array | `[ 'jetpack', 'woocommerce-services' ],` | An array of plugin slugs to install. | diff --git a/packages/js/components/src/plugins/index.js b/packages/js/components/src/plugins/index.js new file mode 100644 index 00000000000..b721d5739fc --- /dev/null +++ b/packages/js/components/src/plugins/index.js @@ -0,0 +1,210 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { createElement, Component, Fragment } from '@wordpress/element'; +import { compose } from '@wordpress/compose'; +import PropTypes from 'prop-types'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { PLUGINS_STORE_NAME } from '@woocommerce/data'; + +export class Plugins extends Component { + constructor() { + super( ...arguments ); + + this.state = { + hasErrors: false, + }; + + this.installAndActivate = this.installAndActivate.bind( this ); + this.skipInstaller = this.skipInstaller.bind( this ); + this.handleErrors = this.handleErrors.bind( this ); + this.handleSuccess = this.handleSuccess.bind( this ); + } + + componentDidMount() { + const { autoInstall } = this.props; + + if ( autoInstall ) { + this.installAndActivate(); + } + } + + async installAndActivate( event ) { + if ( event ) { + event.preventDefault(); + } + + const { + installAndActivatePlugins, + isRequesting, + pluginSlugs, + } = this.props; + + // Avoid double activating. + if ( isRequesting ) { + return false; + } + + installAndActivatePlugins( pluginSlugs ) + .then( ( response ) => { + this.handleSuccess( response.data.activated, response ); + } ) + .catch( ( response ) => { + this.handleErrors( response.errors, response ); + } ); + } + + handleErrors( errors, response ) { + const { onError } = this.props; + + this.setState( { hasErrors: true } ); + onError( errors, response ); + } + + handleSuccess( activePlugins, response ) { + const { onComplete } = this.props; + onComplete( activePlugins, response ); + } + + skipInstaller() { + this.props.onSkip(); + } + + render() { + const { + isRequesting, + skipText, + autoInstall, + pluginSlugs, + onAbort, + abortText, + } = this.props; + const { hasErrors } = this.state; + + if ( hasErrors ) { + return ( + <Fragment> + <Button + isPrimary + isBusy={ isRequesting } + onClick={ this.installAndActivate } + > + { __( 'Retry', 'woocommerce' ) } + </Button> + <Button onClick={ this.skipInstaller }> + { __( 'Continue without installing', 'woocommerce' ) } + </Button> + </Fragment> + ); + } + + if ( autoInstall ) { + return null; + } + + if ( pluginSlugs.length === 0 ) { + return ( + <Fragment> + <Button + isPrimary + isBusy={ isRequesting } + onClick={ this.skipInstaller } + > + { __( 'Continue', 'woocommerce' ) } + </Button> + </Fragment> + ); + } + + return ( + <Fragment> + <Button + isBusy={ isRequesting } + isPrimary + onClick={ this.installAndActivate } + > + { __( 'Install & enable', 'woocommerce' ) } + </Button> + <Button isTertiary onClick={ this.skipInstaller }> + { skipText || __( 'No thanks', 'woocommerce' ) } + </Button> + { onAbort && ( + <Button isTertiary onClick={ onAbort }> + { abortText || __( 'Abort', 'woocommerce' ) } + </Button> + ) } + </Fragment> + ); + } +} + +Plugins.propTypes = { + /** + * Called when the plugin installer is successfully completed. + */ + onComplete: PropTypes.func.isRequired, + /** + * Called when the plugin installer completes with an error. + */ + onError: PropTypes.func, + /** + * Called when the plugin installer is skipped. + */ + onSkip: PropTypes.func, + /** + * Text used for the skip installer button. + */ + skipText: PropTypes.string, + /** + * If installation should happen automatically, or require user confirmation. + */ + autoInstall: PropTypes.bool, + /** + * An array of plugin slugs to install. + */ + pluginSlugs: PropTypes.arrayOf( PropTypes.string ), + /** + * Called when the plugin connection is aborted. + */ + onAbort: PropTypes.func, + /** + * Text used for the abort connection button. + */ + abortText: PropTypes.string, +}; + +Plugins.defaultProps = { + autoInstall: false, + onError: () => {}, + onSkip: () => {}, + pluginSlugs: [ 'jetpack', 'woocommerce-services' ], +}; + +export default compose( + withSelect( ( select ) => { + const { + getActivePlugins, + getInstalledPlugins, + isPluginsRequesting, + } = select( PLUGINS_STORE_NAME ); + + const isRequesting = + isPluginsRequesting( 'activatePlugins' ) || + isPluginsRequesting( 'installPlugins' ); + + return { + isRequesting, + activePlugins: getActivePlugins(), + installedPlugins: getInstalledPlugins(), + }; + } ), + withDispatch( ( dispatch ) => { + const { installAndActivatePlugins } = dispatch( PLUGINS_STORE_NAME ); + + return { + installAndActivatePlugins, + }; + } ) +)( Plugins ); diff --git a/packages/js/components/src/plugins/test/index.js b/packages/js/components/src/plugins/test/index.js new file mode 100644 index 00000000000..9aa8ead6fa4 --- /dev/null +++ b/packages/js/components/src/plugins/test/index.js @@ -0,0 +1,136 @@ +/** + * External dependencies + */ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ + +import { Plugins } from '../index.js'; + +describe( 'Rendering', () => { + it( 'should render nothing when autoInstalling', async () => { + const installAndActivatePlugins = jest.fn().mockResolvedValue( { + success: true, + data: { + activated: [ 'jetpack' ], + }, + } ); + const onComplete = jest.fn(); + + const { queryByRole } = render( + <Plugins + autoInstall + pluginSlugs={ [ 'jetpack' ] } + onComplete={ onComplete } + installAndActivatePlugins={ installAndActivatePlugins } + /> + ); + + expect( queryByRole( 'button' ) ).toBeNull(); + } ); + + it( 'should render a continue button when no pluginSlugs are given', async () => { + const { getByRole } = render( + <Plugins pluginSlugs={ [] } onComplete={ () => {} } /> + ); + + expect( + getByRole( 'button', { name: 'Continue' } ) + ).toBeInTheDocument(); + } ); + + it( 'should render install and no thanks buttons', async () => { + const { getByRole } = render( + <Plugins pluginSlugs={ [ 'jetpack' ] } onComplete={ () => {} } /> + ); + + expect( + getByRole( 'button', { name: 'Install & enable' } ) + ).toBeInTheDocument(); + expect( + getByRole( 'button', { name: 'No thanks' } ) + ).toBeInTheDocument(); + } ); + + it( 'should render an abort button when the abort handler is provided', async () => { + const { getByRole, getAllByRole } = render( + <Plugins + pluginSlugs={ [ 'jetpack' ] } + onComplete={ () => {} } + onAbort={ () => {} } + /> + ); + + expect( getAllByRole( 'button' ) ).toHaveLength( 3 ); + expect( getByRole( 'button', { name: 'Abort' } ) ).toBeInTheDocument(); + } ); +} ); + +describe( 'Installing and activating', () => { + it( 'should call installAndActivatePlugins and onComplete', async () => { + const response = { + success: true, + data: { + activated: [ 'jetpack' ], + }, + }; + const installAndActivatePlugins = jest + .fn() + .mockResolvedValue( response ); + const onComplete = jest.fn(); + + const { getByRole } = render( + <Plugins + pluginSlugs={ [ 'jetpack' ] } + onComplete={ onComplete } + installAndActivatePlugins={ installAndActivatePlugins } + /> + ); + + userEvent.click( getByRole( 'button', { name: 'Install & enable' } ) ); + + expect( installAndActivatePlugins ).toHaveBeenCalledWith( [ + 'jetpack', + ] ); + + await waitFor( () => + expect( onComplete ).toHaveBeenCalledWith( [ 'jetpack' ], response ) + ); + } ); +} ); + +describe( 'Installing and activating errors', () => { + it( 'should call installAndActivatePlugins and onComplete', async () => { + const response = { + errors: { + 'failed-plugin': [ 'error message' ], + }, + }; + const installAndActivatePlugins = jest + .fn() + .mockRejectedValue( response ); + const onComplete = jest.fn(); + const onError = jest.fn(); + + const { getByRole } = render( + <Plugins + pluginSlugs={ [ 'jetpack' ] } + onComplete={ onComplete } + installAndActivatePlugins={ installAndActivatePlugins } + onError={ onError } + /> + ); + + userEvent.click( getByRole( 'button', { name: 'Install & enable' } ) ); + + expect( onComplete ).not.toHaveBeenCalled(); + + await waitFor( () => + expect( onError ).toHaveBeenCalledWith( response.errors, response ) + ); + } ); +} ); diff --git a/packages/js/components/src/product-image/README.md b/packages/js/components/src/product-image/README.md new file mode 100644 index 00000000000..79b9a89231d --- /dev/null +++ b/packages/js/components/src/product-image/README.md @@ -0,0 +1,31 @@ +ProductImage +=== + +Use `ProductImage` to display a product's or variation's featured image. +If no image can be found, a placeholder matching the front-end image +placeholder will be displayed. + +## Usage + +```jsx +// Use a real WooCommerce Product here. +const product = { + images: [ + { + src: 'https://cldup.com/6L9h56D9Bw.jpg', + }, + ], +}; + +<ProductImage product={ product } /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`width` | Number | `60` | The width of image to display +`height` | Number | `60` | The height of image to display +`className` | String | `''` | Additional CSS classes +`product` | Object | `null` | Product or variation object. The image to display will be pulled from `product.images` or `variation.image`. See https://woocommerce.github.io/woocommerce-rest-api-docs/#product-properties and https://woocommerce.github.io/woocommerce-rest-api-docs/#product-variation-properties +`alt` | String | `null` | Text to use as the image alt attribute diff --git a/packages/js/components/src/product-image/index.js b/packages/js/components/src/product-image/index.js new file mode 100644 index 00000000000..799b61eb2aa --- /dev/null +++ b/packages/js/components/src/product-image/index.js @@ -0,0 +1,89 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import { get } from 'lodash'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { placeholderWhiteBackground as placeholder } from './placeholder'; + +/** + * Use `ProductImage` to display a product's or variation's featured image. + * If no image can be found, a placeholder matching the front-end image + * placeholder will be displayed. + * + * @param {Object} props + * @param {Object} props.product + * @param {string} props.alt + * @param {number} props.width + * @param {number} props.height + * @param {string} props.className + * @return {Object} - + */ +const ProductImage = ( { + product, + alt, + width, + height, + className, + ...props +} ) => { + // The first returned image from the API is the featured/product image. + const productImage = + get( product, [ 'images', 0 ] ) || get( product, [ 'image' ] ); + const src = ( productImage && productImage.src ) || false; + const altText = alt || ( productImage && productImage.alt ) || ''; + + const classes = classnames( 'woocommerce-product-image', className, { + 'is-placeholder': ! src, + } ); + + return ( + <img + className={ classes } + src={ src || placeholder } + width={ width } + height={ height } + alt={ altText } + { ...props } + /> + ); +}; + +ProductImage.propTypes = { + /** + * The width of image to display. + */ + width: PropTypes.number, + /** + * The height of image to display. + */ + height: PropTypes.number, + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * Product or variation object. The image to display will be pulled from + * `product.images` or `variation.image`. + * See https://woocommerce.github.io/woocommerce-rest-api-docs/#product-properties + * and https://woocommerce.github.io/woocommerce-rest-api-docs/#product-variation-properties + */ + product: PropTypes.object, + /** + * Text to use as the image alt attribute. + */ + alt: PropTypes.string, +}; + +ProductImage.defaultProps = { + width: 33, + height: 33, + className: '', +}; + +export default ProductImage; diff --git a/packages/js/components/src/product-image/placeholder.js b/packages/js/components/src/product-image/placeholder.js new file mode 100644 index 00000000000..fd686ea27d7 --- /dev/null +++ b/packages/js/components/src/product-image/placeholder.js @@ -0,0 +1,7 @@ +// eslint-disable-next-line max-len +const placeholderWhiteBackground = + "data:image/svg+xml;utf8,%3Csvg width='421' height='421' xmlns='http://www.w3.org/2000/svg' xmlns:svg='http://www.w3.org/2000/svg'%3E%3Cstyle type='text/css'%3E.st0%7Bfill:url(%23SVGID_1_);stroke:%23717275;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10%7D .st1%7Bfill:%23FFFFFF;%7D .st2%7Bfill:%23717275;%7D .st3%7Bfill:%23DCDDE0;stroke:%23717275;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;%7D%3C/style%3E%3CradialGradient cx='105.8248' cy='287.7805' gradientUnits='userSpaceOnUse' id='SVGID_1_' r='372.6935'%3E%3Cstop offset='0.2613' stop-color='%23DCDDE0'/%3E%3Cstop offset='0.633' stop-color='%23D8DADD'/%3E%3Cstop offset='0.9665' stop-color='%23CECFD3'/%3E%3Cstop offset='1' stop-color='%23CCCED2'/%3E%3C/radialGradient%3E%3Cg class='layer' display='inline'%3E%3Ctitle%3ELayer 2%3C/title%3E%3Crect fill='%23ffffff' height='417.99996' id='svg_7' stroke-dasharray='null' stroke-linecap='null' stroke-linejoin='null' stroke-width='null' width='417.99996' x='1.50002' y='1.5'/%3E%3C/g%3E%3Cg class='layer' display='inline'%3E%3Ctitle%3ELayer 1%3C/title%3E%3Cg id='svg_2'/%3E%3Cg id='svg_6'%3E%3Cpath class='st0' d='m330.44409,336.12693c-0.12194,0.36582 0,0.67068 0.30485,0.79262c1.40232,-0.79262 3.17047,-1.0365 4.63377,-0.48776c0.67068,-1.46329 0.12194,-2.43882 0.06097,-3.90212c-0.91456,-15.66945 -0.73165,-31.9486 -0.73165,-47.73998c0,-16.34012 -0.30485,-32.74121 0.54874,-48.9594c0.79262,-15.9743 1.89009,-31.5218 1.28038,-47.55707c-0.60971,-15.79139 -0.06097,-31.70471 0.73165,-47.37416c0.36582,-8.04812 0.79262,-15.66945 0.36582,-23.77854c-0.48776,-9.08462 -0.36582,-21.88845 -0.36582,-30.97307c0,-1.0365 0.18291,-1.82912 -0.79262,-2.43882c-0.79262,-0.48776 -1.52427,-0.42679 -2.43882,-0.42679c-2.49979,0 -4.87765,-0.36582 -7.37744,-0.36582c-1.52427,0 -2.86562,0 -4.32891,0.30485c-0.79262,0.12194 -1.52427,0.06097 -2.31688,0.06097c-0.85359,0 -1.52427,0.18291 -2.31688,0.36582c-6.88968,1.15844 -15.73042,2.49979 -22.62009,1.76815c-6.88968,-0.73165 -13.71839,-0.67068 -20.54709,-2.01203c-6.46288,-1.28038 -12.92577,-0.42679 -19.38865,-0.42679c-4.146,0 -8.23103,0 -12.37703,0c-3.96309,0 -7.80424,-0.73165 -11.8283,-0.73165c-6.52385,0.06097 -13.10868,0.12194 -19.63253,0.36582c-4.51182,0.18291 -8.84074,0 -13.35256,0.42679c-6.82871,0.60971 -13.77936,1.09747 -20.66903,1.0365c-3.59727,0 -7.07259,0.24388 -10.60889,-0.42679c-3.17047,-0.60971 -6.58483,-0.12194 -9.7553,0.18291c-3.96309,0.36582 -7.86521,1.0365 -11.8283,1.0365c-3.84115,-0.06097 -7.74327,-0.85359 -11.58441,-0.85359c-4.38988,-0.12194 -8.59686,0.42679 -12.98674,0.12194c-5.36541,-0.36582 -10.60889,-0.06097 -15.91333,-0.12194c-0.91456,0 -1.34135,0.24388 -2.13397,0.36582c-0.79262,0.12194 -1.40232,0.36582 -2.19494,0.36582c-0.85359,0 -1.34135,-0.30485 -2.13397,-0.36582c-1.64621,-0.18291 -5.91415,-0.06097 -7.49938,-0.48776c-1.70718,-0.42679 -3.41435,-0.91456 -5.12153,-1.34135c-1.40232,1.34135 -1.52427,3.5363 -1.64621,5.48735c-0.85359,21.58359 -0.73165,43.16719 -0.60971,64.75078c0,4.99959 0.06097,9.99918 0.60971,14.9378c0.42679,3.84115 1.15844,7.6823 1.46329,11.52344c0.48776,6.219 -0.06097,12.49897 -0.36582,18.71798c-0.36582,7.31647 -0.36582,14.63295 0.06097,21.94942c0.97553,18.90089 -0.48776,40.30157 -0.79262,59.20246c-0.36582,18.41312 -0.67068,37.1311 3.90212,54.99549c4.63377,-1.82912 17.13274,1.15844 22.55912,1.40232c5.85318,0.24388 11.8283,0.30485 17.74245,0.30485c6.76774,0 13.59644,-0.36582 20.30321,0c8.47491,0.42679 16.09624,2.31688 24.63212,1.82912c4.146,-0.24388 8.65783,0.36582 12.68189,0.36582c3.29241,0 9.87724,-1.09747 12.25509,0.73165c8.77977,0 20.18127,0.12194 28.90007,-0.73165c9.08462,-0.85359 19.38865,-1.21941 28.47327,-0.36582c7.37744,0.73165 14.45003,1.34135 21.82748,0.36582c4.63377,-0.60971 9.14559,-1.09747 13.9013,-1.09747c4.32891,0 8.292,-1.58524 12.49897,-1.46329c4.63377,0.12194 9.38947,1.40232 14.02324,1.89009c3.04853,0.30485 9.63336,-2.49979 12.49897,-1.21941l-0.00003,-0.00001z' fill='black' id='svg_1'/%3E%3Cpath class='st1' d='m313.79912,275.40021c-1.09747,0 -3.90212,-4.20697 -4.99959,-5.85318c-0.42679,-0.67068 -0.79262,-1.21941 -1.0365,-1.52427c-1.76815,-2.25591 -4.02406,-4.51182 -6.219,-6.6458c-0.67068,-0.67068 -1.40232,-1.40232 -2.073,-2.073c-3.5363,-3.59727 -9.02365,-7.98715 -13.16965,-9.51141c-1.40232,-0.48776 -2.56077,-1.21941 -3.71921,-1.82912c-2.19494,-1.28038 -4.20697,-2.43882 -7.49938,-2.49979c-0.30485,0 -0.67068,0 -0.97553,0c-3.78018,0 -7.49938,0.48776 -11.34053,1.09747c-7.19453,1.09747 -16.82789,7.49938 -21.52262,14.32809c-0.73165,1.09747 -1.15844,2.31688 -1.21941,3.5363c-2.31688,-0.54874 -5.6093,-3.17047 -7.62133,-4.75571c-0.30485,-0.24388 -0.60971,-0.48776 -0.85359,-0.67068c-2.74368,-2.13397 -4.75571,-4.5728 -6.95065,-7.2555c-1.28038,-1.52427 -2.62174,-3.1095 -4.08503,-4.63377c-7.92618,-8.17006 -16.88886,-15.48653 -25.54668,-22.55912c-4.51182,-3.65824 -9.14559,-7.43841 -13.59644,-11.27956c-5.79221,-4.99959 -10.365,-9.81627 -14.45003,-15.12071c-0.42679,-0.54874 -0.85359,-1.09747 -1.21941,-1.64621c-1.58524,-2.13397 -3.23144,-4.26794 -5.12153,-6.15803c-0.67068,-0.67068 -2.31688,-1.89009 -4.20697,-3.23144c-2.98756,-2.19494 -7.56035,-5.48735 -7.92618,-6.70677c-0.42679,-1.40232 -3.5363,-3.5363 -3.59727,-3.5363c-0.85359,-0.48776 -1.76815,-1.40232 -1.82912,-2.62174l-0.06097,-0.85359l-0.79262,0.36582c-2.86562,1.34135 -4.93862,3.41435 -7.07259,5.6093c-0.36582,0.36582 -0.67068,0.73165 -1.0365,1.0365c-5.67027,5.73124 -11.15762,11.64539 -16.27915,17.49856c-2.49979,2.86562 -5.12153,6.03609 -7.31647,9.38947c-0.60971,0.91456 -1.15844,2.01203 -1.70718,3.17047c-0.91456,1.89009 -1.89009,3.84115 -3.04853,4.99959c-0.36582,-0.54874 -0.73165,-1.64621 -0.91456,-2.13397c-0.06097,-0.18291 -0.12194,-0.42679 -0.18291,-0.54874c-1.28038,-3.17047 -0.79262,-6.52385 -0.30485,-10.06015c0.36582,-2.49979 0.73165,-4.99959 0.48776,-7.62133c-0.12194,-0.97553 -0.30485,-2.01203 -0.48776,-3.04853c-0.60971,-3.47532 -1.21941,-7.01162 -0.73165,-10.24306c1.21941,-9.63336 2.43882,-19.51059 3.04853,-29.32686c0.97553,-9.69433 0.42679,-18.83992 -0.18291,-28.53424c-0.12194,-2.43882 -0.30485,-4.99959 -0.42679,-7.49938c0,-1.0365 -0.06097,-2.13397 -0.06097,-3.17047c-0.12194,-3.17047 -0.24388,-6.219 0.60971,-9.20656c0.18291,-0.12194 0.48776,-0.30485 0.85359,-0.36582l2.37785,1.0365c2.25591,0.91456 5.1825,1.40232 8.90171,1.40232c3.41435,0 7.01162,-0.36582 10.54791,-0.73165c3.23144,-0.30485 6.34094,-0.60971 9.02365,-0.60971c0.67068,0 1.28038,0 1.89009,0.06097c1.52427,0.12194 3.04853,0.30485 4.51182,0.48776c2.13397,0.24388 4.38988,0.54874 6.58483,0.54874c0.54874,0 1.09747,0 1.58524,0c3.90212,0 7.92618,-0.18291 11.76733,-0.36582c3.17047,-0.12194 6.46288,-0.30485 9.69433,-0.36582c5.48735,-0.06097 11.09665,-0.36582 16.46206,-0.60971c2.25591,-0.12194 4.45085,-0.24388 6.70677,-0.30485c4.38988,-0.18291 8.90171,-0.18291 13.23062,-0.18291c3.1095,0 6.34094,0 9.57238,-0.06097c6.6458,-0.18291 13.35256,-0.54874 19.81545,-0.97553c6.88968,-0.42679 14.08421,-0.85359 21.09583,-0.97553c1.76815,-0.06097 3.5363,-0.06097 5.24347,-0.06097c3.84115,-0.06097 7.80424,-0.06097 11.76733,-0.30485c2.25591,-0.12194 4.5728,-0.36582 6.82871,-0.54874c3.78018,-0.36582 7.74327,-0.67068 11.58441,-0.67068c1.40232,0 2.74368,0.06097 4.02406,0.12194c1.89009,0.12194 3.78018,0.48776 5.79221,0.85359c2.62174,0.48776 5.30444,0.91456 7.92618,0.91456c0.67068,0 1.34135,-0.06097 1.95106,-0.12194c-0.73165,1.40232 -0.73165,3.17047 -0.67068,4.08503c0.12194,2.31688 0.12194,4.81668 0.12194,7.19453c0,9.38947 -0.97553,18.29118 -2.01203,27.74163c-0.36582,3.23144 -0.73165,6.6458 -1.0365,9.99918c-0.60971,13.65742 0,28.10745 0.60971,40.8503l0,4.63377c0.30485,4.146 0,8.23103 -0.30485,12.13315c-0.30485,3.90212 -0.60971,7.92618 -0.30485,11.95024c1.28038,12.07218 1.82912,23.71757 1.64621,34.6313l0.42679,10.60889l0.06097,0.06097c0.48776,1.34135 0.97553,6.95065 -0.24388,8.77977c-0.36582,0.42679 -0.60971,0.48776 -0.79262,0.48776l0,0l-0.00004,0.00002z' id='svg_3'/%3E%3Cpath class='st2' d='m296.54444,101.02428c1.40232,0 2.68271,0.06097 3.96309,0.12194c1.82912,0.12194 3.71921,0.48776 5.73124,0.79262c2.62174,0.48776 5.36541,0.97553 8.04812,0.97553c0.36582,0 0.67068,0 1.0365,0c-0.36582,1.21941 -0.36582,2.49979 -0.36582,3.41435c0.12194,2.31688 0.12194,4.75571 0.12194,7.13356c0,9.38947 -0.97553,18.29118 -2.01203,27.68065c-0.36582,3.29241 -0.73165,6.6458 -1.0365,9.99918l0,0l0,0c-0.60971,13.59644 0,28.10745 0.60971,40.8503l0,4.63377l0,0.06097l0,0.06097c0.30485,4.02406 0,8.10909 -0.30485,12.01121c-0.30485,3.90212 -0.60971,7.98715 -0.30485,12.01121l0,0l0,0c1.28038,12.01121 1.82912,23.65659 1.64621,34.50936l0,0.06097l0,0.06097l0.42679,10.48694l0,0.18291l0.06097,0.18291c0.48776,1.34135 0.85359,6.70677 -0.18291,8.292c-0.06097,0.06097 -0.12194,0.18291 -0.18291,0.18291c-1.0365,-0.36582 -3.65824,-4.26794 -4.51182,-5.54833c-0.48776,-0.67068 -0.79262,-1.21941 -1.09747,-1.58524c-1.82912,-2.31688 -4.08503,-4.51182 -6.219,-6.70677c-0.67068,-0.67068 -1.40232,-1.34135 -2.073,-2.073c-3.59727,-3.65824 -9.14559,-8.04812 -13.41353,-9.63336c-1.34135,-0.48776 -2.49979,-1.15844 -3.59727,-1.82912c-2.13397,-1.21941 -4.32891,-2.49979 -7.80424,-2.62174c-0.30485,0 -0.67068,0 -0.97553,0c-3.84115,0 -7.56035,0.48776 -11.46247,1.09747c-7.37744,1.09747 -17.19371,7.62133 -21.94942,14.57197c-0.67068,0.97553 -1.09747,2.01203 -1.28038,3.1095c-2.13397,-0.79262 -4.93862,-3.04853 -6.70677,-4.45085c-0.30485,-0.24388 -0.60971,-0.48776 -0.85359,-0.67068c-2.68271,-2.073 -4.69474,-4.51182 -6.88968,-7.13356c-1.28038,-1.52427 -2.62174,-3.1095 -4.08503,-4.69474c-7.92618,-8.17006 -16.88886,-15.48653 -25.60765,-22.55912c-4.51182,-3.65824 -9.14559,-7.43841 -13.53547,-11.27956c-5.73124,-4.99959 -10.30403,-9.7553 -14.38906,-15.05974c-0.42679,-0.54874 -0.85359,-1.09747 -1.21941,-1.64621c-1.58524,-2.13397 -3.29241,-4.32891 -5.1825,-6.219c-0.73165,-0.73165 -2.31688,-1.89009 -4.26794,-3.29241c-2.37785,-1.70718 -7.31647,-5.30444 -7.6823,-6.40191c-0.48776,-1.70718 -3.84115,-3.90212 -3.84115,-3.90212c-0.67068,-0.42679 -1.46329,-1.15844 -1.52427,-2.13397l-0.12194,-1.70718l-1.58524,0.73165c-2.98756,1.34135 -5.1825,3.59727 -7.2555,5.73124c-0.36582,0.36582 -0.67068,0.73165 -1.0365,1.0365c-5.67027,5.73124 -11.15762,11.64539 -16.27915,17.49856c-2.56077,2.92659 -5.1825,6.09706 -7.37744,9.45044c-0.60971,0.91456 -1.15844,2.073 -1.76815,3.29241c-0.73165,1.46329 -1.46329,3.04853 -2.37785,4.146c-0.18291,-0.48776 -0.42679,-0.97553 -0.48776,-1.28038c-0.06097,-0.24388 -0.18291,-0.42679 -0.24388,-0.54874c-1.21941,-2.98756 -0.79262,-6.27997 -0.24388,-9.69433c0.36582,-2.49979 0.73165,-5.12153 0.48776,-7.74327l0,0l0,0c-0.12194,-0.97553 -0.30485,-1.95106 -0.48776,-2.98756c-0.60971,-3.41435 -1.15844,-6.95065 -0.73165,-10.06015c1.21941,-9.63336 2.43882,-19.51059 3.04853,-29.32686c0.97553,-9.7553 0.42679,-18.90089 -0.18291,-28.65618c-0.12194,-2.43882 -0.30485,-4.93862 -0.42679,-7.43841c0,-1.09747 -0.06097,-2.13397 -0.06097,-3.23144c-0.12194,-3.04853 -0.18291,-5.97512 0.54874,-8.84074c0.06097,-0.06097 0.18291,-0.06097 0.24388,-0.12194l2.25591,0.97553c2.31688,0.97553 5.30444,1.46329 9.14559,1.46329c3.41435,0 7.07259,-0.36582 10.60889,-0.73165c3.23144,-0.30485 6.27997,-0.60971 8.96268,-0.60971c0.67068,0 1.28038,0 1.82912,0.06097c1.46329,0.12194 2.98756,0.30485 4.45085,0.42679c2.19494,0.24388 4.38988,0.54874 6.6458,0.54874c0.54874,0 1.09747,0 1.58524,0c3.96309,0 7.92618,-0.18291 11.76733,-0.36582c3.17047,-0.12194 6.46288,-0.30485 9.69433,-0.36582c5.48735,-0.06097 11.09665,-0.36582 16.46206,-0.60971c2.25591,-0.12194 4.45085,-0.24388 6.70677,-0.30485c4.38988,-0.18291 8.84074,-0.18291 13.16965,-0.18291c3.1095,0 6.40191,0 9.57238,-0.06097c6.6458,-0.18291 13.35256,-0.54874 19.87642,-0.97553c6.88968,-0.42679 14.02324,-0.85359 21.09583,-0.97553c1.76815,-0.06097 3.5363,-0.06097 5.24347,-0.06097c3.84115,-0.06097 7.86521,-0.06097 11.76733,-0.30485c2.31688,-0.12194 4.63377,-0.36582 6.88968,-0.54874c3.78018,-0.30485 7.74327,-0.67068 11.52344,-0.67068l0,0m0,-1.21941c-6.15803,0 -12.31606,0.85359 -18.47409,1.21941c-5.67027,0.30485 -11.34053,0.24388 -16.94983,0.36582c-13.65742,0.24388 -27.25386,1.58524 -40.97225,1.95106c-7.62133,0.18291 -15.18168,-0.06097 -22.80301,0.30485c-7.74327,0.30485 -15.42556,0.79262 -23.16883,0.91456c-7.13356,0.12194 -14.26712,0.73165 -21.46165,0.73165c-0.54874,0 -1.0365,0 -1.58524,0c-3.71921,-0.06097 -7.37744,-0.73165 -11.03568,-1.0365c-0.60971,-0.06097 -1.21941,-0.06097 -1.89009,-0.06097c-5.6093,0 -13.04771,1.34135 -19.51059,1.34135c-3.23144,0 -6.27997,-0.30485 -8.7188,-1.34135l-2.49979,-1.09747c-0.91456,0 -1.52427,0.60971 -1.52427,0.60971c-1.21941,4.26794 -0.60971,8.53588 -0.60971,12.80383c0.60971,12.19412 1.82912,23.77854 0.60971,35.97266c-0.60971,9.7553 -1.82912,19.51059 -3.04853,29.26589c-0.60971,4.26794 0.60971,9.14559 1.21941,13.41353c0.60971,6.09706 -2.43882,12.19412 -0.12194,17.80342c0.30485,0.73165 0.97553,2.80465 1.64621,3.29241c2.31688,-1.70718 3.65824,-6.15803 5.30444,-8.7188c2.13397,-3.29241 4.75571,-6.46288 7.2555,-9.3285c5.24347,-5.97512 10.66986,-11.8283 16.21818,-17.43759c2.49979,-2.49979 4.69474,-5.06056 7.92618,-6.58483c0.12194,1.34135 1.0365,2.43882 2.13397,3.1095c0.24388,0.12194 2.98756,2.073 3.29241,3.17047c0.60971,2.19494 10.42597,8.35297 12.25509,10.24306c2.37785,2.37785 4.32891,5.12153 6.34094,7.74327c4.38988,5.67027 9.14559,10.54791 14.57197,15.24265c12.98674,11.27956 27.07095,21.40068 39.08216,33.77771c3.90212,4.02406 6.6458,8.47491 11.09665,11.88927c2.31688,1.82912 6.6458,5.48735 9.51141,5.67027c-0.12194,-1.40232 0.36582,-2.74368 1.15844,-3.90212c4.32891,-6.34094 13.65742,-12.92577 21.09583,-14.08421c3.65824,-0.54874 7.49938,-1.09747 11.27956,-1.09747c0.30485,0 0.60971,0 0.97553,0c4.81668,0.12194 6.95065,2.80465 10.97471,4.32891c4.26794,1.58524 9.69433,6.03609 12.98674,9.38947c2.74368,2.80465 5.85318,5.67027 8.17006,8.65783c0.97553,1.28038 4.63377,7.56035 6.52385,7.56035c0,0 0.06097,0 0.06097,0c2.74368,-0.18291 2.073,-8.41394 1.46329,-10.12112l-0.42679,-10.48694c0.18291,-11.52344 -0.42679,-23.10786 -1.64621,-34.69227c-0.60971,-7.92618 1.21941,-15.85236 0.60971,-24.02242l0,-4.69474c-0.60971,-13.35256 -1.21941,-27.3758 -0.60971,-40.78933c1.21941,-12.80383 3.04853,-24.99795 3.04853,-37.80177c0,-2.43882 0,-4.87765 -0.12194,-7.19453c-0.06097,-1.58524 0.12194,-3.84115 1.52427,-4.87765c-1.09747,0.24388 -2.25591,0.30485 -3.41435,0.30485c-4.5728,0 -9.26753,-1.46329 -13.65742,-1.76815c-1.34135,0.12194 -2.68271,0.06097 -4.08503,0.06097l0,0l0.00003,0zm22.80301,1.09747c-0.67068,0 -1.21941,0.18291 -1.64621,0.48776c0.60971,-0.12194 1.21941,-0.30485 1.82912,-0.48776c-0.06097,0 -0.12194,0 -0.18291,0l0,0z' id='svg_4'/%3E%3Cpath class='st3' d='m235.75674,146.69126c-3.1095,3.41435 -4.38988,9.81627 -4.81668,14.20615c-0.60971,6.03609 -1.46329,10.97471 2.74368,15.66945c4.63377,5.12153 12.55994,9.87724 19.20574,11.64539c3.47532,0.97553 7.49938,-0.73165 10.7918,-1.40232c7.92618,-1.70718 11.95024,-6.52385 15.42556,-13.9013c4.20697,-8.77977 0.67068,-15.73042 -2.86562,-23.96145c-3.84115,-8.90171 -15.5475,-12.92577 -24.81504,-11.70636c-3.78018,0.48776 -5.91415,2.80465 -8.90171,4.87765c-1.64621,1.15844 -6.52385,2.80465 -6.76774,4.5728l0.00001,-0.00001z' id='svg_5'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"; +// eslint-disable-next-line max-len +const placeholderTransparentBackground = + "data:image/svg+xml;utf8,%3Csvg version='1.1' id='Layer_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' viewBox='0 0 421 421' style='enable-background:new 0 0 421 421;' xml:space='preserve'%3E%3Cstyle type='text/css'%3E .st0%7Bfill:url(%23SVGID_1_);stroke:%23717275;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;%7D .st1%7Bfill:%23FFFFFF;%7D .st2%7Bfill:%23717275;%7D .st3%7Bfill:%23DCDDE0;stroke:%23717275;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;%7D%0A%3C/style%3E%3CradialGradient id='SVGID_1_' cx='105.8248' cy='287.7805' r='372.6935' gradientUnits='userSpaceOnUse'%3E%3Cstop offset='0.2613' style='stop-color:%23DCDDE0'/%3E%3Cstop offset='0.633' style='stop-color:%23D8DADD'/%3E%3Cstop offset='0.9665' style='stop-color:%23CECFD3'/%3E%3Cstop offset='1' style='stop-color:%23CCCED2'/%3E%3C/radialGradient%3E%3Cpath class='st0' d='M407.2,416.7c-0.2,0.6,0,1.1,0.5,1.3c2.3-1.3,5.2-1.7,7.6-0.8c1.1-2.4,0.2-4,0.1-6.4 c-1.5-25.7-1.2-52.4-1.2-78.3c0-26.8-0.5-53.7,0.9-80.3c1.3-26.2,3.1-51.7,2.1-78c-1-25.9-0.1-52,1.2-77.7c0.6-13.2,1.3-25.7,0.6-39 c-0.8-14.9-0.6-35.9-0.6-50.8c0-1.7,0.3-3-1.3-4c-1.3-0.8-2.5-0.7-4-0.7c-4.1,0-8-0.6-12.1-0.6c-2.5,0-4.7,0-7.1,0.5 c-1.3,0.2-2.5,0.1-3.8,0.1c-1.4,0-2.5,0.3-3.8,0.6c-11.3,1.9-25.8,4.1-37.1,2.9c-11.3-1.2-22.5-1.1-33.7-3.3 c-10.6-2.1-21.2-0.7-31.8-0.7c-6.8,0-13.5,0-20.3,0c-6.5,0-12.8-1.2-19.4-1.2c-10.7,0.1-21.5,0.2-32.2,0.6c-7.4,0.3-14.5,0-21.9,0.7 c-11.2,1-22.6,1.8-33.9,1.7c-5.9,0-11.6,0.4-17.4-0.7c-5.2-1-10.8-0.2-16,0.3c-6.5,0.6-12.9,1.7-19.4,1.7c-6.3-0.1-12.7-1.4-19-1.4 C77,3,70.1,3.9,62.9,3.4c-8.8-0.6-17.4-0.1-26.1-0.2c-1.5,0-2.2,0.4-3.5,0.6C32,4,31,4.4,29.7,4.4c-1.4,0-2.2-0.5-3.5-0.6 C23.5,3.5,16.5,3.7,13.9,3c-2.8-0.7-5.6-1.5-8.4-2.2C3.2,3,3,6.6,2.8,9.8C1.4,45.2,1.6,80.6,1.8,116c0,8.2,0.1,16.4,1,24.5 c0.7,6.3,1.9,12.6,2.4,18.9c0.8,10.2-0.1,20.5-0.6,30.7c-0.6,12-0.6,24,0.1,36c1.6,31-0.8,66.1-1.3,97.1 c-0.6,30.2-1.1,60.9,6.4,90.2c7.6-3,28.1,1.9,37,2.3c9.6,0.4,19.4,0.5,29.1,0.5c11.1,0,22.3-0.6,33.3,0c13.9,0.7,26.4,3.8,40.4,3 c6.8-0.4,14.2,0.6,20.8,0.6c5.4,0,16.2-1.8,20.1,1.2c14.4,0,33.1,0.2,47.4-1.2c14.9-1.4,31.8-2,46.7-0.6c12.1,1.2,23.7,2.2,35.8,0.6 c7.6-1,15-1.8,22.8-1.8c7.1,0,13.6-2.6,20.5-2.4c7.6,0.2,15.4,2.3,23,3.1C391.7,419.2,402.5,414.6,407.2,416.7z'/%3E%3Cg%3E%3Cpath class='st1' d='M379.9,317.1c-1.8,0-6.4-6.9-8.2-9.6c-0.7-1.1-1.3-2-1.7-2.5c-2.9-3.7-6.6-7.4-10.2-10.9 c-1.1-1.1-2.3-2.3-3.4-3.4c-5.8-5.9-14.8-13.1-21.6-15.6c-2.3-0.8-4.2-2-6.1-3c-3.6-2.1-6.9-4-12.3-4.1c-0.5,0-1.1,0-1.6,0 c-6.2,0-12.3,0.8-18.6,1.8c-11.8,1.8-27.6,12.3-35.3,23.5c-1.2,1.8-1.9,3.8-2,5.8c-3.8-0.9-9.2-5.2-12.5-7.8 c-0.5-0.4-1-0.8-1.4-1.1c-4.5-3.5-7.8-7.5-11.4-11.9c-2.1-2.5-4.3-5.1-6.7-7.6c-13-13.4-27.7-25.4-41.9-37 c-7.4-6-15-12.2-22.3-18.5c-9.5-8.2-17-16.1-23.7-24.8c-0.7-0.9-1.4-1.8-2-2.7c-2.6-3.5-5.3-7-8.4-10.1c-1.1-1.1-3.8-3.1-6.9-5.3 c-4.9-3.6-12.4-9-13-11c-0.7-2.3-5.8-5.8-5.9-5.8c-1.4-0.8-2.9-2.3-3-4.3l-0.1-1.4l-1.3,0.6c-4.7,2.2-8.1,5.6-11.6,9.2 c-0.6,0.6-1.1,1.2-1.7,1.7c-9.3,9.4-18.3,19.1-26.7,28.7c-4.1,4.7-8.4,9.9-12,15.4c-1,1.5-1.9,3.3-2.8,5.2c-1.5,3.1-3.1,6.3-5,8.2 c-0.6-0.9-1.2-2.7-1.5-3.5c-0.1-0.3-0.2-0.7-0.3-0.9c-2.1-5.2-1.3-10.7-0.5-16.5c0.6-4.1,1.2-8.2,0.8-12.5c-0.2-1.6-0.5-3.3-0.8-5 c-1-5.7-2-11.5-1.2-16.8c2-15.8,4-32,5-48.1c1.6-15.9,0.7-30.9-0.3-46.8c-0.2-4-0.5-8.2-0.7-12.3c0-1.7-0.1-3.5-0.1-5.2 c-0.2-5.2-0.4-10.2,1-15.1c0.3-0.2,0.8-0.5,1.4-0.6l3.9,1.7c3.7,1.5,8.5,2.3,14.6,2.3c5.6,0,11.5-0.6,17.3-1.2 c5.3-0.5,10.4-1,14.8-1c1.1,0,2.1,0,3.1,0.1c2.5,0.2,5,0.5,7.4,0.8c3.5,0.4,7.2,0.9,10.8,0.9c0.9,0,1.8,0,2.6,0 c6.4,0,13-0.3,19.3-0.6c5.2-0.2,10.6-0.5,15.9-0.6c9-0.1,18.2-0.6,27-1c3.7-0.2,7.3-0.4,11-0.5c7.2-0.3,14.6-0.3,21.7-0.3 c5.1,0,10.4,0,15.7-0.1c10.9-0.3,21.9-0.9,32.5-1.6c11.3-0.7,23.1-1.4,34.6-1.6c2.9-0.1,5.8-0.1,8.6-0.1c6.3-0.1,12.8-0.1,19.3-0.5 c3.7-0.2,7.5-0.6,11.2-0.9c6.2-0.6,12.7-1.1,19-1.1c2.3,0,4.5,0.1,6.6,0.2c3.1,0.2,6.2,0.8,9.5,1.4c4.3,0.8,8.7,1.5,13,1.5 c1.1,0,2.2-0.1,3.2-0.2c-1.2,2.3-1.2,5.2-1.1,6.7c0.2,3.8,0.2,7.9,0.2,11.8c0,15.4-1.6,30-3.3,45.5c-0.6,5.3-1.2,10.9-1.7,16.4 c-1,22.4,0,46.1,1,67l0,7.6c0.5,6.8,0,13.5-0.5,19.9c-0.5,6.4-1,13-0.5,19.6c2.1,19.8,3,38.9,2.7,56.8l0.7,17.4l0.1,0.1 c0.8,2.2,1.6,11.4-0.4,14.4C380.6,317,380.2,317.1,379.9,317.1L379.9,317.1z'/%3E%3Cpath class='st2' d='M351.6,31.1c2.3,0,4.4,0.1,6.5,0.2c3,0.2,6.1,0.8,9.4,1.3c4.3,0.8,8.8,1.6,13.2,1.6c0.6,0,1.1,0,1.7,0 c-0.6,2-0.6,4.1-0.6,5.6c0.2,3.8,0.2,7.8,0.2,11.7c0,15.4-1.6,30-3.3,45.4c-0.6,5.4-1.2,10.9-1.7,16.4l0,0l0,0c-1,22.3,0,46.1,1,67 v7.6v0.1l0,0.1c0.5,6.6,0,13.3-0.5,19.7c-0.5,6.4-1,13.1-0.5,19.7l0,0l0,0c2.1,19.7,3,38.8,2.7,56.6l0,0.1l0,0.1l0.7,17.2l0,0.3 l0.1,0.3c0.8,2.2,1.4,11-0.3,13.6c-0.1,0.1-0.2,0.3-0.3,0.3c-1.7-0.6-6-7-7.4-9.1c-0.8-1.1-1.3-2-1.8-2.6c-3-3.8-6.7-7.4-10.2-11 c-1.1-1.1-2.3-2.2-3.4-3.4c-5.9-6-15-13.2-22-15.8c-2.2-0.8-4.1-1.9-5.9-3c-3.5-2-7.1-4.1-12.8-4.3c-0.5,0-1.1,0-1.6,0 c-6.3,0-12.4,0.8-18.8,1.8c-12.1,1.8-28.2,12.5-36,23.9c-1.1,1.6-1.8,3.3-2.1,5.1c-3.5-1.3-8.1-5-11-7.3c-0.5-0.4-1-0.8-1.4-1.1 c-4.4-3.4-7.7-7.4-11.3-11.7c-2.1-2.5-4.3-5.1-6.7-7.7c-13-13.4-27.7-25.4-42-37c-7.4-6-15-12.2-22.2-18.5 c-9.4-8.2-16.9-16-23.6-24.7c-0.7-0.9-1.4-1.8-2-2.7c-2.6-3.5-5.4-7.1-8.5-10.2c-1.2-1.2-3.8-3.1-7-5.4c-3.9-2.8-12-8.7-12.6-10.5 c-0.8-2.8-6.3-6.4-6.3-6.4c-1.1-0.7-2.4-1.9-2.5-3.5l-0.2-2.8l-2.6,1.2c-4.9,2.2-8.5,5.9-11.9,9.4c-0.6,0.6-1.1,1.2-1.7,1.7 c-9.3,9.4-18.3,19.1-26.7,28.7c-4.2,4.8-8.5,10-12.1,15.5c-1,1.5-1.9,3.4-2.9,5.4c-1.2,2.4-2.4,5-3.9,6.8c-0.3-0.8-0.7-1.6-0.8-2.1 c-0.1-0.4-0.3-0.7-0.4-0.9c-2-4.9-1.3-10.3-0.4-15.9c0.6-4.1,1.2-8.4,0.8-12.7l0,0l0,0c-0.2-1.6-0.5-3.2-0.8-4.9 c-1-5.6-1.9-11.4-1.2-16.5c2-15.8,4-32,5-48.1c1.6-16,0.7-31-0.3-47c-0.2-4-0.5-8.1-0.7-12.2c0-1.8-0.1-3.5-0.1-5.3 c-0.2-5-0.3-9.8,0.9-14.5c0.1-0.1,0.3-0.1,0.4-0.2l3.7,1.6c3.8,1.6,8.7,2.4,15,2.4c5.6,0,11.6-0.6,17.4-1.2c5.3-0.5,10.3-1,14.7-1 c1.1,0,2.1,0,3,0.1c2.4,0.2,4.9,0.5,7.3,0.7c3.6,0.4,7.2,0.9,10.9,0.9c0.9,0,1.8,0,2.6,0c6.5,0,13-0.3,19.3-0.6 c5.2-0.2,10.6-0.5,15.9-0.6c9-0.1,18.2-0.6,27-1c3.7-0.2,7.3-0.4,11-0.5c7.2-0.3,14.5-0.3,21.6-0.3c5.1,0,10.5,0,15.7-0.1 c10.9-0.3,21.9-0.9,32.6-1.6c11.3-0.7,23-1.4,34.6-1.6c2.9-0.1,5.8-0.1,8.6-0.1c6.3-0.1,12.9-0.1,19.3-0.5 c3.8-0.2,7.6-0.6,11.3-0.9C338.9,31.7,345.4,31.1,351.6,31.1L351.6,31.1 M351.6,29.1c-10.1,0-20.2,1.4-30.3,2 c-9.3,0.5-18.6,0.4-27.8,0.6c-22.4,0.4-44.7,2.6-67.2,3.2c-12.5,0.3-24.9-0.1-37.4,0.5c-12.7,0.5-25.3,1.3-38,1.5 c-11.7,0.2-23.4,1.2-35.2,1.2c-0.9,0-1.7,0-2.6,0c-6.1-0.1-12.1-1.2-18.1-1.7c-1-0.1-2-0.1-3.1-0.1c-9.2,0-21.4,2.2-32,2.2 c-5.3,0-10.3-0.5-14.3-2.2l-4.1-1.8c-1.5,0-2.5,1-2.5,1c-2,7-1,14-1,21c1,20,3,39,1,59c-1,16-3,32-5,48c-1,7,1,15,2,22 c1,10-4,20-0.2,29.2c0.5,1.2,1.6,4.6,2.7,5.4c3.8-2.8,6-10.1,8.7-14.3c3.5-5.4,7.8-10.6,11.9-15.3c8.6-9.8,17.5-19.4,26.6-28.6 c4.1-4.1,7.7-8.3,13-10.8c0.2,2.2,1.7,4,3.5,5.1c0.4,0.2,4.9,3.4,5.4,5.2c1,3.6,17.1,13.7,20.1,16.8c3.9,3.9,7.1,8.4,10.4,12.7 c7.2,9.3,15,17.3,23.9,25c21.3,18.5,44.4,35.1,64.1,55.4c6.4,6.6,10.9,13.9,18.2,19.5c3.8,3,10.9,9,15.6,9.3 c-0.2-2.3,0.6-4.5,1.9-6.4c7.1-10.4,22.4-21.2,34.6-23.1c6-0.9,12.3-1.8,18.5-1.8c0.5,0,1,0,1.6,0c7.9,0.2,11.4,4.6,18,7.1 c7,2.6,15.9,9.9,21.3,15.4c4.5,4.6,9.6,9.3,13.4,14.2c1.6,2.1,7.6,12.4,10.7,12.4c0,0,0.1,0,0.1,0c4.5-0.3,3.4-13.8,2.4-16.6 l-0.7-17.2c0.3-18.9-0.7-37.9-2.7-56.9c-1-13,2-26,1-39.4v-7.7c-1-21.9-2-44.9-1-66.9c2-21,5-41,5-62c0-4,0-8-0.2-11.8 c-0.1-2.6,0.2-6.3,2.5-8c-1.8,0.4-3.7,0.5-5.6,0.5c-7.5,0-15.2-2.4-22.4-2.9C356.1,29.2,353.9,29.1,351.6,29.1L351.6,29.1z M389,30.9c-1.1,0-2,0.3-2.7,0.8c1-0.2,2-0.5,3-0.8C389.2,30.9,389.1,30.9,389,30.9L389,30.9z'/%3E%3C/g%3E%3Cpath class='st3' d='M251.9,106c-5.1,5.6-7.2,16.1-7.9,23.3c-1,9.9-2.4,18,4.5,25.7c7.6,8.4,20.6,16.2,31.5,19.1 c5.7,1.6,12.3-1.2,17.7-2.3c13-2.8,19.6-10.7,25.3-22.8c6.9-14.4,1.1-25.8-4.7-39.3c-6.3-14.6-25.5-21.2-40.7-19.2 c-6.2,0.8-9.7,4.6-14.6,8C260.3,100.4,252.3,103.1,251.9,106z'/%3E%3C/svg%3E"; +export { placeholderWhiteBackground, placeholderTransparentBackground }; diff --git a/packages/js/components/src/product-image/stories/index.js b/packages/js/components/src/product-image/stories/index.js new file mode 100644 index 00000000000..1d6f0a504a6 --- /dev/null +++ b/packages/js/components/src/product-image/stories/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import { ProductImage } from '@woocommerce/components'; + +export const Basic = () => ( + <div> + <ProductImage product={ null } /> + <ProductImage product={ { images: [] } } /> + <ProductImage + product={ { + images: [ + { + src: 'https://cldup.com/6L9h56D9Bw.jpg', + }, + ], + } } + /> + </div> +); + +export default { + title: 'WooCommerce Admin/components/ProductImage', + component: ProductImage, +}; diff --git a/packages/js/components/src/product-image/style.scss b/packages/js/components/src/product-image/style.scss new file mode 100644 index 00000000000..2ac4c12de1b --- /dev/null +++ b/packages/js/components/src/product-image/style.scss @@ -0,0 +1,3 @@ +.woocommerce-product-image { + border-radius: 2px; +} diff --git a/packages/js/components/src/product-image/test/__snapshots__/index.js.snap b/packages/js/components/src/product-image/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000..4385a65b7c2 --- /dev/null +++ b/packages/js/components/src/product-image/test/__snapshots__/index.js.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProductImage should fallback to empty alt attribute if not passed via prop or product object 1`] = ` +<div> + <img + alt="" + class="woocommerce-product-image" + height="33" + src="https://i.cloudup.com/pt4DjwRB84-3000x3000.png" + width="33" + /> +</div> +`; + +exports[`ProductImage should fallback to product alt text 1`] = ` +<div> + <img + alt="hello world" + class="woocommerce-product-image" + height="33" + src="https://i.cloudup.com/pt4DjwRB84-3000x3000.png" + width="33" + /> +</div> +`; + +exports[`ProductImage should render a placeholder image if no product images are found 1`] = ` +<div> + <img + alt="" + class="woocommerce-product-image is-placeholder" + height="33" + src="data:image/svg+xml;utf8,%3Csvg width='421' height='421' xmlns='http://www.w3.org/2000/svg' xmlns:svg='http://www.w3.org/2000/svg'%3E%3Cstyle type='text/css'%3E.st0%7Bfill:url(%23SVGID_1_);stroke:%23717275;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10%7D .st1%7Bfill:%23FFFFFF;%7D .st2%7Bfill:%23717275;%7D .st3%7Bfill:%23DCDDE0;stroke:%23717275;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;%7D%3C/style%3E%3CradialGradient cx='105.8248' cy='287.7805' gradientUnits='userSpaceOnUse' id='SVGID_1_' r='372.6935'%3E%3Cstop offset='0.2613' stop-color='%23DCDDE0'/%3E%3Cstop offset='0.633' stop-color='%23D8DADD'/%3E%3Cstop offset='0.9665' stop-color='%23CECFD3'/%3E%3Cstop offset='1' stop-color='%23CCCED2'/%3E%3C/radialGradient%3E%3Cg class='layer' display='inline'%3E%3Ctitle%3ELayer 2%3C/title%3E%3Crect fill='%23ffffff' height='417.99996' id='svg_7' stroke-dasharray='null' stroke-linecap='null' stroke-linejoin='null' stroke-width='null' width='417.99996' x='1.50002' y='1.5'/%3E%3C/g%3E%3Cg class='layer' display='inline'%3E%3Ctitle%3ELayer 1%3C/title%3E%3Cg id='svg_2'/%3E%3Cg id='svg_6'%3E%3Cpath class='st0' d='m330.44409,336.12693c-0.12194,0.36582 0,0.67068 0.30485,0.79262c1.40232,-0.79262 3.17047,-1.0365 4.63377,-0.48776c0.67068,-1.46329 0.12194,-2.43882 0.06097,-3.90212c-0.91456,-15.66945 -0.73165,-31.9486 -0.73165,-47.73998c0,-16.34012 -0.30485,-32.74121 0.54874,-48.9594c0.79262,-15.9743 1.89009,-31.5218 1.28038,-47.55707c-0.60971,-15.79139 -0.06097,-31.70471 0.73165,-47.37416c0.36582,-8.04812 0.79262,-15.66945 0.36582,-23.77854c-0.48776,-9.08462 -0.36582,-21.88845 -0.36582,-30.97307c0,-1.0365 0.18291,-1.82912 -0.79262,-2.43882c-0.79262,-0.48776 -1.52427,-0.42679 -2.43882,-0.42679c-2.49979,0 -4.87765,-0.36582 -7.37744,-0.36582c-1.52427,0 -2.86562,0 -4.32891,0.30485c-0.79262,0.12194 -1.52427,0.06097 -2.31688,0.06097c-0.85359,0 -1.52427,0.18291 -2.31688,0.36582c-6.88968,1.15844 -15.73042,2.49979 -22.62009,1.76815c-6.88968,-0.73165 -13.71839,-0.67068 -20.54709,-2.01203c-6.46288,-1.28038 -12.92577,-0.42679 -19.38865,-0.42679c-4.146,0 -8.23103,0 -12.37703,0c-3.96309,0 -7.80424,-0.73165 -11.8283,-0.73165c-6.52385,0.06097 -13.10868,0.12194 -19.63253,0.36582c-4.51182,0.18291 -8.84074,0 -13.35256,0.42679c-6.82871,0.60971 -13.77936,1.09747 -20.66903,1.0365c-3.59727,0 -7.07259,0.24388 -10.60889,-0.42679c-3.17047,-0.60971 -6.58483,-0.12194 -9.7553,0.18291c-3.96309,0.36582 -7.86521,1.0365 -11.8283,1.0365c-3.84115,-0.06097 -7.74327,-0.85359 -11.58441,-0.85359c-4.38988,-0.12194 -8.59686,0.42679 -12.98674,0.12194c-5.36541,-0.36582 -10.60889,-0.06097 -15.91333,-0.12194c-0.91456,0 -1.34135,0.24388 -2.13397,0.36582c-0.79262,0.12194 -1.40232,0.36582 -2.19494,0.36582c-0.85359,0 -1.34135,-0.30485 -2.13397,-0.36582c-1.64621,-0.18291 -5.91415,-0.06097 -7.49938,-0.48776c-1.70718,-0.42679 -3.41435,-0.91456 -5.12153,-1.34135c-1.40232,1.34135 -1.52427,3.5363 -1.64621,5.48735c-0.85359,21.58359 -0.73165,43.16719 -0.60971,64.75078c0,4.99959 0.06097,9.99918 0.60971,14.9378c0.42679,3.84115 1.15844,7.6823 1.46329,11.52344c0.48776,6.219 -0.06097,12.49897 -0.36582,18.71798c-0.36582,7.31647 -0.36582,14.63295 0.06097,21.94942c0.97553,18.90089 -0.48776,40.30157 -0.79262,59.20246c-0.36582,18.41312 -0.67068,37.1311 3.90212,54.99549c4.63377,-1.82912 17.13274,1.15844 22.55912,1.40232c5.85318,0.24388 11.8283,0.30485 17.74245,0.30485c6.76774,0 13.59644,-0.36582 20.30321,0c8.47491,0.42679 16.09624,2.31688 24.63212,1.82912c4.146,-0.24388 8.65783,0.36582 12.68189,0.36582c3.29241,0 9.87724,-1.09747 12.25509,0.73165c8.77977,0 20.18127,0.12194 28.90007,-0.73165c9.08462,-0.85359 19.38865,-1.21941 28.47327,-0.36582c7.37744,0.73165 14.45003,1.34135 21.82748,0.36582c4.63377,-0.60971 9.14559,-1.09747 13.9013,-1.09747c4.32891,0 8.292,-1.58524 12.49897,-1.46329c4.63377,0.12194 9.38947,1.40232 14.02324,1.89009c3.04853,0.30485 9.63336,-2.49979 12.49897,-1.21941l-0.00003,-0.00001z' fill='black' id='svg_1'/%3E%3Cpath class='st1' d='m313.79912,275.40021c-1.09747,0 -3.90212,-4.20697 -4.99959,-5.85318c-0.42679,-0.67068 -0.79262,-1.21941 -1.0365,-1.52427c-1.76815,-2.25591 -4.02406,-4.51182 -6.219,-6.6458c-0.67068,-0.67068 -1.40232,-1.40232 -2.073,-2.073c-3.5363,-3.59727 -9.02365,-7.98715 -13.16965,-9.51141c-1.40232,-0.48776 -2.56077,-1.21941 -3.71921,-1.82912c-2.19494,-1.28038 -4.20697,-2.43882 -7.49938,-2.49979c-0.30485,0 -0.67068,0 -0.97553,0c-3.78018,0 -7.49938,0.48776 -11.34053,1.09747c-7.19453,1.09747 -16.82789,7.49938 -21.52262,14.32809c-0.73165,1.09747 -1.15844,2.31688 -1.21941,3.5363c-2.31688,-0.54874 -5.6093,-3.17047 -7.62133,-4.75571c-0.30485,-0.24388 -0.60971,-0.48776 -0.85359,-0.67068c-2.74368,-2.13397 -4.75571,-4.5728 -6.95065,-7.2555c-1.28038,-1.52427 -2.62174,-3.1095 -4.08503,-4.63377c-7.92618,-8.17006 -16.88886,-15.48653 -25.54668,-22.55912c-4.51182,-3.65824 -9.14559,-7.43841 -13.59644,-11.27956c-5.79221,-4.99959 -10.365,-9.81627 -14.45003,-15.12071c-0.42679,-0.54874 -0.85359,-1.09747 -1.21941,-1.64621c-1.58524,-2.13397 -3.23144,-4.26794 -5.12153,-6.15803c-0.67068,-0.67068 -2.31688,-1.89009 -4.20697,-3.23144c-2.98756,-2.19494 -7.56035,-5.48735 -7.92618,-6.70677c-0.42679,-1.40232 -3.5363,-3.5363 -3.59727,-3.5363c-0.85359,-0.48776 -1.76815,-1.40232 -1.82912,-2.62174l-0.06097,-0.85359l-0.79262,0.36582c-2.86562,1.34135 -4.93862,3.41435 -7.07259,5.6093c-0.36582,0.36582 -0.67068,0.73165 -1.0365,1.0365c-5.67027,5.73124 -11.15762,11.64539 -16.27915,17.49856c-2.49979,2.86562 -5.12153,6.03609 -7.31647,9.38947c-0.60971,0.91456 -1.15844,2.01203 -1.70718,3.17047c-0.91456,1.89009 -1.89009,3.84115 -3.04853,4.99959c-0.36582,-0.54874 -0.73165,-1.64621 -0.91456,-2.13397c-0.06097,-0.18291 -0.12194,-0.42679 -0.18291,-0.54874c-1.28038,-3.17047 -0.79262,-6.52385 -0.30485,-10.06015c0.36582,-2.49979 0.73165,-4.99959 0.48776,-7.62133c-0.12194,-0.97553 -0.30485,-2.01203 -0.48776,-3.04853c-0.60971,-3.47532 -1.21941,-7.01162 -0.73165,-10.24306c1.21941,-9.63336 2.43882,-19.51059 3.04853,-29.32686c0.97553,-9.69433 0.42679,-18.83992 -0.18291,-28.53424c-0.12194,-2.43882 -0.30485,-4.99959 -0.42679,-7.49938c0,-1.0365 -0.06097,-2.13397 -0.06097,-3.17047c-0.12194,-3.17047 -0.24388,-6.219 0.60971,-9.20656c0.18291,-0.12194 0.48776,-0.30485 0.85359,-0.36582l2.37785,1.0365c2.25591,0.91456 5.1825,1.40232 8.90171,1.40232c3.41435,0 7.01162,-0.36582 10.54791,-0.73165c3.23144,-0.30485 6.34094,-0.60971 9.02365,-0.60971c0.67068,0 1.28038,0 1.89009,0.06097c1.52427,0.12194 3.04853,0.30485 4.51182,0.48776c2.13397,0.24388 4.38988,0.54874 6.58483,0.54874c0.54874,0 1.09747,0 1.58524,0c3.90212,0 7.92618,-0.18291 11.76733,-0.36582c3.17047,-0.12194 6.46288,-0.30485 9.69433,-0.36582c5.48735,-0.06097 11.09665,-0.36582 16.46206,-0.60971c2.25591,-0.12194 4.45085,-0.24388 6.70677,-0.30485c4.38988,-0.18291 8.90171,-0.18291 13.23062,-0.18291c3.1095,0 6.34094,0 9.57238,-0.06097c6.6458,-0.18291 13.35256,-0.54874 19.81545,-0.97553c6.88968,-0.42679 14.08421,-0.85359 21.09583,-0.97553c1.76815,-0.06097 3.5363,-0.06097 5.24347,-0.06097c3.84115,-0.06097 7.80424,-0.06097 11.76733,-0.30485c2.25591,-0.12194 4.5728,-0.36582 6.82871,-0.54874c3.78018,-0.36582 7.74327,-0.67068 11.58441,-0.67068c1.40232,0 2.74368,0.06097 4.02406,0.12194c1.89009,0.12194 3.78018,0.48776 5.79221,0.85359c2.62174,0.48776 5.30444,0.91456 7.92618,0.91456c0.67068,0 1.34135,-0.06097 1.95106,-0.12194c-0.73165,1.40232 -0.73165,3.17047 -0.67068,4.08503c0.12194,2.31688 0.12194,4.81668 0.12194,7.19453c0,9.38947 -0.97553,18.29118 -2.01203,27.74163c-0.36582,3.23144 -0.73165,6.6458 -1.0365,9.99918c-0.60971,13.65742 0,28.10745 0.60971,40.8503l0,4.63377c0.30485,4.146 0,8.23103 -0.30485,12.13315c-0.30485,3.90212 -0.60971,7.92618 -0.30485,11.95024c1.28038,12.07218 1.82912,23.71757 1.64621,34.6313l0.42679,10.60889l0.06097,0.06097c0.48776,1.34135 0.97553,6.95065 -0.24388,8.77977c-0.36582,0.42679 -0.60971,0.48776 -0.79262,0.48776l0,0l-0.00004,0.00002z' id='svg_3'/%3E%3Cpath class='st2' d='m296.54444,101.02428c1.40232,0 2.68271,0.06097 3.96309,0.12194c1.82912,0.12194 3.71921,0.48776 5.73124,0.79262c2.62174,0.48776 5.36541,0.97553 8.04812,0.97553c0.36582,0 0.67068,0 1.0365,0c-0.36582,1.21941 -0.36582,2.49979 -0.36582,3.41435c0.12194,2.31688 0.12194,4.75571 0.12194,7.13356c0,9.38947 -0.97553,18.29118 -2.01203,27.68065c-0.36582,3.29241 -0.73165,6.6458 -1.0365,9.99918l0,0l0,0c-0.60971,13.59644 0,28.10745 0.60971,40.8503l0,4.63377l0,0.06097l0,0.06097c0.30485,4.02406 0,8.10909 -0.30485,12.01121c-0.30485,3.90212 -0.60971,7.98715 -0.30485,12.01121l0,0l0,0c1.28038,12.01121 1.82912,23.65659 1.64621,34.50936l0,0.06097l0,0.06097l0.42679,10.48694l0,0.18291l0.06097,0.18291c0.48776,1.34135 0.85359,6.70677 -0.18291,8.292c-0.06097,0.06097 -0.12194,0.18291 -0.18291,0.18291c-1.0365,-0.36582 -3.65824,-4.26794 -4.51182,-5.54833c-0.48776,-0.67068 -0.79262,-1.21941 -1.09747,-1.58524c-1.82912,-2.31688 -4.08503,-4.51182 -6.219,-6.70677c-0.67068,-0.67068 -1.40232,-1.34135 -2.073,-2.073c-3.59727,-3.65824 -9.14559,-8.04812 -13.41353,-9.63336c-1.34135,-0.48776 -2.49979,-1.15844 -3.59727,-1.82912c-2.13397,-1.21941 -4.32891,-2.49979 -7.80424,-2.62174c-0.30485,0 -0.67068,0 -0.97553,0c-3.84115,0 -7.56035,0.48776 -11.46247,1.09747c-7.37744,1.09747 -17.19371,7.62133 -21.94942,14.57197c-0.67068,0.97553 -1.09747,2.01203 -1.28038,3.1095c-2.13397,-0.79262 -4.93862,-3.04853 -6.70677,-4.45085c-0.30485,-0.24388 -0.60971,-0.48776 -0.85359,-0.67068c-2.68271,-2.073 -4.69474,-4.51182 -6.88968,-7.13356c-1.28038,-1.52427 -2.62174,-3.1095 -4.08503,-4.69474c-7.92618,-8.17006 -16.88886,-15.48653 -25.60765,-22.55912c-4.51182,-3.65824 -9.14559,-7.43841 -13.53547,-11.27956c-5.73124,-4.99959 -10.30403,-9.7553 -14.38906,-15.05974c-0.42679,-0.54874 -0.85359,-1.09747 -1.21941,-1.64621c-1.58524,-2.13397 -3.29241,-4.32891 -5.1825,-6.219c-0.73165,-0.73165 -2.31688,-1.89009 -4.26794,-3.29241c-2.37785,-1.70718 -7.31647,-5.30444 -7.6823,-6.40191c-0.48776,-1.70718 -3.84115,-3.90212 -3.84115,-3.90212c-0.67068,-0.42679 -1.46329,-1.15844 -1.52427,-2.13397l-0.12194,-1.70718l-1.58524,0.73165c-2.98756,1.34135 -5.1825,3.59727 -7.2555,5.73124c-0.36582,0.36582 -0.67068,0.73165 -1.0365,1.0365c-5.67027,5.73124 -11.15762,11.64539 -16.27915,17.49856c-2.56077,2.92659 -5.1825,6.09706 -7.37744,9.45044c-0.60971,0.91456 -1.15844,2.073 -1.76815,3.29241c-0.73165,1.46329 -1.46329,3.04853 -2.37785,4.146c-0.18291,-0.48776 -0.42679,-0.97553 -0.48776,-1.28038c-0.06097,-0.24388 -0.18291,-0.42679 -0.24388,-0.54874c-1.21941,-2.98756 -0.79262,-6.27997 -0.24388,-9.69433c0.36582,-2.49979 0.73165,-5.12153 0.48776,-7.74327l0,0l0,0c-0.12194,-0.97553 -0.30485,-1.95106 -0.48776,-2.98756c-0.60971,-3.41435 -1.15844,-6.95065 -0.73165,-10.06015c1.21941,-9.63336 2.43882,-19.51059 3.04853,-29.32686c0.97553,-9.7553 0.42679,-18.90089 -0.18291,-28.65618c-0.12194,-2.43882 -0.30485,-4.93862 -0.42679,-7.43841c0,-1.09747 -0.06097,-2.13397 -0.06097,-3.23144c-0.12194,-3.04853 -0.18291,-5.97512 0.54874,-8.84074c0.06097,-0.06097 0.18291,-0.06097 0.24388,-0.12194l2.25591,0.97553c2.31688,0.97553 5.30444,1.46329 9.14559,1.46329c3.41435,0 7.07259,-0.36582 10.60889,-0.73165c3.23144,-0.30485 6.27997,-0.60971 8.96268,-0.60971c0.67068,0 1.28038,0 1.82912,0.06097c1.46329,0.12194 2.98756,0.30485 4.45085,0.42679c2.19494,0.24388 4.38988,0.54874 6.6458,0.54874c0.54874,0 1.09747,0 1.58524,0c3.96309,0 7.92618,-0.18291 11.76733,-0.36582c3.17047,-0.12194 6.46288,-0.30485 9.69433,-0.36582c5.48735,-0.06097 11.09665,-0.36582 16.46206,-0.60971c2.25591,-0.12194 4.45085,-0.24388 6.70677,-0.30485c4.38988,-0.18291 8.84074,-0.18291 13.16965,-0.18291c3.1095,0 6.40191,0 9.57238,-0.06097c6.6458,-0.18291 13.35256,-0.54874 19.87642,-0.97553c6.88968,-0.42679 14.02324,-0.85359 21.09583,-0.97553c1.76815,-0.06097 3.5363,-0.06097 5.24347,-0.06097c3.84115,-0.06097 7.86521,-0.06097 11.76733,-0.30485c2.31688,-0.12194 4.63377,-0.36582 6.88968,-0.54874c3.78018,-0.30485 7.74327,-0.67068 11.52344,-0.67068l0,0m0,-1.21941c-6.15803,0 -12.31606,0.85359 -18.47409,1.21941c-5.67027,0.30485 -11.34053,0.24388 -16.94983,0.36582c-13.65742,0.24388 -27.25386,1.58524 -40.97225,1.95106c-7.62133,0.18291 -15.18168,-0.06097 -22.80301,0.30485c-7.74327,0.30485 -15.42556,0.79262 -23.16883,0.91456c-7.13356,0.12194 -14.26712,0.73165 -21.46165,0.73165c-0.54874,0 -1.0365,0 -1.58524,0c-3.71921,-0.06097 -7.37744,-0.73165 -11.03568,-1.0365c-0.60971,-0.06097 -1.21941,-0.06097 -1.89009,-0.06097c-5.6093,0 -13.04771,1.34135 -19.51059,1.34135c-3.23144,0 -6.27997,-0.30485 -8.7188,-1.34135l-2.49979,-1.09747c-0.91456,0 -1.52427,0.60971 -1.52427,0.60971c-1.21941,4.26794 -0.60971,8.53588 -0.60971,12.80383c0.60971,12.19412 1.82912,23.77854 0.60971,35.97266c-0.60971,9.7553 -1.82912,19.51059 -3.04853,29.26589c-0.60971,4.26794 0.60971,9.14559 1.21941,13.41353c0.60971,6.09706 -2.43882,12.19412 -0.12194,17.80342c0.30485,0.73165 0.97553,2.80465 1.64621,3.29241c2.31688,-1.70718 3.65824,-6.15803 5.30444,-8.7188c2.13397,-3.29241 4.75571,-6.46288 7.2555,-9.3285c5.24347,-5.97512 10.66986,-11.8283 16.21818,-17.43759c2.49979,-2.49979 4.69474,-5.06056 7.92618,-6.58483c0.12194,1.34135 1.0365,2.43882 2.13397,3.1095c0.24388,0.12194 2.98756,2.073 3.29241,3.17047c0.60971,2.19494 10.42597,8.35297 12.25509,10.24306c2.37785,2.37785 4.32891,5.12153 6.34094,7.74327c4.38988,5.67027 9.14559,10.54791 14.57197,15.24265c12.98674,11.27956 27.07095,21.40068 39.08216,33.77771c3.90212,4.02406 6.6458,8.47491 11.09665,11.88927c2.31688,1.82912 6.6458,5.48735 9.51141,5.67027c-0.12194,-1.40232 0.36582,-2.74368 1.15844,-3.90212c4.32891,-6.34094 13.65742,-12.92577 21.09583,-14.08421c3.65824,-0.54874 7.49938,-1.09747 11.27956,-1.09747c0.30485,0 0.60971,0 0.97553,0c4.81668,0.12194 6.95065,2.80465 10.97471,4.32891c4.26794,1.58524 9.69433,6.03609 12.98674,9.38947c2.74368,2.80465 5.85318,5.67027 8.17006,8.65783c0.97553,1.28038 4.63377,7.56035 6.52385,7.56035c0,0 0.06097,0 0.06097,0c2.74368,-0.18291 2.073,-8.41394 1.46329,-10.12112l-0.42679,-10.48694c0.18291,-11.52344 -0.42679,-23.10786 -1.64621,-34.69227c-0.60971,-7.92618 1.21941,-15.85236 0.60971,-24.02242l0,-4.69474c-0.60971,-13.35256 -1.21941,-27.3758 -0.60971,-40.78933c1.21941,-12.80383 3.04853,-24.99795 3.04853,-37.80177c0,-2.43882 0,-4.87765 -0.12194,-7.19453c-0.06097,-1.58524 0.12194,-3.84115 1.52427,-4.87765c-1.09747,0.24388 -2.25591,0.30485 -3.41435,0.30485c-4.5728,0 -9.26753,-1.46329 -13.65742,-1.76815c-1.34135,0.12194 -2.68271,0.06097 -4.08503,0.06097l0,0l0.00003,0zm22.80301,1.09747c-0.67068,0 -1.21941,0.18291 -1.64621,0.48776c0.60971,-0.12194 1.21941,-0.30485 1.82912,-0.48776c-0.06097,0 -0.12194,0 -0.18291,0l0,0z' id='svg_4'/%3E%3Cpath class='st3' d='m235.75674,146.69126c-3.1095,3.41435 -4.38988,9.81627 -4.81668,14.20615c-0.60971,6.03609 -1.46329,10.97471 2.74368,15.66945c4.63377,5.12153 12.55994,9.87724 19.20574,11.64539c3.47532,0.97553 7.49938,-0.73165 10.7918,-1.40232c7.92618,-1.70718 11.95024,-6.52385 15.42556,-13.9013c4.20697,-8.77977 0.67068,-15.73042 -2.86562,-23.96145c-3.84115,-8.90171 -15.5475,-12.92577 -24.81504,-11.70636c-3.78018,0.48776 -5.91415,2.80465 -8.90171,4.87765c-1.64621,1.15844 -6.52385,2.80465 -6.76774,4.5728l0.00001,-0.00001z' id='svg_5'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E" + width="33" + /> +</div> +`; + +exports[`ProductImage should render a product image 1`] = ` +<div> + <img + alt="" + class="woocommerce-product-image" + height="33" + src="https://i.cloudup.com/pt4DjwRB84-3000x3000.png" + width="33" + /> +</div> +`; + +exports[`ProductImage should render a variations image 1`] = ` +<div> + <img + alt="" + class="woocommerce-product-image" + height="33" + src="https://i.cloudup.com/pt4DjwRB84-3000x3000.png" + width="33" + /> +</div> +`; + +exports[`ProductImage should render the passed alt prop 1`] = ` +<div> + <img + alt="testing" + class="woocommerce-product-image is-placeholder" + height="33" + src="data:image/svg+xml;utf8,%3Csvg width='421' height='421' xmlns='http://www.w3.org/2000/svg' xmlns:svg='http://www.w3.org/2000/svg'%3E%3Cstyle type='text/css'%3E.st0%7Bfill:url(%23SVGID_1_);stroke:%23717275;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10%7D .st1%7Bfill:%23FFFFFF;%7D .st2%7Bfill:%23717275;%7D .st3%7Bfill:%23DCDDE0;stroke:%23717275;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;%7D%3C/style%3E%3CradialGradient cx='105.8248' cy='287.7805' gradientUnits='userSpaceOnUse' id='SVGID_1_' r='372.6935'%3E%3Cstop offset='0.2613' stop-color='%23DCDDE0'/%3E%3Cstop offset='0.633' stop-color='%23D8DADD'/%3E%3Cstop offset='0.9665' stop-color='%23CECFD3'/%3E%3Cstop offset='1' stop-color='%23CCCED2'/%3E%3C/radialGradient%3E%3Cg class='layer' display='inline'%3E%3Ctitle%3ELayer 2%3C/title%3E%3Crect fill='%23ffffff' height='417.99996' id='svg_7' stroke-dasharray='null' stroke-linecap='null' stroke-linejoin='null' stroke-width='null' width='417.99996' x='1.50002' y='1.5'/%3E%3C/g%3E%3Cg class='layer' display='inline'%3E%3Ctitle%3ELayer 1%3C/title%3E%3Cg id='svg_2'/%3E%3Cg id='svg_6'%3E%3Cpath class='st0' d='m330.44409,336.12693c-0.12194,0.36582 0,0.67068 0.30485,0.79262c1.40232,-0.79262 3.17047,-1.0365 4.63377,-0.48776c0.67068,-1.46329 0.12194,-2.43882 0.06097,-3.90212c-0.91456,-15.66945 -0.73165,-31.9486 -0.73165,-47.73998c0,-16.34012 -0.30485,-32.74121 0.54874,-48.9594c0.79262,-15.9743 1.89009,-31.5218 1.28038,-47.55707c-0.60971,-15.79139 -0.06097,-31.70471 0.73165,-47.37416c0.36582,-8.04812 0.79262,-15.66945 0.36582,-23.77854c-0.48776,-9.08462 -0.36582,-21.88845 -0.36582,-30.97307c0,-1.0365 0.18291,-1.82912 -0.79262,-2.43882c-0.79262,-0.48776 -1.52427,-0.42679 -2.43882,-0.42679c-2.49979,0 -4.87765,-0.36582 -7.37744,-0.36582c-1.52427,0 -2.86562,0 -4.32891,0.30485c-0.79262,0.12194 -1.52427,0.06097 -2.31688,0.06097c-0.85359,0 -1.52427,0.18291 -2.31688,0.36582c-6.88968,1.15844 -15.73042,2.49979 -22.62009,1.76815c-6.88968,-0.73165 -13.71839,-0.67068 -20.54709,-2.01203c-6.46288,-1.28038 -12.92577,-0.42679 -19.38865,-0.42679c-4.146,0 -8.23103,0 -12.37703,0c-3.96309,0 -7.80424,-0.73165 -11.8283,-0.73165c-6.52385,0.06097 -13.10868,0.12194 -19.63253,0.36582c-4.51182,0.18291 -8.84074,0 -13.35256,0.42679c-6.82871,0.60971 -13.77936,1.09747 -20.66903,1.0365c-3.59727,0 -7.07259,0.24388 -10.60889,-0.42679c-3.17047,-0.60971 -6.58483,-0.12194 -9.7553,0.18291c-3.96309,0.36582 -7.86521,1.0365 -11.8283,1.0365c-3.84115,-0.06097 -7.74327,-0.85359 -11.58441,-0.85359c-4.38988,-0.12194 -8.59686,0.42679 -12.98674,0.12194c-5.36541,-0.36582 -10.60889,-0.06097 -15.91333,-0.12194c-0.91456,0 -1.34135,0.24388 -2.13397,0.36582c-0.79262,0.12194 -1.40232,0.36582 -2.19494,0.36582c-0.85359,0 -1.34135,-0.30485 -2.13397,-0.36582c-1.64621,-0.18291 -5.91415,-0.06097 -7.49938,-0.48776c-1.70718,-0.42679 -3.41435,-0.91456 -5.12153,-1.34135c-1.40232,1.34135 -1.52427,3.5363 -1.64621,5.48735c-0.85359,21.58359 -0.73165,43.16719 -0.60971,64.75078c0,4.99959 0.06097,9.99918 0.60971,14.9378c0.42679,3.84115 1.15844,7.6823 1.46329,11.52344c0.48776,6.219 -0.06097,12.49897 -0.36582,18.71798c-0.36582,7.31647 -0.36582,14.63295 0.06097,21.94942c0.97553,18.90089 -0.48776,40.30157 -0.79262,59.20246c-0.36582,18.41312 -0.67068,37.1311 3.90212,54.99549c4.63377,-1.82912 17.13274,1.15844 22.55912,1.40232c5.85318,0.24388 11.8283,0.30485 17.74245,0.30485c6.76774,0 13.59644,-0.36582 20.30321,0c8.47491,0.42679 16.09624,2.31688 24.63212,1.82912c4.146,-0.24388 8.65783,0.36582 12.68189,0.36582c3.29241,0 9.87724,-1.09747 12.25509,0.73165c8.77977,0 20.18127,0.12194 28.90007,-0.73165c9.08462,-0.85359 19.38865,-1.21941 28.47327,-0.36582c7.37744,0.73165 14.45003,1.34135 21.82748,0.36582c4.63377,-0.60971 9.14559,-1.09747 13.9013,-1.09747c4.32891,0 8.292,-1.58524 12.49897,-1.46329c4.63377,0.12194 9.38947,1.40232 14.02324,1.89009c3.04853,0.30485 9.63336,-2.49979 12.49897,-1.21941l-0.00003,-0.00001z' fill='black' id='svg_1'/%3E%3Cpath class='st1' d='m313.79912,275.40021c-1.09747,0 -3.90212,-4.20697 -4.99959,-5.85318c-0.42679,-0.67068 -0.79262,-1.21941 -1.0365,-1.52427c-1.76815,-2.25591 -4.02406,-4.51182 -6.219,-6.6458c-0.67068,-0.67068 -1.40232,-1.40232 -2.073,-2.073c-3.5363,-3.59727 -9.02365,-7.98715 -13.16965,-9.51141c-1.40232,-0.48776 -2.56077,-1.21941 -3.71921,-1.82912c-2.19494,-1.28038 -4.20697,-2.43882 -7.49938,-2.49979c-0.30485,0 -0.67068,0 -0.97553,0c-3.78018,0 -7.49938,0.48776 -11.34053,1.09747c-7.19453,1.09747 -16.82789,7.49938 -21.52262,14.32809c-0.73165,1.09747 -1.15844,2.31688 -1.21941,3.5363c-2.31688,-0.54874 -5.6093,-3.17047 -7.62133,-4.75571c-0.30485,-0.24388 -0.60971,-0.48776 -0.85359,-0.67068c-2.74368,-2.13397 -4.75571,-4.5728 -6.95065,-7.2555c-1.28038,-1.52427 -2.62174,-3.1095 -4.08503,-4.63377c-7.92618,-8.17006 -16.88886,-15.48653 -25.54668,-22.55912c-4.51182,-3.65824 -9.14559,-7.43841 -13.59644,-11.27956c-5.79221,-4.99959 -10.365,-9.81627 -14.45003,-15.12071c-0.42679,-0.54874 -0.85359,-1.09747 -1.21941,-1.64621c-1.58524,-2.13397 -3.23144,-4.26794 -5.12153,-6.15803c-0.67068,-0.67068 -2.31688,-1.89009 -4.20697,-3.23144c-2.98756,-2.19494 -7.56035,-5.48735 -7.92618,-6.70677c-0.42679,-1.40232 -3.5363,-3.5363 -3.59727,-3.5363c-0.85359,-0.48776 -1.76815,-1.40232 -1.82912,-2.62174l-0.06097,-0.85359l-0.79262,0.36582c-2.86562,1.34135 -4.93862,3.41435 -7.07259,5.6093c-0.36582,0.36582 -0.67068,0.73165 -1.0365,1.0365c-5.67027,5.73124 -11.15762,11.64539 -16.27915,17.49856c-2.49979,2.86562 -5.12153,6.03609 -7.31647,9.38947c-0.60971,0.91456 -1.15844,2.01203 -1.70718,3.17047c-0.91456,1.89009 -1.89009,3.84115 -3.04853,4.99959c-0.36582,-0.54874 -0.73165,-1.64621 -0.91456,-2.13397c-0.06097,-0.18291 -0.12194,-0.42679 -0.18291,-0.54874c-1.28038,-3.17047 -0.79262,-6.52385 -0.30485,-10.06015c0.36582,-2.49979 0.73165,-4.99959 0.48776,-7.62133c-0.12194,-0.97553 -0.30485,-2.01203 -0.48776,-3.04853c-0.60971,-3.47532 -1.21941,-7.01162 -0.73165,-10.24306c1.21941,-9.63336 2.43882,-19.51059 3.04853,-29.32686c0.97553,-9.69433 0.42679,-18.83992 -0.18291,-28.53424c-0.12194,-2.43882 -0.30485,-4.99959 -0.42679,-7.49938c0,-1.0365 -0.06097,-2.13397 -0.06097,-3.17047c-0.12194,-3.17047 -0.24388,-6.219 0.60971,-9.20656c0.18291,-0.12194 0.48776,-0.30485 0.85359,-0.36582l2.37785,1.0365c2.25591,0.91456 5.1825,1.40232 8.90171,1.40232c3.41435,0 7.01162,-0.36582 10.54791,-0.73165c3.23144,-0.30485 6.34094,-0.60971 9.02365,-0.60971c0.67068,0 1.28038,0 1.89009,0.06097c1.52427,0.12194 3.04853,0.30485 4.51182,0.48776c2.13397,0.24388 4.38988,0.54874 6.58483,0.54874c0.54874,0 1.09747,0 1.58524,0c3.90212,0 7.92618,-0.18291 11.76733,-0.36582c3.17047,-0.12194 6.46288,-0.30485 9.69433,-0.36582c5.48735,-0.06097 11.09665,-0.36582 16.46206,-0.60971c2.25591,-0.12194 4.45085,-0.24388 6.70677,-0.30485c4.38988,-0.18291 8.90171,-0.18291 13.23062,-0.18291c3.1095,0 6.34094,0 9.57238,-0.06097c6.6458,-0.18291 13.35256,-0.54874 19.81545,-0.97553c6.88968,-0.42679 14.08421,-0.85359 21.09583,-0.97553c1.76815,-0.06097 3.5363,-0.06097 5.24347,-0.06097c3.84115,-0.06097 7.80424,-0.06097 11.76733,-0.30485c2.25591,-0.12194 4.5728,-0.36582 6.82871,-0.54874c3.78018,-0.36582 7.74327,-0.67068 11.58441,-0.67068c1.40232,0 2.74368,0.06097 4.02406,0.12194c1.89009,0.12194 3.78018,0.48776 5.79221,0.85359c2.62174,0.48776 5.30444,0.91456 7.92618,0.91456c0.67068,0 1.34135,-0.06097 1.95106,-0.12194c-0.73165,1.40232 -0.73165,3.17047 -0.67068,4.08503c0.12194,2.31688 0.12194,4.81668 0.12194,7.19453c0,9.38947 -0.97553,18.29118 -2.01203,27.74163c-0.36582,3.23144 -0.73165,6.6458 -1.0365,9.99918c-0.60971,13.65742 0,28.10745 0.60971,40.8503l0,4.63377c0.30485,4.146 0,8.23103 -0.30485,12.13315c-0.30485,3.90212 -0.60971,7.92618 -0.30485,11.95024c1.28038,12.07218 1.82912,23.71757 1.64621,34.6313l0.42679,10.60889l0.06097,0.06097c0.48776,1.34135 0.97553,6.95065 -0.24388,8.77977c-0.36582,0.42679 -0.60971,0.48776 -0.79262,0.48776l0,0l-0.00004,0.00002z' id='svg_3'/%3E%3Cpath class='st2' d='m296.54444,101.02428c1.40232,0 2.68271,0.06097 3.96309,0.12194c1.82912,0.12194 3.71921,0.48776 5.73124,0.79262c2.62174,0.48776 5.36541,0.97553 8.04812,0.97553c0.36582,0 0.67068,0 1.0365,0c-0.36582,1.21941 -0.36582,2.49979 -0.36582,3.41435c0.12194,2.31688 0.12194,4.75571 0.12194,7.13356c0,9.38947 -0.97553,18.29118 -2.01203,27.68065c-0.36582,3.29241 -0.73165,6.6458 -1.0365,9.99918l0,0l0,0c-0.60971,13.59644 0,28.10745 0.60971,40.8503l0,4.63377l0,0.06097l0,0.06097c0.30485,4.02406 0,8.10909 -0.30485,12.01121c-0.30485,3.90212 -0.60971,7.98715 -0.30485,12.01121l0,0l0,0c1.28038,12.01121 1.82912,23.65659 1.64621,34.50936l0,0.06097l0,0.06097l0.42679,10.48694l0,0.18291l0.06097,0.18291c0.48776,1.34135 0.85359,6.70677 -0.18291,8.292c-0.06097,0.06097 -0.12194,0.18291 -0.18291,0.18291c-1.0365,-0.36582 -3.65824,-4.26794 -4.51182,-5.54833c-0.48776,-0.67068 -0.79262,-1.21941 -1.09747,-1.58524c-1.82912,-2.31688 -4.08503,-4.51182 -6.219,-6.70677c-0.67068,-0.67068 -1.40232,-1.34135 -2.073,-2.073c-3.59727,-3.65824 -9.14559,-8.04812 -13.41353,-9.63336c-1.34135,-0.48776 -2.49979,-1.15844 -3.59727,-1.82912c-2.13397,-1.21941 -4.32891,-2.49979 -7.80424,-2.62174c-0.30485,0 -0.67068,0 -0.97553,0c-3.84115,0 -7.56035,0.48776 -11.46247,1.09747c-7.37744,1.09747 -17.19371,7.62133 -21.94942,14.57197c-0.67068,0.97553 -1.09747,2.01203 -1.28038,3.1095c-2.13397,-0.79262 -4.93862,-3.04853 -6.70677,-4.45085c-0.30485,-0.24388 -0.60971,-0.48776 -0.85359,-0.67068c-2.68271,-2.073 -4.69474,-4.51182 -6.88968,-7.13356c-1.28038,-1.52427 -2.62174,-3.1095 -4.08503,-4.69474c-7.92618,-8.17006 -16.88886,-15.48653 -25.60765,-22.55912c-4.51182,-3.65824 -9.14559,-7.43841 -13.53547,-11.27956c-5.73124,-4.99959 -10.30403,-9.7553 -14.38906,-15.05974c-0.42679,-0.54874 -0.85359,-1.09747 -1.21941,-1.64621c-1.58524,-2.13397 -3.29241,-4.32891 -5.1825,-6.219c-0.73165,-0.73165 -2.31688,-1.89009 -4.26794,-3.29241c-2.37785,-1.70718 -7.31647,-5.30444 -7.6823,-6.40191c-0.48776,-1.70718 -3.84115,-3.90212 -3.84115,-3.90212c-0.67068,-0.42679 -1.46329,-1.15844 -1.52427,-2.13397l-0.12194,-1.70718l-1.58524,0.73165c-2.98756,1.34135 -5.1825,3.59727 -7.2555,5.73124c-0.36582,0.36582 -0.67068,0.73165 -1.0365,1.0365c-5.67027,5.73124 -11.15762,11.64539 -16.27915,17.49856c-2.56077,2.92659 -5.1825,6.09706 -7.37744,9.45044c-0.60971,0.91456 -1.15844,2.073 -1.76815,3.29241c-0.73165,1.46329 -1.46329,3.04853 -2.37785,4.146c-0.18291,-0.48776 -0.42679,-0.97553 -0.48776,-1.28038c-0.06097,-0.24388 -0.18291,-0.42679 -0.24388,-0.54874c-1.21941,-2.98756 -0.79262,-6.27997 -0.24388,-9.69433c0.36582,-2.49979 0.73165,-5.12153 0.48776,-7.74327l0,0l0,0c-0.12194,-0.97553 -0.30485,-1.95106 -0.48776,-2.98756c-0.60971,-3.41435 -1.15844,-6.95065 -0.73165,-10.06015c1.21941,-9.63336 2.43882,-19.51059 3.04853,-29.32686c0.97553,-9.7553 0.42679,-18.90089 -0.18291,-28.65618c-0.12194,-2.43882 -0.30485,-4.93862 -0.42679,-7.43841c0,-1.09747 -0.06097,-2.13397 -0.06097,-3.23144c-0.12194,-3.04853 -0.18291,-5.97512 0.54874,-8.84074c0.06097,-0.06097 0.18291,-0.06097 0.24388,-0.12194l2.25591,0.97553c2.31688,0.97553 5.30444,1.46329 9.14559,1.46329c3.41435,0 7.07259,-0.36582 10.60889,-0.73165c3.23144,-0.30485 6.27997,-0.60971 8.96268,-0.60971c0.67068,0 1.28038,0 1.82912,0.06097c1.46329,0.12194 2.98756,0.30485 4.45085,0.42679c2.19494,0.24388 4.38988,0.54874 6.6458,0.54874c0.54874,0 1.09747,0 1.58524,0c3.96309,0 7.92618,-0.18291 11.76733,-0.36582c3.17047,-0.12194 6.46288,-0.30485 9.69433,-0.36582c5.48735,-0.06097 11.09665,-0.36582 16.46206,-0.60971c2.25591,-0.12194 4.45085,-0.24388 6.70677,-0.30485c4.38988,-0.18291 8.84074,-0.18291 13.16965,-0.18291c3.1095,0 6.40191,0 9.57238,-0.06097c6.6458,-0.18291 13.35256,-0.54874 19.87642,-0.97553c6.88968,-0.42679 14.02324,-0.85359 21.09583,-0.97553c1.76815,-0.06097 3.5363,-0.06097 5.24347,-0.06097c3.84115,-0.06097 7.86521,-0.06097 11.76733,-0.30485c2.31688,-0.12194 4.63377,-0.36582 6.88968,-0.54874c3.78018,-0.30485 7.74327,-0.67068 11.52344,-0.67068l0,0m0,-1.21941c-6.15803,0 -12.31606,0.85359 -18.47409,1.21941c-5.67027,0.30485 -11.34053,0.24388 -16.94983,0.36582c-13.65742,0.24388 -27.25386,1.58524 -40.97225,1.95106c-7.62133,0.18291 -15.18168,-0.06097 -22.80301,0.30485c-7.74327,0.30485 -15.42556,0.79262 -23.16883,0.91456c-7.13356,0.12194 -14.26712,0.73165 -21.46165,0.73165c-0.54874,0 -1.0365,0 -1.58524,0c-3.71921,-0.06097 -7.37744,-0.73165 -11.03568,-1.0365c-0.60971,-0.06097 -1.21941,-0.06097 -1.89009,-0.06097c-5.6093,0 -13.04771,1.34135 -19.51059,1.34135c-3.23144,0 -6.27997,-0.30485 -8.7188,-1.34135l-2.49979,-1.09747c-0.91456,0 -1.52427,0.60971 -1.52427,0.60971c-1.21941,4.26794 -0.60971,8.53588 -0.60971,12.80383c0.60971,12.19412 1.82912,23.77854 0.60971,35.97266c-0.60971,9.7553 -1.82912,19.51059 -3.04853,29.26589c-0.60971,4.26794 0.60971,9.14559 1.21941,13.41353c0.60971,6.09706 -2.43882,12.19412 -0.12194,17.80342c0.30485,0.73165 0.97553,2.80465 1.64621,3.29241c2.31688,-1.70718 3.65824,-6.15803 5.30444,-8.7188c2.13397,-3.29241 4.75571,-6.46288 7.2555,-9.3285c5.24347,-5.97512 10.66986,-11.8283 16.21818,-17.43759c2.49979,-2.49979 4.69474,-5.06056 7.92618,-6.58483c0.12194,1.34135 1.0365,2.43882 2.13397,3.1095c0.24388,0.12194 2.98756,2.073 3.29241,3.17047c0.60971,2.19494 10.42597,8.35297 12.25509,10.24306c2.37785,2.37785 4.32891,5.12153 6.34094,7.74327c4.38988,5.67027 9.14559,10.54791 14.57197,15.24265c12.98674,11.27956 27.07095,21.40068 39.08216,33.77771c3.90212,4.02406 6.6458,8.47491 11.09665,11.88927c2.31688,1.82912 6.6458,5.48735 9.51141,5.67027c-0.12194,-1.40232 0.36582,-2.74368 1.15844,-3.90212c4.32891,-6.34094 13.65742,-12.92577 21.09583,-14.08421c3.65824,-0.54874 7.49938,-1.09747 11.27956,-1.09747c0.30485,0 0.60971,0 0.97553,0c4.81668,0.12194 6.95065,2.80465 10.97471,4.32891c4.26794,1.58524 9.69433,6.03609 12.98674,9.38947c2.74368,2.80465 5.85318,5.67027 8.17006,8.65783c0.97553,1.28038 4.63377,7.56035 6.52385,7.56035c0,0 0.06097,0 0.06097,0c2.74368,-0.18291 2.073,-8.41394 1.46329,-10.12112l-0.42679,-10.48694c0.18291,-11.52344 -0.42679,-23.10786 -1.64621,-34.69227c-0.60971,-7.92618 1.21941,-15.85236 0.60971,-24.02242l0,-4.69474c-0.60971,-13.35256 -1.21941,-27.3758 -0.60971,-40.78933c1.21941,-12.80383 3.04853,-24.99795 3.04853,-37.80177c0,-2.43882 0,-4.87765 -0.12194,-7.19453c-0.06097,-1.58524 0.12194,-3.84115 1.52427,-4.87765c-1.09747,0.24388 -2.25591,0.30485 -3.41435,0.30485c-4.5728,0 -9.26753,-1.46329 -13.65742,-1.76815c-1.34135,0.12194 -2.68271,0.06097 -4.08503,0.06097l0,0l0.00003,0zm22.80301,1.09747c-0.67068,0 -1.21941,0.18291 -1.64621,0.48776c0.60971,-0.12194 1.21941,-0.30485 1.82912,-0.48776c-0.06097,0 -0.12194,0 -0.18291,0l0,0z' id='svg_4'/%3E%3Cpath class='st3' d='m235.75674,146.69126c-3.1095,3.41435 -4.38988,9.81627 -4.81668,14.20615c-0.60971,6.03609 -1.46329,10.97471 2.74368,15.66945c4.63377,5.12153 12.55994,9.87724 19.20574,11.64539c3.47532,0.97553 7.49938,-0.73165 10.7918,-1.40232c7.92618,-1.70718 11.95024,-6.52385 15.42556,-13.9013c4.20697,-8.77977 0.67068,-15.73042 -2.86562,-23.96145c-3.84115,-8.90171 -15.5475,-12.92577 -24.81504,-11.70636c-3.78018,0.48776 -5.91415,2.80465 -8.90171,4.87765c-1.64621,1.15844 -6.52385,2.80465 -6.76774,4.5728l0.00001,-0.00001z' id='svg_5'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E" + width="33" + /> +</div> +`; diff --git a/packages/js/components/src/product-image/test/index.js b/packages/js/components/src/product-image/test/index.js new file mode 100644 index 00000000000..f22c107d7e0 --- /dev/null +++ b/packages/js/components/src/product-image/test/index.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ProductImage from '../'; + +describe( 'ProductImage', () => { + test( 'should render the passed alt prop', () => { + const { container } = render( <ProductImage alt="testing" /> ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'should fallback to product alt text', () => { + const product = { + name: 'Test Product', + images: [ + { + src: 'https://i.cloudup.com/pt4DjwRB84-3000x3000.png', + alt: 'hello world', + }, + ], + }; + const { container } = render( <ProductImage product={ product } /> ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'should fallback to empty alt attribute if not passed via prop or product object', () => { + const product = { + name: 'Test Product', + images: [ + { + src: 'https://i.cloudup.com/pt4DjwRB84-3000x3000.png', + }, + ], + }; + const { container } = render( <ProductImage product={ product } /> ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'should have the correct width and height', () => { + const image = <ProductImage width={ 30 } height={ 30 } />; + expect( image.props.width ).toBe( 30 ); + expect( image.props.height ).toBe( 30 ); + } ); + + test( 'should render a product image', () => { + const product = { + name: 'Test Product', + images: [ + { + src: 'https://i.cloudup.com/pt4DjwRB84-3000x3000.png', + }, + ], + }; + const { container } = render( <ProductImage product={ product } /> ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'should render a variations image', () => { + const variation = { + name: 'Test Variation', + image: { + src: 'https://i.cloudup.com/pt4DjwRB84-3000x3000.png', + }, + }; + const { container } = render( <ProductImage product={ variation } /> ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'should render a placeholder image if no product images are found', () => { + const product = { + name: 'Test Product', + }; + const { container } = render( <ProductImage product={ product } /> ); + expect( container ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/js/components/src/rating/README.md b/packages/js/components/src/rating/README.md new file mode 100644 index 00000000000..929d94396b1 --- /dev/null +++ b/packages/js/components/src/rating/README.md @@ -0,0 +1,62 @@ +Rating +=== + +Use `Rating` to display a set of stars, filled, empty or half-filled, that represents a +rating in a scale between 0 and the prop `totalStars` (default 5). + +## Usage + +```jsx +<Rating rating={ 2.5 } totalStars={ 6 } /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`rating` | Number | `0` | Number of stars that should be filled. You can pass a partial number of stars like `2.5` +`totalStars` | Number | `5` | The total number of stars the rating is out of +`size` | Number | `18` | The size in pixels the stars should be rendered at +`className` | String | `null` | Additional CSS classes + + +ProductRating +=== + +Display a set of stars representing the product's average rating. + +## Usage + +```jsx +// Use a real WooCommerce Product here. +const product = { average_rating: 3.5 }; + +<ProductRating product={ product } /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`product` | Object | `null` | (required) A product object containing a `average_rating`. See https://woocommerce.github.io/woocommerce-rest-api-docs/#products + + +ReviewRating +=== + +Display a set of stars representing the review's rating. + +## Usage + +```jsx +// Use a real WooCommerce Review here. +const review = { rating: 5 }; + +<ReviewRating review={ review } /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`review` | Object | `null` | (required) A review object containing a `rating`. See https://woocommerce.github.io/woocommerce-rest-api-docs/#retrieve-product-reviews diff --git a/packages/js/components/src/rating/index.js b/packages/js/components/src/rating/index.js new file mode 100644 index 00000000000..8e36d15ca4a --- /dev/null +++ b/packages/js/components/src/rating/index.js @@ -0,0 +1,92 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import classnames from 'classnames'; +import { createElement, Component } from '@wordpress/element'; +import StarIcon from 'gridicons/dist/star'; +import PropTypes from 'prop-types'; + +/** + * Use `Rating` to display a set of stars, filled, empty or half-filled, that represents a + * rating in a scale between 0 and the prop `totalStars` (default 5). + */ +class Rating extends Component { + stars( icon ) { + const { size, totalStars } = this.props; + + const starStyles = { + width: size + 'px', + height: size + 'px', + }; + + const stars = []; + for ( let i = 0; i < totalStars; i++ ) { + const Icon = icon || StarIcon; + stars.push( <Icon key={ 'star-' + i } style={ starStyles } /> ); + } + return stars; + } + + render() { + const { rating, totalStars, className, icon, outlineIcon } = this.props; + + const classes = classnames( 'woocommerce-rating', className ); + const perStar = 100 / totalStars; + const outlineStyles = { + width: Math.round( perStar * rating ) + '%', + }; + + const label = sprintf( + __( '%1$s out of %2$s stars.', 'woocommerce' ), + rating, + totalStars + ); + return ( + <div className={ classes } aria-label={ label }> + { this.stars( icon ) } + <div + className="woocommerce-rating__star-outline" + style={ outlineStyles } + > + { this.stars( outlineIcon || icon ) } + </div> + </div> + ); + } +} + +Rating.propTypes = { + /** + * Number of stars that should be filled. You can pass a partial number of stars like `2.5`. + */ + rating: PropTypes.number, + /** + * The total number of stars the rating is out of. + */ + totalStars: PropTypes.number, + /** + * The size in pixels the stars should be rendered at. + */ + size: PropTypes.number, + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * Icon used, defaults to StarIcon + */ + icon: PropTypes.elementType, + /** + * Outline icon used, the not selected rating. Defaults to props.icon or StarIcon + */ + outlineIcon: PropTypes.elementType, +}; + +Rating.defaultProps = { + rating: 0, + totalStars: 5, + size: 18, +}; + +export default Rating; diff --git a/packages/js/components/src/rating/product.js b/packages/js/components/src/rating/product.js new file mode 100644 index 00000000000..ea5a8b4131d --- /dev/null +++ b/packages/js/components/src/rating/product.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Rating from './index'; + +/** + * Display a set of stars representing the product's average rating. + * + * @param {Object} props + * @param {Object} props.product + * @return {Object} - + */ +const ProductRating = ( { product, ...props } ) => { + const rating = ( product && product.average_rating ) || 0; + return <Rating rating={ rating } { ...props } />; +}; + +ProductRating.propTypes = { + /** + * A product object containing a `average_rating`. + * See https://woocommerce.github.io/woocommerce-rest-api-docs/#products. + */ + product: PropTypes.object.isRequired, +}; + +export default ProductRating; diff --git a/packages/js/components/src/rating/review.js b/packages/js/components/src/rating/review.js new file mode 100644 index 00000000000..5e3cb468298 --- /dev/null +++ b/packages/js/components/src/rating/review.js @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Rating from './index'; + +/** + * Display a set of stars representing the review's rating. + * + * @param {Object} props + * @param {Object} props.review + * @return {Object} - + */ +const ReviewRating = ( { review, ...props } ) => { + const rating = ( review && review.rating ) || 0; + return <Rating rating={ rating } { ...props } />; +}; + +ReviewRating.propTypes = { + /** + * A review object containing a `rating`. + * See https://woocommerce.github.io/woocommerce-rest-api-docs/#retrieve-product-reviews. + */ + review: PropTypes.object.isRequired, +}; + +export default ReviewRating; diff --git a/packages/js/components/src/rating/stories/index.js b/packages/js/components/src/rating/stories/index.js new file mode 100644 index 00000000000..c3d57d32e0e --- /dev/null +++ b/packages/js/components/src/rating/stories/index.js @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import Rating from '../'; + +export default { + title: 'WooCommerce Admin/components/Rating', + component: Rating, + args: { + rating: 4.5, + totalStars: Rating.defaultProps.totalStars, + size: Rating.defaultProps.size, + }, +}; + +export const Default = ( args ) => <Rating { ...args } />; diff --git a/packages/js/components/src/rating/style.scss b/packages/js/components/src/rating/style.scss new file mode 100644 index 00000000000..3fc2ac4715d --- /dev/null +++ b/packages/js/components/src/rating/style.scss @@ -0,0 +1,23 @@ +.woocommerce-rating { + position: relative; + vertical-align: middle; + display: inline-block; + overflow: hidden; + white-space: nowrap; + + .gridicon { + fill: $gray-200; + } + + .woocommerce-rating__star-outline { + position: absolute; + left: 0; + top: 0; + white-space: nowrap; + overflow: hidden; + + .gridicon { + fill: $gray-700; + } + } +} diff --git a/packages/js/components/src/rating/test/__snapshots__/index.js.snap b/packages/js/components/src/rating/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000..3a5ad15c501 --- /dev/null +++ b/packages/js/components/src/rating/test/__snapshots__/index.js.snap @@ -0,0 +1,959 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProductRating should render rating based on product object 1`] = ` +<div> + <div + aria-label="2.5 out of 5 stars." + class="woocommerce-rating" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <div + class="woocommerce-rating__star-outline" + style="width: 50%;" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + </div> + </div> +</div> +`; + +exports[`Rating should render different icons if specified 1`] = ` +<div> + <div + aria-label="2 out of 5 stars." + class="woocommerce-rating" + > + <svg + class="gridicon gridicons-star-outline" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 6.308l1.176 3.167.347.936.997.041 3.374.139-2.647 2.092-.784.62.27.962.911 3.249-2.814-1.871-.83-.553-.83.552-2.814 1.871.911-3.249.27-.962-.784-.62-2.648-2.092 3.374-.139.997-.041.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.891 18.18 21l-2.002-7.141L22 9.257l-7.418-.305L12 2z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star-outline" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 6.308l1.176 3.167.347.936.997.041 3.374.139-2.647 2.092-.784.62.27.962.911 3.249-2.814-1.871-.83-.553-.83.552-2.814 1.871.911-3.249.27-.962-.784-.62-2.648-2.092 3.374-.139.997-.041.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.891 18.18 21l-2.002-7.141L22 9.257l-7.418-.305L12 2z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star-outline" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 6.308l1.176 3.167.347.936.997.041 3.374.139-2.647 2.092-.784.62.27.962.911 3.249-2.814-1.871-.83-.553-.83.552-2.814 1.871.911-3.249.27-.962-.784-.62-2.648-2.092 3.374-.139.997-.041.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.891 18.18 21l-2.002-7.141L22 9.257l-7.418-.305L12 2z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star-outline" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 6.308l1.176 3.167.347.936.997.041 3.374.139-2.647 2.092-.784.62.27.962.911 3.249-2.814-1.871-.83-.553-.83.552-2.814 1.871.911-3.249.27-.962-.784-.62-2.648-2.092 3.374-.139.997-.041.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.891 18.18 21l-2.002-7.141L22 9.257l-7.418-.305L12 2z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star-outline" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 6.308l1.176 3.167.347.936.997.041 3.374.139-2.647 2.092-.784.62.27.962.911 3.249-2.814-1.871-.83-.553-.83.552-2.814 1.871.911-3.249.27-.962-.784-.62-2.648-2.092 3.374-.139.997-.041.347-.936L12 6.308M12 2L9.418 8.953 2 9.257l5.822 4.602L5.82 21 12 16.891 18.18 21l-2.002-7.141L22 9.257l-7.418-.305L12 2z" + /> + </g> + </svg> + <div + class="woocommerce-rating__star-outline" + style="width: 40%;" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + </div> + </div> +</div> +`; + +exports[`Rating should render stars at a different size 1`] = ` +<div> + <div + aria-label="1 out of 5 stars." + class="woocommerce-rating" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <div + class="woocommerce-rating__star-outline" + style="width: 20%;" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 36px; height: 36px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + </div> + </div> +</div> +`; + +exports[`Rating should render the correct amount of total stars 1`] = ` +<div> + <div + aria-label="4 out of 6 stars." + class="woocommerce-rating" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <div + class="woocommerce-rating__star-outline" + style="width: 67%;" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + </div> + </div> +</div> +`; + +exports[`Rating should render the passed rating prop 1`] = ` +<div> + <div + aria-label="4 out of 5 stars." + class="woocommerce-rating" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <div + class="woocommerce-rating__star-outline" + style="width: 80%;" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + </div> + </div> +</div> +`; + +exports[`ReviewRating should render rating based on review object 1`] = ` +<div> + <div + aria-label="1.5 out of 5 stars." + class="woocommerce-rating" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <div + class="woocommerce-rating__star-outline" + style="width: 30%;" + > + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + <svg + class="gridicon gridicons-star" + height="24" + style="width: 18px; height: 18px;" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 2l2.582 6.953L22 9.257l-5.822 4.602L18.18 21 12 16.891 5.82 21l2.002-7.141L2 9.257l7.418-.304z" + /> + </g> + </svg> + </div> + </div> +</div> +`; diff --git a/packages/js/components/src/rating/test/index.js b/packages/js/components/src/rating/test/index.js new file mode 100644 index 00000000000..56d56918a5f --- /dev/null +++ b/packages/js/components/src/rating/test/index.js @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import StarIcon from 'gridicons/dist/star'; +import StarOutlineIcon from 'gridicons/dist/star-outline'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Rating from '../'; +import ProductRating from '../product'; +import ReviewRating from '../review'; + +describe( 'Rating', () => { + test( 'should render the passed rating prop', () => { + const { container } = render( <Rating rating={ 4 } /> ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'should render the correct amount of total stars', () => { + const { container } = render( + <Rating rating={ 4 } totalStars={ 6 } /> + ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'should render stars at a different size', () => { + const { container } = render( <Rating rating={ 1 } size={ 36 } /> ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'should render different icons if specified', () => { + const { container } = render( + <Rating + rating={ 2 } + icon={ StarOutlineIcon } + outlineIcon={ StarIcon } + /> + ); + expect( container ).toMatchSnapshot(); + } ); +} ); + +describe( 'ReviewRating', () => { + test( 'should render rating based on review object', () => { + const review = { + review: 'Nice T-shirt!', + rating: 1.5, + }; + const { container } = render( <ReviewRating review={ review } /> ); + expect( container ).toMatchSnapshot(); + } ); +} ); + +describe( 'ProductRating', () => { + test( 'should render rating based on product object', () => { + const product = { + name: 'Test Product', + average_rating: 2.5, + }; + const { container } = render( <ProductRating product={ product } /> ); + expect( container ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/js/components/src/scroll-to/README.md b/packages/js/components/src/scroll-to/README.md new file mode 100644 index 00000000000..066503c735a --- /dev/null +++ b/packages/js/components/src/scroll-to/README.md @@ -0,0 +1,15 @@ +ScrollTo +=== + + + +## Usage + +```jsx +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`offset` | String | ``'0'`` | The offset from the top of the component diff --git a/packages/js/components/src/scroll-to/index.js b/packages/js/components/src/scroll-to/index.js new file mode 100644 index 00000000000..fbf31485157 --- /dev/null +++ b/packages/js/components/src/scroll-to/index.js @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { createElement, Component, createRef } from '@wordpress/element'; +import PropTypes from 'prop-types'; + +class ScrollTo extends Component { + constructor( props ) { + super( props ); + this.scrollTo = this.scrollTo.bind( this ); + } + + componentDidMount() { + setTimeout( this.scrollTo, 250 ); + } + + scrollTo() { + const { offset } = this.props; + if ( this.ref.current && this.ref.current.offsetTop ) { + window.scrollTo( + 0, + this.ref.current.offsetTop + parseInt( offset, 10 ) + ); + } else { + setTimeout( this.scrollTo, 250 ); + } + } + + render() { + const { children } = this.props; + this.ref = createRef(); + return <span ref={ this.ref }>{ children }</span>; + } +} + +ScrollTo.propTypes = { + /** + * The offset from the top of the component. + */ + offset: PropTypes.string, +}; + +ScrollTo.defaultProps = { + offset: '0', +}; + +export default ScrollTo; diff --git a/packages/js/components/src/scroll-to/stories/index.js b/packages/js/components/src/scroll-to/stories/index.js new file mode 100644 index 00000000000..1f291b8f299 --- /dev/null +++ b/packages/js/components/src/scroll-to/stories/index.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { ScrollTo } from '@woocommerce/components'; + +export const Basic = () => ( + <ScrollTo> + <div> + Have the web browser automatically scroll to this component on page + render. + </div> + </ScrollTo> +); + +export default { + title: 'WooCommerce Admin/components/ScrollTo', + component: ScrollTo, +}; diff --git a/packages/js/components/src/search-list-control/README.md b/packages/js/components/src/search-list-control/README.md new file mode 100644 index 00000000000..f2d7a97e56d --- /dev/null +++ b/packages/js/components/src/search-list-control/README.md @@ -0,0 +1,72 @@ +SearchListControl +=== + +Component to display a searchable, selectable list of items. + +## Usage + +```jsx +<SearchListControl + list={ list } + isLoading={ loading } + selected={ selected } + onChange={ items => setState( { selected: items } ) } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`className` | String | `null` | Additional CSS classes +`isHierarchical` | Boolean | `null` | Whether the list of items is hierarchical or not. If true, each list item is expected to have a parent property +`isLoading` | Boolean | `null` | Whether the list of items is still loading +`isSingle` | Boolean | `null` | Restrict selections to one item +`list` | Array | `null` | A complete list of item objects, each with id, name properties. This is displayed as a clickable/keyboard-able list, and possibly filtered by the search term (searches name) +`messages` | Object | `null` | Messages displayed or read to the user. Configure these to reflect your object type. See `defaultMessages` above for examples +`onChange` | Function | `null` | (required) Callback fired when selected items change, whether added, cleared, or removed. Passed an array of item objects (as passed in via props.list) +`onSearch` | Function | `null` | Callback fired when the search field is used +`renderItem` | Function | `null` | Callback to render each item in the selection list, allows any custom object-type rendering +`selected` | Array | `null` | (required) The list of currently selected items +`search` | String | `null` | +`setState` | Function | `null` | +`debouncedSpeak` | Function | `null` | +`instanceId` | Number | `null` | + +### `list` item structure: + + - `id`: Number + - `name`: String + +### `messages` object structure: + + - `clear`: String - A more detailed label for the "Clear all" button, read to screen reader users. + - `list`: String - Label for the list of selectable items, only read to screen reader users. + - `noItems`: String - Message to display when the list is empty (implies nothing loaded from the server +or parent component). + - `noResults`: String - Message to display when no matching results are found. %s is the search term. + - `search`: String - Label for the search input + - `selected`: Function - Label for the selected items. This is actually a function, so that we can pass +through the count of currently selected items. + - `updated`: String - Label indicating that search results have changed, read to screen reader users. + + +SearchListItem +=== + +## Usage + +Used implicitly by `SearchListControl` when the `renderItem` prop is omitted. + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`className` | String | `null` | Additional CSS classes +`countLabel` | ReactNode | `null` | Label to display in the count bubble. Takes preference over `item.count`. +`depth` | Number | `0` | Depth, non-zero if the list is hierarchical +`item` | Object | `null` | Current item to display +`isSelected` | Boolean | `null` | Whether this item is selected +`isSingle` | Boolean | `null` | Whether this should only display a single item (controls radio vs checkbox icon) +`onSelect` | Function | `null` | Callback for selecting the item +`search` | String | `''` | Search string, used to highlight the substring in the item name diff --git a/packages/js/components/src/search-list-control/hierarchy.js b/packages/js/components/src/search-list-control/hierarchy.js new file mode 100644 index 00000000000..71c58ca6adf --- /dev/null +++ b/packages/js/components/src/search-list-control/hierarchy.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { forEach, groupBy, keyBy } from 'lodash'; + +/** + * Returns terms in a tree form. + * + * @param {Array} filteredList Array of terms, possibly a subset of all terms, in flat format. + * @param {Array} list Array of the full list of terms, defaults to the filteredList. + * + * @return {Array} Array of terms in tree format. + */ +export function buildTermsTree( filteredList, list = filteredList ) { + const termsByParent = groupBy( filteredList, 'parent' ); + const listById = keyBy( list, 'id' ); + + const getParentsName = ( term = {} ) => { + if ( ! term.parent ) { + return term.name ? [ term.name ] : []; + } + + const parentName = getParentsName( listById[ term.parent ] ); + return [ ...parentName, term.name ]; + }; + + const fillWithChildren = ( terms ) => { + return terms.map( ( term ) => { + const children = termsByParent[ term.id ]; + delete termsByParent[ term.id ]; + return { + ...term, + breadcrumbs: getParentsName( listById[ term.parent ] ), + children: + children && children.length + ? fillWithChildren( children ) + : [], + }; + } ); + }; + + const tree = fillWithChildren( termsByParent[ '0' ] || [] ); + delete termsByParent[ '0' ]; + + // anything left in termsByParent has no visible parent + forEach( termsByParent, ( terms ) => { + tree.push( ...fillWithChildren( terms || [] ) ); + } ); + + return tree; +} diff --git a/packages/js/components/src/search-list-control/index.js b/packages/js/components/src/search-list-control/index.js new file mode 100644 index 00000000000..aa48b856867 --- /dev/null +++ b/packages/js/components/src/search-list-control/index.js @@ -0,0 +1,331 @@ +/** + * External dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import { + Button, + Spinner, + TextControl, + withSpokenMessages, +} from '@wordpress/components'; +import { + createElement, + Fragment, + useState, + useEffect, +} from '@wordpress/element'; +import { compose, withInstanceId } from '@wordpress/compose'; +import { escapeRegExp, findIndex } from 'lodash'; +import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import { buildTermsTree } from './hierarchy'; +import SearchListItem from './item'; +import Tag from '../tag'; + +const defaultMessages = { + clear: __( 'Clear all selected items', 'woocommerce' ), + noItems: __( 'No items found.', 'woocommerce' ), + noResults: __( 'No results for %s', 'woocommerce' ), + search: __( 'Search for items', 'woocommerce' ), + selected: ( n ) => + sprintf( + /* translators: Number of items selected from list. */ + _n( '%d item selected', '%d items selected', n, 'woocommerce' ), + n + ), + updated: __( 'Search results updated.', 'woocommerce' ), +}; + +/** + * Component to display a searchable, selectable list of items. + * + * @param {Object} props + */ +export const SearchListControl = ( props ) => { + const [ searchValue, setSearchValue ] = useState( props.search || '' ); + const { + isSingle, + isLoading, + onChange, + selected, + instanceId, + messages: propsMessages, + isCompact, + debouncedSpeak, + onSearch, + className = '', + } = props; + + const messages = { ...defaultMessages, ...propsMessages }; + + useEffect( () => { + if ( typeof onSearch === 'function' ) { + onSearch( searchValue ); + } + }, [ onSearch, searchValue ] ); + + const onRemove = ( id ) => { + return () => { + if ( isSingle ) { + onChange( [] ); + } + const i = findIndex( selected, { id } ); + onChange( [ + ...selected.slice( 0, i ), + ...selected.slice( i + 1 ), + ] ); + }; + }; + + const onSelect = ( item ) => { + return () => { + if ( isSelected( item ) ) { + onRemove( item.id )(); + return; + } + if ( isSingle ) { + onChange( [ item ] ); + } else { + onChange( [ ...selected, item ] ); + } + }; + }; + + const isSelected = ( item ) => + findIndex( selected, { id: item.id } ) !== -1; + + const getFilteredList = ( list, search ) => { + const { isHierarchical } = props; + if ( ! search ) { + return isHierarchical ? buildTermsTree( list ) : list; + } + const re = new RegExp( escapeRegExp( search ), 'i' ); + debouncedSpeak( messages.updated ); + const filteredList = list + .map( ( item ) => ( re.test( item.name ) ? item : false ) ) + .filter( Boolean ); + return isHierarchical + ? buildTermsTree( filteredList, list ) + : filteredList; + }; + + const defaultRenderItem = ( args ) => { + return <SearchListItem { ...args } />; + }; + + const renderList = ( list, depth = 0 ) => { + const renderItem = props.renderItem || defaultRenderItem; + if ( ! list ) { + return null; + } + + return list.map( ( item ) => ( + <Fragment key={ item.id }> + <li> + { renderItem( { + item, + isSelected: isSelected( item ), + onSelect, + isSingle, + search: searchValue, + depth, + controlId: instanceId, + } ) } + </li> + { renderList( item.children, depth + 1 ) } + </Fragment> + ) ); + }; + + const renderListSection = () => { + if ( isLoading ) { + return ( + <div className="woocommerce-search-list__list is-loading"> + <Spinner /> + </div> + ); + } + const list = getFilteredList( props.list, searchValue ); + + if ( ! list.length ) { + return ( + <div className="woocommerce-search-list__list is-not-found"> + <span className="woocommerce-search-list__not-found-icon"> + <NoticeOutlineIcon + role="img" + aria-hidden="true" + focusable="false" + /> + </span> + <span className="woocommerce-search-list__not-found-text"> + { searchValue + ? // eslint-disable-next-line @wordpress/valid-sprintf + sprintf( messages.noResults, searchValue ) + : messages.noItems } + </span> + </div> + ); + } + + return ( + <ul className="woocommerce-search-list__list"> + { renderList( list ) } + </ul> + ); + }; + + const renderSelectedSection = () => { + if ( isLoading || isSingle || ! selected ) { + return null; + } + + const selectedCount = selected.length; + return ( + <div className="woocommerce-search-list__selected"> + <div className="woocommerce-search-list__selected-header"> + <strong>{ messages.selected( selectedCount ) }</strong> + { selectedCount > 0 ? ( + <Button + isLink + isDestructive + onClick={ onChange( [] ) } + aria-label={ messages.clear } + > + { __( 'Clear all', 'woocommerce' ) } + </Button> + ) : null } + </div> + { selectedCount > 0 ? ( + <ul> + { selected.map( ( item, i ) => ( + <li key={ i }> + <Tag + label={ item.name } + id={ item.id } + remove={ onRemove } + /> + </li> + ) ) } + </ul> + ) : null } + </div> + ); + }; + + return ( + <div + className={ classnames( 'woocommerce-search-list', className, { + 'is-compact': isCompact, + } ) } + > + { renderSelectedSection() } + + <div className="woocommerce-search-list__search"> + <TextControl + label={ messages.search } + type="search" + value={ searchValue } + onChange={ ( value ) => setSearchValue( value ) } + /> + </div> + + { renderListSection() } + </div> + ); +}; + +SearchListControl.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * Whether it should be displayed in a compact way, so it occupies less space. + */ + isCompact: PropTypes.bool, + /** + * Whether the list of items is hierarchical or not. If true, each list item is expected to + * have a parent property. + */ + isHierarchical: PropTypes.bool, + /** + * Whether the list of items is still loading. + */ + isLoading: PropTypes.bool, + /** + * Restrict selections to one item. + */ + isSingle: PropTypes.bool, + /** + * A complete list of item objects, each with id, name properties. This is displayed as a + * clickable/keyboard-able list, and possibly filtered by the search term (searches name). + */ + list: PropTypes.arrayOf( + PropTypes.shape( { + id: PropTypes.number, + name: PropTypes.string, + } ) + ), + /** + * Messages displayed or read to the user. Configure these to reflect your object type. + * See `defaultMessages` above for examples. + */ + messages: PropTypes.shape( { + /** + * A more detailed label for the "Clear all" button, read to screen reader users. + */ + clear: PropTypes.string, + /** + * Message to display when the list is empty (implies nothing loaded from the server + * or parent component). + */ + noItems: PropTypes.string, + /** + * Message to display when no matching results are found. %s is the search term. + */ + noResults: PropTypes.string, + /** + * Label for the search input + */ + search: PropTypes.string, + /** + * Label for the selected items. This is actually a function, so that we can pass + * through the count of currently selected items. + */ + selected: PropTypes.func, + /** + * Label indicating that search results have changed, read to screen reader users. + */ + updated: PropTypes.string, + } ), + /** + * Callback fired when selected items change, whether added, cleared, or removed. + * Passed an array of item objects (as passed in via props.list). + */ + onChange: PropTypes.func.isRequired, + /** + * Callback fired when the search field is used. + */ + onSearch: PropTypes.func, + /** + * Callback to render each item in the selection list, allows any custom object-type rendering. + */ + renderItem: PropTypes.func, + /** + * The list of currently selected items. + */ + selected: PropTypes.array.isRequired, + // from withSpokenMessages + debouncedSpeak: PropTypes.func, + // from withInstanceId + instanceId: PropTypes.number, +}; + +export default compose( [ withSpokenMessages, withInstanceId ] )( + SearchListControl +); diff --git a/packages/js/components/src/search-list-control/item.js b/packages/js/components/src/search-list-control/item.js new file mode 100644 index 00000000000..9b404be2af5 --- /dev/null +++ b/packages/js/components/src/search-list-control/item.js @@ -0,0 +1,154 @@ +/** + * External dependencies + */ +import { createElement, Fragment } from '@wordpress/element'; +import { escapeRegExp, first, last, isNil } from 'lodash'; +import PropTypes from 'prop-types'; + +function getHighlightedName( name, search ) { + if ( ! search ) { + return name; + } + const re = new RegExp( escapeRegExp( search ), 'ig' ); + const nameParts = name.split( re ); + return nameParts.map( ( part, i ) => { + if ( i === 0 ) { + return part; + } + return ( + <Fragment key={ i }> + <strong>{ search }</strong> + { part } + </Fragment> + ); + } ); +} + +function getBreadcrumbsForDisplay( breadcrumbs ) { + if ( breadcrumbs.length === 1 ) { + return first( breadcrumbs ); + } + if ( breadcrumbs.length === 2 ) { + return first( breadcrumbs ) + ' › ' + last( breadcrumbs ); + } + + return first( breadcrumbs ) + ' … ' + last( breadcrumbs ); +} + +const SearchListItem = ( { + countLabel, + className, + depth = 0, + controlId = '', + item, + isSelected, + isSingle, + onSelect, + search = '', + ...props +} ) => { + const showCount = ! isNil( countLabel ) || ! isNil( item.count ); + const classes = [ className, 'woocommerce-search-list__item' ]; + classes.push( `depth-${ depth }` ); + if ( isSingle ) { + classes.push( 'is-radio-button' ); + } + if ( showCount ) { + classes.push( 'has-count' ); + } + const hasBreadcrumbs = item.breadcrumbs && item.breadcrumbs.length; + const name = props.name || `search-list-item-${ controlId }`; + const id = `${ name }-${ item.id }`; + + return ( + <label htmlFor={ id } className={ classes.join( ' ' ) }> + { isSingle ? ( + <input + type="radio" + id={ id } + name={ name } + value={ item.value } + onChange={ onSelect( item ) } + checked={ isSelected } + className="woocommerce-search-list__item-input" + { ...props } + ></input> + ) : ( + <input + type="checkbox" + id={ id } + name={ name } + value={ item.value } + onChange={ onSelect( item ) } + checked={ isSelected } + className="woocommerce-search-list__item-input" + { ...props } + ></input> + ) } + + <span className="woocommerce-search-list__item-label"> + { hasBreadcrumbs ? ( + <span className="woocommerce-search-list__item-prefix"> + { getBreadcrumbsForDisplay( item.breadcrumbs ) } + </span> + ) : null } + <span className="woocommerce-search-list__item-name"> + { getHighlightedName( item.name, search ) } + </span> + </span> + + { !! showCount && ( + <span className="woocommerce-search-list__item-count"> + { countLabel || item.count } + </span> + ) } + </label> + ); +}; + +SearchListItem.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * Label to display in the count bubble. Takes preference over `item.count`. + */ + countLabel: PropTypes.node, + /** + * Unique id of the parent control. + */ + controlId: PropTypes.node, + /** + * Depth, non-zero if the list is hierarchical. + */ + depth: PropTypes.number, + /** + * Current item to display. + */ + item: PropTypes.object, + /** + * Name of the inputs. Used to group input controls together. See: + * https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-name + * If not provided, a default name will be generated using the controlId. + */ + name: PropTypes.string, + /** + * Whether this item is selected. + */ + isSelected: PropTypes.bool, + /** + * Whether this should only display a single item (controls radio vs checkbox icon). + */ + isSingle: PropTypes.bool, + /** + * Callback for selecting the item. + */ + onSelect: PropTypes.func, + /** + * Search string, used to highlight the substring in the item name. + */ + search: PropTypes.string, +}; + +export default SearchListItem; diff --git a/packages/js/components/src/search-list-control/stories/index.js b/packages/js/components/src/search-list-control/stories/index.js new file mode 100644 index 00000000000..3b12300a605 --- /dev/null +++ b/packages/js/components/src/search-list-control/stories/index.js @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import { SearchListControl } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; + +const SearchListControlExample = ( { showCount, isCompact, isSingle } ) => { + const [ selected, setSelected ] = useState( [] ); + const [ loading, setLoading ] = useState( false ); + let list = [ + { id: 1, name: 'Apricots' }, + { id: 2, name: 'Clementine' }, + { id: 3, name: 'Elderberry' }, + { id: 4, name: 'Guava' }, + { id: 5, name: 'Lychee' }, + { id: 6, name: 'Mulberry' }, + ]; + const counts = [ 3, 1, 1, 5, 2, 0 ]; + + if ( showCount ) { + list = list.map( ( item, i ) => ( { ...item, count: counts[ i ] } ) ); + } + + return ( + <div> + <button onClick={ () => setLoading( ! loading ) }> + Toggle loading state + </button> + <SearchListControl + list={ list } + isCompact={ isCompact } + isLoading={ loading } + selected={ selected } + onChange={ ( items ) => setSelected( items ) } + isSingle={ isSingle } + /> + </div> + ); +}; + +export const Basic = ( args ) => <SearchListControlExample { ...args } />; + +export default { + title: 'WooCommerce Admin/components/SearchListControl', + component: SearchListControl, + args: { + showCount: false, + isCompact: false, + isSingle: false, + }, + argTypes: { + showCount: { + control: { + type: 'boolean', + }, + }, + isCompact: { + control: { + type: 'boolean', + }, + }, + isSingle: { + control: { + type: 'boolean', + }, + }, + }, +}; diff --git a/packages/js/components/src/search-list-control/style.scss b/packages/js/components/src/search-list-control/style.scss new file mode 100644 index 00000000000..ff285096b0b --- /dev/null +++ b/packages/js/components/src/search-list-control/style.scss @@ -0,0 +1,224 @@ +.woocommerce-search-list { + width: 100%; + padding: 0 0 $gap; + text-align: left; +} + +.woocommerce-search-list__selected { + margin: $gap 0; + padding: $gap 0 0; + // 76px is the height of 1 row of tags. + min-height: 76px; + border-top: 1px solid $gray-100; + + .woocommerce-search-list__selected-header { + margin-bottom: $gap-smaller; + + button { + margin-left: $gap-small; + } + } + + .woocommerce-tag__text { + max-width: 13em; + } + + ul { + list-style: none; + + li { + float: left; + } + } +} + +.woocommerce-search-list__search { + margin: $gap 0; + padding: $gap 0 0; + border-top: 1px solid $gray-100; + + .components-base-control__field { + margin-bottom: $gap; + } +} + +.woocommerce-search-list__list { + border: 1px solid $gray-200; + padding: 0; + max-height: 17em; + overflow-x: hidden; + overflow-y: auto; + + li { + margin-bottom: 0; + } + + &.is-loading { + padding: $gap-small 0; + text-align: center; + border: none; + } + + &.is-not-found { + padding: $gap-small 0; + text-align: center; + border: none; + + .woocommerce-search-list__not-found-icon, + .woocommerce-search-list__not-found-text { + display: inline-block; + } + + .woocommerce-search-list__not-found-icon { + margin-right: $gap; + + .gridicon { + vertical-align: top; + margin-top: -1px; + } + } + } + + .components-spinner { + float: none; + margin: 0 auto; + } + + .components-menu-group__label { + @include visually-hidden; + } + + & > [role='menu'] { + border: 1px solid $gray-100; + border-bottom: none; + } + + .woocommerce-search-list__item { + display: flex; + align-items: center; + margin-bottom: 0; + padding: $gap-small $gap; + background: $studio-white; + // !important to keep the border around on hover + border-bottom: 1px solid $gray-100; + color: $gray-700; + + @include hover-state { + background: $gray-100; + } + + &:active, + &:focus { + box-shadow: none; + } + + .woocommerce-search-list__item-input { + margin: 0 $gap-smaller 0 0; + } + + .woocommerce-search-list__item-label { + display: flex; + flex: 1; + } + + &.depth-0 + .depth-1 { + // Hide the border on the preceding list item + margin-top: -1px; + } + + &:not(.depth-0) { + border-bottom: 0 !important; + } + + &:not(.depth-0) + .depth-0 { + border-top: 1px solid $gray-100; + } + + // Anything deeper than 5 levels will use this fallback depth + &[class*='depth-'] .woocommerce-search-list__item-label::before { + margin-right: $gap-smallest; + content: str-repeat('— ', 5); + } + + &.depth-0 .woocommerce-search-list__item-label::before { + margin-right: 0; + content: ''; + } + + @for $i from 1 to 5 { + &.depth-#{$i} .woocommerce-search-list__item-label::before { + content: str-repeat('— ', $i); + } + } + + .woocommerce-search-list__item-name { + display: inline-block; + } + + .woocommerce-search-list__item-prefix { + display: none; + color: $gray-700; + } + + &.is-searching, + &.is-skip-level { + .woocommerce-search-list__item-label { + // Un-flex the label, so the prefix (breadcrumbs) and name are aligned. + display: inline-block; + } + + .woocommerce-search-list__item-prefix { + display: inline; + + &::after { + margin-right: $gap-smallest; + content: ' ›'; + } + } + } + + &.is-searching { + .woocommerce-search-list__item-name { + color: $gray-900; + } + } + + &.has-count { + > .components-menu-item__item { + width: 100%; + } + } + + .woocommerce-search-list__item-count { + flex: 0 1 auto; + padding: math.div($gap-smallest, 2) $gap-smaller; + border: 1px solid $gray-100; + border-radius: 12px; + font-size: 0.8em; + line-height: 1.4; + color: $gray-700; + background: $studio-white; + white-space: nowrap; + } + } + + li:last-child .woocommerce-search-list__item { + border-bottom: none; + } +} + +.woocommerce-search-list.is-compact { + .woocommerce-search-list__selected { + margin: 0 0 $gap; + padding: 0; + border-top: none; + // 54px is the height of 1 row of tags in the sidebar. + min-height: 54px; + } + + .woocommerce-search-list__search { + margin: 0 0 $gap; + padding: 0; + border-top: none; + } +} diff --git a/packages/js/components/src/search-list-control/test/__snapshots__/index.js.snap b/packages/js/components/src/search-list-control/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000..d6f47750f9d --- /dev/null +++ b/packages/js/components/src/search-list-control/test/__snapshots__/index.js.snap @@ -0,0 +1,4266 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SearchListControl should render a search box and list of hierarchical options 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-10" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-10" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-1" + for="search-list-item-1-2" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-prefix" + > + Apricots + </span> + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-1" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-prefix" + > + Apricots + </span> + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-2" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-prefix" + > + Apricots › Elderberry + </span> + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-10" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-10" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-1" + for="search-list-item-1-2" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-prefix" + > + Apricots + </span> + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-1" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-prefix" + > + Apricots + </span> + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-2" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-prefix" + > + Apricots › Elderberry + </span> + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box and list of options 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-0" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-0" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-0" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-0" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box and list of options with a custom className 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list test-search" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-1" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-1" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list test-search" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-1" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-1" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box and list of options, with a custom render callback for each item 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-9" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-9" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <div> + Apricots + ! + </div> + </li> + <li> + <div> + Clementine + ! + </div> + </li> + <li> + <div> + Elderberry + ! + </div> + </li> + <li> + <div> + Guava + ! + </div> + </li> + <li> + <div> + Lychee + ! + </div> + </li> + <li> + <div> + Mulberry + ! + </div> + </li> + </ul> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-9" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-9" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <div> + Apricots + ! + </div> + </li> + <li> + <div> + Clementine + ! + </div> + </li> + <li> + <div> + Elderberry + ! + </div> + </li> + <li> + <div> + Guava + ! + </div> + </li> + <li> + <div> + Lychee + ! + </div> + </li> + <li> + <div> + Mulberry + ! + </div> + </li> + </ul> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box and list of options, with a custom search input message 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-8" + > + Testing search label + </label> + <input + class="components-text-control__input" + id="inspector-text-control-8" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-8" + > + Testing search label + </label> + <input + class="components-text-control__input" + id="inspector-text-control-8" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box and no options 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-4" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-4" + type="search" + value="" + /> + </div> + </div> + </div> + <div + class="woocommerce-search-list__list is-not-found" + > + <span + class="woocommerce-search-list__not-found-icon" + > + <svg + aria-hidden="true" + class="gridicon gridicons-notice-outline" + focusable="false" + height="24" + role="img" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 4c4.411 0 8 3.589 8 8s-3.589 8-8 8-8-3.589-8-8 3.589-8 8-8m0-2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 13h-2v2h2v-2zm-2-2h2l.5-6h-3l.5 6z" + /> + </g> + </svg> + </span> + <span + class="woocommerce-search-list__not-found-text" + > + No items found. + </span> + </div> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-4" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-4" + type="search" + value="" + /> + </div> + </div> + </div> + <div + class="woocommerce-search-list__list is-not-found" + > + <span + class="woocommerce-search-list__not-found-icon" + > + <svg + aria-hidden="true" + class="gridicon gridicons-notice-outline" + focusable="false" + height="24" + role="img" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 4c4.411 0 8 3.589 8 8s-3.589 8-8 8-8-3.589-8-8 3.589-8 8-8m0-2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 13h-2v2h2v-2zm-2-2h2l.5-6h-3l.5 6z" + /> + </g> + </svg> + </span> + <span + class="woocommerce-search-list__not-found-text" + > + No items found. + </span> + </div> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box with a search term, and no matching options 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-7" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-7" + type="search" + value="no matches" + /> + </div> + </div> + </div> + <div + class="woocommerce-search-list__list is-not-found" + > + <span + class="woocommerce-search-list__not-found-icon" + > + <svg + aria-hidden="true" + class="gridicon gridicons-notice-outline" + focusable="false" + height="24" + role="img" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 4c4.411 0 8 3.589 8 8s-3.589 8-8 8-8-3.589-8-8 3.589-8 8-8m0-2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 13h-2v2h2v-2zm-2-2h2l.5-6h-3l.5 6z" + /> + </g> + </svg> + </span> + <span + class="woocommerce-search-list__not-found-text" + > + No results for no matches + </span> + </div> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-7" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-7" + type="search" + value="no matches" + /> + </div> + </div> + </div> + <div + class="woocommerce-search-list__list is-not-found" + > + <span + class="woocommerce-search-list__not-found-icon" + > + <svg + aria-hidden="true" + class="gridicon gridicons-notice-outline" + focusable="false" + height="24" + role="img" + viewBox="0 0 24 24" + width="24" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M12 4c4.411 0 8 3.589 8 8s-3.589 8-8 8-8-3.589-8-8 3.589-8 8-8m0-2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm1 13h-2v2h2v-2zm-2-2h2l.5-6h-3l.5 6z" + /> + </g> + </svg> + </span> + <span + class="woocommerce-search-list__not-found-text" + > + No results for no matches + </span> + </div> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box with a search term, and only matching options 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-5" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-5" + type="search" + value="berry" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elder + <strong> + berry + </strong> + + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mul + <strong> + berry + </strong> + + </span> + </span> + </label> + </li> + </ul> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-5" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-5" + type="search" + value="berry" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elder + <strong> + berry + </strong> + + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mul + <strong> + berry + </strong> + + </span> + </span> + </label> + </li> + </ul> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box with a search term, and only matching options, regardless of case sensitivity 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-6" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-6" + type="search" + value="bERry" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elder + <strong> + bERry + </strong> + + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mul + <strong> + bERry + </strong> + + </span> + </span> + </label> + </li> + </ul> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 0 items selected + </strong> + </div> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-6" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-6" + type="search" + value="bERry" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elder + <strong> + bERry + </strong> + + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mul + <strong> + bERry + </strong> + + </span> + </span> + </label> + </li> + </ul> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box, a list of options, and 1 selected item 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 1 item selected + </strong> + <button + aria-label="Clear all selected items" + class="components-button is-link is-destructive" + type="button" + > + Clear all + </button> + </div> + <ul> + <li> + <span + class="woocommerce-tag has-remove" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-0" + > + <span + class="screen-reader-text" + > + Clementine + </span> + <span + aria-hidden="true" + > + Clementine + </span> + </span> + <button + aria-describedby="woocommerce-tag__label-0" + aria-label="Remove Clementine" + class="components-button woocommerce-tag__remove" + type="button" + > + <svg + aria-hidden="true" + class="clear-icon" + focusable="false" + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z" + /> + </svg> + </button> + </span> + </li> + </ul> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-2" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-2" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + checked="" + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 1 item selected + </strong> + <button + aria-label="Clear all selected items" + class="components-button is-link is-destructive" + type="button" + > + Clear all + </button> + </div> + <ul> + <li> + <span + class="woocommerce-tag has-remove" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-0" + > + <span + class="screen-reader-text" + > + Clementine + </span> + <span + aria-hidden="true" + > + Clementine + </span> + </span> + <button + aria-describedby="woocommerce-tag__label-0" + aria-label="Remove Clementine" + class="components-button woocommerce-tag__remove" + type="button" + > + <svg + aria-hidden="true" + class="clear-icon" + focusable="false" + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z" + /> + </svg> + </button> + </span> + </li> + </ul> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-2" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-2" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + checked="" + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`SearchListControl should render a search box, a list of options, and 2 selected item 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 2 items selected + </strong> + <button + aria-label="Clear all selected items" + class="components-button is-link is-destructive" + type="button" + > + Clear all + </button> + </div> + <ul> + <li> + <span + class="woocommerce-tag has-remove" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-1" + > + <span + class="screen-reader-text" + > + Clementine + </span> + <span + aria-hidden="true" + > + Clementine + </span> + </span> + <button + aria-describedby="woocommerce-tag__label-1" + aria-label="Remove Clementine" + class="components-button woocommerce-tag__remove" + type="button" + > + <svg + aria-hidden="true" + class="clear-icon" + focusable="false" + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z" + /> + </svg> + </button> + </span> + </li> + <li> + <span + class="woocommerce-tag has-remove" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-2" + > + <span + class="screen-reader-text" + > + Guava + </span> + <span + aria-hidden="true" + > + Guava + </span> + </span> + <button + aria-describedby="woocommerce-tag__label-2" + aria-label="Remove Guava" + class="components-button woocommerce-tag__remove" + type="button" + > + <svg + aria-hidden="true" + class="clear-icon" + focusable="false" + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z" + /> + </svg> + </button> + </span> + </li> + </ul> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-3" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-3" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + checked="" + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + checked="" + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-search-list" + > + <div + class="woocommerce-search-list__selected" + > + <div + class="woocommerce-search-list__selected-header" + > + <strong> + 2 items selected + </strong> + <button + aria-label="Clear all selected items" + class="components-button is-link is-destructive" + type="button" + > + Clear all + </button> + </div> + <ul> + <li> + <span + class="woocommerce-tag has-remove" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-1" + > + <span + class="screen-reader-text" + > + Clementine + </span> + <span + aria-hidden="true" + > + Clementine + </span> + </span> + <button + aria-describedby="woocommerce-tag__label-1" + aria-label="Remove Clementine" + class="components-button woocommerce-tag__remove" + type="button" + > + <svg + aria-hidden="true" + class="clear-icon" + focusable="false" + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z" + /> + </svg> + </button> + </span> + </li> + <li> + <span + class="woocommerce-tag has-remove" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-2" + > + <span + class="screen-reader-text" + > + Guava + </span> + <span + aria-hidden="true" + > + Guava + </span> + </span> + <button + aria-describedby="woocommerce-tag__label-2" + aria-label="Remove Guava" + class="components-button woocommerce-tag__remove" + type="button" + > + <svg + aria-hidden="true" + class="clear-icon" + focusable="false" + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z" + /> + </svg> + </button> + </span> + </li> + </ul> + </div> + <div + class="woocommerce-search-list__search" + > + <div + class="components-base-control css-wdf2ti-Wrapper e1puf3u4" + > + <div + class="components-base-control__field css-igk9ll-StyledField e1puf3u3" + > + <label + class="components-base-control__label css-eweeby-StyledLabel-labelStyles e1puf3u2" + for="inspector-text-control-3" + > + Search for items + </label> + <input + class="components-text-control__input" + id="inspector-text-control-3" + type="search" + value="" + /> + </div> + </div> + </div> + <ul + class="woocommerce-search-list__list" + > + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-1" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-1" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Apricots + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-2" + > + <input + checked="" + class="woocommerce-search-list__item-input" + id="search-list-item-1-2" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Clementine + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-3" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-3" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Elderberry + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-4" + > + <input + checked="" + class="woocommerce-search-list__item-input" + id="search-list-item-1-4" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Guava + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-5" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-5" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Lychee + </span> + </span> + </label> + </li> + <li> + <label + class=" woocommerce-search-list__item depth-0" + for="search-list-item-1-6" + > + <input + class="woocommerce-search-list__item-input" + id="search-list-item-1-6" + name="search-list-item-1" + type="checkbox" + value="" + /> + <span + class="woocommerce-search-list__item-label" + > + <span + class="woocommerce-search-list__item-name" + > + Mulberry + </span> + </span> + </label> + </li> + </ul> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/js/components/src/search-list-control/test/hierarchy.js b/packages/js/components/src/search-list-control/test/hierarchy.js new file mode 100644 index 00000000000..b5d53216619 --- /dev/null +++ b/packages/js/components/src/search-list-control/test/hierarchy.js @@ -0,0 +1,204 @@ +/** + * Internal dependencies + */ +import { buildTermsTree } from '../hierarchy'; + +const list = [ + { id: 1, name: 'Apricots', parent: 0 }, + { id: 2, name: 'Clementine', parent: 0 }, + { id: 3, name: 'Elderberry', parent: 2 }, + { id: 4, name: 'Guava', parent: 2 }, + { id: 5, name: 'Lychee', parent: 3 }, + { id: 6, name: 'Mulberry', parent: 0 }, + { id: 7, name: 'Tamarind', parent: 5 }, +]; + +describe( 'buildTermsTree', () => { + test( 'should return an empty array on empty input', () => { + const tree = buildTermsTree( [] ); + expect( tree ).toEqual( [] ); + } ); + + test( 'should return a flat array when there are no parent relationships', () => { + const tree = buildTermsTree( [ + { id: 1, name: 'Apricots', parent: 0 }, + { id: 2, name: 'Clementine', parent: 0 }, + ] ); + expect( tree ).toEqual( [ + { + id: 1, + name: 'Apricots', + parent: 0, + breadcrumbs: [], + children: [], + }, + { + id: 2, + name: 'Clementine', + parent: 0, + breadcrumbs: [], + children: [], + }, + ] ); + } ); + + test( 'should return a tree of items', () => { + const tree = buildTermsTree( list ); + expect( tree ).toEqual( [ + { + id: 1, + name: 'Apricots', + parent: 0, + breadcrumbs: [], + children: [], + }, + { + id: 2, + name: 'Clementine', + parent: 0, + breadcrumbs: [], + children: [ + { + id: 3, + name: 'Elderberry', + parent: 2, + breadcrumbs: [ 'Clementine' ], + children: [ + { + id: 5, + name: 'Lychee', + parent: 3, + breadcrumbs: [ 'Clementine', 'Elderberry' ], + children: [ + { + id: 7, + name: 'Tamarind', + parent: 5, + breadcrumbs: [ + 'Clementine', + 'Elderberry', + 'Lychee', + ], + children: [], + }, + ], + }, + ], + }, + { + id: 4, + name: 'Guava', + parent: 2, + breadcrumbs: [ 'Clementine' ], + children: [], + }, + ], + }, + { + id: 6, + name: 'Mulberry', + parent: 0, + breadcrumbs: [], + children: [], + }, + ] ); + } ); + + test( 'should return a tree of items, with orphan categories appended to the end', () => { + const filteredList = [ + { id: 1, name: 'Apricots', parent: 0 }, + { id: 2, name: 'Clementine', parent: 0 }, + { id: 4, name: 'Guava', parent: 2 }, + { id: 5, name: 'Lychee', parent: 3 }, + { id: 6, name: 'Mulberry', parent: 0 }, + ]; + const tree = buildTermsTree( filteredList, list ); + expect( tree ).toEqual( [ + { + id: 1, + name: 'Apricots', + parent: 0, + breadcrumbs: [], + children: [], + }, + { + id: 2, + name: 'Clementine', + parent: 0, + breadcrumbs: [], + children: [ + { + id: 4, + name: 'Guava', + parent: 2, + breadcrumbs: [ 'Clementine' ], + children: [], + }, + ], + }, + { + id: 6, + name: 'Mulberry', + parent: 0, + breadcrumbs: [], + children: [], + }, + { + id: 5, + name: 'Lychee', + parent: 3, + breadcrumbs: [ 'Clementine', 'Elderberry' ], + children: [], + }, + ] ); + } ); + + test( 'should return a tree of items, with orphan categories appended to the end, with children of thier own', () => { + const filteredList = [ + { id: 1, name: 'Apricots', parent: 0 }, + { id: 3, name: 'Elderberry', parent: 2 }, + { id: 4, name: 'Guava', parent: 2 }, + { id: 5, name: 'Lychee', parent: 3 }, + { id: 6, name: 'Mulberry', parent: 0 }, + ]; + const tree = buildTermsTree( filteredList, list ); + expect( tree ).toEqual( [ + { + id: 1, + name: 'Apricots', + parent: 0, + breadcrumbs: [], + children: [], + }, + { + id: 6, + name: 'Mulberry', + parent: 0, + breadcrumbs: [], + children: [], + }, + { + id: 3, + name: 'Elderberry', + parent: 2, + breadcrumbs: [ 'Clementine' ], + children: [ + { + id: 5, + name: 'Lychee', + parent: 3, + breadcrumbs: [ 'Clementine', 'Elderberry' ], + children: [], + }, + ], + }, + { + id: 4, + name: 'Guava', + parent: 2, + breadcrumbs: [ 'Clementine' ], + children: [], + }, + ] ); + } ); +} ); diff --git a/packages/js/components/src/search-list-control/test/index.js b/packages/js/components/src/search-list-control/test/index.js new file mode 100644 index 00000000000..e364900a710 --- /dev/null +++ b/packages/js/components/src/search-list-control/test/index.js @@ -0,0 +1,197 @@ +/** + * External dependencies + */ +import { render, fireEvent } from '@testing-library/react'; +import { noop } from 'lodash'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { SearchListControl } from '../'; + +const list = [ + { id: 1, name: 'Apricots' }, + { id: 2, name: 'Clementine' }, + { id: 3, name: 'Elderberry' }, + { id: 4, name: 'Guava' }, + { id: 5, name: 'Lychee' }, + { id: 6, name: 'Mulberry' }, +]; + +const hierarchicalList = [ + { id: 1, name: 'Apricots', parent: 0 }, + { id: 2, name: 'Clementine', parent: 1 }, + { id: 3, name: 'Elderberry', parent: 1 }, + { id: 4, name: 'Guava', parent: 3 }, + { id: 5, name: 'Lychee', parent: 0 }, + { id: 6, name: 'Mulberry', parent: 0 }, +]; + +describe( 'SearchListControl', () => { + test( 'should render a search box and list of options', () => { + const component = render( + <SearchListControl + instanceId={ 1 } + list={ list } + selected={ [] } + onChange={ noop } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box and list of options with a custom className', () => { + const component = render( + <SearchListControl + instanceId={ 1 } + className="test-search" + list={ list } + selected={ [] } + onChange={ noop } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box, a list of options, and 1 selected item', () => { + const component = render( + <SearchListControl + instanceId={ 1 } + list={ list } + selected={ [ list[ 1 ] ] } + onChange={ noop } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box, a list of options, and 2 selected item', () => { + const component = render( + <SearchListControl + instanceId={ 1 } + list={ list } + selected={ [ list[ 1 ], list[ 3 ] ] } + onChange={ noop } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box and no options', () => { + const component = render( + <SearchListControl + instanceId={ 1 } + list={ [] } + selected={ [] } + onChange={ noop } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box with a search term, and only matching options', () => { + const component = render( + <SearchListControl + instanceId={ 1 } + list={ list } + search="berry" + selected={ [] } + onChange={ noop } + debouncedSpeak={ noop } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box with a search term, and only matching options, regardless of case sensitivity', () => { + const component = render( + <SearchListControl + instanceId={ 1 } + list={ list } + search="bERry" + selected={ [] } + onChange={ noop } + debouncedSpeak={ noop } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box with a search term, and no matching options', () => { + const component = render( + <SearchListControl + instanceId={ 1 } + list={ list } + search="no matches" + selected={ [] } + onChange={ noop } + debouncedSpeak={ noop } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box and list of options, with a custom search input message', () => { + const messages = { search: 'Testing search label' }; + const component = render( + <SearchListControl + instanceId={ 1 } + list={ list } + selected={ [] } + onChange={ noop } + messages={ messages } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box and list of options, with a custom render callback for each item', () => { + const renderItem = ( { item } ) => ( + <div key={ item.id }>{ item.name }!</div> + ); // eslint-disable-line + const component = render( + <SearchListControl + instanceId={ 1 } + list={ list } + selected={ [] } + onChange={ noop } + renderItem={ renderItem } + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should render a search box and list of hierarchical options', () => { + const component = render( + <SearchListControl + instanceId={ 1 } + list={ hierarchicalList } + selected={ [] } + onChange={ noop } + isHierarchical + /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'should match options after changing search control', () => { + const { getByLabelText, getAllByText } = render( + <SearchListControl + instanceId={ 1 } + list={ list } + search="" + selected={ [] } + onChange={ noop } + debouncedSpeak={ noop } + /> + ); + + fireEvent.change( getByLabelText( 'Search for items' ), { + target: { + value: 'berry', + }, + } ); + expect( getAllByText( 'berry' ).length ).toBe( 2 ); + } ); +} ); diff --git a/packages/js/components/src/search/README.md b/packages/js/components/src/search/README.md new file mode 100644 index 00000000000..f4c2b382622 --- /dev/null +++ b/packages/js/components/src/search/README.md @@ -0,0 +1,37 @@ +Search +=== + +A search box which autocompletes results while typing, allowing for the user to select an existing object +(product, order, customer, etc). Currently only products are supported. + +## Usage + +```jsx +<Search + type="products" + placeholder="Search for a product" + selected={ selected } + onChange={ items => setState( { selected: items } ) } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`allowFreeTextSearch` | Boolean | `false` | Render additional options in the autocompleter to allow free text entering depending on the type +`className` | String | `null` | Class name applied to parent div +`onChange` | Function | `noop` | Function called when selected results change, passed result list +`type` | One of: 'categories', 'countries', 'coupons', 'customers', 'downloadIps', 'emails', 'orders', 'products', 'taxes', 'usernames', 'variations', 'custom' | `null` | (required) The object type to be used in searching +`autocompleter` | Completer | `null` | Custom [completer](https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface) to be used in searching. Required when `type` is 'custom' +`placeholder` | String | `null` | A placeholder for the search input +`selected` | Array | `[]` | An array of objects describing selected values. If the label of the selected value is omitted, the Tag of that value will not be rendered inside the search box. +`inlineTags` | Boolean | `false` | Render tags inside input, otherwise render below input +`showClearButton` | Boolean | `false` | Render a 'Clear' button next to the input box to remove its contents +`staticResults` | Boolean | `false` | Render results list positioned statically instead of absolutely +`disabled` | Boolean | `false` | Whether the control is disabled or not + +### `selected` item structure: + +- `id`: One of type: number, string +- `label`: String \ No newline at end of file diff --git a/packages/js/components/src/search/autocompleters/attributes.js b/packages/js/components/src/search/autocompleters/attributes.js new file mode 100644 index 00000000000..e9870af472d --- /dev/null +++ b/packages/js/components/src/search/autocompleters/attributes.js @@ -0,0 +1,171 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import interpolateComponents from '@automattic/interpolate-components'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * A product attributes completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'attributes', + className: 'woocommerce-search__product-result', + options( search ) { + const query = search + ? { + search, + per_page: 10, + orderby: 'count', + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/products/attributes', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( attribute ) { + return attribute.id; + }, + getOptionKeywords( attribute ) { + return [ attribute.name ]; + }, + getFreeTextOptions( query ) { + const label = ( + <span key="name" className="woocommerce-search__result-name"> + { interpolateComponents( { + mixedString: __( + 'All attributes with names that include {{query /}}', + 'woocommerce' + ), + components: { + query: ( + <strong className="components-form-token-field__suggestion-match"> + { query } + </strong> + ), + }, + } ) } + </span> + ); + const nameOption = { + key: 'name', + label, + value: { id: query, name: query }, + }; + + return [ nameOption ]; + }, + getOptionLabel( attribute, query ) { + const match = computeSuggestionMatch( attribute.name, query ) || {}; + + return ( + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ attribute.name } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( attribute ) { + const value = { + key: attribute.id, + label: attribute.name, + }; + return value; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/categories.js b/packages/js/components/src/search/autocompleters/categories.js new file mode 100644 index 00000000000..435b52101ee --- /dev/null +++ b/packages/js/components/src/search/autocompleters/categories.js @@ -0,0 +1,171 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import interpolateComponents from '@automattic/interpolate-components'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * A product categories completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'categories', + className: 'woocommerce-search__product-result', + options( search ) { + const query = search + ? { + search, + per_page: 10, + orderby: 'count', + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/products/categories', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( category ) { + return category.id; + }, + getOptionKeywords( cat ) { + return [ cat.name ]; + }, + getFreeTextOptions( query ) { + const label = ( + <span key="name" className="woocommerce-search__result-name"> + { interpolateComponents( { + mixedString: __( + 'All categories with titles that include {{query /}}', + 'woocommerce' + ), + components: { + query: ( + <strong className="components-form-token-field__suggestion-match"> + { query } + </strong> + ), + }, + } ) } + </span> + ); + const titleOption = { + key: 'title', + label, + value: { id: query, name: query }, + }; + + return [ titleOption ]; + }, + getOptionLabel( cat, query ) { + const match = computeSuggestionMatch( cat.name, query ) || {}; + // @todo Bring back ProductImage, but allow for product category image + return ( + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ cat.name } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( cat ) { + const value = { + key: cat.id, + label: cat.name, + }; + return value; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/countries.js b/packages/js/components/src/search/autocompleters/countries.js new file mode 100644 index 00000000000..114b9ae96a8 --- /dev/null +++ b/packages/js/components/src/search/autocompleters/countries.js @@ -0,0 +1,168 @@ +/** + * External dependencies + */ +import { decodeEntities } from '@wordpress/html-entities'; +import { createElement, Fragment } from '@wordpress/element'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; +import Flag from '../../flag'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +// Cache countries to avoid repeated requests. +let allCountries = null; + +/** + * A country completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'countries', + className: 'woocommerce-search__country-result', + isDebounced: true, + options() { + // Returned cached countries if we've already received them. + if ( allCountries ) { + return Promise.resolve( allCountries ); + } + // Make the request for country data. + return apiFetch( { path: '/wc-analytics/data/countries' } ).then( + ( result ) => { + // Cache the response. + allCountries = result; + return allCountries; + } + ); + }, + getOptionIdentifier( country ) { + return country.code; + }, + getSearchExpression( query ) { + return '^' + query; + }, + getOptionKeywords( country ) { + return [ country.code, decodeEntities( country.name ) ]; + }, + getOptionLabel( country, query ) { + const name = decodeEntities( country.name ); + const match = computeSuggestionMatch( name, query ) || {}; + + return ( + <Fragment> + <Flag + key="thumbnail" + className="woocommerce-search__result-thumbnail" + code={ country.code } + size={ 18 } + hideFromScreenReader + /> + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ name } + > + { query ? ( + <Fragment> + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </Fragment> + ) : ( + name + ) } + </span> + </Fragment> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( country ) { + const value = { + key: country.code, + label: decodeEntities( country.name ), + }; + return value; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/coupons.js b/packages/js/components/src/search/autocompleters/coupons.js new file mode 100644 index 00000000000..4764f2495b3 --- /dev/null +++ b/packages/js/components/src/search/autocompleters/coupons.js @@ -0,0 +1,169 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import interpolateComponents from '@automattic/interpolate-components'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * A coupon completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'coupons', + className: 'woocommerce-search__coupon-result', + options( search ) { + const query = search + ? { + search, + per_page: 10, + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/coupons', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( coupon ) { + return coupon.id; + }, + getOptionKeywords( coupon ) { + return [ coupon.code ]; + }, + getFreeTextOptions( query ) { + const label = ( + <span key="name" className="woocommerce-search__result-name"> + { interpolateComponents( { + mixedString: __( + 'All coupons with codes that include {{query /}}', + 'woocommerce' + ), + components: { + query: ( + <strong className="components-form-token-field__suggestion-match"> + { query } + </strong> + ), + }, + } ) } + </span> + ); + const codeOption = { + key: 'code', + label, + value: { id: query, code: query }, + }; + + return [ codeOption ]; + }, + getOptionLabel( coupon, query ) { + const match = computeSuggestionMatch( coupon.code, query ) || {}; + return ( + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ coupon.code } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( coupon ) { + const value = { + key: coupon.id, + label: coupon.code, + }; + return value; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/customers.js b/packages/js/components/src/search/autocompleters/customers.js new file mode 100644 index 00000000000..7d33f8669fd --- /dev/null +++ b/packages/js/components/src/search/autocompleters/customers.js @@ -0,0 +1,169 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import interpolateComponents from '@automattic/interpolate-components'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * A customer completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'customers', + className: 'woocommerce-search__customers-result', + options( name ) { + const query = name + ? { + search: name, + searchby: 'name', + per_page: 10, + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/customers', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( customer ) { + return customer.id; + }, + getOptionKeywords( customer ) { + return [ customer.name ]; + }, + getFreeTextOptions( query ) { + const label = ( + <span key="name" className="woocommerce-search__result-name"> + { interpolateComponents( { + mixedString: __( + 'All customers with names that include {{query /}}', + 'woocommerce' + ), + components: { + query: ( + <strong className="components-form-token-field__suggestion-match"> + { query } + </strong> + ), + }, + } ) } + </span> + ); + const nameOption = { + key: 'name', + label, + value: { id: query, name: query }, + }; + + return [ nameOption ]; + }, + getOptionLabel( customer, query ) { + const match = computeSuggestionMatch( customer.name, query ) || {}; + return ( + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ customer.name } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( customer ) { + return { + key: customer.id, + label: customer.name, + }; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/download-ips.js b/packages/js/components/src/search/autocompleters/download-ips.js new file mode 100644 index 00000000000..62c76952adf --- /dev/null +++ b/packages/js/components/src/search/autocompleters/download-ips.js @@ -0,0 +1,138 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * A download IP address autocompleter. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'download-ips', + className: 'woocommerce-search__download-ip-result', + options( match ) { + const query = match + ? { + match, + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/data/download-ips', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( download ) { + return download.user_ip_address; + }, + getOptionKeywords( download ) { + return [ download.user_ip_address ]; + }, + getOptionLabel( download, query ) { + const match = + computeSuggestionMatch( download.user_ip_address, query ) || {}; + return ( + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ download.user_ip_address } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ); + }, + getOptionCompletion( download ) { + return { + key: download.user_ip_address, + label: download.user_ip_address, + }; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/emails.js b/packages/js/components/src/search/autocompleters/emails.js new file mode 100644 index 00000000000..c6d689bdaac --- /dev/null +++ b/packages/js/components/src/search/autocompleters/emails.js @@ -0,0 +1,141 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * A customer email completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'emails', + className: 'woocommerce-search__emails-result', + options( search ) { + const query = search + ? { + search, + searchby: 'email', + per_page: 10, + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/customers', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( customer ) { + return customer.id; + }, + getOptionKeywords( customer ) { + return [ customer.email ]; + }, + getOptionLabel( customer, query ) { + const match = computeSuggestionMatch( customer.email, query ) || {}; + return ( + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ customer.email } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( customer ) { + return { + key: customer.id, + label: customer.email, + }; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/index.js b/packages/js/components/src/search/autocompleters/index.js new file mode 100644 index 00000000000..30d13e5d291 --- /dev/null +++ b/packages/js/components/src/search/autocompleters/index.js @@ -0,0 +1,16 @@ +/** + * Export all autocompleters + */ +export { default as attributes } from './attributes'; +export { default as productCategory } from './categories'; +export { default as countries } from './countries'; +export { default as coupons } from './coupons'; +export { default as customers } from './customers'; +export { default as downloadIps } from './download-ips'; +export { default as emails } from './emails'; +export { default as orders } from './orders'; +export { default as product } from './product'; +export { default as taxes } from './taxes'; +export { default as usernames } from './usernames'; +export { default as variableProduct } from './variable-product'; +export { default as variations } from './variations'; diff --git a/packages/js/components/src/search/autocompleters/orders.js b/packages/js/components/src/search/autocompleters/orders.js new file mode 100644 index 00000000000..06ee794f198 --- /dev/null +++ b/packages/js/components/src/search/autocompleters/orders.js @@ -0,0 +1,138 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * A orders autocompleter. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'orders', + className: 'woocommerce-search__order-result', + options( search ) { + const query = search + ? { + number: search, + per_page: 10, + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/orders', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( order ) { + return order.id; + }, + getOptionKeywords( order ) { + return [ '#' + order.number ]; + }, + getOptionLabel( order, query ) { + const match = computeSuggestionMatch( '#' + order.number, query ) || {}; + return ( + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ '#' + order.number } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ); + }, + getOptionCompletion( order ) { + return { + key: order.id, + label: '#' + order.number, + }; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/product.js b/packages/js/components/src/search/autocompleters/product.js new file mode 100644 index 00000000000..2c8046fd9fa --- /dev/null +++ b/packages/js/components/src/search/autocompleters/product.js @@ -0,0 +1,180 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import { createElement, Fragment } from '@wordpress/element'; +import interpolateComponents from '@automattic/interpolate-components'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; +import ProductImage from '../../product-image'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ +/** + * A products completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'products', + className: 'woocommerce-search__product-result', + options( search ) { + const query = search + ? { + search, + per_page: 10, + orderby: 'popularity', + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/products', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( product ) { + return product.id; + }, + getOptionKeywords( product ) { + return [ product.name, product.sku ]; + }, + getFreeTextOptions( query ) { + const label = ( + <span key="name" className="woocommerce-search__result-name"> + { interpolateComponents( { + mixedString: __( + 'All products with titles that include {{query /}}', + 'woocommerce' + ), + components: { + query: ( + <strong className="components-form-token-field__suggestion-match"> + { query } + </strong> + ), + }, + } ) } + </span> + ); + const titleOption = { + key: 'title', + label, + value: { id: query, name: query }, + }; + + return [ titleOption ]; + }, + getOptionLabel( product, query ) { + const match = computeSuggestionMatch( product.name, query ) || {}; + return ( + <Fragment> + <ProductImage + key="thumbnail" + className="woocommerce-search__result-thumbnail" + product={ product } + width={ 18 } + height={ 18 } + alt="" + /> + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ product.name } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + </Fragment> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( product ) { + const value = { + key: product.id, + label: product.name, + }; + return value; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/taxes.js b/packages/js/components/src/search/autocompleters/taxes.js new file mode 100644 index 00000000000..8a3341e24f3 --- /dev/null +++ b/packages/js/components/src/search/autocompleters/taxes.js @@ -0,0 +1,169 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import interpolateComponents from '@automattic/interpolate-components'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch, getTaxCode } from './utils'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * A tax completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'taxes', + className: 'woocommerce-search__tax-result', + options( search ) { + const query = search + ? { + code: search, + per_page: 10, + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/taxes', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( tax ) { + return tax.id; + }, + getOptionKeywords( tax ) { + return [ tax.id, getTaxCode( tax ) ]; + }, + getFreeTextOptions( query ) { + const label = ( + <span key="name" className="woocommerce-search__result-name"> + { interpolateComponents( { + mixedString: __( + 'All taxes with codes that include {{query /}}', + 'woocommerce' + ), + components: { + query: ( + <strong className="components-form-token-field__suggestion-match"> + { query } + </strong> + ), + }, + } ) } + </span> + ); + const codeOption = { + key: 'code', + label, + value: { id: query, name: query }, + }; + + return [ codeOption ]; + }, + getOptionLabel( tax, query ) { + const match = computeSuggestionMatch( getTaxCode( tax ), query ) || {}; + return ( + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ tax.code } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( tax ) { + const value = { + key: tax.id, + label: getTaxCode( tax ), + }; + return value; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/usernames.js b/packages/js/components/src/search/autocompleters/usernames.js new file mode 100644 index 00000000000..9899b46ea56 --- /dev/null +++ b/packages/js/components/src/search/autocompleters/usernames.js @@ -0,0 +1,141 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * A customer username completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'usernames', + className: 'woocommerce-search__usernames-result', + options( search ) { + const query = search + ? { + search, + searchby: 'username', + per_page: 10, + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/customers', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( customer ) { + return customer.id; + }, + getOptionKeywords( customer ) { + return [ customer.username ]; + }, + getOptionLabel( customer, query ) { + const match = computeSuggestionMatch( customer.username, query ) || {}; + return ( + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ customer.username } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( customer ) { + return { + key: customer.id, + label: customer.username, + }; + }, +}; diff --git a/packages/js/components/src/search/autocompleters/utils.js b/packages/js/components/src/search/autocompleters/utils.js new file mode 100644 index 00000000000..792e7bd4811 --- /dev/null +++ b/packages/js/components/src/search/autocompleters/utils.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Parse a string suggestion, split apart by where the first matching query is. + * Used to display matched partial in bold. + * + * @param {string} suggestion The item's label as returned from the API. + * @param {string} query The search term to match in the string. + * @return {Object} A list in three parts: before, match, and after. + */ +export function computeSuggestionMatch( suggestion, query ) { + if ( ! query ) { + return null; + } + const indexOfMatch = suggestion + .toLocaleLowerCase() + .indexOf( query.toLocaleLowerCase() ); + + return { + suggestionBeforeMatch: decodeEntities( + suggestion.substring( 0, indexOfMatch ) + ), + suggestionMatch: decodeEntities( + suggestion.substring( indexOfMatch, indexOfMatch + query.length ) + ), + suggestionAfterMatch: decodeEntities( + suggestion.substring( indexOfMatch + query.length ) + ), + }; +} + +export function getTaxCode( tax ) { + return [ + tax.country, + tax.state, + tax.name || __( 'TAX', 'woocommerce' ), + tax.priority, + ] + .filter( Boolean ) + .map( ( item ) => item.toString().toUpperCase().trim() ) + .join( '-' ); +} diff --git a/packages/js/components/src/search/autocompleters/variable-product.js b/packages/js/components/src/search/autocompleters/variable-product.js new file mode 100644 index 00000000000..7179ba99a90 --- /dev/null +++ b/packages/js/components/src/search/autocompleters/variable-product.js @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import productsAutocompleter from './product'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ +/** + * A variable products completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + ...productsAutocompleter, + name: 'products', + options( search ) { + const query = search + ? { + search, + per_page: 10, + orderby: 'popularity', + type: 'variable', + } + : {}; + return apiFetch( { + path: addQueryArgs( '/wc-analytics/products', query ), + } ); + }, +}; diff --git a/packages/js/components/src/search/autocompleters/variations.js b/packages/js/components/src/search/autocompleters/variations.js new file mode 100644 index 00000000000..82f141d7887 --- /dev/null +++ b/packages/js/components/src/search/autocompleters/variations.js @@ -0,0 +1,204 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; +import { createElement, Fragment } from '@wordpress/element'; +import { getQuery } from '@woocommerce/navigation'; + +/** + * Internal dependencies + */ +import { computeSuggestionMatch } from './utils'; +import ProductImage from '../../product-image'; + +/** + * A raw completer option. + * + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @return {(CompleterOption[]|Promise.<CompleterOption[]>)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @return {string[]} list of key words to search. + */ + +/** + * @callback FnIsOptionDisabled + * @param {CompleterOption} option a completer option. + * + * @return {string[]} whether or not the given option is disabled. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @return {(string|Array.<(string|Node)>)} list of react components to render. + */ + +/** + * @callback FnAllowContext + * @param {string} before the string before the auto complete trigger and query. + * @param {string} after the string after the autocomplete trigger and query. + * + * @return {boolean} true if the completer can handle. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {'insert-at-caret'|'replace'} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * + * @typedef {(string|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {string} query the text value of the autocomplete query. + * + * @return {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} WPCompleter + * @property {string} name a way to identify a completer, useful for selective overriding. + * @property {?string} className A class to apply to the popup menu. + * @property {string} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {?FnIsOptionDisabled} isOptionDisabled get whether or not the given option is disabled. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + +/** + * Create a variation name by concatenating each of the variation's + * attribute option strings. + * + * @param {Object} variation - variation returned by the api + * @param {Array} variation.attributes - attribute objects, with option property. + * @param {string} variation.name - name of variation. + * @return {string} - formatted variation name + */ +function getVariationName( { attributes, name } ) { + const separator = + window.wcSettings.variationTitleAttributesSeparator || ' - '; + + if ( name.indexOf( separator ) > -1 ) { + return name; + } + + const attributeList = attributes + .map( ( { option } ) => option ) + .join( ', ' ); + + return attributeList ? name + separator + attributeList : name; +} + +/** + * A variations completer. + * See https://github.com/WordPress/gutenberg/tree/master/packages/components/src/autocomplete#the-completer-interface + * + * @type {WPCompleter} + */ +export default { + name: 'variations', + className: 'woocommerce-search__product-result', + options( search ) { + const query = search + ? { + search, + per_page: 30, + _fields: [ + 'attributes', + 'description', + 'id', + 'name', + 'sku', + ], + } + : {}; + const product = getQuery().products; + + // Product was specified, search only its variations. + if ( product ) { + if ( product.includes( ',' ) ) { + // eslint-disable-next-line no-console + console.warn( + 'Invalid product id supplied to Variations autocompleter' + ); + } + return apiFetch( { + path: addQueryArgs( + `/wc-analytics/products/${ product }/variations`, + query + ), + } ); + } + + // Product was not specified, search all variations. + return apiFetch( { + path: addQueryArgs( '/wc-analytics/variations', query ), + } ); + }, + isDebounced: true, + getOptionIdentifier( variation ) { + return variation.id; + }, + getOptionKeywords( variation ) { + return [ getVariationName( variation ), variation.sku ]; + }, + getOptionLabel( variation, query ) { + const match = + computeSuggestionMatch( getVariationName( variation ), query ) || + {}; + return ( + <Fragment> + <ProductImage + key="thumbnail" + className="woocommerce-search__result-thumbnail" + product={ variation } + width={ 18 } + height={ 18 } + alt="" + /> + <span + key="name" + className="woocommerce-search__result-name" + aria-label={ variation.description } + > + { match.suggestionBeforeMatch } + <strong className="components-form-token-field__suggestion-match"> + { match.suggestionMatch } + </strong> + { match.suggestionAfterMatch } + </span> + </Fragment> + ); + }, + // This is slightly different than gutenberg/Autocomplete, we don't support different methods + // of replace/insertion, so we can just return the value. + getOptionCompletion( variation ) { + return { + key: variation.id, + label: getVariationName( variation ), + }; + }, +}; diff --git a/packages/js/components/src/search/index.js b/packages/js/components/src/search/index.js new file mode 100644 index 00000000000..7cbdf093313 --- /dev/null +++ b/packages/js/components/src/search/index.js @@ -0,0 +1,284 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import { noop } from 'lodash'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import SelectControl from '../select-control'; +import { + attributes, + countries, + coupons, + customers, + downloadIps, + emails, + orders, + product, + productCategory, + taxes, + usernames, + variableProduct, + variations, +} from './autocompleters'; + +/** + * A search box which autocompletes results while typing, allowing for the user to select an existing object + * (product, order, customer, etc). Currently only products are supported. + */ +export class Search extends Component { + constructor( props ) { + super( props ); + this.state = { + options: [], + }; + this.appendFreeTextSearch = this.appendFreeTextSearch.bind( this ); + this.fetchOptions = this.fetchOptions.bind( this ); + this.updateSelected = this.updateSelected.bind( this ); + } + + getAutocompleter() { + switch ( this.props.type ) { + case 'attributes': + return attributes; + case 'categories': + return productCategory; + case 'countries': + return countries; + case 'coupons': + return coupons; + case 'customers': + return customers; + case 'downloadIps': + return downloadIps; + case 'emails': + return emails; + case 'orders': + return orders; + case 'products': + return product; + case 'taxes': + return taxes; + case 'usernames': + return usernames; + case 'variableProducts': + return variableProduct; + case 'variations': + return variations; + case 'custom': + if ( + ! this.props.autocompleter || + typeof this.props.autocompleter !== 'object' + ) { + throw new Error( + "Invalid autocompleter provided to Search component, it requires a completer object when using 'custom' type." + ); + } + return this.props.autocompleter; + default: + return {}; + } + } + + getFormattedOptions( options, query ) { + const autocompleter = this.getAutocompleter(); + const formattedOptions = []; + + options.forEach( ( option ) => { + const formattedOption = { + key: autocompleter.getOptionIdentifier( option ), + label: autocompleter.getOptionLabel( option, query ), + keywords: autocompleter + .getOptionKeywords( option ) + .filter( Boolean ), + value: option, + }; + formattedOptions.push( formattedOption ); + } ); + + return formattedOptions; + } + + fetchOptions( previousOptions, query ) { + if ( ! query ) { + return []; + } + + const autocompleterOptions = this.getAutocompleter().options; + + // Support arrays, sync- & async functions that returns an array. + const resolvedOptions = Promise.resolve( + typeof autocompleterOptions === 'function' + ? autocompleterOptions( query ) + : autocompleterOptions || [] + ); + return resolvedOptions.then( async ( response ) => { + const options = this.getFormattedOptions( response, query ); + this.setState( { options } ); + return options; + } ); + } + + updateSelected( selected ) { + const { onChange } = this.props; + const autocompleter = this.getAutocompleter(); + + const formattedSelections = selected.map( ( option ) => { + return option.value + ? autocompleter.getOptionCompletion( option.value ) + : option; + } ); + + onChange( formattedSelections ); + } + + appendFreeTextSearch( options, query ) { + const { allowFreeTextSearch } = this.props; + + if ( ! query || ! query.length ) { + return []; + } + + if ( ! allowFreeTextSearch ) { + return options; + } + const autocompleter = this.getAutocompleter(); + + return [ ...autocompleter.getFreeTextOptions( query ), ...options ]; + } + + render() { + const autocompleter = this.getAutocompleter(); + const { + className, + inlineTags, + placeholder, + selected, + showClearButton, + staticResults, + disabled, + multiple, + } = this.props; + const { options } = this.state; + const inputType = autocompleter.inputType + ? autocompleter.inputType + : 'text'; + + return ( + <div> + <SelectControl + className={ classnames( 'woocommerce-search', className, { + 'is-static-results': staticResults, + } ) } + disabled={ disabled } + hideBeforeSearch + inlineTags={ inlineTags } + isSearchable + getSearchExpression={ autocompleter.getSearchExpression } + multiple={ multiple } + placeholder={ placeholder } + onChange={ this.updateSelected } + onFilter={ this.appendFreeTextSearch } + onSearch={ this.fetchOptions } + options={ options } + searchDebounceTime={ 500 } + searchInputType={ inputType } + selected={ selected } + showClearButton={ showClearButton } + /> + </div> + ); + } +} + +Search.propTypes = { + /** + * Render additional options in the autocompleter to allow free text entering depending on the type. + */ + allowFreeTextSearch: PropTypes.bool, + /** + * Class name applied to parent div. + */ + className: PropTypes.string, + /** + * Function called when selected results change, passed result list. + */ + onChange: PropTypes.func, + /** + * The object type to be used in searching. + */ + type: PropTypes.oneOf( [ + 'attributes', + 'categories', + 'countries', + 'coupons', + 'customers', + 'downloadIps', + 'emails', + 'orders', + 'products', + 'taxes', + 'usernames', + 'variableProducts', + 'variations', + 'custom', + ] ).isRequired, + /** + * The custom autocompleter to be used in searching when type is 'custom' + */ + autocompleter: PropTypes.object, + /** + * A placeholder for the search input. + */ + placeholder: PropTypes.string, + /** + * An array of objects describing selected values or optionally a string for a single value. + * If the label of the selected value is omitted, the Tag of that value will not + * be rendered inside the search box. + */ + selected: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.arrayOf( + PropTypes.shape( { + key: PropTypes.oneOfType( [ + PropTypes.number, + PropTypes.string, + ] ).isRequired, + label: PropTypes.string, + } ) + ), + ] ), + /** + * Render tags inside input, otherwise render below input. + */ + inlineTags: PropTypes.bool, + /** + * Render a 'Clear' button next to the input box to remove its contents. + */ + showClearButton: PropTypes.bool, + /** + * Render results list positioned statically instead of absolutely. + */ + staticResults: PropTypes.bool, + /** + * Whether the control is disabled or not. + */ + disabled: PropTypes.bool, +}; + +Search.defaultProps = { + allowFreeTextSearch: false, + onChange: noop, + selected: [], + inlineTags: false, + showClearButton: false, + staticResults: false, + disabled: false, + multiple: true, +}; + +export default Search; diff --git a/packages/js/components/src/search/stories/index.js b/packages/js/components/src/search/stories/index.js new file mode 100644 index 00000000000..c463b721e64 --- /dev/null +++ b/packages/js/components/src/search/stories/index.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { H, Search, Section } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; + +const SearchExample = () => { + const [ selected, setSelected ] = useState( [] ); + const [ inlineSelected, setInlineSelect ] = useState( [] ); + + return ( + <div> + <H>Tags Below Input</H> + <Section component={ false }> + <Search + type="products" + placeholder="Search for a product" + selected={ selected } + onChange={ ( items ) => setSelected( items ) } + /> + </Section> + <H>Tags Inline with Input</H> + <Section component={ false }> + <Search + type="products" + placeholder="Search for a product" + selected={ inlineSelected } + onChange={ ( items ) => setInlineSelect( items ) } + inlineTags + /> + </Section> + </div> + ); +}; + +export const Basic = () => <SearchExample />; + +export default { + title: 'WooCommerce Admin/components/Search', + component: Search, +}; diff --git a/packages/js/components/src/search/style.scss b/packages/js/components/src/search/style.scss new file mode 100644 index 00000000000..6f50c26b3e7 --- /dev/null +++ b/packages/js/components/src/search/style.scss @@ -0,0 +1,124 @@ +.woocommerce-search.woocommerce-select-control { + position: relative; + + .woocommerce-select-control__control-icon { + position: absolute; + top: 50%; + left: 10px; + transform: translateY(-50%); + color: $gray-400; + font-size: 20px; + } + + &:not(.has-inline-tags) { + .woocommerce-tag { + margin: 8px 6px 0 0; + } + } + + .woocommerce-select-control__control { + height: auto; + border: 1px solid $gray-400; + font-size: 13px; + width: 100%; + padding: 3px 2px 3px 36px; + border-radius: 4px; + border-color: $gray-700; + box-shadow: $shadow-popover; + + &.is-active { + border-color: var(--wp-admin-theme-color); + } + } + + .components-base-control .woocommerce-select-control__control-input { + margin: 0; + font-size: 13px; + min-height: auto; + + &[type='number']::-webkit-outer-spin-button, + &[type='number']::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + + .components-base-control .components-base-control__label { + font-size: 13px; + color: #72777c; + margin: 0; + width: calc(100% - 36px); + top: 50%; + left: 36px; + } + + .is-active.components-base-control .components-base-control__label, + .with-value.components-base-control .components-base-control__label, + &.has-inline-tags + .has-tags.components-base-control + .components-base-control__label { + display: none; + } + + .components-base-control .woocommerce-select-control__tags { + margin: 0; + } + + .components-base-control .woocommerce-tag { + max-height: 24px; + } + + .woocommerce-select-control__listbox { + border: 1px solid $gray-400; + top: 38px; + } + + &.is-static-results .woocommerce-select-control__listbox { + position: static; + } + + // This a single `button` in the autocomplete popover + .woocommerce-select-control__option { + margin-bottom: 0; + display: flex; + flex-direction: row; + flex-grow: 1; + flex-shrink: 0; + align-items: center; + padding: $gap-small; + color: $studio-woocommerce-purple; + text-align: left; + background: $gray-100; + border-bottom: 1px solid $gray-100; + font-size: 13px; + min-height: 43px; + height: auto; + + &:last-of-type { + border-bottom: none; + } + + &:hover { + box-shadow: none; + color: $studio-woocommerce-purple; + background: $gray-200; + } + + &.is-selected, + &:focus, + &:active { + color: $studio-woocommerce-purple; + background: $studio-white; + box-shadow: inset 0 0 0 1px $gray-200, + inset 0 0 0 2px $wp-admin-sidebar; + } + + .woocommerce-search__result-thumbnail { + margin-right: $gap-small; + } + } +} + +.woocommerce-search__result-name { + text-decoration: underline; // @todo Not actually a link, should underline? +} diff --git a/packages/js/components/src/search/test/index.js b/packages/js/components/src/search/test/index.js new file mode 100644 index 00000000000..f0d6b1b3b5e --- /dev/null +++ b/packages/js/components/src/search/test/index.js @@ -0,0 +1,139 @@ +/** + * External dependencies + */ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Search } from '../index'; +import { computeSuggestionMatch } from '../autocompleters/utils'; + +const delay = ( timeout ) => + new Promise( ( resolve ) => setTimeout( resolve, timeout ) ); + +describe( 'Search', () => { + it( 'shows the free text search option', () => { + const { getByRole, queryAllByRole } = render( + <Search type="products" allowFreeTextSearch /> + ); + userEvent.type( getByRole( 'combobox' ), 'Product Query' ); + expect( queryAllByRole( 'option' ) ).toHaveLength( 1 ); + + userEvent.clear( getByRole( 'combobox' ) ); + expect( queryAllByRole( 'option' ) ).toHaveLength( 0 ); + } ); + + describe( 'with `type="custom"`', () => { + let sampleOptions, sampleAutocompleter; + beforeEach( () => { + sampleOptions = [ + { name: 'Apple', id: 1 }, + { name: 'Orange', id: 2 }, + { name: 'Grapes', id: 3 }, + ]; + sampleAutocompleter = { + options: sampleOptions, + getOptionIdentifier: ( fruit ) => fruit.id, + getOptionLabel: ( option ) => ( + <nicer-label>{ option.name }</nicer-label> + ), + getOptionKeywords: ( option ) => [ option.name ], + getOptionCompletion: ( attribute ) => ( { + key: attribute.id, + label: attribute.name, + } ), + }; + } ); + describe( 'renders options given in `autocompleter.options`', () => { + it( 'as a static array', async () => { + const { getByRole, queryAllByRole } = render( + <Search + type="custom" + autocompleter={ sampleAutocompleter } + /> + ); + // Emulate typing to render available options. + userEvent.type( getByRole( 'combobox' ), 'A' ); + // Wait for async options procesing. + await waitFor( () => { + expect( queryAllByRole( 'option' ) ).toHaveLength( 3 ); + } ); + } ); + + it( 'being a function that for the given query returns an array', async () => { + const optionsSpy = jest + .fn() + .mockName( 'autocompleter.options' ); + + const customAutocompleter = { + ...sampleAutocompleter, + // Set the options as a function that returns an array. + options: ( ...args ) => { + optionsSpy( ...args ); + return sampleOptions; + }, + }; + + const { getByRole, queryAllByRole } = render( + <Search + type="custom" + autocompleter={ customAutocompleter } + /> + ); + // Emulate typing to render available options. + userEvent.type( getByRole( 'combobox' ), 'A' ); + // Wait for async options procesing. + await waitFor( () => { + expect( optionsSpy ).toBeCalledWith( 'A' ); + } ); + await waitFor( () => { + expect( queryAllByRole( 'option' ) ).toHaveLength( 3 ); + } ); + } ); + + it( 'being a function that for the given query returns a promise for an array', async () => { + const optionsSpy = jest + .fn() + .mockName( 'autocompleter.options' ); + + const customAutocompleter = { + ...sampleAutocompleter, + // Set the options as a function that returns a promise for an array. + options: async ( ...args ) => { + optionsSpy( ...args ); + await delay( 1 ); + return sampleOptions; + }, + }; + + const { getByRole, queryAllByRole } = render( + <Search + type="custom" + autocompleter={ customAutocompleter } + /> + ); + // Emulate typing to render available options. + userEvent.type( getByRole( 'combobox' ), 'A' ); + // Wait for async options procesing. + await waitFor( () => { + expect( optionsSpy ).toBeCalledWith( 'A' ); + } ); + await waitFor( () => { + expect( queryAllByRole( 'option' ) ).toHaveLength( 3 ); + } ); + } ); + } ); + } ); + it( 'returns an object with decoded text', () => { + const decodedText = computeSuggestionMatch( + 'A test & a test', + 'test' + ); + const expected = + '{"suggestionBeforeMatch":"A ","suggestionMatch":"test","suggestionAfterMatch":" & a test"}'; + expect( JSON.stringify( decodedText ) ).toBe( expected ); + } ); +} ); diff --git a/packages/js/components/src/section-header/README.md b/packages/js/components/src/section-header/README.md new file mode 100644 index 00000000000..109ec33c084 --- /dev/null +++ b/packages/js/components/src/section-header/README.md @@ -0,0 +1,18 @@ +SectionHeader +=== + +A header component. The header can contain a title, actions via children, and an `EllipsisMenu` menu. + +## Usage + +```jsx +<SectionHeader title="Section title" /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`className` | String | `null` | Additional CSS classes +`menu` | (custom validator) | `null` | An `EllipsisMenu`, with filters used to control the content visible in this card +`title` | One of type: string, node | `null` | (required) The title to use for this card diff --git a/packages/js/components/src/section-header/index.js b/packages/js/components/src/section-header/index.js new file mode 100644 index 00000000000..70b86463c49 --- /dev/null +++ b/packages/js/components/src/section-header/index.js @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import EllipsisMenu from '../ellipsis-menu'; +import { H } from '../section/header'; +import { validateComponent } from '../lib/proptype-validator'; + +/** + * A header component. The header can contain a title, actions via children, and an `EllipsisMenu` menu. + */ +class SectionHeader extends Component { + render() { + const { children, menu, title } = this.props; + const className = classnames( + 'woocommerce-section-header', + this.props.className + ); + return ( + <div className={ className }> + <H className="woocommerce-section-header__title woocommerce-section-header__header-item"> + { title } + </H> + <hr role="presentation" /> + { children && ( + <div className="woocommerce-section-header__actions woocommerce-section-header__header-item"> + { children } + </div> + ) } + { menu && ( + <div className="woocommerce-section-header__menu woocommerce-section-header__header-item"> + { menu } + </div> + ) } + </div> + ); + } +} + +SectionHeader.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * An `EllipsisMenu`, with filters used to control the content visible in this card + */ + menu: validateComponent( EllipsisMenu ), + /** + * The title to use for this card. + */ + title: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ) + .isRequired, +}; + +export default SectionHeader; diff --git a/packages/js/components/src/section-header/stories/index.js b/packages/js/components/src/section-header/stories/index.js new file mode 100644 index 00000000000..eebef95df8f --- /dev/null +++ b/packages/js/components/src/section-header/stories/index.js @@ -0,0 +1,11 @@ +/** + * External dependencies + */ +import { SectionHeader } from '@woocommerce/components'; + +export const Basic = () => <SectionHeader title={ 'Store Performance' } />; + +export default { + title: 'WooCommerce Admin/components/SectionHeader', + component: SectionHeader, +}; diff --git a/packages/js/components/src/section-header/style.scss b/packages/js/components/src/section-header/style.scss new file mode 100644 index 00000000000..fbf4bd78572 --- /dev/null +++ b/packages/js/components/src/section-header/style.scss @@ -0,0 +1,79 @@ + + +.woocommerce-section-header { + padding: ($gap - 3); + border-bottom: none; + display: flex; + justify-content: space-between; + + @include breakpoint( '<782px' ) { + margin-left: -16px; + margin-right: -16px; + margin-bottom: $gap-small; + border-left: none; + border-right: none; + width: auto; + } + + hr { + align-self: center; + flex-grow: 1; + height: 1px; + margin: 0 10px; + } + + @include breakpoint( '<782px' ) { + &.has-interval-select { + position: relative; + padding-bottom: 30px; + .woocommerce-chart__interval-select { + position: absolute; + left: 0; + bottom: 0; + padding-left: 6px; + } + } + } +} + +.woocommerce-section-header__actions, +.woocommerce-section-header__menu { + text-align: right; +} +.woocommerce-section-header__actions { + display: flex; + flex-grow: 1; + justify-content: flex-end; + + .components-base-control { + padding-top: 0; + min-height: 34px; + + .components-base-control__field { + margin-bottom: 0; + select { + background: transparent; + } + } + } +} + +.woocommerce-ellipsis-menu__toggle { + padding: 0; +} + +.woocommerce-section-header__menu { + display: flex; + flex-direction: column; + justify-content: center; +} + +.woocommerce-section-header__title { + margin: 0 $gap 0 0; + // EllipsisMenu is 24px, so to match we add 6px padding around the + // heading text, which we know is 18px from line-height. + padding: 3px 0; + @include font-size( 20 ); + line-height: 2.2; + font-weight: 400; +} diff --git a/packages/js/components/src/section-header/test/__snapshots__/index.js.snap b/packages/js/components/src/section-header/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000..2989e1da556 --- /dev/null +++ b/packages/js/components/src/section-header/test/__snapshots__/index.js.snap @@ -0,0 +1,112 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SectionHeader it renders correctly 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <div + class="woocommerce-section-header" + > + <h2 + class="woocommerce-section-header__title woocommerce-section-header__header-item" + > + A SectionHeader Example + </h2> + <hr + role="presentation" + /> + </div> + </div> + </body>, + "container": <div> + <div + class="woocommerce-section-header" + > + <h2 + class="woocommerce-section-header__title woocommerce-section-header__header-item" + > + A SectionHeader Example + </h2> + <hr + role="presentation" + /> + </div> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/js/components/src/section-header/test/index.js b/packages/js/components/src/section-header/test/index.js new file mode 100644 index 00000000000..5034677e27d --- /dev/null +++ b/packages/js/components/src/section-header/test/index.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import SectionHeader from '../'; + +describe( 'SectionHeader', () => { + test( 'should have correct title', () => { + const sectionHeader = <SectionHeader title="A SectionHeader Example" />; + expect( sectionHeader.props.title ).toBe( 'A SectionHeader Example' ); + } ); + + test( 'it renders correctly', () => { + const component = render( + <SectionHeader title="A SectionHeader Example" /> + ); + expect( component ).toMatchSnapshot(); + } ); +} ); diff --git a/packages/js/components/src/section/README.md b/packages/js/components/src/section/README.md new file mode 100644 index 00000000000..85675dc9154 --- /dev/null +++ b/packages/js/components/src/section/README.md @@ -0,0 +1,38 @@ +H +=== + +These components are used to frame out the page content for accessible heading hierarchy. Instead of defining fixed heading levels +(`h2`, `h3`, …) you can use `<H />` to create "section headings", which look to the parent `<Section />`s for the appropriate +heading level. + +## Usage + +```jsx +<div> + <H>Header using a contextual level (h3)</H> + <Section component="article"> + <p>This is an article component wrapper.</p> + <H>Another header with contextual level (h4)</H> + <Section component={ false }> + <p>There is no wrapper component here.</p> + <H>This is an h5</H> + </Section> + </Section> +</div> +``` + +Section +=== + +The section wrapper, used to indicate a sub-section (and change the header level context). + +## Usage + +See above + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`component` | One of type: func, string, bool | `null` | The wrapper component for this section. Optional, defaults to `div`. If passed false, no wrapper is used. Additional props passed to Section are passed on to the component +`children` | ReactNode | `null` | The children inside this section, rendered in the `component`. This increases the context level for the next heading used diff --git a/packages/js/components/src/section/context.js b/packages/js/components/src/section/context.js new file mode 100644 index 00000000000..bee68a79b3a --- /dev/null +++ b/packages/js/components/src/section/context.js @@ -0,0 +1,13 @@ +/** + * External dependencies + */ +import { createContext } from '@wordpress/element'; + +/** + * Context container for heading level. We start at 2 because the `h1` is defined in <Header /> + * + * See https://medium.com/@Heydon/managing-heading-levels-in-design-systems-18be9a746fa3 + */ +const Level = createContext( 2 ); + +export { Level }; diff --git a/packages/js/components/src/section/header.js b/packages/js/components/src/section/header.js new file mode 100644 index 00000000000..2b19bb9bb1c --- /dev/null +++ b/packages/js/components/src/section/header.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import { createElement, useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Level } from './context'; + +/** + * These components are used to frame out the page content for accessible heading hierarchy. Instead of defining fixed heading levels + * (`h2`, `h3`, …) you can use `<H />` to create "section headings", which look to the parent `<Section />`s for the appropriate + * heading level. + * + * @type {HTMLElement} + */ +export function H( props ) { + const level = useContext( Level ); + + const Heading = 'h' + Math.min( level, 6 ); + return <Heading { ...props } />; +} diff --git a/packages/js/components/src/section/index.js b/packages/js/components/src/section/index.js new file mode 100644 index 00000000000..bc35bc4efa5 --- /dev/null +++ b/packages/js/components/src/section/index.js @@ -0,0 +1,2 @@ +export { Section } from './section'; +export { H } from './header'; diff --git a/packages/js/components/src/section/section.js b/packages/js/components/src/section/section.js new file mode 100644 index 00000000000..43e1ad0849e --- /dev/null +++ b/packages/js/components/src/section/section.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { Level } from './context'; + +/** + * The section wrapper, used to indicate a sub-section (and change the header level context). + * + * @param {Object} props + * @param {import('react').ComponentType=} props.component + * @param {import('react').ReactNode} props.children Children to render in the tip. + * @param {string=} props.className + * @return {JSX.Element} - + */ +export function Section( { component, children, ...props } ) { + const Component = component || 'div'; + return ( + <Level.Consumer> + { ( level ) => ( + <Level.Provider value={ level + 1 }> + { component === false ? ( + children + ) : ( + <Component { ...props }>{ children }</Component> + ) } + </Level.Provider> + ) } + </Level.Consumer> + ); +} + +Section.propTypes = { + /** + * The wrapper component for this section. Optional, defaults to `div`. If passed false, no wrapper is used. Additional props + * passed to Section are passed on to the component. + */ + component: PropTypes.oneOfType( [ + PropTypes.func, + PropTypes.string, + PropTypes.bool, + ] ), + /** + * The children inside this section, rendered in the `component`. This increases the context level for the next heading used. + */ + children: PropTypes.node, + /** + * Optional classname + */ + className: PropTypes.string, +}; diff --git a/packages/js/components/src/section/stories/index.js b/packages/js/components/src/section/stories/index.js new file mode 100644 index 00000000000..c651b742921 --- /dev/null +++ b/packages/js/components/src/section/stories/index.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import { H, Section } from '@woocommerce/components'; + +export const Basic = () => ( + <div> + <H>Header using a contextual level (h3)</H> + <Section component="article"> + <p>This is an article component wrapper.</p> + <H>Another header with contextual level (h4)</H> + <Section component={ false }> + <p>There is no wrapper component here.</p> + <H>This is an h5</H> + </Section> + </Section> + </div> +); + +export default { + title: 'WooCommerce Admin/components/Section', + component: Section, +}; diff --git a/packages/js/components/src/segmented-selection/README.md b/packages/js/components/src/segmented-selection/README.md new file mode 100644 index 00000000000..75202cb1f5b --- /dev/null +++ b/packages/js/components/src/segmented-selection/README.md @@ -0,0 +1,39 @@ +SegmentedSelection +=== + +Create a panel of styled selectable options rendering stylized checkboxes and labels + +## Usage + +```jsx +<SegmentedSelection + options={ [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + { value: 'three', label: 'Three' }, + { value: 'four', label: 'Four' }, + ] } + selected={ selected } + legend="Select a number" + onSelect={ ( data ) => setState( { selected: data[ name ] } ) } + name={ name } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`className` | String | `null` | Additional CSS classes +`options` | Array | `null` | (required) An Array of options to render. The array needs to be composed of objects with properties `label` and `value` +`selected` | String | `null` | Value of selected item +`onSelect` | Function | `null` | (required) Callback to be executed after selection +`name` | String | `null` | (required) This will be the key in the key and value arguments supplied to `onSelect` +`legend` | String | `null` | (required) Create a legend visible to screen readers + +### `options` structure + +The `options` array needs to be composed of objects with properties: + +- `value`: String - Input value for this option. +- `label`: String - Label for this option. \ No newline at end of file diff --git a/packages/js/components/src/segmented-selection/index.js b/packages/js/components/src/segmented-selection/index.js new file mode 100644 index 00000000000..da13337076f --- /dev/null +++ b/packages/js/components/src/segmented-selection/index.js @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import { partial, uniqueId } from 'lodash'; + +/** + * Create a panel of styled selectable options rendering stylized checkboxes and labels + */ +class SegmentedSelection extends Component { + render() { + const { + className, + options, + selected, + onSelect, + name, + legend, + } = this.props; + + return ( + <fieldset className="woocommerce-segmented-selection"> + <legend className="screen-reader-text">{ legend }</legend> + <div + className={ classnames( + className, + 'woocommerce-segmented-selection__container' + ) } + > + { options.map( ( { value, label } ) => { + if ( ! value || ! label ) { + return null; + } + const id = uniqueId( `${ value }_` ); + return ( + <div + className="woocommerce-segmented-selection__item" + key={ value } + > + { /* eslint-disable jsx-a11y/label-has-for */ } + <input + className="woocommerce-segmented-selection__input" + type="radio" + name={ name } + id={ id } + checked={ selected === value } + onChange={ partial( onSelect, { + [ name ]: value, + } ) } + /> + <label htmlFor={ id }> + <span className="woocommerce-segmented-selection__label"> + { label } + </span> + </label> + { /* eslint-enable jsx-a11y/label-has-for */ } + </div> + ); + } ) } + </div> + </fieldset> + ); + } +} + +SegmentedSelection.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * An Array of options to render. The array needs to be composed of objects with properties `label` and `value`. + */ + options: PropTypes.arrayOf( + PropTypes.shape( { + value: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + } ) + ).isRequired, + /** + * Value of selected item. + */ + selected: PropTypes.string, + /** + * Callback to be executed after selection + */ + onSelect: PropTypes.func.isRequired, + /** + * This will be the key in the key and value arguments supplied to `onSelect`. + */ + name: PropTypes.string.isRequired, + /** + * Create a legend visible to screen readers. + */ + legend: PropTypes.string.isRequired, +}; + +export default SegmentedSelection; diff --git a/packages/js/components/src/segmented-selection/stories/index.js b/packages/js/components/src/segmented-selection/stories/index.js new file mode 100644 index 00000000000..56f77962805 --- /dev/null +++ b/packages/js/components/src/segmented-selection/stories/index.js @@ -0,0 +1,33 @@ +/** + * External dependencies + */ +import { SegmentedSelection } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; + +const name = 'number'; + +const SegmentedSelectionExample = () => { + const [ selected, setSelected ] = useState( 'two' ); + + return ( + <SegmentedSelection + options={ [ + { value: 'one', label: 'One' }, + { value: 'two', label: 'Two' }, + { value: 'three', label: 'Three' }, + { value: 'four', label: 'Four' }, + ] } + selected={ selected } + legend="Select a number" + onSelect={ ( data ) => setSelected( data[ name ] ) } + name={ name } + /> + ); +}; + +export const Basic = () => <SegmentedSelectionExample />; + +export default { + title: 'WooCommerce Admin/components/SegmentedSelection', + component: SegmentedSelection, +}; diff --git a/packages/js/components/src/segmented-selection/style.scss b/packages/js/components/src/segmented-selection/style.scss new file mode 100644 index 00000000000..f6f62c0125c --- /dev/null +++ b/packages/js/components/src/segmented-selection/style.scss @@ -0,0 +1,73 @@ +.woocommerce-segmented-selection { + width: 100%; + color: $gray-700; +} + +.woocommerce-segmented-selection__container { + width: 100%; + grid-template-columns: 1fr 1fr; + display: grid; + border-top: 1px solid $gray-400; + border-bottom: 1px solid $gray-400; + background-color: $gray-400; +} + +.woocommerce-segmented-selection__item { + &:nth-child(2n) { + border-left: 1px solid $gray-400; + border-top: 1px solid $gray-400; + } + + &:nth-child(2n + 1) { + border-top: 1px solid $gray-400; + } + + &:nth-child(-n + 2) { + border-top: 0; + } +} + +.woocommerce-segmented-selection__label { + background-color: $gray-100; + padding: $gap-small $gap-small $gap-small $gap-larger; + position: relative; + display: block; + height: 100%; + + &:active { + background-color: $gray-200; + } + + &:hover { + background-color: $gray-200; + } +} + +.woocommerce-segmented-selection__input { + opacity: 0; + position: absolute; + left: -9999px; + + &:active + label .woocommerce-segmented-selection__label { + background-color: $gray-200; + } + + &:checked + label .woocommerce-segmented-selection__label { + background-color: $studio-white; + font-weight: 600; + + &::before { + content: ''; + width: 8px; + height: 8px; + background-color: var(--wp-admin-theme-color); + position: absolute; + top: 50%; + transform: translate(-20px, -50%); + } + } + + &:focus + label .woocommerce-segmented-selection__label { + box-shadow: inset 0 0 0 1px $wp-admin-sidebar; + } +} diff --git a/packages/js/components/src/select-control/README.md b/packages/js/components/src/select-control/README.md new file mode 100644 index 00000000000..f7fe0c96499 --- /dev/null +++ b/packages/js/components/src/select-control/README.md @@ -0,0 +1,58 @@ +# SelectControl + +A search box which filters options while typing, +allowing a user to select from an option from a filtered list. + +## Usage + +```jsx +const options = [ + { + key: 'apple', + label: 'Apple', + value: { id: 'apple' }, + }, + { + key: 'apricot', + label: 'Apricot', + value: { id: 'apricot' }, + }, +]; + +<SelectControl + label="Single value" + onChange={ ( selected ) => setState( { singleSelected: selected } ) } + options={ options } + placeholder="Start typing to filter options..." + selected={ singleSelected } +/>; +``` + +### Props + +| Name | Type | Default | Description | +| ------------------------ | ------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `className` | string | `null` | Class name applied to parent div | +| `excludeSelectedOptions` | boolean | `true` | Exclude already selected options from the options list | +| `onFilter` | function | `identity` | Add or remove items to the list of options after filtering, passed the array of filtered options and should return an array of options. | +| `getSearchExpression` | function | `identity` | Function to add regex expression to the filter the results, passed the search query | +| `help` | string\|node | `null` | Help text to be appended beneath the input | +| `inlineTags` | boolean | `false` | Render tags inside input, otherwise render below input | +| `label` | string | `null` | A label to use for the main input | +| `onChange` | function | `noop` | Function called when selected results change, passed result list | +| `onSearch` | function | `noop` | Function to run after the search query is updated, passed the search query | +| `options` | array | `null` | (required) An array of objects for the options list. The option along with its key, label and value will be returned in the onChange event | +| `placeholder` | string | `null` | A placeholder for the search input | +| `selected` | array | `[]` | An array of objects describing selected values. If the label of the selected value is omitted, the Tag of that value will not be rendered inside the search box | +| `maxResults` | number | `0` | A limit for the number of results shown in the options menu. Set to 0 for no limit | +| `multiple` | boolean | `false` | Allow multiple option selections | +| `showClearButton` | boolean | `false` | Render a 'Clear' button next to the input box to remove its contents | +| `hideBeforeSearch` | boolean | `false` | Only show list options after typing a search query | +| `staticList` | boolean | `false` | Render results list positioned statically instead of absolutely | + +### onChange value + +The onChange value defaults to an array of the selected option(s), but will also reflect what has been passed in the `selected` prop. +If the `selected` prop has the value set as a `string`, the `onChange` method will also be called with a string value - the `key` of the selected option (if multiple is `false`). + +Only string or array are the supported types here. diff --git a/packages/js/components/src/select-control/control.js b/packages/js/components/src/select-control/control.js new file mode 100644 index 00000000000..c1baa3bf07d --- /dev/null +++ b/packages/js/components/src/select-control/control.js @@ -0,0 +1,345 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { BACKSPACE, DOWN, UP } from '@wordpress/keycodes'; +import { createElement, Component, createRef } from '@wordpress/element'; +import { Icon, search } from '@wordpress/icons'; +import classnames from 'classnames'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import Tags from './tags'; + +/** + * A search control to allow user input to filter the options. + */ +class Control extends Component { + constructor( props ) { + super( props ); + this.state = { + isActive: false, + }; + + this.input = createRef(); + + this.updateSearch = this.updateSearch.bind( this ); + this.onFocus = this.onFocus.bind( this ); + this.onBlur = this.onBlur.bind( this ); + this.onKeyDown = this.onKeyDown.bind( this ); + } + + updateSearch( onSearch ) { + return ( event ) => { + onSearch( event.target.value ); + }; + } + + onFocus( onSearch ) { + const { + isSearchable, + setExpanded, + showAllOnFocus, + updateSearchOptions, + } = this.props; + + return ( event ) => { + this.setState( { isActive: true } ); + if ( isSearchable && showAllOnFocus ) { + event.target.select(); + updateSearchOptions( '' ); + } else if ( isSearchable ) { + onSearch( event.target.value ); + } else { + setExpanded( true ); + } + }; + } + + onBlur() { + const { onBlur } = this.props; + + if ( typeof onBlur === 'function' ) { + onBlur(); + } + + this.setState( { isActive: false } ); + } + + onKeyDown( event ) { + const { + decrementSelectedIndex, + incrementSelectedIndex, + selected, + onChange, + query, + setExpanded, + } = this.props; + + if ( BACKSPACE === event.keyCode && ! query && selected.length ) { + onChange( [ ...selected.slice( 0, -1 ) ] ); + } + + if ( DOWN === event.keyCode ) { + incrementSelectedIndex(); + setExpanded( true ); + event.preventDefault(); + event.stopPropagation(); + } + + if ( UP === event.keyCode ) { + decrementSelectedIndex(); + setExpanded( true ); + event.preventDefault(); + event.stopPropagation(); + } + } + + renderButton() { + const { multiple, selected } = this.props; + + if ( multiple || ! selected.length ) { + return null; + } + + return ( + <div className="woocommerce-select-control__control-value"> + { selected[ 0 ].label } + </div> + ); + } + + renderInput() { + const { + activeId, + disabled, + hasTags, + inlineTags, + instanceId, + isExpanded, + isSearchable, + listboxId, + onSearch, + placeholder, + searchInputType, + autoComplete, + } = this.props; + const { isActive } = this.state; + + return ( + <input + autoComplete={ autoComplete || 'off' } + className="woocommerce-select-control__control-input" + id={ `woocommerce-select-control-${ instanceId }__control-input` } + ref={ this.input } + type={ isSearchable ? searchInputType : 'button' } + value={ this.getInputValue() } + placeholder={ isActive ? placeholder : '' } + onChange={ this.updateSearch( onSearch ) } + onFocus={ this.onFocus( onSearch ) } + onBlur={ this.onBlur } + onKeyDown={ this.onKeyDown } + role="combobox" + aria-autocomplete="list" + aria-expanded={ isExpanded } + aria-haspopup="true" + aria-owns={ listboxId } + aria-controls={ listboxId } + aria-activedescendant={ activeId } + aria-describedby={ + hasTags && inlineTags + ? `search-inline-input-${ instanceId }` + : null + } + disabled={ disabled } + /> + ); + } + + getInputValue() { + const { + inlineTags, + isFocused, + isSearchable, + multiple, + query, + selected, + } = this.props; + const selectedValue = selected.length ? selected[ 0 ].label : ''; + + // Show the selected value for simple select dropdowns. + if ( ! multiple && ! isFocused && ! inlineTags ) { + return selectedValue; + } + + // Show the search query when focused on searchable controls. + if ( isSearchable && isFocused && query ) { + return query; + } + + return ''; + } + + render() { + const { + className, + disabled, + hasTags, + help, + inlineTags, + instanceId, + isSearchable, + label, + query, + } = this.props; + const { isActive } = this.state; + + return ( + // Disable reason: The div below visually simulates an input field. Its + // child input is the actual input and responds accordingly to all keyboard + // events, but click events need to be passed onto the child input. There + // is no appropriate aria role for describing this situation, which is only + // for the benefit of sighted users. + /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ + <div + className={ classnames( + 'components-base-control', + 'woocommerce-select-control__control', + className, + { + empty: ! query || query.length === 0, + 'is-active': isActive, + 'has-tags': inlineTags && hasTags, + 'with-value': this.getInputValue().length, + 'has-error': !! help, + 'is-disabled': disabled, + } + ) } + onClick={ ( event ) => { + // Don't focus the input if the click event is from the error message. + if ( + event.target.className !== + 'components-base-control__help' + ) { + this.input.current.focus(); + } + } } + > + { isSearchable && ( + <Icon + className="woocommerce-select-control__control-icon" + icon={ search } + /> + ) } + { inlineTags && <Tags { ...this.props } /> } + + <div className="components-base-control__field"> + { !! label && ( + <label + htmlFor={ `woocommerce-select-control-${ instanceId }__control-input` } + className="components-base-control__label" + > + { label } + </label> + ) } + { this.renderInput() } + { inlineTags && ( + <span + id={ `search-inline-input-${ instanceId }` } + className="screen-reader-text" + > + { __( + 'Move backward for selected items', + 'woocommerce' + ) } + </span> + ) } + { !! help && ( + <p + id={ `woocommerce-select-control-${ instanceId }__help` } + className="components-base-control__help" + > + { help } + </p> + ) } + </div> + </div> + /* eslint-enable jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */ + ); + } +} + +Control.propTypes = { + /** + * Bool to determine if tags should be rendered. + */ + hasTags: PropTypes.bool, + /** + * Help text to be appended beneath the input. + */ + help: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ), + /** + * Render tags inside input, otherwise render below input. + */ + inlineTags: PropTypes.bool, + /** + * Allow the select options to be filtered by search input. + */ + isSearchable: PropTypes.bool, + /** + * ID of the main SelectControl instance. + */ + instanceId: PropTypes.number, + /** + * A label to use for the main input. + */ + label: PropTypes.string, + /** + * ID used for a11y in the listbox. + */ + listboxId: PropTypes.string, + /** + * Function called when the input is blurred. + */ + onBlur: PropTypes.func, + /** + * Function called when selected results change, passed result list. + */ + onChange: PropTypes.func, + /** + * Function called when input field is changed or focused. + */ + onSearch: PropTypes.func, + /** + * A placeholder for the search input. + */ + placeholder: PropTypes.string, + /** + * Search query entered by user. + */ + query: PropTypes.string, + /** + * An array of objects describing selected values. If the label of the selected + * value is omitted, the Tag of that value will not be rendered inside the + * search box. + */ + selected: PropTypes.arrayOf( + PropTypes.shape( { + key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ) + .isRequired, + label: PropTypes.string, + } ) + ), + /** + * Show all options on focusing, even if a query exists. + */ + showAllOnFocus: PropTypes.bool, + /** + * Control input autocomplete field, defaults: off. + */ + autoComplete: PropTypes.string, +}; + +export default Control; diff --git a/packages/js/components/src/select-control/index.js b/packages/js/components/src/select-control/index.js new file mode 100644 index 00000000000..c7166568f10 --- /dev/null +++ b/packages/js/components/src/select-control/index.js @@ -0,0 +1,570 @@ +/** + * External dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import classnames from 'classnames'; +import { Component, createElement } from '@wordpress/element'; +import { debounce, escapeRegExp, identity, noop } from 'lodash'; +import PropTypes from 'prop-types'; +import { withFocusOutside, withSpokenMessages } from '@wordpress/components'; +import { withInstanceId, compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import List from './list'; +import Tags from './tags'; +import Control from './control'; + +const initialState = { isExpanded: false, isFocused: false, query: '' }; + +/** + * A search box which filters options while typing, + * allowing a user to select from an option from a filtered list. + */ +export class SelectControl extends Component { + constructor( props ) { + super( props ); + + const { selected, options, excludeSelectedOptions } = props; + this.state = { + ...initialState, + searchOptions: [], + selectedIndex: + selected && options?.length && ! excludeSelectedOptions + ? options.findIndex( ( option ) => option.key === selected ) + : null, + }; + + this.bindNode = this.bindNode.bind( this ); + this.decrementSelectedIndex = this.decrementSelectedIndex.bind( this ); + this.incrementSelectedIndex = this.incrementSelectedIndex.bind( this ); + this.onAutofillChange = this.onAutofillChange.bind( this ); + this.updateSearchOptions = debounce( + this.updateSearchOptions.bind( this ), + props.searchDebounceTime + ); + this.search = this.search.bind( this ); + this.selectOption = this.selectOption.bind( this ); + this.setExpanded = this.setExpanded.bind( this ); + this.setNewValue = this.setNewValue.bind( this ); + } + + bindNode( node ) { + this.node = node; + } + + reset( selected = this.getSelected() ) { + const { multiple, excludeSelectedOptions } = this.props; + const newState = { ...initialState }; + // Reset selectedIndex if single selection. + if ( ! multiple && selected.length && selected[ 0 ].key ) { + newState.selectedIndex = ! excludeSelectedOptions + ? this.props.options.findIndex( + ( i ) => i.key === selected[ 0 ].key + ) + : null; + } + + this.setState( newState ); + } + + handleFocusOutside() { + this.reset(); + } + + hasMultiple() { + const { multiple, selected } = this.props; + + if ( ! multiple ) { + return false; + } + + if ( Array.isArray( selected ) ) { + return selected.some( ( item ) => Boolean( item.label ) ); + } + + return Boolean( selected ); + } + + getSelected() { + const { multiple, options, selected } = this.props; + + // Return the passed value if an array is provided. + if ( multiple || Array.isArray( selected ) ) { + return selected; + } + + const selectedOption = options.find( + ( option ) => option.key === selected + ); + return selectedOption ? [ selectedOption ] : []; + } + + selectOption( option ) { + const { multiple, selected } = this.props; + const newSelected = multiple ? [ ...selected, option ] : [ option ]; + + this.reset( newSelected ); + + const oldSelected = Array.isArray( selected ) + ? selected + : [ { key: selected } ]; + const isSelected = oldSelected.findIndex( + ( val ) => val.key === option.key + ); + if ( isSelected === -1 ) { + this.setNewValue( newSelected ); + } + + // After selecting option, the list will reset and we'd need to correct selectedIndex. + const newSelectedIndex = this.props.excludeSelectedOptions + ? // Since we're excluding the selected option, invalidate selection + // so re-focusing wont immediately set it to the neigbouring option. + null + : this.getOptions().findIndex( ( i ) => i.key === option.key ); + + this.setState( { + selectedIndex: newSelectedIndex, + } ); + } + + setNewValue( newValue ) { + const { onChange, selected, multiple } = this.props; + const { query } = this.state; + // Trigger a change if the selected value is different and pass back + // an array or string depending on the original value. + if ( multiple || Array.isArray( selected ) ) { + onChange( newValue, query ); + } else { + onChange( newValue.length > 0 ? newValue[ 0 ].key : '', query ); + } + } + + decrementSelectedIndex() { + const { selectedIndex } = this.state; + const options = this.getOptions(); + const nextSelectedIndex = + selectedIndex !== null + ? ( selectedIndex === 0 ? options.length : selectedIndex ) - 1 + : options.length - 1; + + this.setState( { selectedIndex: nextSelectedIndex } ); + } + + incrementSelectedIndex() { + const { selectedIndex } = this.state; + const options = this.getOptions(); + const nextSelectedIndex = + selectedIndex !== null ? ( selectedIndex + 1 ) % options.length : 0; + + this.setState( { selectedIndex: nextSelectedIndex } ); + } + + announce( searchOptions ) { + const { debouncedSpeak } = this.props; + if ( ! debouncedSpeak ) { + return; + } + if ( !! searchOptions.length ) { + debouncedSpeak( + sprintf( + _n( + '%d result found, use up and down arrow keys to navigate.', + '%d results found, use up and down arrow keys to navigate.', + searchOptions.length, + 'woocommerce' + ), + searchOptions.length + ), + 'assertive' + ); + } else { + debouncedSpeak( __( 'No results.', 'woocommerce' ), 'assertive' ); + } + } + + getOptions() { + const { isSearchable, options, excludeSelectedOptions } = this.props; + const { searchOptions } = this.state; + const selectedKeys = this.getSelected().map( ( option ) => option.key ); + const shownOptions = isSearchable ? searchOptions : options; + + if ( excludeSelectedOptions ) { + return shownOptions.filter( + ( option ) => ! selectedKeys.includes( option.key ) + ); + } + return shownOptions; + } + + getOptionsByQuery( options, query ) { + const { getSearchExpression, maxResults, onFilter } = this.props; + const filtered = []; + + // Create a regular expression to filter the options. + const expression = getSearchExpression( + escapeRegExp( query ? query.trim() : '' ) + ); + const search = expression ? new RegExp( expression, 'i' ) : /^$/; + + for ( let i = 0; i < options.length; i++ ) { + const option = options[ i ]; + + // Merge label into keywords + let { keywords = [] } = option; + if ( typeof option.label === 'string' ) { + keywords = [ ...keywords, option.label ]; + } + + const isMatch = keywords.some( ( keyword ) => + search.test( keyword ) + ); + if ( ! isMatch ) { + continue; + } + + filtered.push( option ); + + // Abort early if max reached + if ( maxResults && filtered.length === maxResults ) { + break; + } + } + + return onFilter( filtered, query ); + } + + setExpanded( value ) { + this.setState( { isExpanded: value } ); + } + + search( query ) { + const cacheSearchOptions = this.cacheSearchOptions || []; + const searchOptions = + query !== null && ! query.length && ! this.props.hideBeforeSearch + ? cacheSearchOptions + : this.getOptionsByQuery( cacheSearchOptions, query ); + + this.setState( + { + query, + isFocused: true, + searchOptions, + selectedIndex: + query?.length > 0 ? null : this.state.selectedIndex, // Only reset selectedIndex if we're actually searching. + }, + () => { + this.setState( { + isExpanded: Boolean( this.getOptions().length ), + } ); + } + ); + + this.updateSearchOptions( query ); + } + + updateSearchOptions( query ) { + const { hideBeforeSearch, options, onSearch } = this.props; + + const promise = ( this.activePromise = Promise.resolve( + onSearch( options, query ) + ).then( ( promiseOptions ) => { + if ( promise !== this.activePromise ) { + // Another promise has become active since this one was asked to resolve, so do nothing, + // or else we might end triggering a race condition updating the state. + return; + } + + this.cacheSearchOptions = promiseOptions; + + // Get all options if `hideBeforeSearch` is enabled and query is not null. + const searchOptions = + query !== null && ! query.length && ! hideBeforeSearch + ? promiseOptions + : this.getOptionsByQuery( promiseOptions, query ); + + this.setState( + { + searchOptions, + selectedIndex: + query?.length > 0 ? null : this.state.selectedIndex, // Only reset selectedIndex if we're actually searching. + }, + () => { + this.setState( { + isExpanded: Boolean( this.getOptions().length ), + } ); + this.announce( searchOptions ); + } + ); + } ) ); + } + + onAutofillChange( event ) { + const { options } = this.props; + const searchOptions = this.getOptionsByQuery( + options, + event.target.value + ); + + if ( searchOptions.length === 1 ) { + this.selectOption( searchOptions[ 0 ] ); + } + } + + render() { + const { + autofill, + children, + className, + disabled, + controlClassName, + inlineTags, + instanceId, + isSearchable, + options, + } = this.props; + const { isExpanded, isFocused, selectedIndex } = this.state; + + const hasMultiple = this.hasMultiple(); + const { key: selectedKey = '' } = options[ selectedIndex ] || {}; + const listboxId = isExpanded + ? `woocommerce-select-control__listbox-${ instanceId }` + : null; + const activeId = isExpanded + ? `woocommerce-select-control__option-${ instanceId }-${ selectedKey }` + : null; + + return ( + <div + className={ classnames( + 'woocommerce-select-control', + className, + { + 'has-inline-tags': hasMultiple && inlineTags, + 'is-focused': isFocused, + 'is-searchable': isSearchable, + } + ) } + ref={ this.bindNode } + > + { autofill && ( + <input + onChange={ this.onAutofillChange } + name={ autofill } + type="text" + className="woocommerce-select-control__autofill-input" + tabIndex="-1" + /> + ) } + { children } + <Control + { ...this.props } + { ...this.state } + activeId={ activeId } + className={ controlClassName } + disabled={ disabled } + hasTags={ hasMultiple } + isExpanded={ isExpanded } + listboxId={ listboxId } + onSearch={ this.search } + selected={ this.getSelected() } + onChange={ this.setNewValue } + setExpanded={ this.setExpanded } + updateSearchOptions={ this.updateSearchOptions } + decrementSelectedIndex={ this.decrementSelectedIndex } + incrementSelectedIndex={ this.incrementSelectedIndex } + /> + { ! inlineTags && hasMultiple && ( + <Tags { ...this.props } selected={ this.getSelected() } /> + ) } + { isExpanded && ( + <List + { ...this.props } + { ...this.state } + activeId={ activeId } + listboxId={ listboxId } + node={ this.node } + onSelect={ this.selectOption } + onSearch={ this.search } + options={ this.getOptions() } + decrementSelectedIndex={ this.decrementSelectedIndex } + incrementSelectedIndex={ this.incrementSelectedIndex } + setExpanded={ this.setExpanded } + /> + ) } + </div> + ); + } +} + +SelectControl.propTypes = { + /** + * Name to use for the autofill field, not used if no string is passed. + */ + autofill: PropTypes.string, + /** + * A renderable component (or string) which will be displayed before the `Control` of this component. + */ + children: PropTypes.node, + /** + * Class name applied to parent div. + */ + className: PropTypes.string, + /** + * Class name applied to control wrapper. + */ + controlClassName: PropTypes.string, + /** + * Allow the select options to be disabled. + */ + disabled: PropTypes.bool, + /** + * Exclude already selected options from the options list. + */ + excludeSelectedOptions: PropTypes.bool, + /** + * Add or remove items to the list of options after filtering, + * passed the array of filtered options and should return an array of options. + */ + onFilter: PropTypes.func, + /** + * Function to add regex expression to the filter the results, passed the search query. + */ + getSearchExpression: PropTypes.func, + /** + * Help text to be appended beneath the input. + */ + help: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ), + /** + * Render tags inside input, otherwise render below input. + */ + inlineTags: PropTypes.bool, + /** + * Allow the select options to be filtered by search input. + */ + isSearchable: PropTypes.bool, + /** + * A label to use for the main input. + */ + label: PropTypes.string, + /** + * Function called when selected results change, passed result list. + */ + onChange: PropTypes.func, + /** + * Function run after search query is updated, passed previousOptions and query, + * should return a promise with an array of updated options. + */ + onSearch: PropTypes.func, + /** + * An array of objects for the options list. The option along with its key, label and + * value will be returned in the onChange event. + */ + options: PropTypes.arrayOf( + PropTypes.shape( { + isDisabled: PropTypes.bool, + key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ) + .isRequired, + keywords: PropTypes.arrayOf( + PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] ) + ), + label: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.object, + ] ), + value: PropTypes.any, + } ) + ).isRequired, + /** + * A placeholder for the search input. + */ + placeholder: PropTypes.string, + /** + * Time in milliseconds to debounce the search function after typing. + */ + searchDebounceTime: PropTypes.number, + /** + * An array of objects describing selected values or optionally a string for a single value. + * If the label of the selected value is omitted, the Tag of that value will not + * be rendered inside the search box. + */ + selected: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.arrayOf( + PropTypes.shape( { + key: PropTypes.oneOfType( [ + PropTypes.number, + PropTypes.string, + ] ).isRequired, + label: PropTypes.string, + } ) + ), + ] ), + /** + * A limit for the number of results shown in the options menu. Set to 0 for no limit. + */ + maxResults: PropTypes.number, + /** + * Allow multiple option selections. + */ + multiple: PropTypes.bool, + /** + * Render a 'Clear' button next to the input box to remove its contents. + */ + showClearButton: PropTypes.bool, + /** + * The input type for the search box control. + */ + searchInputType: PropTypes.oneOf( [ + 'text', + 'search', + 'number', + 'email', + 'tel', + 'url', + ] ), + /** + * Only show list options after typing a search query. + */ + hideBeforeSearch: PropTypes.bool, + /** + * Show all options on focusing, even if a query exists. + */ + showAllOnFocus: PropTypes.bool, + /** + * Render results list positioned statically instead of absolutely. + */ + staticList: PropTypes.bool, + /** + * autocomplete prop for the Control input field. + */ + autoComplete: PropTypes.string, +}; + +SelectControl.defaultProps = { + autofill: null, + excludeSelectedOptions: true, + getSearchExpression: identity, + inlineTags: false, + isSearchable: false, + onChange: noop, + onFilter: identity, + onSearch: ( options ) => Promise.resolve( options ), + maxResults: 0, + multiple: false, + searchDebounceTime: 0, + searchInputType: 'search', + selected: [], + showAllOnFocus: false, + showClearButton: false, + hideBeforeSearch: false, + staticList: false, + autoComplete: 'off', +}; + +export default compose( [ + withSpokenMessages, + withInstanceId, + withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside +] )( SelectControl ); diff --git a/packages/js/components/src/select-control/list.js b/packages/js/components/src/select-control/list.js new file mode 100644 index 00000000000..bca27a6b19b --- /dev/null +++ b/packages/js/components/src/select-control/list.js @@ -0,0 +1,250 @@ +/** + * External dependencies + */ +import { Button } from '@wordpress/components'; +import classnames from 'classnames'; +import { createElement, Component, createRef } from '@wordpress/element'; +import { isEqual } from 'lodash'; +import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, TAB } from '@wordpress/keycodes'; +import PropTypes from 'prop-types'; + +/** + * A list box that displays filtered options after search. + */ +class List extends Component { + constructor() { + super( ...arguments ); + + this.handleKeyDown = this.handleKeyDown.bind( this ); + this.select = this.select.bind( this ); + this.optionRefs = {}; + this.listbox = createRef(); + } + + componentDidUpdate( prevProps ) { + const { options, selectedIndex } = this.props; + + // Remove old option refs to avoid memory leaks. + if ( ! isEqual( options, prevProps.options ) ) { + this.optionRefs = {}; + } + + if ( selectedIndex !== prevProps.selectedIndex ) { + this.scrollToOption( selectedIndex ); + } + } + + getOptionRef( index ) { + if ( ! this.optionRefs.hasOwnProperty( index ) ) { + this.optionRefs[ index ] = createRef(); + } + + return this.optionRefs[ index ]; + } + + select( option ) { + const { onSelect } = this.props; + + if ( option.isDisabled ) { + return; + } + + onSelect( option ); + } + + scrollToOption( index ) { + const listbox = this.listbox.current; + + if ( listbox.scrollHeight <= listbox.clientHeight ) { + return; + } + + if ( ! this.optionRefs[ index ] ) { + return; + } + + const option = this.optionRefs[ index ].current; + const scrollBottom = listbox.clientHeight + listbox.scrollTop; + const elementBottom = option.offsetTop + option.offsetHeight; + if ( elementBottom > scrollBottom ) { + listbox.scrollTop = elementBottom - listbox.clientHeight; + } else if ( option.offsetTop < listbox.scrollTop ) { + listbox.scrollTop = option.offsetTop; + } + } + + handleKeyDown( event ) { + const { + decrementSelectedIndex, + incrementSelectedIndex, + options, + onSearch, + selectedIndex, + setExpanded, + } = this.props; + if ( options.length === 0 ) { + return; + } + + switch ( event.keyCode ) { + case UP: + decrementSelectedIndex(); + event.preventDefault(); + event.stopPropagation(); + break; + + case DOWN: + incrementSelectedIndex(); + event.preventDefault(); + event.stopPropagation(); + break; + + case ENTER: + if ( options[ selectedIndex ] ) { + this.select( options[ selectedIndex ] ); + } + event.preventDefault(); + event.stopPropagation(); + break; + + case LEFT: + case RIGHT: + setExpanded( false ); + break; + + case ESCAPE: + setExpanded( false ); + onSearch( null ); + return; + + case TAB: + if ( options[ selectedIndex ] ) { + this.select( options[ selectedIndex ] ); + } + setExpanded( false ); + break; + + default: + } + } + + toggleKeyEvents( isListening ) { + const { node } = this.props; + // This exists because we must capture ENTER key presses before RichText. + // It seems that react fires the simulated capturing events after the + // native browser event has already bubbled so we can't stopPropagation + // and avoid RichText getting the event from TinyMCE, hence we must + // register a native event handler. + const handler = isListening + ? 'addEventListener' + : 'removeEventListener'; + node[ handler ]( 'keydown', this.handleKeyDown, true ); + } + + componentDidMount() { + const { selectedIndex } = this.props; + if ( selectedIndex > -1 ) { + this.scrollToOption( selectedIndex ); + } + this.toggleKeyEvents( true ); + } + + componentWillUnmount() { + this.toggleKeyEvents( false ); + } + + render() { + const { + instanceId, + listboxId, + options, + selectedIndex, + staticList, + } = this.props; + const listboxClasses = classnames( + 'woocommerce-select-control__listbox', + { + 'is-static': staticList, + } + ); + + return ( + <div + ref={ this.listbox } + id={ listboxId } + role="listbox" + className={ listboxClasses } + tabIndex="-1" + > + { options.map( ( option, index ) => ( + <Button + ref={ this.getOptionRef( index ) } + key={ option.key } + id={ `woocommerce-select-control__option-${ instanceId }-${ option.key }` } + role="option" + aria-selected={ index === selectedIndex } + disabled={ option.isDisabled } + className={ classnames( + 'woocommerce-select-control__option', + { + 'is-selected': index === selectedIndex, + } + ) } + onClick={ () => this.select( option ) } + tabIndex="-1" + > + { option.label } + </Button> + ) ) } + </div> + ); + } +} + +List.propTypes = { + /** + * ID of the main SelectControl instance. + */ + instanceId: PropTypes.number, + /** + * ID used for a11y in the listbox. + */ + listboxId: PropTypes.string, + /** + * Parent node to bind keyboard events to. + */ + // eslint-disable-next-line no-undef + node: PropTypes.instanceOf( Element ).isRequired, + /** + * Function to execute when an option is selected. + */ + onSelect: PropTypes.func, + /** + * Array of options to display. + */ + options: PropTypes.arrayOf( + PropTypes.shape( { + isDisabled: PropTypes.bool, + key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ) + .isRequired, + keywords: PropTypes.arrayOf( + PropTypes.oneOfType( [ PropTypes.string, PropTypes.number ] ) + ), + label: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.object, + ] ), + value: PropTypes.any, + } ) + ).isRequired, + /** + * Integer for the currently selected item. + */ + selectedIndex: PropTypes.number, + /** + * Bool to determine if the list should be positioned absolutely or staticly. + */ + staticList: PropTypes.bool, +}; + +export default List; diff --git a/packages/js/components/src/select-control/stories/index.js b/packages/js/components/src/select-control/stories/index.js new file mode 100644 index 00000000000..9e0d5b0107f --- /dev/null +++ b/packages/js/components/src/select-control/stories/index.js @@ -0,0 +1,169 @@ +/** + * External dependencies + */ +import { SelectControl } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; + +const options = [ + { + key: 'apple', + label: 'Apple', + value: { id: 'apple' }, + }, + { + key: 'apricot', + label: 'Apricot', + value: { id: 'apricot' }, + }, + { + key: 'banana', + label: 'Banana', + keywords: [ 'best', 'fruit' ], + value: { id: 'banana' }, + }, + { + key: 'blueberry', + label: 'Blueberry', + value: { id: 'blueberry' }, + }, + { + key: 'cherry', + label: 'Cherry', + value: { id: 'cherry' }, + }, + { + key: 'cantaloupe', + label: 'Cantaloupe', + value: { id: 'cantaloupe' }, + }, + { + key: 'dragonfruit', + label: 'Dragon Fruit', + value: { id: 'dragonfruit' }, + }, + { + key: 'elderberry', + label: 'Elderberry', + value: { id: 'elderberry' }, + }, +]; + +const SelectControlExample = () => { + const [ state, setState ] = useState( { + simpleSelected: [], + simpleMultipleSelected: [], + singleSelected: [], + singleSelectedShowAll: [], + multipleSelected: [], + inlineSelected: [], + allOptionsIncludingSelected: options[ options.length - 1 ].key, + } ); + + const { + simpleSelected, + simpleMultipleSelected, + singleSelected, + singleSelectedShowAll, + multipleSelected, + inlineSelected, + allOptionsIncludingSelected, + } = state; + + return ( + <div> + <SelectControl + label="Simple single value" + onChange={ ( selected ) => + setState( { ...state, simpleSelected: selected } ) + } + options={ options } + placeholder="Start typing to filter options..." + selected={ simpleSelected } + /> + <br /> + <SelectControl + label="Multiple values" + multiple + onChange={ ( selected ) => + setState( { ...state, simpleMultipleSelected: selected } ) + } + options={ options } + placeholder="Start typing to filter options..." + selected={ simpleMultipleSelected } + /> + <br /> + <SelectControl + label="Show all options with default selected" + onChange={ ( selected ) => + setState( { + ...state, + allOptionsIncludingSelected: selected, + } ) + } + options={ options } + placeholder="Start typing to filter options..." + selected={ allOptionsIncludingSelected } + showAllOnFocus + isSearchable + excludeSelectedOptions={ false } + /> + <br /> + <SelectControl + label="Single value searchable" + isSearchable + onChange={ ( selected ) => + setState( { ...state, singleSelected: selected } ) + } + options={ options } + placeholder="Start typing to filter options..." + selected={ singleSelected } + /> + <br /> + <SelectControl + label="Single value searchable with options on refocus" + isSearchable + onChange={ ( selected ) => + setState( { ...state, singleSelectedShowAll: selected } ) + } + options={ options } + placeholder="Start typing to filter options..." + selected={ singleSelectedShowAll } + showAllOnFocus + /> + <br /> + <SelectControl + label="Inline tags searchable" + isSearchable + multiple + inlineTags + onChange={ ( selected ) => + setState( { ...state, inlineSelected: selected } ) + } + options={ options } + placeholder="Start typing to filter options..." + selected={ inlineSelected } + /> + <br /> + <SelectControl + hideBeforeSearch + isSearchable + label="Hidden options before search" + multiple + onChange={ ( selected ) => + setState( { ...state, multipleSelected: selected } ) + } + options={ options } + placeholder="Start typing to filter options..." + selected={ multipleSelected } + showClearButton + /> + </div> + ); +}; + +export const Basic = () => <SelectControlExample />; + +export default { + title: 'WooCommerce Admin/components/SelectControl', + component: SelectControl, +}; diff --git a/packages/js/components/src/select-control/style.scss b/packages/js/components/src/select-control/style.scss new file mode 100644 index 00000000000..fc870497a8a --- /dev/null +++ b/packages/js/components/src/select-control/style.scss @@ -0,0 +1,161 @@ +.woocommerce-select-control { + position: relative; + + .components-base-control { + height: 56px; + display: flex; + align-items: center; + border: 1px solid $studio-gray-20; + border-radius: 3px; + background: $studio-white; + padding: $gap-small; + position: relative; + + .woocommerce-select-control__tags { + margin: $gap-small $gap-smallest 0 0; + } + + .woocommerce-tag { + max-height: 20px; + } + + .components-base-control__field { + display: flex; + align-items: center; + flex: 1; + margin-bottom: 0; + max-width: 100%; + } + + .components-base-control__label { + position: absolute; + top: 50%; + transform: translateY(-50%); + color: $studio-gray-50; + font-size: 16px; + line-height: 1.5em; + } + + .woocommerce-select-control__control-input { + font-size: 16px; + border: 0; + box-shadow: none; + color: $studio-gray-80; + margin: $gap-small 0 0 0; + padding-left: 0; + padding-right: 0; + width: 100%; + line-height: 24px; + text-align: left; + letter-spacing: inherit; + background: transparent; + + &::-webkit-search-cancel-button { + display: none; + } + + &:focus { + outline: none; + } + } + + i { + color: #636d75; + margin-right: $gap-small; + width: 24px; + } + + &.is-active { + box-shadow: 0 0 0 1px $studio-wordpress-blue-50; + border-color: $studio-wordpress-blue-50; + } + + &.with-value .components-base-control__label, + &.has-tags .components-base-control__label { + font-size: 12px; + margin-top: -$gap-small; + } + + &.is-disabled { + opacity: 0.5; + .components-base-control__label { + cursor: default; + } + } + } + + .woocommerce-select-control__autofill-input { + position: absolute; + z-index: -1; + } + + .woocommerce-select-control__tags { + position: relative; + margin: $gap-small 0 0 0; + + &.has-clear { + padding-right: $gap-large; + } + } + + .woocommerce-tag { + max-height: 24px; + } + + .woocommerce-select-control__clear { + position: absolute; + right: 10px; + top: calc(50% - 10px); + + & > .clear-icon { + color: #c9c9c9; + } + } + + .woocommerce-select-control__listbox { + background: $studio-white; + display: flex; + flex-direction: column; + align-items: stretch; + box-shadow: $muriel-box-shadow-6dp; + border-radius: 3px; + position: absolute; + left: 0; + right: 0; + top: 57px; + z-index: 10; + overflow-y: auto; + max-height: 350px; + + &.is-static { + position: static; + } + } + + .woocommerce-select-control__option { + padding: $gap; + min-height: 56px; + font-size: 16px; + text-align: left; + + &.is-selected, + &:hover { + background: $studio-gray-0; + } + } + + &.is-searchable { + .components-base-control__label { + left: 48px; + } + + .components-base-control.is-active .components-base-control__label { + font-size: 12px; + margin-top: -$gap-small; + } + + .woocommerce-select-control__control-input { + padding-left: 12px; + } + } +} diff --git a/packages/js/components/src/select-control/tags.js b/packages/js/components/src/select-control/tags.js new file mode 100644 index 00000000000..296e27bd4b2 --- /dev/null +++ b/packages/js/components/src/select-control/tags.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { Icon, cancelCircleFilled } from '@wordpress/icons'; +import { createElement, Component, Fragment } from '@wordpress/element'; +import { findIndex } from 'lodash'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import Tag from '../tag'; + +/** + * A list of tags to display selected items. + */ +class Tags extends Component { + constructor( props ) { + super( props ); + this.removeAll = this.removeAll.bind( this ); + this.removeResult = this.removeResult.bind( this ); + } + + removeAll() { + const { onChange } = this.props; + onChange( [] ); + } + + removeResult( key ) { + return () => { + const { selected, onChange } = this.props; + const i = findIndex( selected, { key } ); + onChange( [ + ...selected.slice( 0, i ), + ...selected.slice( i + 1 ), + ] ); + }; + } + + render() { + const { selected, showClearButton } = this.props; + if ( ! selected.length ) { + return null; + } + + return ( + <Fragment> + <div className="woocommerce-select-control__tags"> + { selected.map( ( item, i ) => { + if ( ! item.label ) { + return null; + } + const screenReaderLabel = sprintf( + __( '%1$s (%2$s of %3$s)', 'woocommerce' ), + item.label, + i + 1, + selected.length + ); + return ( + <Tag + key={ item.key } + id={ item.key } + label={ item.label } + remove={ this.removeResult } + screenReaderLabel={ screenReaderLabel } + /> + ); + } ) } + </div> + { showClearButton && ( + <Button + className="woocommerce-select-control__clear" + isLink + onClick={ this.removeAll } + > + <Icon + icon={ cancelCircleFilled } + className="clear-icon" + /> + <span className="screen-reader-text"> + { __( 'Clear all', 'woocommerce' ) } + </span> + </Button> + ) } + </Fragment> + ); + } +} + +Tags.propTypes = { + /** + * Function called when selected results change, passed result list. + */ + onChange: PropTypes.func, + /** + * Function to execute when an option is selected. + */ + onSelect: PropTypes.func, + /** + * An array of objects describing selected values. If the label of the selected + * value is omitted, the Tag of that value will not be rendered inside the + * search box. + */ + selected: PropTypes.arrayOf( + PropTypes.shape( { + key: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ) + .isRequired, + label: PropTypes.string, + } ) + ), + /** + * Render a 'Clear' button next to the input box to remove its contents. + */ + showClearButton: PropTypes.bool, +}; + +export default Tags; diff --git a/packages/js/components/src/select-control/test/index.js b/packages/js/components/src/select-control/test/index.js new file mode 100644 index 00000000000..685ceaee0e7 --- /dev/null +++ b/packages/js/components/src/select-control/test/index.js @@ -0,0 +1,560 @@ +/** + * External dependencies + */ +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { SelectControl } from '../index'; + +describe( 'SelectControl', () => { + const query = 'lorem'; + const options = [ + { key: '1', label: 'lorem 1', value: { id: '1' } }, + { key: '2', label: 'lorem 2', value: { id: '2' } }, + { key: '3', label: 'bar', value: { id: '3' } }, + ]; + + it( 'returns all elements', () => { + const { getByRole } = render( <SelectControl options={ options } /> ); + + userEvent.click( getByRole( 'combobox' ) ); + + expect( + getByRole( 'option', { name: 'lorem 1' } ) + ).toBeInTheDocument(); + expect( + getByRole( 'option', { name: 'lorem 2' } ) + ).toBeInTheDocument(); + expect( getByRole( 'option', { name: 'bar' } ) ).toBeInTheDocument(); + } ); + + it( 'returns matching elements', async () => { + const { getByRole, queryByRole } = render( + <SelectControl isSearchable options={ options } /> + ); + + userEvent.type( getByRole( 'combobox' ), query ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( + getByRole( 'option', { name: 'lorem 1' } ) + ).toBeInTheDocument() + ); + + expect( + getByRole( 'option', { name: 'lorem 2' } ) + ).toBeInTheDocument(); + expect( queryByRole( 'option', { name: 'bar' } ) ).toBeNull(); + } ); + + it( "doesn't return matching excluded elements", async () => { + const { getByRole, queryByRole } = render( + <SelectControl + isSearchable + options={ options } + selected={ [ options[ 1 ] ] } + excludeSelectedOptions + multiple + /> + ); + + userEvent.type( getByRole( 'combobox' ), query ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( + getByRole( 'option', { name: 'lorem 1' } ) + ).toBeInTheDocument() + ); + + expect( queryByRole( 'option', { name: 'lorem 2' } ) ).toBeNull(); + expect( queryByRole( 'option', { name: 'bar' } ) ).toBeNull(); + } ); + + it( 'trims spaces from input', async () => { + const { getByRole, queryByRole } = render( + <SelectControl isSearchable options={ options } /> + ); + + userEvent.type( getByRole( 'combobox' ), ' ' + query + ' ' ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( + getByRole( 'option', { name: 'lorem 1' } ) + ).toBeInTheDocument() + ); + + expect( + getByRole( 'option', { name: 'lorem 2' } ) + ).toBeInTheDocument(); + expect( queryByRole( 'option', { name: 'bar' } ) ).toBeNull(); + } ); + + it( 'limits results', async () => { + const { getByRole, getAllByRole } = render( + <SelectControl isSearchable options={ options } maxResults={ 1 } /> + ); + + userEvent.type( getByRole( 'combobox' ), query ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( + getByRole( 'option', { name: 'lorem 1' } ) + ).toBeInTheDocument() + ); + + expect( getAllByRole( 'option' ) ).toHaveLength( 1 ); + } ); + + it( 'shows options initially', async () => { + const { getByRole, getAllByRole } = render( + <SelectControl isSearchable options={ options } /> + ); + + userEvent.click( getByRole( 'combobox' ) ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( getAllByRole( 'option' ) ).toHaveLength( 3 ) + ); + } ); + + it( 'hides options before query', async () => { + const { getByRole, getAllByRole, queryAllByRole } = render( + <SelectControl hideBeforeSearch isSearchable options={ options } /> + ); + + userEvent.click( getByRole( 'combobox' ) ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( queryAllByRole( 'option' ) ).toHaveLength( 0 ) + ); + + userEvent.type( getByRole( 'combobox' ), query ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( getAllByRole( 'option' ) ).toHaveLength( 2 ) + ); + } ); + + it( 'appends an option after filtering', async () => { + const { getByRole, getAllByRole } = render( + <SelectControl + options={ options } + onFilter={ ( filteredOptions ) => + filteredOptions.concat( [ + { key: 'new-option', label: 'New options' }, + ] ) + } + /> + ); + + userEvent.type( getByRole( 'combobox' ), query ); + + // Wait for the search promise to resolve. + await waitFor( () => + // TODO: check the actual options here - "bar" is shown, where I expected "new options". -Jeff + expect( getAllByRole( 'option' ) ).toHaveLength( 3 ) + ); + } ); + + it( 'changes the options on search', async () => { + const queriedOptions = []; + // eslint-disable-next-line no-shadow + const queryOptions = ( options, searchedQuery ) => { + if ( searchedQuery === 'test' ) { + queriedOptions.push( { + key: 'test-option', + label: 'Test option', + value: { id: '4' }, + } ); + } + return queriedOptions; + }; + + const { getByRole, getAllByRole } = render( + <SelectControl + isSearchable + options={ queriedOptions } + onSearch={ queryOptions } + onFilter={ () => queriedOptions } + /> + ); + + userEvent.type( getByRole( 'combobox' ), 'test' ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( + getByRole( 'option', { name: 'Test option' } ) + ).toBeInTheDocument() + ); + + expect( getAllByRole( 'option' ) ).toHaveLength( 1 ); + } ); + + it( 'should not automatically select first option on focus', async () => { + const onChangeMock = jest.fn(); + const { getByRole, queryByRole } = render( + <SelectControl + isSearchable + showAllOnFocus + options={ options } + onSearch={ () => options } + onFilter={ () => options } + onChange={ onChangeMock } + /> + ); + getByRole( 'combobox' ).focus(); + await waitFor( () => + expect( getByRole( 'option', { name: 'bar' } ) ).toBeInTheDocument() + ); + expect( queryByRole( 'option', { selected: true } ) ).toBeNull(); + } ); + + describe( 'selected value', () => { + it( 'should return an array if array', async () => { + const onChangeMock = jest.fn(); + const { getByRole } = render( + <SelectControl + isSearchable + selected={ [ { ...options[ 0 ] } ] } + options={ options } + onSearch={ () => options } + onFilter={ () => options } + onChange={ onChangeMock } + /> + ); + + userEvent.clear( getByRole( 'combobox' ) ); + userEvent.type( getByRole( 'combobox' ), 'test' ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + userEvent.click( getByRole( 'option', { name: 'bar' } ) ); + expect( onChangeMock ).toHaveBeenCalledWith( + [ options[ 2 ] ], + 'test' + ); + } ); + + it( 'should return key value as string if selected is string', async () => { + const onChangeMock = jest.fn(); + const { getByRole } = render( + <SelectControl + isSearchable + selected={ options[ 0 ].key } + options={ options } + onSearch={ () => options } + onFilter={ () => options } + onChange={ onChangeMock } + /> + ); + + userEvent.clear( getByRole( 'combobox' ) ); + userEvent.type( getByRole( 'combobox' ), 'test' ); + + // Wait for the search promise to resolve. + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + userEvent.click( getByRole( 'option', { name: 'bar' } ) ); + expect( onChangeMock ).toHaveBeenCalledWith( + options[ 2 ].key, + 'test' + ); + } ); + + describe( 'prop excludeSelectedOptions', () => { + it( 'should preserve selected option when focused', async () => { + const onChangeMock = jest.fn(); + const { getByRole } = render( + <SelectControl + isSearchable + showAllOnFocus + selected={ options[ 2 ].key } + options={ options } + onSearch={ () => options } + onFilter={ () => options } + onChange={ onChangeMock } + excludeSelectedOptions={ false } + /> + ); + getByRole( 'combobox' ).focus(); + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + // In browser, the <Button> in <List> component is automatically "selected" when <Control> lost focus. + // I was not able to produce the same behaviour with unit test, but a click on the currently + // selected option should be sufficient to simulate the logic in this test. + userEvent.click( getByRole( 'option', { selected: true } ) ); + expect( onChangeMock ).toHaveBeenCalledTimes( 0 ); + } ); + + it( 'should reset selected option if searching', async () => { + const onChangeMock = jest.fn(); + const { getByRole, queryByRole } = render( + <SelectControl + isSearchable + showAllOnFocus + selected={ options[ 2 ].key } + options={ options } + onSearch={ () => options } + onFilter={ () => options } + onChange={ onChangeMock } + excludeSelectedOptions={ false } + /> + ); + + getByRole( 'combobox' ).focus(); + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + + userEvent.click( getByRole( 'option', { name: 'bar' } ) ); + userEvent.clear( getByRole( 'combobox' ) ); + userEvent.type( getByRole( 'combobox' ), 'bar' ); + + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + + expect( + queryByRole( 'option', { selected: true } ) + ).toBeNull(); + } ); + } ); + + it( 'disables the component', async () => { + const { getByRole } = render( + <SelectControl disabled options={ options } /> + ); + + await waitFor( () => + expect( getByRole( 'combobox' ) ).toBeDisabled() + ); + } ); + + describe( 'control onChange', () => { + it( 'should return array if selected is array and onChange triggered from control', () => { + const onChangeMock = jest.fn(); + const { getByRole } = render( + <SelectControl + isSearchable + selected={ [ { ...options[ 0 ] } ] } + options={ options } + onSearch={ () => options } + onFilter={ () => options } + onChange={ onChangeMock } + /> + ); + + userEvent.clear( getByRole( 'combobox' ) ); + userEvent.type( getByRole( 'combobox' ), '{backspace}' ); + + expect( onChangeMock ).toHaveBeenCalledWith( [], '' ); + } ); + + it( 'should return string if selected is string and onChange triggered from control', () => { + const onChangeMock = jest.fn(); + const { getByRole } = render( + <SelectControl + isSearchable + selected={ options[ 0 ].key } + options={ options } + onSearch={ () => options } + onFilter={ () => options } + onChange={ onChangeMock } + /> + ); + + userEvent.clear( getByRole( 'combobox' ) ); + userEvent.type( getByRole( 'combobox' ), '{backspace}' ); + + expect( onChangeMock ).toHaveBeenCalledWith( '', '' ); + } ); + } ); + } ); + + it( 'displays multiple selection not inline', async () => { + const { getByText } = render( + <SelectControl + isSearchable + options={ options } + selected={ [ options[ 1 ] ] } + multiple + inlineTags={ false } + /> + ); + + expect( getByText( options[ 1 ].label ) ).toBeInTheDocument(); + } ); + + describe( 'keyboard interaction', () => { + it( 'pressing keydown on combobox should highlight first option', async () => { + const { getByRole } = render( + <SelectControl + options={ options } + onSearch={ () => options } + onFilter={ () => options } + selected={ null } + /> + ); + + getByRole( 'combobox' ).focus(); + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + + userEvent.type( getByRole( 'combobox' ), '{arrowdown}' ); + + expect( + getByRole( 'option', { selected: true } ).textContent + ).toEqual( options[ 0 ].label ); + } ); + + it( 'pressing keydown on combobox with selected should highlight the next option', async () => { + const { getByRole } = render( + <SelectControl + options={ options } + onSearch={ () => options } + onFilter={ () => options } + selected={ options[ 1 ].key } + excludeSelectedOptions={ false } + /> + ); + + getByRole( 'combobox' ).focus(); + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + + userEvent.type( getByRole( 'combobox' ), '{arrowdown}' ); + + expect( + getByRole( 'option', { selected: true } ).textContent + ).toEqual( options[ 2 ].label ); + } ); + + it( 'pressing keydown on combobox with selected last option should rotate to first option', async () => { + const { getByRole } = render( + <SelectControl + options={ options } + onSearch={ () => options } + onFilter={ () => options } + selected={ options[ options.length - 1 ].key } + excludeSelectedOptions={ false } + /> + ); + + getByRole( 'combobox' ).focus(); + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + + userEvent.type( getByRole( 'combobox' ), '{arrowdown}' ); + + expect( + getByRole( 'option', { selected: true } ).textContent + ).toEqual( options[ 0 ].label ); + } ); + + it( 'pressing keyup on combobox should highlight last option', async () => { + const { getByRole } = render( + <SelectControl + options={ options } + onSearch={ () => options } + onFilter={ () => options } + selected={ null } + /> + ); + + getByRole( 'combobox' ).focus(); + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + + userEvent.type( getByRole( 'combobox' ), '{arrowup}' ); + + expect( + getByRole( 'option', { selected: true } ).textContent + ).toEqual( options[ options.length - 1 ].label ); + } ); + + it( 'pressing tab on combobox should hide option list', async () => { + const { getByRole, queryByRole } = render( + <SelectControl + options={ options } + onSearch={ () => options } + onFilter={ () => options } + selected={ null } + /> + ); + + getByRole( 'combobox' ).focus(); + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + + userEvent.type( getByRole( 'combobox' ), '{tab}' ); + + expect( queryByRole( 'option', { selected: true } ) ).toBeNull(); + } ); + + it( 'pressing enter should select highlighted option', async () => { + const onChangeMock = jest.fn(); + const { getByRole } = render( + <SelectControl + options={ options } + onSearch={ () => options } + onFilter={ () => options } + selected={ null } + excludeSelectedOptions={ false } + onChange={ onChangeMock } + /> + ); + + getByRole( 'combobox' ).focus(); + await waitFor( () => + expect( + getByRole( 'option', { name: 'bar' } ) + ).toBeInTheDocument() + ); + + userEvent.type( getByRole( 'combobox' ), '{arrowdown}{enter}' ); + + expect( onChangeMock ).toHaveBeenCalled(); + } ); + } ); +} ); diff --git a/packages/js/components/src/spinner/README.md b/packages/js/components/src/spinner/README.md new file mode 100644 index 00000000000..b85003bd1eb --- /dev/null +++ b/packages/js/components/src/spinner/README.md @@ -0,0 +1,16 @@ +Spinner +=== + +Spinner - An indeterminate circular progress indicator. + +## Usage + +```jsx +<Spinner /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`className` | String | `null` | Additional class name to style the component diff --git a/packages/js/components/src/spinner/index.js b/packages/js/components/src/spinner/index.js new file mode 100644 index 00000000000..76ddfd2935a --- /dev/null +++ b/packages/js/components/src/spinner/index.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; + +/** + * Spinner - An indeterminate circular progress indicator. + */ +class Spinner extends Component { + render() { + const { className } = this.props; + const classes = classnames( 'woocommerce-spinner', className ); + return ( + <svg + className={ classes } + viewBox="0 0 100 100" + xmlns="http://www.w3.org/2000/svg" + > + <circle + className="woocommerce-spinner__circle" + fill="none" + strokeWidth="5" + strokeLinecap="round" + cx="50" + cy="50" + r="30" + /> + </svg> + ); + } +} + +Spinner.propTypes = { + /** + * Additional class name to style the component. + */ + className: PropTypes.string, +}; + +export default Spinner; diff --git a/packages/js/components/src/spinner/stories/index.js b/packages/js/components/src/spinner/stories/index.js new file mode 100644 index 00000000000..b50da952dd5 --- /dev/null +++ b/packages/js/components/src/spinner/stories/index.js @@ -0,0 +1,15 @@ +/** + * External dependencies + */ +import { Spinner } from '@woocommerce/components'; + +export const Basic = () => ( + <div> + <Spinner /> + </div> +); + +export default { + title: 'WooCommerce Admin/components/Spinner', + component: Spinner, +}; diff --git a/packages/js/components/src/spinner/style.scss b/packages/js/components/src/spinner/style.scss new file mode 100644 index 00000000000..31e37988890 --- /dev/null +++ b/packages/js/components/src/spinner/style.scss @@ -0,0 +1,38 @@ +@keyframes rotate { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(270deg); + } +} + +@keyframes growAndShrink { + 0%, + 100% { + stroke-dashoffset: 200; + } + 50% { + stroke-dashoffset: 50; + transform: rotate(135deg); + } + 100% { + transform: rotate(450deg); + } +} + +.woocommerce-spinner { + animation: rotate 2s linear infinite; + width: 40px; + min-width: 40px; + height: 40px; + max-height: 40px; +} + +.woocommerce-spinner__circle { + stroke-dasharray: 200; + stroke-dashoffset: 0; + transform-origin: center; + animation: growAndShrink 2s ease-in-out infinite; + stroke: $studio-gray-90; +} diff --git a/packages/js/components/src/stepper/README.md b/packages/js/components/src/stepper/README.md new file mode 100644 index 00000000000..07ad76f7bc6 --- /dev/null +++ b/packages/js/components/src/stepper/README.md @@ -0,0 +1,49 @@ +Stepper +=== + +A stepper component to indicate progress in a set number of steps. + +## Usage + +```jsx +const steps = [ + { + key: 'first', + label: 'First', + description: 'Step item description', + content: <div>First step content.</div>, + }, + { + key: 'second', + label: 'Second', + description: 'Step item description', + content: <div>Second step content.</div>, + }, +]; + +<Stepper + steps={ steps } + currentStep="first" + isPending={ true } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`className` | String | `null` | Additional class name to style the component +`currentStep` | String | `null` | (required) The current step's key +`steps` | Array | `null` | (required) An array of steps used +`isVertical` | Boolean | `false` | If the stepper is vertical instead of horizontal +`isPending` | Boolean | `false` | Optionally mark the current step as pending to show a spinner + +### `steps` structure + +Array of step objects with properties: + +- `key:` String - Key used to identify step. +- `label`: String - Label displayed in stepper. +- `description`: String - Description displayed beneath the label. +- `isComplete`: Boolean - Optionally mark a step complete regardless of step index. +- `content`: ReactNode - Content displayed when the step is active. \ No newline at end of file diff --git a/packages/js/components/src/stepper/check-icon.tsx b/packages/js/components/src/stepper/check-icon.tsx new file mode 100644 index 00000000000..40c14d70a76 --- /dev/null +++ b/packages/js/components/src/stepper/check-icon.tsx @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => { + // we need a unique mask id because HTML ids are global in nature and collisions result in strange outcomes + const maskId = `check-icon-mask-${ Math.floor( + Math.random() * 10000000 + ) }`; + return ( + <svg + role="img" + aria-hidden="true" + focusable="false" + width="18" + height="18" + viewBox="0 0 18 18" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <mask + id={ maskId } + mask-type="alpha" + maskUnits="userSpaceOnUse" + x="2" + y="3" + width="14" + height="12" + > + <path + d="M6.59631 11.9062L3.46881 8.77875L2.40381 9.83625L6.59631 14.0287L15.5963 + 5.02875L14.5388 3.97125L6.59631 11.9062Z" + fill="white" + /> + </mask> + <g mask={ `url(#${ maskId })` }> + <rect width="18" height="18" fill="white" /> + </g> + </svg> + ); +}; diff --git a/packages/js/components/src/stepper/index.tsx b/packages/js/components/src/stepper/index.tsx new file mode 100644 index 00000000000..0e068becfae --- /dev/null +++ b/packages/js/components/src/stepper/index.tsx @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { createElement, Fragment } from '@wordpress/element'; +import React from 'react'; + +/** + * Internal dependencies + */ +import Spinner from '../spinner'; +import CheckIcon from './check-icon'; + +interface StepperProps { + /** Additional class name to style the component. */ + className?: string; + /** The current step's key. */ + currentStep: string; + /** An array of steps used. */ + steps: Array< { + /** Content displayed when the step is active. */ + content: React.ReactNode; + /** Description displayed beneath the label. */ + description: string | Array< string >; + /** Optionally mark a step complete regardless of step index. */ + isComplete?: boolean; + /** Key used to identify step. */ + key: string; + /** Label displayed in stepper. */ + label: string; + /** A function to be called when the step label is clicked. */ + onClick?: ( key: string ) => void; + } >; + /** If the stepper is vertical instead of horizontal. */ + isVertical?: boolean; + /** Optionally mark the current step as pending to show a spinner. */ + isPending?: boolean; +} + +/** + * A stepper component to indicate progress in a set number of steps. + */ +export const Stepper: React.FC< StepperProps > = ( { + className, + currentStep, + steps, + isVertical = false, + isPending = false, +}: StepperProps ) => { + const renderCurrentStepContent = () => { + const step = steps.find( ( s ) => currentStep === s.key ); + + if ( ! step || ! step.content ) { + return null; + } + + return ( + <div className="woocommerce-stepper_content">{ step.content }</div> + ); + }; + + const currentIndex = steps.findIndex( ( s ) => currentStep === s.key ); + const stepperClassName = classnames( 'woocommerce-stepper', className, { + 'is-vertical': isVertical, + } ); + + return ( + <div className={ stepperClassName }> + <div className="woocommerce-stepper__steps"> + { steps.map( ( step, i ) => { + const { + key, + label, + description, + isComplete, + onClick, + } = step; + const isCurrentStep = key === currentStep; + const stepClassName = classnames( + 'woocommerce-stepper__step', + { + 'is-active': isCurrentStep, + 'is-complete': + typeof isComplete !== 'undefined' + ? isComplete + : currentIndex > i, + } + ); + const icon = + isCurrentStep && isPending ? ( + <Spinner /> + ) : ( + <div className="woocommerce-stepper__step-icon"> + <span className="woocommerce-stepper__step-number"> + { i + 1 } + </span> + <CheckIcon /> + </div> + ); + + const LabelWrapper = + typeof onClick === 'function' ? 'button' : 'div'; + return ( + <Fragment key={ key }> + <div className={ stepClassName }> + <LabelWrapper + className="woocommerce-stepper__step-label-wrapper" + onClick={ + typeof onClick === 'function' + ? () => onClick( key ) + : undefined + } + > + { icon } + <div className="woocommerce-stepper__step-text"> + <span className="woocommerce-stepper__step-label"> + { label } + </span> + { description && ( + <span className="woocommerce-stepper__step-description"> + { description } + </span> + ) } + </div> + </LabelWrapper> + { isCurrentStep && + isVertical && + renderCurrentStepContent() } + </div> + { ! isVertical && ( + <div className="woocommerce-stepper__step-divider" /> + ) } + </Fragment> + ); + } ) } + </div> + + { ! isVertical && renderCurrentStepContent() } + </div> + ); +}; + +export default Stepper; diff --git a/packages/js/components/src/stepper/stories/index.js b/packages/js/components/src/stepper/stories/index.js new file mode 100644 index 00000000000..f395dae6c43 --- /dev/null +++ b/packages/js/components/src/stepper/stories/index.js @@ -0,0 +1,135 @@ +/** + * External dependencies + */ +import { Stepper } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; + +const BasicExamples = () => { + const [ state, setState ] = useState( { + currentStep: 'first', + isComplete: false, + isPending: false, + } ); + const { currentStep, isComplete, isPending } = state; + + const goToStep = ( key ) => { + setState( { currentStep: key } ); + }; + + const steps = [ + { + key: 'first', + label: 'First', + description: 'Step item description', + content: <div>First step content.</div>, + onClick: goToStep, + }, + { + key: 'second', + label: 'Second', + description: 'Step item description', + content: <div>Second step content.</div>, + onClick: goToStep, + }, + { + label: 'Third', + key: 'third', + description: 'Step item description', + content: <div>Third step content.</div>, + onClick: goToStep, + }, + { + label: 'Fourth', + key: 'fourth', + description: 'Step item description', + content: <div>Fourth step content.</div>, + onClick: goToStep, + }, + ]; + + const currentIndex = steps.findIndex( ( s ) => currentStep === s.key ); + + if ( isComplete ) { + steps.forEach( ( s ) => ( s.isComplete = true ) ); + } + + return ( + <div> + { isComplete ? ( + <button + onClick={ () => + setState( { + ...state, + currentStep: 'first', + isComplete: false, + } ) + } + > + Reset + </button> + ) : ( + <div> + <button + onClick={ () => + setState( { + ...state, + currentStep: steps[ currentIndex - 1 ].key, + } ) + } + disabled={ currentIndex < 1 } + > + Previous step + </button> + <button + onClick={ () => + setState( { + ...state, + currentStep: steps[ currentIndex + 1 ].key, + } ) + } + disabled={ currentIndex >= steps.length - 1 } + > + Next step + </button> + <button + onClick={ () => + setState( { ...state, isComplete: true } ) + } + disabled={ currentIndex !== steps.length - 1 } + > + Complete + </button> + <button + onClick={ () => + setState( { ...state, isPending: ! isPending } ) + } + > + Toggle Spinner + </button> + </div> + ) } + + <Stepper + steps={ steps } + currentStep={ currentStep } + isPending={ isPending } + /> + + <br /> + + <Stepper + isPending={ isPending } + isVertical={ true } + steps={ steps } + currentStep={ currentStep } + /> + </div> + ); +}; + +export const Examples = () => <BasicExamples />; + +export default { + title: 'WooCommerce Admin/components/Stepper', + component: Stepper, +}; diff --git a/packages/js/components/src/stepper/style.scss b/packages/js/components/src/stepper/style.scss new file mode 100644 index 00000000000..38c8af2fb30 --- /dev/null +++ b/packages/js/components/src/stepper/style.scss @@ -0,0 +1,179 @@ +.woocommerce-stepper { + $step-icon-size: 24px; + + .woocommerce-stepper__steps { + display: flex; + justify-content: space-around; + margin-bottom: $gap-large; + } + + .woocommerce-stepper__step { + padding: $gap-smaller; + font-weight: 400; + position: relative; + + .woocommerce-stepper__step-label-wrapper { + display: flex; + text-align: left; + border: 0; + background-color: transparent; + padding: 0; + + &:focus { + outline: none; + box-shadow: none; + } + } + + button.woocommerce-stepper__step-label-wrapper { + cursor: pointer; + } + + .woocommerce-stepper__step-text { + width: 100%; + } + + .woocommerce-stepper__step-label { + color: $gray-900; + line-height: $step-icon-size; + font-size: 16px; + } + + .woocommerce-stepper__step-description { + display: none; + font-size: 14px; + color: $gray-700; + font-weight: 400; + margin-top: 2px; + } + + .woocommerce-stepper__step-icon svg { + display: none; + } + + .woocommerce-spinner { + display: block; + margin-right: $gap-small; + max-height: $step-icon-size; + min-width: 24px; + width: 24px; + border-radius: 50%; + background: var(--wp-admin-theme-color); + } + + .woocommerce-spinner__circle { + stroke: $studio-white; + } + + &.is-active, + &.is-complete { + .woocommerce-stepper__step-icon { + background: var(--wp-admin-theme-color); + color: $studio-white; + } + + .woocommerce-stepper__step-label { + color: $gray-900; + } + } + + &.is-active { + .woocommerce-stepper__step-icon { + font-weight: 600; + } + .woocommerce-stepper__step-label { + font-weight: 600; + margin: 0; + } + } + + &.is-complete { + .woocommerce-stepper__step-number { + display: none; + } + svg { + display: inline; + } + } + } + + .woocommerce-stepper__step-icon { + font-size: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + width: $step-icon-size; + height: $step-icon-size; + min-width: $step-icon-size; + margin-right: $gap-small; + background: $gray-100; + color: $gray-700; + border-radius: 50%; + } + + .woocommerce-stepper__step-divider { + align-self: flex-start; + flex-grow: 1; + border-bottom: 1px solid $gray-100; + margin-top: math.div($step-icon-size, 2) + $gap-smaller; + + &:last-child { + display: none; + } + } + + @include breakpoint( '<782px' ) { + .woocommerce-stepper__step-label { + display: none; + padding-top: 24px; + } + .woocommerce-stepper__step-icon { + margin-right: 0; + } + } + + &.is-vertical { + .woocommerce-stepper__steps { + align-items: initial; + flex-direction: column; + margin-bottom: 0; + } + + .woocommerce-stepper__step { + padding-bottom: $gap-larger; + } + + .woocommerce-stepper__step::after { + content: ''; + position: absolute; + left: math.div($step-icon-size, 2) + $gap-smaller; + top: $step-icon-size + ( $gap-smaller * 2 ); + height: calc(100% - #{$step-icon-size} - #{$gap-smaller * 2}); + border-left: 1px solid $gray-100; + } + + .woocommerce-stepper__step:last-child { + padding-bottom: $gap-smaller; + &::after { + display: none; + } + } + + .woocommerce-stepper__step-label { + display: initial; + } + + .woocommerce-stepper__step-icon { + margin-right: $gap-small; + } + + .woocommerce-stepper__step-description { + display: block; + } + + .woocommerce-stepper_content { + margin-top: $gap; + margin-left: $gap-small + $step-icon-size; + } + } +} diff --git a/packages/js/components/src/style.scss b/packages/js/components/src/style.scss new file mode 100644 index 00000000000..ce36a25ac06 --- /dev/null +++ b/packages/js/components/src/style.scss @@ -0,0 +1,40 @@ +/** + * Internal Dependencies + */ +@import 'abbreviated-card/style.scss'; +@import 'calendar/style.scss'; +@import 'chart/style.scss'; +@import 'chart/d3chart/style.scss'; +@import 'chart/d3chart/d3base/style.scss'; +@import 'dropdown-button/style.scss'; +@import 'ellipsis-menu/style.scss'; +@import 'empty-content/style.scss'; +@import 'advanced-filters/style.scss'; +@import 'date-range-filter-picker/style.scss'; +@import 'filter-picker/style.scss'; +@import 'filters/style.scss'; +@import 'flag/style.scss'; +@import 'image-upload/style.scss'; +@import 'list/style.scss'; +@import 'order-status/style.scss'; +@import 'pagination/style.scss'; +@import 'pill/style.scss'; +@import 'product-image/style.scss'; +@import 'rating/style.scss'; +@import 'search/style.scss'; +@import 'search-list-control/style.scss'; +@import 'section-header/style.scss'; +@import 'segmented-selection/style.scss'; +@import 'select-control/style.scss'; +@import 'stepper/style.scss'; +@import 'spinner/style.scss'; +@import 'summary/style.scss'; +@import 'table/style.scss'; +@import 'tag/style.scss'; +@import 'text-control/style.scss'; +@import 'text-control-with-affixes/style.scss'; +@import 'timeline/style.scss'; +@import 'view-more-list/style.scss'; +@import 'web-preview/style.scss'; +@import 'badge/style.scss'; +@import 'dynamic-form/style.scss'; diff --git a/packages/js/components/src/summary/README.md b/packages/js/components/src/summary/README.md new file mode 100644 index 00000000000..d4f377ec08a --- /dev/null +++ b/packages/js/components/src/summary/README.md @@ -0,0 +1,84 @@ +SummaryList +=== + +A container element for a list of SummaryNumbers. This component handles detecting & switching to the mobile format on smaller screens. + +## Usage + +```jsx +<SummaryList> + { () => { + return [ + <SummaryNumber + key="revenue" + value={ '$829.40' } + label="Total sales" + delta={ 29 } + href="/analytics/report" + > + <span>27 orders</span> + </SummaryNumber>, + <SummaryNumber + key="refunds" + value={ '$24.00' } + label="Refunds" + delta={ -10 } + href="/analytics/report" + selected + />, + ]; + } } +</SummaryList> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`children` | Function | `null` | (required) A function returning a list of `<SummaryNumber />`s +`label` | String | `__( 'Performance Indicators', 'woocommerce' )` | An optional label of this group, read to screen reader users + + +SummaryNumber +=== + +A component to show a value, label, and optionally a change percentage and children node. Can also act as a link to a specific report focus. + +## Usage + +See above + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`delta` | Number | `null` | A number to represent the percentage change since the last comparison period - positive numbers will show a green up arrow, negative numbers will show a red down arrow, and zero will show a flat right arrow. If omitted, no change value will display +`href` | String | `''` | An internal link to the report focused on this number +`isOpen` | Boolean | `false` | Boolean describing whether the menu list is open. Only applies in mobile view, and only applies to the toggle-able item (first in the list) +`label` | String | `null` | (required) A string description of this value, ex "Revenue", or "New Customers" +`onToggle` | Function | `null` | A function used to switch the given SummaryNumber to a button, and called on click +`prevLabel` | String | `__( 'Previous period:', 'woocommerce' )` | A string description of the previous value's timeframe, ex "Previous year:" +`prevValue` | One of type: number, string | `null` | A string or number value to display - a string is allowed so we can accept currency formatting. If omitted, this section won't display +`reverseTrend` | Boolean | `false` | A boolean used to indicate that a negative delta is "good", and should be styled like a positive (and vice-versa) +`selected` | Boolean | `false` | A boolean used to show a highlight style on this number +`value` | One of type: number, string | `null` | A string or number value to display - a string is allowed so we can accept currency formatting +`onLinkClickCallback` | Function | `noop` | A function to be called after a SummaryNumber, rendered as a link, is clicked + + +SummaryListPlaceholder +=== + +`SummaryListPlaceholder` behaves like `SummaryList` but displays placeholder summary items instead of data. This can be used while loading data. + +## Usage + +```jsx +<SummaryListPlaceholder numberOfItems={ 2 } /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`numberOfItems` | Number | `null` | (required) An integer with the number of summary items to display +`numberOfRows` | | `5` | diff --git a/packages/js/components/src/summary/index.js b/packages/js/components/src/summary/index.js new file mode 100644 index 00000000000..ebc6aad1836 --- /dev/null +++ b/packages/js/components/src/summary/index.js @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createElement, Children, cloneElement } from '@wordpress/element'; +import { Dropdown } from '@wordpress/components'; +import PropTypes from 'prop-types'; +import { withViewportMatch } from '@wordpress/viewport'; + +/** + * Internal dependencies + */ +import Menu from './menu'; + +/** + * A container element for a list of SummaryNumbers. This component handles detecting & switching to + * the mobile format on smaller screens. + * + * @param {Object} props + * @param {Node} props.children + * @param {string} props.isDropdownBreakpoint + * @param {string} props.label + * @return {Object} - + */ +const SummaryList = ( { children, isDropdownBreakpoint, label } ) => { + const items = children( {} ); + // We default to "one" because we can't have empty children. + const itemCount = Children.count( items ) || 1; + const orientation = isDropdownBreakpoint ? 'vertical' : 'horizontal'; + const summaryMenu = ( + <Menu + label={ label } + orientation={ orientation } + itemCount={ itemCount } + items={ items } + /> + ); + + // On large screens, or if there are not multiple SummaryNumbers, we'll display the plain list. + if ( ! isDropdownBreakpoint || itemCount < 2 ) { + return summaryMenu; + } + + const selected = items.find( ( item ) => !! item.props.selected ); + if ( ! selected ) { + return summaryMenu; + } + + return ( + <Dropdown + className="woocommerce-summary" + position="bottom" + headerTitle={ label } + renderToggle={ ( { isOpen, onToggle } ) => + cloneElement( selected, { onToggle, isOpen } ) + } + renderContent={ ( renderContentArgs ) => ( + <Menu + label={ label } + orientation={ orientation } + itemCount={ itemCount } + items={ children( renderContentArgs ) } + /> + ) } + /> + ); +}; + +SummaryList.propTypes = { + /** + * A function returning a list of `<SummaryNumber />`s + */ + children: PropTypes.func.isRequired, + /** + * An optional label of this group, read to screen reader users. + */ + label: PropTypes.string, +}; + +SummaryList.defaultProps = { + label: __( 'Performance Indicators', 'woocommerce' ), +}; + +export default withViewportMatch( { + isDropdownBreakpoint: '< large', +} )( SummaryList ); diff --git a/packages/js/components/src/summary/menu.js b/packages/js/components/src/summary/menu.js new file mode 100644 index 00000000000..1a81baf4377 --- /dev/null +++ b/packages/js/components/src/summary/menu.js @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import { NavigableMenu } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import classnames from 'classnames'; +import { uniqueId } from 'lodash'; +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { getHasItemsClass } from './utils'; + +const Menu = ( { label, orientation, itemCount, items } ) => { + const instanceId = uniqueId( 'woocommerce-summary-helptext-' ); + const hasItemsClass = getHasItemsClass( itemCount ); + const classes = classnames( 'woocommerce-summary', { + [ hasItemsClass ]: orientation === 'horizontal', + } ); + + return ( + <NavigableMenu + aria-label={ label } + aria-describedby={ instanceId } + orientation={ orientation } + stopNavigationEvents + > + <p id={ instanceId } className="screen-reader-text"> + { __( + 'List of data points available for filtering. Use arrow keys to cycle through ' + + 'the list. Click a data point for a detailed report.', + 'woocommerce' + ) } + </p> + <ul className={ classes }>{ items }</ul> + </NavigableMenu> + ); +}; + +Menu.propTypes = { + /** + * An optional label of this group, read to screen reader users. + */ + label: PropTypes.string, + /** + * Item layout orientation. + */ + orientation: PropTypes.oneOf( [ 'vertical', 'horizontal' ] ).isRequired, + /** + * A list of `<SummaryNumber />`s. + */ + items: PropTypes.node.isRequired, + /** + * Number of items. + */ + itemCount: PropTypes.number.isRequired, +}; + +export default Menu; diff --git a/packages/js/components/src/summary/number.js b/packages/js/components/src/summary/number.js new file mode 100644 index 00000000000..e789ec4a504 --- /dev/null +++ b/packages/js/components/src/summary/number.js @@ -0,0 +1,244 @@ +/** + * External dependencies + */ +import { Button, Tooltip } from '@wordpress/components'; +import { sprintf, __ } from '@wordpress/i18n'; +import classnames from 'classnames'; +import ChevronDownIcon from 'gridicons/dist/chevron-down'; +import { isNil, noop } from 'lodash'; +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; +import { Icon, info } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import Link from '../link'; +import { Text } from '../experimental'; + +/** + * A component to show a value, label, and optionally a change percentage and children node. Can also act as a link to a specific report focus. + * + * @param {Object} props + * @param {Node} props.children + * @param {number} props.delta Change percentage. Float precision is rendered as given. + * @param {string} props.href + * @param {string} props.hrefType + * @param {boolean} props.isOpen + * @param {string} props.label + * @param {string} props.labelTooltipText + * @param {Function} props.onToggle + * @param {string} props.prevLabel + * @param {number|string} props.prevValue + * @param {boolean} props.reverseTrend + * @param {boolean} props.selected + * @param {number|string} props.value + * @param {Function} props.onLinkClickCallback + * @return {Object} - + */ +const SummaryNumber = ( { + children, + delta, + href, + hrefType, + isOpen, + label, + labelTooltipText, + onToggle, + prevLabel, + prevValue, + reverseTrend, + selected, + value, + onLinkClickCallback, +} ) => { + const liClasses = classnames( 'woocommerce-summary__item-container', { + 'is-dropdown-button': onToggle, + 'is-dropdown-expanded': isOpen, + } ); + const classes = classnames( 'woocommerce-summary__item', { + 'is-selected': selected, + 'is-good-trend': reverseTrend ? delta < 0 : delta > 0, + 'is-bad-trend': reverseTrend ? delta > 0 : delta < 0, + } ); + + let screenReaderLabel = + delta > 0 + ? sprintf( + __( 'Up %f%% from %s', 'woocommerce' ), + delta, + prevLabel + ) + : sprintf( + __( 'Down %f%% from %s', 'woocommerce' ), + Math.abs( delta ), + prevLabel + ); + if ( ! delta ) { + screenReaderLabel = sprintf( + __( 'No change from %s', 'woocommerce' ), + prevLabel + ); + } + + let Container; + const containerProps = { + className: classes, + 'aria-current': selected ? 'page' : null, + }; + + if ( onToggle || href ) { + const isButton = !! onToggle; + Container = isButton ? Button : Link; + if ( isButton ) { + containerProps.onClick = onToggle; + containerProps[ 'aria-expanded' ] = isOpen; + } else { + containerProps.href = href; + containerProps.role = 'menuitem'; + containerProps.onClick = onLinkClickCallback; + containerProps.type = hrefType; + } + } else { + Container = 'div'; + } + + return ( + <li className={ liClasses }> + <Container { ...containerProps }> + <div className="woocommerce-summary__item-label"> + <Text variant="body.small" size="14" lineHeight="20px"> + { label } + </Text> + { labelTooltipText && ( + <Tooltip + text={ labelTooltipText } + position="top center" + > + <div className="woocommerce-summary__info-tooltip"> + <Icon + width={ 20 } + height={ 20 } + icon={ info } + /> + </div> + </Tooltip> + ) } + </div> + + <div className="woocommerce-summary__item-data"> + <div className="woocommerce-summary__item-value"> + <Text variant="title.small" size="20" lineHeight="28px"> + { ! isNil( value ) + ? value + : __( 'N/A', 'woocommerce' ) } + </Text> + </div> + + <Tooltip + text={ + ! isNil( prevValue ) + ? `${ prevLabel } ${ prevValue }` + : __( 'N/A', 'woocommerce' ) + } + position="top center" + > + <div + className="woocommerce-summary__item-delta" + role="presentation" + aria-label={ screenReaderLabel } + > + <Text variant="caption" size="12" lineHeight="16px"> + { ! isNil( delta ) + ? sprintf( + __( '%f%%', 'woocommerce' ), + delta + ) + : __( 'N/A', 'woocommerce' ) } + </Text> + </div> + </Tooltip> + </div> + { onToggle ? ( + <ChevronDownIcon + className="woocommerce-summary__toggle" + size={ 24 } + /> + ) : null } + { children } + </Container> + </li> + ); +}; + +SummaryNumber.propTypes = { + /** + * A number to represent the percentage change since the last comparison period - positive numbers will show + * a green up arrow, negative numbers will show a red down arrow, and zero will show a flat right arrow. + * If omitted, no change value will display. + */ + delta: PropTypes.number, + /** + * An internal link to the report focused on this number. + */ + href: PropTypes.string, + /** + * The type of the link + */ + hrefType: PropTypes.oneOf( [ 'wp-admin', 'wc-admin', 'external' ] ) + .isRequired, + /** + * Boolean describing whether the menu list is open. Only applies in mobile view, + * and only applies to the toggle-able item (first in the list). + */ + isOpen: PropTypes.bool, + /** + * A string description of this value, ex "Revenue", or "New Customers" + */ + label: PropTypes.string.isRequired, + /** + * A string that will displayed via a Tooltip next to the label + */ + labelTooltipText: PropTypes.string, + /** + * A function used to switch the given SummaryNumber to a button, and called on click. + */ + onToggle: PropTypes.func, + /** + * A string description of the previous value's timeframe, ex "Previous year:". + */ + prevLabel: PropTypes.string, + /** + * A string or number value to display - a string is allowed so we can accept currency formatting. + * If omitted, this section won't display. + */ + prevValue: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ), + /** + * A boolean used to indicate that a negative delta is "good", and should be styled like a positive (and vice-versa). + */ + reverseTrend: PropTypes.bool, + /** + * A boolean used to show a highlight style on this number. + */ + selected: PropTypes.bool, + /** + * A string or number value to display - a string is allowed so we can accept currency formatting. + */ + value: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ), + /** + * A function to be called after a SummaryNumber, rendered as a link, is clicked. + */ + onLinkClickCallback: PropTypes.func, +}; + +SummaryNumber.defaultProps = { + href: '', + hrefType: 'wc-admin', + isOpen: false, + prevLabel: __( 'Previous period:', 'woocommerce' ), + reverseTrend: false, + selected: false, + onLinkClickCallback: noop, +}; + +export default SummaryNumber; diff --git a/packages/js/components/src/summary/placeholder.js b/packages/js/components/src/summary/placeholder.js new file mode 100644 index 00000000000..f1abfd8ccf7 --- /dev/null +++ b/packages/js/components/src/summary/placeholder.js @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import classnames from 'classnames'; +import { range } from 'lodash'; +import PropTypes from 'prop-types'; +import { withViewportMatch } from '@wordpress/viewport'; + +/** + * Internal dependencies + */ +import { getHasItemsClass } from './utils'; + +export const SummaryNumberPlaceholder = ( { className } ) => ( + <li + data-testid="summary-placeholder" + className={ classnames( + 'woocommerce-summary__item-container is-placeholder', + className + ) } + > + <div className="woocommerce-summary__item"> + <div className="woocommerce-summary__item-label" /> + <div className="woocommerce-summary__item-data"> + <div className="woocommerce-summary__item-value" /> + <div className="woocommerce-summary__item-delta" /> + </div> + </div> + </li> +); + +/** + * `SummaryListPlaceholder` behaves like `SummaryList` but displays placeholder summary items instead of data. + * This can be used while loading data. + */ +class SummaryListPlaceholder extends Component { + render() { + const { isDropdownBreakpoint } = this.props; + const numberOfItems = isDropdownBreakpoint + ? 1 + : this.props.numberOfItems; + + const hasItemsClass = getHasItemsClass( numberOfItems ); + const classes = classnames( 'woocommerce-summary', { + [ hasItemsClass ]: ! isDropdownBreakpoint, + 'is-placeholder': true, + } ); + + return ( + <ul className={ classes } aria-hidden="true"> + { range( numberOfItems ).map( ( i ) => { + return <SummaryNumberPlaceholder key={ i } />; + } ) } + </ul> + ); + } +} + +SummaryListPlaceholder.propTypes = { + /** + * An integer with the number of summary items to display. + */ + numberOfItems: PropTypes.number.isRequired, +}; + +SummaryListPlaceholder.defaultProps = { + numberOfRows: 5, +}; + +export default withViewportMatch( { + isDropdownBreakpoint: '< large', +} )( SummaryListPlaceholder ); diff --git a/packages/js/components/src/summary/stories/index.js b/packages/js/components/src/summary/stories/index.js new file mode 100644 index 00000000000..62a7b107f46 --- /dev/null +++ b/packages/js/components/src/summary/stories/index.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { SummaryList, SummaryNumber } from '@woocommerce/components'; + +export const Basic = () => ( + <SummaryList> + { () => { + return [ + <SummaryNumber + key="revenue" + value={ '$829.40' } + label="Total sales" + delta={ 29 } + href="/analytics/report" + > + <span>27 orders</span> + </SummaryNumber>, + <SummaryNumber + key="refunds" + value={ '$24.00' } + label="Refunds" + delta={ -10.12 } + href="/analytics/report" + selected + />, + <SummaryNumber + key="coupons" + value={ '$49.90' } + label="Coupons" + href="/analytics/report" + />, + ]; + } } + </SummaryList> +); + +export default { + title: 'WooCommerce Admin/components/SummaryList', + component: SummaryList, +}; diff --git a/packages/js/components/src/summary/style.scss b/packages/js/components/src/summary/style.scss new file mode 100644 index 00000000000..7f8b4c871a3 --- /dev/null +++ b/packages/js/components/src/summary/style.scss @@ -0,0 +1,394 @@ +// Set up some local color variables to make the CSS more clear +$border: $gray-200; + +// A local mixin to generate the grid template and border logic +@mixin make-cols( $i ) { + grid-template-columns: repeat($i, 1fr); + + .woocommerce-summary__item-container:nth-of-type(#{$i}n) + .woocommerce-summary__item { + border-right-color: $border; + } + + .woocommerce-summary__item-container:nth-of-type(#{$i}n ++ 1):nth-last-of-type(-n + #{$i}) { + .woocommerce-summary__item, + & ~ .woocommerce-summary__item-container .woocommerce-summary__item { + border-bottom-color: $border; + } + } +} + +@mixin wrap-contents() { + .woocommerce-summary__item-value, + &.is-placeholder { + .woocommerce-summary__item-prev-label { + margin-right: calc(100% - 80px); + } + } +} + +.woocommerce-summary { + margin: $gap 0; + display: grid; + border-width: 1px 0 0 1px; + border-style: solid; + border-color: $border; + background-color: $gray-100; + box-shadow: inset -1px -1px 0 $border; + width: 100%; + + @include breakpoint( '<782px' ) { + & { + border-width: 0; + } + + &.is-placeholder { + border-top: 0; + } + + .woocommerce-summary__item-container.is-placeholder { + border-top: 1px solid $border; + } + } + + // Specificity + .components-popover:not(.components-tooltip) { + // !important to override element-level styles + position: static !important; + top: auto !important; + left: auto !important; + right: auto !important; + bottom: auto !important; + margin-top: 0 !important; + margin-left: 0; + + .components-popover__header { + display: none; + } + + .components-popover__content { + position: static; + left: auto; + right: auto; + margin: 0; + width: 100%; + max-width: 100% !important; + max-height: 100% !important; + box-shadow: none; + border: none; + transform: none; + + .woocommerce-summary__item.is-selected { + display: none; + } + } + } + + .components-popover__content & { + max-height: 100%; + margin-top: 0; + margin-bottom: 0; + overflow-y: auto; + border: none; + } + + .woocommerce-summary__item-value, + .woocommerce-summary__item-delta { + flex: 1 0 auto; + } + + .woocommerce-summary__item-delta { + flex: 0 1 auto; + display: flex; + } + + &, + &.has-one-item, + &.has-1-items { + grid-template-columns: 1fr; + } + + &.has-2-items { + @include make-cols( 2 ); + } + + &.has-3-items { + @include make-cols( 3 ); + } + + &.has-4-items, + &.has-7-items, + &.has-8-items { + @include make-cols( 4 ); + } + + &.has-5-items { + @include make-cols( 5 ); + @include wrap-contents; + } + + @include breakpoint( '>1440px' ) { + &.has-6-items { + @include make-cols( 6 ); + @include wrap-contents; + } + + &.has-9-items, + &.has-10-items { + @include make-cols( 5 ); + @include wrap-contents; + } + } + + @include breakpoint( '<1440px' ) { + &.has-4-items, + &.has-7-items, + &.has-8-items { + @include wrap-contents; + } + + &.has-6-items, + &.has-9-items { + @include make-cols( 3 ); + } + + &.has-10-items { + @include make-cols( 4 ); + @include wrap-contents; + } + + &.has-9-items, + &.has-10-items { + .woocommerce-summary__item-container:nth-of-type(5n) + .woocommerce-summary__item { + border-right-color: $border; + } + } + } + + @include breakpoint( '<960px' ) { + .woocommerce-summary__item { + // One-col layout for all items means right border is always "outer" + border-right-color: $border; + } + } + + @include breakpoint( '<782px' ) { + .woocommerce-summary__item-container { + margin-left: -16px; + margin-right: -16px; + width: auto; + + .woocommerce-summary__item { + // Remove the border when the button is edge-to-edge + border-right: none; + } + } + .components-popover.components-popover { + margin-left: -16px; + margin-right: -16px; + + .woocommerce-summary__item-container { + margin-left: 0; + margin-right: 0; + } + } + } +} + +.woocommerce-summary__item-container { + margin-bottom: 0; + + &:last-of-type .woocommerce-summary__item { + // Make sure the last item always uses the outer-border color. + border-bottom-color: $border !important; + } + + &.is-dropdown-button { + padding: 0; + list-style: none; + border-right: 1px solid $border; + + .components-button { + border-bottom: 1px solid $border; + text-align: left; + display: block; + } + + @include breakpoint( '<782px' ) { + border-right: none; + } + } + + &.is-placeholder { + .woocommerce-summary__item { + height: 117px; + } + + .woocommerce-summary__item-label { + @include placeholder(); + display: inline-block; + height: 20px; + margin-top: 2.2px; + max-width: 110px; + width: 70%; + } + + .woocommerce-summary__item-data { + justify-content: space-between; + } + + .woocommerce-summary__item-value { + @include placeholder(); + display: inline-block; + height: 28px; + width: 60px; + max-width: 60px; + } + + .woocommerce-summary__item-delta { + @include placeholder(); + width: 60px; + border-radius: 2px; + } + } +} + +.woocommerce-summary__item { + display: flex; + flex-direction: column; + height: 100%; + padding: $gap-large; + background-color: #f8f9fa; + border-bottom: 1px solid $border; + border-right: 1px solid $border; + line-height: 1.4em; + text-decoration: none; + color: $gray-900; + + &.components-button { + height: auto; + padding: $spacing; + align-items: normal; + } + + &:hover { + background-color: $gray-100; + color: var(--wp-admin-theme-color); + + .woocommerce-summary__item-label { + color: var(--wp-admin-theme-color); + } + } + + &:active { + background-color: $gray-100; + } + + &:focus { + // !important to override button styles + box-shadow: inset -1px 1px 0 $gray-700, inset 1px -1px 0 $gray-700 !important; + } + + &.is-selected { + &:focus { + // !important to override button styles + box-shadow: inset -1px -1px 0 $gray-700, inset 1px 0 0 $gray-700, + inset 0 4px 0 var(--wp-admin-theme-color) !important; + } + } + + .is-dropdown-button & { + position: relative; + width: 100%; + padding-right: 2 * $gap + 24px; + + @include breakpoint( '<782px' ) { + border-right: none; + } + } + + .woocommerce-summary__item-data { + display: flex; + justify-content: space-between; + } + + .woocommerce-summary__item-label { + display: flex; + margin-bottom: $gap; + + color: $gray-700; + } + + .woocommerce-summary__info-tooltip { + color: $gray-600; + line-height: 1em; + margin-left: $gap-smallest; + + svg { + fill: currentColor; + } + } + + .woocommerce-summary__item-value { + margin-bottom: $gap-smallest; + font-weight: 500; + color: $gray-900; + } + + .woocommerce-summary__item-delta { + padding: 5px; + border-radius: 3px; + height: min-content; + background-color: $gray-100; + color: $gray-900; + } + + &.is-selected { + background: $studio-white; + box-shadow: inset 0 4px 0 var(--wp-admin-theme-color); + + .woocommerce-summary__item-value { + font-weight: 600; + } + + .woocommerce-summary__item-delta { + font-weight: 400; + } + } + + &.is-good-trend .woocommerce-summary__item-delta { + background-color: $alert-green; + color: $white; + } + + &.is-bad-trend .woocommerce-summary__item-delta { + background-color: $alert-red; + color: $white; + } + + .woocommerce-summary__toggle { + position: absolute; + top: 44px; + right: $gap; + @include animate-transform; + } + + .is-dropdown-expanded & { + .woocommerce-summary__toggle { + transform: rotate(-180deg); + } + } + + .components-popover__content & { + .woocommerce-summary__item-label { + margin-bottom: 0; + } + + .woocommerce-summary__item-value, + .woocommerce-summary__item-delta { + margin-bottom: 0; + } + } +} diff --git a/packages/js/components/src/summary/utils.js b/packages/js/components/src/summary/utils.js new file mode 100644 index 00000000000..08cf97a3ca4 --- /dev/null +++ b/packages/js/components/src/summary/utils.js @@ -0,0 +1,9 @@ +/** + * Get a class name depending on item count. + * + * @param {number} count - Item count. + * @return {string} - class name. + */ +export function getHasItemsClass( count ) { + return count < 10 ? `has-${ count }-items` : 'has-10-items'; +} diff --git a/packages/js/components/src/table/README.md b/packages/js/components/src/table/README.md new file mode 100644 index 00000000000..21e30c4a83f --- /dev/null +++ b/packages/js/components/src/table/README.md @@ -0,0 +1,264 @@ +TableCard +=== + +This is an accessible, sortable, and scrollable table for displaying tabular data (like revenue and other analytics data). +It accepts `headers` for column headers, and `rows` for the table content. +`rowHeader` can be used to define the index of the row header (or false if no header). + +`TableCard` serves as Card wrapper & contains a card header, `<Table />`, `<TableSummary />`, and `<Pagination />`. +This includes filtering and comparison functionality for report pages. + +## Usage + +```jsx +const headers = [ + { key: 'month', label: 'Month' }, + { key: 'orders', label: 'Orders' }, + { key: 'revenue', label: 'Revenue' }, +]; +const rows = [ + [ + { display: 'January', value: 1 }, + { display: 10, value: 10 }, + { display: '$530.00', value: 530 }, + ], + [ + { display: 'February', value: 2 }, + { display: 13, value: 13 }, + { display: '$675.00', value: 675 }, + ], + [ + { display: 'March', value: 3 }, + { display: 9, value: 9 }, + { display: '$460.00', value: 460 }, + ], +]; +const summary = [ + { label: 'Gross Income', value: '$830.00' }, + { label: 'Taxes', value: '$96.32' }, + { label: 'Shipping', value: '$50.00' }, +]; + +<TableCard + title="Revenue last week" + rows={ rows } + headers={ headers } + query={ { page: 2 } } + rowsPerPage={ 7 } + totalRows={ 10 } + summary={ summary } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`compareBy` | String | `null` | The string to use as a query parameter when comparing row items +`compareParam` | String | `'filter'` | Url query parameter compare function operates on +`headers` | Array | `null` | An array of column headers (see `Table` props) +`labels` | Object | `null` | Custom labels for table header actions +`ids` | Array | `null` | A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ] +`isLoading` | Boolean | `false` | Defines if the table contents are loading. It will display `TablePlaceholder` component instead of `Table` if that's the case +`onQueryChange` | Function | `noop` | A function which returns a callback function to update the query string for a given `param` +`onColumnsChange` | Function | `noop` | A function which returns a callback function which is called upon the user changing the visiblity of columns +`onSearch` | Function | `noop` | A function which is called upon the user searching in the table header +`onSort` | Function | `undefined` | A function which is called upon the user changing the sorting of the table +`downloadable` | Boolean | `false` | Whether the table must be downloadable. If true, the download button will appear +`onClickDownload` | Function | `null` | A callback function called when the "download" button is pressed. Optional, if used, the download button will appear +`query` | Object | `{}` | An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }` +`rowHeader` | One of type: number, bool | `0` | Which column should be the row header, defaults to the first item (`0`) (see `Table` props) +`rows` | Array | `[]` | (required) An array of arrays of display/value object pairs (see `Table` props) +`rowsPerPage` | Number | `null` | (required) The total number of rows to display per page +`searchBy` | String | `null` | The string to use as a query parameter when searching row items +`showMenu` | Boolean | `true` | Boolean to determine whether or not ellipsis menu is shown +`summary` | Array | `null` | An array of objects with `label` & `value` properties, which display in a line under the table. Optional, can be left off to show no summary +`title` | String | `null` | (required) The title used in the card header, also used as the caption for the content in this table +`totalRows` | Number | `null` | (required) The total number of rows (across all pages) +`baseSearchQuery` | Object | `{}` | Pass in query parameters to be included in the path when onSearch creates a new url +`rowKey` | Function(row, index): string | `null` | Function used for the row key. + +### `labels` structure + +Table header action labels object with properties: + +- `compareButton`: String - Compare button label +- `downloadButton`: String - Download button label +- `helpText`: String - +- `placeholder`: String - + +### `summary` structure + +Array of summary items objects with properties: + +- `label`: ReactNode +- `value`: One of type: string, number + + +EmptyTable +=== + +`EmptyTable` displays a blank space with an optional message passed as a children node +with the purpose of replacing a table with no rows. +It mimics the same height a table would have according to the `numberOfRows` prop. + +## Usage + +```jsx +<EmptyTable> + There are no entries. +</EmptyTable> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`numberOfRows` | Number | `5` | An integer with the number of rows the box should occupy + + +TablePlaceholder +=== + +`TablePlaceholder` behaves like `Table` but displays placeholder boxes instead of data. This can be used while loading. + +## Usage + +```jsx +const headers = [ + { key: 'month', label: 'Month' }, + { key: 'orders', label: 'Orders' }, + { key: 'revenue', label: 'Revenue' }, +]; + +<TablePlaceholder + caption="Revenue last week" + headers={ headers } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`query` | Object | `null` | An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }` +`caption` | String | `null` | (required) A label for the content in this table +`headers` | Array | `null` | An array of column headers (see `Table` props) +`numberOfRows` | Number | `5` | An integer with the number of rows to display + + +TableSummary +=== + +A component to display summarized table data - the list of data passed in on a single line. + +## Usage + +```jsx +const summary = [ + { label: 'Gross Income', value: '$830.00' }, + { label: 'Taxes', value: '$96.32' }, + { label: 'Shipping', value: '$50.00' }, +]; + +<TableSummary data={ summary } /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`data` | Array | `null` | An array of objects with `label` & `value` properties, which display on a single line + +TableSummaryPlaceholder +=== + +A component to display a placeholder box for `TableSummary`. There is no prop for this component. + +## Usage + +```jsx +<TableSummaryPlaceholder /> +``` + +Table +=== + +A table component, without the Card wrapper. This is a basic table display, sortable, but no default filtering. + +Row data should be passed to the component as a list of arrays, where each array is a row in the table. +Headers are passed in separately as an array of objects with column-related properties. For example, +this data would render the following table. + +```js +const headers = [ { label: 'Month' }, { label: 'Orders' }, { label: 'Revenue' } ]; +const rows = [ + [ + { display: 'January', value: 1 }, + { display: 10, value: 10 }, + { display: '$530.00', value: 530 }, + ], + [ + { display: 'February', value: 2 }, + { display: 13, value: 13 }, + { display: '$675.00', value: 675 }, + ], + [ + { display: 'March', value: 3 }, + { display: 9, value: 9 }, + { display: '$460.00', value: 460 }, + ], +] +``` + +| Month | Orders | Revenue | +| ---------|--------|---------| +| January | 10 | $530.00 | +| February | 13 | $675.00 | +| March | 9 | $460.00 | + +## Usage + +```jsx +<Table + caption="Revenue last week" + rows={ rows } + headers={ headers } + rowKey={ row => row.display } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`ariaHidden` | Boolean | `false` | Controls whether this component is hidden from screen readers. Used by the loading state, before there is data to read. Don't use this on real tables unless the table data is loaded elsewhere on the page +`caption` | String | `null` | (required) A label for the content in this table +`className` | String | `null` | Additional CSS classes +`headers` | Array | `[]` | An array of column headers, as objects +`onSort` | Function | `noop` | A function called when sortable table headers are clicked, gets the `header.key` as argument +`query` | Object | `{}` | The query string represented in object form +`rows` | Array | `null` | (required) An array of arrays of display/value object pairs +`rowHeader` | One of type: number, bool | `0` | Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col is checkboxes, for example). Set to false to disable row headers +`rowKey` | Function(row, index): string | `null` | Function used to get the row key. + +### `headers` structure + +Array of column header objects with properties: + +- `defaultSort`: Boolean - Boolean, true if this column is the default for sorting. Only one column should have this set. +- `defaultOrder`: String - String, asc|desc if this column is the default for sorting. Only one column should have this set. +- `isLeftAligned`: Boolean - Boolean, true if this column should be aligned to the left. If not set, it will fallback to this value `! isNumeric`, i.e. text fields are left-aligned and numeric fields are right-aligned. +- `isNumeric`: Boolean - Boolean, true if this column is a number value. +- `isSortable`: Boolean - Boolean, true if this column is sortable. +- `key`: String - The API parameter name for this column, passed to `orderby` when sorting via API. +- `label`: ReactNode - The display label for this column. +- `required`: Boolean - Boolean, true if this column should always display in the table (not shown in toggle-able list). +- `screenReaderLabel`: String - The label used for screen readers for this column. + +### `rows` structure + +Array of arrays representing rows and columns. Column object properties: + +- `display`: ReactNode - Display value, used for rendering - strings or elements are best here. +- `value`: One of type: string, number, bool diff --git a/packages/js/components/src/table/empty.js b/packages/js/components/src/table/empty.js new file mode 100644 index 00000000000..503ab1fc9a2 --- /dev/null +++ b/packages/js/components/src/table/empty.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; + +/** + * `EmptyTable` displays a blank space with an optional message passed as a children node + * with the purpose of replacing a table with no rows. + * It mimics the same height a table would have according to the `numberOfRows` prop. + * + * @param {Object} props + * @param {Node} props.children + * @param {number} props.numberOfRows + * @return {Object} - + */ +const EmptyTable = ( { children, numberOfRows } ) => { + return ( + <div + className="woocommerce-table is-empty" + style={ { '--number-of-rows': numberOfRows } } + > + { children } + </div> + ); +}; + +EmptyTable.propTypes = { + /** + * An integer with the number of rows the box should occupy. + */ + numberOfRows: PropTypes.number, +}; + +EmptyTable.defaultProps = { + numberOfRows: 5, +}; + +export default EmptyTable; diff --git a/packages/js/components/src/table/index.js b/packages/js/components/src/table/index.js new file mode 100644 index 00000000000..b40d94b87d4 --- /dev/null +++ b/packages/js/components/src/table/index.js @@ -0,0 +1,377 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import classnames from 'classnames'; +import { + Card, + CardBody, + CardFooter, + CardHeader, + __experimentalText as Text, +} from '@wordpress/components'; +import { createElement, Component, Fragment } from '@wordpress/element'; +import { find, first, isEqual, without } from 'lodash'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import EllipsisMenu from '../ellipsis-menu'; +import MenuItem from '../ellipsis-menu/menu-item'; +import MenuTitle from '../ellipsis-menu/menu-title'; +import Pagination from '../pagination'; +import Table from './table'; +import TablePlaceholder from './placeholder'; +import TableSummary, { TableSummaryPlaceholder } from './summary'; + +/** + * This is an accessible, sortable, and scrollable table for displaying tabular data (like revenue and other analytics data). + * It accepts `headers` for column headers, and `rows` for the table content. + * `rowHeader` can be used to define the index of the row header (or false if no header). + * + * `TableCard` serves as Card wrapper & contains a card header, `<Table />`, `<TableSummary />`, and `<Pagination />`. + * This includes filtering and comparison functionality for report pages. + */ +class TableCard extends Component { + constructor( props ) { + super( props ); + const showCols = this.getShowCols( props.headers ); + + this.state = { showCols }; + this.onColumnToggle = this.onColumnToggle.bind( this ); + this.onPageChange = this.onPageChange.bind( this ); + } + + componentDidUpdate( { headers: prevHeaders, query: prevQuery } ) { + const { headers, onColumnsChange, query } = this.props; + const { showCols } = this.state; + + if ( ! isEqual( headers, prevHeaders ) ) { + /* eslint-disable react/no-did-update-set-state */ + this.setState( { + showCols: this.getShowCols( headers ), + } ); + /* eslint-enable react/no-did-update-set-state */ + } + if ( + query.orderby !== prevQuery.orderby && + ! showCols.includes( query.orderby ) + ) { + const newShowCols = showCols.concat( query.orderby ); + /* eslint-disable react/no-did-update-set-state */ + this.setState( { + showCols: newShowCols, + } ); + /* eslint-enable react/no-did-update-set-state */ + onColumnsChange( newShowCols ); + } + } + + getShowCols( headers ) { + return headers + .map( ( { key, visible } ) => { + if ( typeof visible === 'undefined' || visible ) { + return key; + } + return false; + } ) + .filter( Boolean ); + } + + getVisibleHeaders() { + const { headers } = this.props; + const { showCols } = this.state; + return headers.filter( ( { key } ) => showCols.includes( key ) ); + } + + getVisibleRows() { + const { headers, rows } = this.props; + const { showCols } = this.state; + + return rows.map( ( row ) => { + return headers + .map( ( { key }, i ) => { + return showCols.includes( key ) && row[ i ]; + } ) + .filter( Boolean ); + } ); + } + + onColumnToggle( key ) { + const { headers, query, onQueryChange, onColumnsChange } = this.props; + + return () => { + this.setState( ( prevState ) => { + const hasKey = prevState.showCols.includes( key ); + + if ( hasKey ) { + // Handle hiding a sorted column + if ( query.orderby === key ) { + const defaultSort = + find( headers, { defaultSort: true } ) || + first( headers ) || + {}; + onQueryChange( 'sort' )( defaultSort.key, 'desc' ); + } + + const showCols = without( prevState.showCols, key ); + onColumnsChange( showCols, key ); + return { showCols }; + } + + const showCols = [ ...prevState.showCols, key ]; + onColumnsChange( showCols, key ); + return { showCols }; + } ); + }; + } + + onPageChange( ...params ) { + const { onPageChange, onQueryChange } = this.props; + if ( onPageChange ) { + onPageChange( ...params ); + } + if ( onQueryChange ) { + onQueryChange( 'paged' )( ...params ); + } + } + + render() { + const { + actions, + className, + hasSearch, + isLoading, + onQueryChange, + onSort, + query, + rowHeader, + rowsPerPage, + showMenu, + summary, + title, + totalRows, + rowKey, + } = this.props; + const { showCols } = this.state; + const allHeaders = this.props.headers; + const headers = this.getVisibleHeaders(); + const rows = this.getVisibleRows(); + const classes = classnames( 'woocommerce-table', className, { + 'has-actions': !! actions, + 'has-menu': showMenu, + 'has-search': hasSearch, + } ); + + return ( + <Card className={ classes }> + <CardHeader> + <Text size={ 16 } weight={ 600 } as="h2" color="#23282d"> + { title } + </Text> + <div className="woocommerce-table__actions"> + { actions } + </div> + { showMenu && ( + <EllipsisMenu + label={ __( + 'Choose which values to display', + 'woocommerce' + ) } + renderContent={ () => ( + <Fragment> + <MenuTitle> + { __( 'Columns:', 'woocommerce' ) } + </MenuTitle> + { allHeaders.map( + ( { key, label, required } ) => { + if ( required ) { + return null; + } + return ( + <MenuItem + checked={ showCols.includes( + key + ) } + isCheckbox + isClickable + key={ key } + onInvoke={ this.onColumnToggle( + key + ) } + > + { label } + </MenuItem> + ); + } + ) } + </Fragment> + ) } + /> + ) } + </CardHeader> + <CardBody size={ null }> + { isLoading ? ( + <Fragment> + <span className="screen-reader-text"> + { __( + 'Your requested data is loading', + 'woocommerce' + ) } + </span> + <TablePlaceholder + numberOfRows={ rowsPerPage } + headers={ headers } + rowHeader={ rowHeader } + caption={ title } + query={ query } + /> + </Fragment> + ) : ( + <Table + rows={ rows } + headers={ headers } + rowHeader={ rowHeader } + caption={ title } + query={ query } + onSort={ onSort || onQueryChange( 'sort' ) } + rowKey={ rowKey } + /> + ) } + </CardBody> + + <CardFooter justify="center"> + { isLoading ? ( + <TableSummaryPlaceholder /> + ) : ( + <Fragment> + <Pagination + key={ parseInt( query.paged, 10 ) || 1 } + page={ parseInt( query.paged, 10 ) || 1 } + perPage={ rowsPerPage } + total={ totalRows } + onPageChange={ this.onPageChange } + onPerPageChange={ onQueryChange( 'per_page' ) } + /> + + { summary && <TableSummary data={ summary } /> } + </Fragment> + ) } + </CardFooter> + </Card> + ); + } +} + +TableCard.propTypes = { + /** + * If a search is provided in actions and should reorder actions on mobile. + */ + hasSearch: PropTypes.bool, + /** + * An array of column headers (see `Table` props). + */ + headers: PropTypes.arrayOf( + PropTypes.shape( { + hiddenByDefault: PropTypes.bool, + defaultSort: PropTypes.bool, + isSortable: PropTypes.bool, + key: PropTypes.string, + label: PropTypes.oneOfType( [ PropTypes.string, PropTypes.node ] ), + required: PropTypes.bool, + } ) + ), + /** + * A list of IDs, matching to the row list so that ids[ 0 ] contains the object ID for the object displayed in row[ 0 ]. + */ + ids: PropTypes.arrayOf( PropTypes.number ), + /** + * Defines if the table contents are loading. + * It will display `TablePlaceholder` component instead of `Table` if that's the case. + */ + isLoading: PropTypes.bool, + /** + * A function which returns a callback function to update the query string for a given `param`. + */ + onQueryChange: PropTypes.func, + /** + * A function which returns a callback function which is called upon the user changing the visiblity of columns. + */ + onColumnsChange: PropTypes.func, + /** + * A function which is called upon the user changing the sorting of the table. + */ + onSort: PropTypes.func, + /** + * An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }`. + */ + query: PropTypes.object, + /** + * Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col + * is checkboxes, for example). Set to false to disable row headers. + */ + rowHeader: PropTypes.oneOfType( [ PropTypes.number, PropTypes.bool ] ), + /** + * An array of arrays of display/value object pairs (see `Table` props). + */ + rows: PropTypes.arrayOf( + PropTypes.arrayOf( + PropTypes.shape( { + display: PropTypes.node, + value: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ] ), + } ) + ) + ).isRequired, + /** + * The total number of rows to display per page. + */ + rowsPerPage: PropTypes.number.isRequired, + /** + * Boolean to determine whether or not ellipsis menu is shown. + */ + showMenu: PropTypes.bool, + /** + * An array of objects with `label` & `value` properties, which display in a line under the table. + * Optional, can be left off to show no summary. + */ + summary: PropTypes.arrayOf( + PropTypes.shape( { + label: PropTypes.node, + value: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.number, + ] ), + } ) + ), + /** + * The title used in the card header, also used as the caption for the content in this table. + */ + title: PropTypes.string.isRequired, + /** + * The total number of rows (across all pages). + */ + totalRows: PropTypes.number.isRequired, + /** + * The rowKey used for the key value on each row, this can be a string of the key or a function that returns the value. + * This uses the index if not defined. + */ + rowKey: PropTypes.func, +}; + +TableCard.defaultProps = { + isLoading: false, + onQueryChange: () => () => {}, + onColumnsChange: () => {}, + onSort: undefined, + query: {}, + rowHeader: 0, + rows: [], + showMenu: true, +}; + +export default TableCard; diff --git a/packages/js/components/src/table/placeholder.js b/packages/js/components/src/table/placeholder.js new file mode 100644 index 00000000000..956730ad1eb --- /dev/null +++ b/packages/js/components/src/table/placeholder.js @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import { range } from 'lodash'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import Table from './table'; + +/** + * `TablePlaceholder` behaves like `Table` but displays placeholder boxes instead of data. This can be used while loading. + */ +class TablePlaceholder extends Component { + render() { + const { numberOfRows, ...tableProps } = this.props; + const rows = range( numberOfRows ).map( () => + this.props.headers.map( () => ( { + display: <span className="is-placeholder" />, + } ) ) + ); + + return ( + <Table + ariaHidden={ true } + classNames="is-loading" + rows={ rows } + { ...tableProps } + /> + ); + } +} + +TablePlaceholder.propTypes = { + /** + * An object of the query parameters passed to the page, ex `{ page: 2, per_page: 5 }`. + */ + query: PropTypes.object, + /** + * A label for the content in this table. + */ + caption: PropTypes.string.isRequired, + /** + * An array of column headers (see `Table` props). + */ + headers: PropTypes.arrayOf( + PropTypes.shape( { + hiddenByDefault: PropTypes.bool, + defaultSort: PropTypes.bool, + isSortable: PropTypes.bool, + key: PropTypes.string, + label: PropTypes.node, + required: PropTypes.bool, + } ) + ), + /** + * An integer with the number of rows to display. + */ + numberOfRows: PropTypes.number, +}; + +TablePlaceholder.defaultProps = { + numberOfRows: 5, +}; + +export default TablePlaceholder; diff --git a/packages/js/components/src/table/stories/empty-table.js b/packages/js/components/src/table/stories/empty-table.js new file mode 100644 index 00000000000..d7688261b34 --- /dev/null +++ b/packages/js/components/src/table/stories/empty-table.js @@ -0,0 +1,11 @@ +/** + * External dependencies + */ +import { EmptyTable } from '@woocommerce/components'; + +export const Basic = () => <EmptyTable>There are no entries.</EmptyTable>; + +export default { + title: 'WooCommerce Admin/components/EmptyTable', + component: EmptyTable, +}; diff --git a/packages/js/components/src/table/stories/index.js b/packages/js/components/src/table/stories/index.js new file mode 100644 index 00000000000..6978af845df --- /dev/null +++ b/packages/js/components/src/table/stories/index.js @@ -0,0 +1,29 @@ +export const headers = [ + { key: 'month', label: 'Month' }, + { key: 'orders', label: 'Orders' }, + { key: 'revenue', label: 'Revenue' }, +]; + +export const rows = [ + [ + { display: 'January', value: 1 }, + { display: 10, value: 10 }, + { display: '$530.00', value: 530 }, + ], + [ + { display: 'February', value: 2 }, + { display: 13, value: 13 }, + { display: '$675.00', value: 675 }, + ], + [ + { display: 'March', value: 3 }, + { display: 9, value: 9 }, + { display: '$460.00', value: 460 }, + ], +]; + +export const summary = [ + { label: 'Gross Income', value: '$830.00' }, + { label: 'Taxes', value: '$96.32' }, + { label: 'Shipping', value: '$50.00' }, +]; diff --git a/packages/js/components/src/table/stories/table-card.js b/packages/js/components/src/table/stories/table-card.js new file mode 100644 index 00000000000..cc485146080 --- /dev/null +++ b/packages/js/components/src/table/stories/table-card.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { TableCard } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { headers, rows, summary } from './index'; + +const TableCardExample = () => { + const [ { query }, setState ] = useState( { + query: { + paged: 1, + }, + } ); + return ( + <TableCard + title="Revenue last week" + rows={ rows } + headers={ headers } + onQueryChange={ ( param ) => ( value ) => + setState( { + query: { + [ param ]: value, + }, + } ) } + query={ query } + rowsPerPage={ 7 } + totalRows={ 10 } + summary={ summary } + /> + ); +}; + +export const Basic = () => <TableCardExample />; + +export default { + title: 'WooCommerce Admin/components/TableCard', + component: TableCard, +}; diff --git a/packages/js/components/src/table/stories/table-placeholder.js b/packages/js/components/src/table/stories/table-placeholder.js new file mode 100644 index 00000000000..ea947bb0125 --- /dev/null +++ b/packages/js/components/src/table/stories/table-placeholder.js @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import { Card } from '@wordpress/components'; +import { TablePlaceholder } from '@woocommerce/components'; + +/** + * Internal dependencies + */ +import { headers } from './index'; + +export const Basic = () => ( + <Card size={ null }> + <TablePlaceholder caption="Revenue last week" headers={ headers } /> + </Card> +); + +export default { + title: 'WooCommerce Admin/components/TablePlaceholder', + component: TablePlaceholder, +}; diff --git a/packages/js/components/src/table/stories/table-summary-placeholder.js b/packages/js/components/src/table/stories/table-summary-placeholder.js new file mode 100644 index 00000000000..6c5e15ed11d --- /dev/null +++ b/packages/js/components/src/table/stories/table-summary-placeholder.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { Card, CardFooter } from '@wordpress/components'; +import { TableSummaryPlaceholder } from '@woocommerce/components'; + +export const Basic = () => ( + <Card> + <CardFooter justify="center"> + <TableSummaryPlaceholder /> + </CardFooter> + </Card> +); + +export default { + title: 'WooCommerce Admin/components/TableSummaryPlaceholder', + component: TableSummaryPlaceholder, +}; diff --git a/packages/js/components/src/table/stories/table-summary.js b/packages/js/components/src/table/stories/table-summary.js new file mode 100644 index 00000000000..31bf8d6c8a3 --- /dev/null +++ b/packages/js/components/src/table/stories/table-summary.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import { TableSummary } from '@woocommerce/components'; +import { Card, CardFooter } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { summary } from './index'; + +export const Basic = () => ( + <Card> + <CardFooter justify="center"> + <TableSummary data={ summary } /> + </CardFooter> + </Card> +); + +export default { + title: 'WooCommerce Admin/components/TableSummary', + component: TableSummary, +}; diff --git a/packages/js/components/src/table/stories/table.js b/packages/js/components/src/table/stories/table.js new file mode 100644 index 00000000000..9d754370e54 --- /dev/null +++ b/packages/js/components/src/table/stories/table.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { Card } from '@wordpress/components'; +import { Table } from '@woocommerce/components'; + +/** + * Internal dependencies + */ +import { rows, headers } from './index'; + +export const Basic = () => ( + <Card size={ null }> + <Table + caption="Revenue last week" + rows={ rows } + headers={ headers } + rowKey={ ( row ) => row[ 0 ].value } + /> + </Card> +); + +export default { + title: 'WooCommerce Admin/components/Table', + component: Table, +}; diff --git a/packages/js/components/src/table/style.scss b/packages/js/components/src/table/style.scss new file mode 100644 index 00000000000..80ff52f4a6e --- /dev/null +++ b/packages/js/components/src/table/style.scss @@ -0,0 +1,315 @@ +.woocommerce-table { + margin-bottom: $gap-largest; + + .woocommerce-table__actions { + display: inline-flex; + justify-content: flex-end; + align-items: center; + + > * { + margin-right: $gap; + + &:last-child { + margin-right: 0; + } + } + + > div { + width: 100%; + } + } + + .components-card__footer { + flex-direction: column; + + > * { + padding-right: 0; + } + } + + $row-text-height: 1.1375rem; + $row-height: '#{$gap * 2} + #{$row-text-height} + 1px'; + $header-row-height: '#{$gap-smaller * 2} + #{$row-text-height} + 1px'; + &.is-empty { + align-items: center; + background: $gray-100; + color: $gray-700; + display: flex; + + height: calc(#{$header-row-height} + ( #{$row-height} ) * var(--number-of-rows)); + justify-content: center; + padding: $gap; + text-align: center; + } + + .woocommerce-pagination { + margin-bottom: $gap; + z-index: 1; + background: $studio-white; + position: relative; + } + + .components-card__header { + align-items: center; + text-align: left; + display: grid; + width: 100%; + grid-template-columns: auto 1fr auto; + } + + @include breakpoint( '<960px' ) { + &.has-search { + .woocommerce-table__actions { + grid-gap: $gap-small; + grid-template-columns: auto 1fr; + grid-row-start: 2; + grid-row-end: 2; + grid-column-start: 1; + grid-column-end: 4; + margin: 0; + } + } + } + + .woocommerce-search { + .woocommerce-select-control__control { + height: 38px; + } + } + + .woocommerce-compare-button { + padding: 3px $gap-small; + height: auto; + } + + .woocommerce-ellipsis-menu { + justify-content: flex-end; + display: flex; + } +} + +.woocommerce-table__caption { + @include font-size( 24 ); + text-align: left; +} + +.components-card__body { + position: relative; +} + +.woocommerce-table__table { + overflow-x: auto; + + &::after, + &::before { + content: ''; + position: absolute; + top: 0; + width: 60px; + height: 100%; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s; + z-index: 1; + } + + &::after { + right: 0; + background: linear-gradient(90deg, rgba($white, 0), $white); + } + + &::before { + left: 0; + background: linear-gradient(90deg, $white, rgba($white, 0)); + } + + &.is-scrollable-right::after, + &.is-scrollable-left::before { + opacity: 1; + } + + table { + border-collapse: collapse; + width: 100%; + } + + tr:hover, + tr:focus-within { + background-color: $gray-200; + td, + th { + background: transparent; + } + } +} + +.woocommerce-table__header, +.woocommerce-table__item, +.woocommerce-table__empty-item { + padding: $gap $gap-large; +} + +.woocommerce-table__header, +.woocommerce-table__item { + @include font-size( 13 ); + text-align: left; + border-bottom: 1px solid $table-border; + + > a:only-child { + display: block; + } + + a { + &:hover, + &:focus { + color: $studio-woocommerce-purple-70; + } + } + + .is-placeholder { + @include placeholder(); + display: inline-block; + height: 16px; + max-width: 120px; + width: 80%; + } + + &:not(.is-left-aligned) { + text-align: right; + + button { + justify-content: flex-end; + padding-right: 24px; + padding-left: 24px; + } + } + + &.is-numeric .is-placeholder { + max-width: 40px; + } + + .is-negative { + color: $alert-red; + font-weight: bold; + } + + &.is-sorted { + background-color: $gray-100; + } + + &.is-checkbox-column { + width: 33px; + max-width: 33px; + padding-right: 0; + padding-left: $gap; + & + th { + border-left: 0; + } + } +} + +.woocommerce-table tr:last-child .woocommerce-table__item { + border-bottom: 0; +} + +.woocommerce-table__empty-item { + text-align: center; + @include font-size( 18 ); + color: $gray-700; + font-weight: bold; + + @include breakpoint( '<782px' ) { + @include font-size( 13 ); + } +} + +th.woocommerce-table__item { + font-weight: normal; +} + +.woocommerce-table__header { + padding: $gap-smaller $gap-large; + background-color: #f8f9fa; + font-weight: bold; + white-space: nowrap; + + &.is-left-aligned.is-sortable { + padding-left: $gap; + svg { + display: inline-flex; + order: 1; + margin-left: 0; + } + } + + .components-button.is-button { + height: auto; + width: 100%; + padding: $gap-smaller $gap-large $gap-smaller 0; + vertical-align: middle; + line-height: 1; + border: none; + background: transparent !important; + box-shadow: none !important; + align-items: center; + + // @todo Add interactive styles + &:hover { + box-shadow: none !important; + } + + &:active { + box-shadow: none !important; + } + } + + &.is-sortable { + padding: 0; + + svg { + visibility: hidden; + margin-left: $gap-smallest; + } + + &.is-sorted .components-button, + .components-button:hover, + .components-button:focus { + svg { + visibility: visible; + } + } + } +} + +.woocommerce-table__summary { + text-align: center; + margin: 0; +} + +.woocommerce-table__summary-item { + display: inline-block; + margin-bottom: 0; + margin-left: $gap-smaller; + margin-right: $gap-smaller; + + $place-holder-width: 200px; + .is-placeholder { + @include placeholder(); + display: inline-block; + height: 16px; + width: $place-holder-width; + } + + .woocommerce-table__summary-label, + .woocommerce-table__summary-value { + display: inline-block; + } + + .woocommerce-table__summary-label { + margin-left: $gap-smallest; + } + + .woocommerce-table__summary-value { + font-weight: 600; + } +} diff --git a/packages/js/components/src/table/summary.js b/packages/js/components/src/table/summary.js new file mode 100644 index 00000000000..e3e85ca724f --- /dev/null +++ b/packages/js/components/src/table/summary.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; + +/** + * A component to display summarized table data - the list of data passed in on a single line. + * + * @param {Object} props + * @param {Array} props.data + * @return {Object} - + */ +const TableSummary = ( { data } ) => { + return ( + <ul className="woocommerce-table__summary" role="complementary"> + { data.map( ( { label, value }, i ) => ( + <li className="woocommerce-table__summary-item" key={ i }> + <span className="woocommerce-table__summary-value"> + { value } + </span> + <span className="woocommerce-table__summary-label"> + { label } + </span> + </li> + ) ) } + </ul> + ); +}; + +TableSummary.propTypes = { + /** + * An array of objects with `label` & `value` properties, which display on a single line. + */ + data: PropTypes.array, +}; + +export default TableSummary; + +/** + * A component to display a placeholder box for `TableSummary`. There is no prop for this component. + * + * @return {Object} - + */ +export const TableSummaryPlaceholder = () => { + return ( + <ul + className="woocommerce-table__summary is-loading" + role="complementary" + > + <li className="woocommerce-table__summary-item"> + <span className="is-placeholder" /> + </li> + </ul> + ); +}; diff --git a/packages/js/components/src/table/table.js b/packages/js/components/src/table/table.js new file mode 100644 index 00000000000..89def764924 --- /dev/null +++ b/packages/js/components/src/table/table.js @@ -0,0 +1,467 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { + createElement, + Component, + createRef, + Fragment, +} from '@wordpress/element'; +import classnames from 'classnames'; +import { Button } from '@wordpress/components'; +import { find, get, noop } from 'lodash'; +import PropTypes from 'prop-types'; +import { withInstanceId } from '@wordpress/compose'; +import { Icon, chevronUp, chevronDown } from '@wordpress/icons'; + +const ASC = 'asc'; +const DESC = 'desc'; + +const getDisplay = ( cell ) => cell.display || null; + +/** + * A table component, without the Card wrapper. This is a basic table display, sortable, but no default filtering. + * + * Row data should be passed to the component as a list of arrays, where each array is a row in the table. + * Headers are passed in separately as an array of objects with column-related properties. For example, + * this data would render the following table. + * + * ```js + * const headers = [ { label: 'Month' }, { label: 'Orders' }, { label: 'Revenue' } ]; + * const rows = [ + * [ + * { display: 'January', value: 1 }, + * { display: 10, value: 10 }, + * { display: '$530.00', value: 530 }, + * ], + * [ + * { display: 'February', value: 2 }, + * { display: 13, value: 13 }, + * { display: '$675.00', value: 675 }, + * ], + * [ + * { display: 'March', value: 3 }, + * { display: 9, value: 9 }, + * { display: '$460.00', value: 460 }, + * ], + * ] + * ``` + * + * | Month | Orders | Revenue | + * | ---------|--------|---------| + * | January | 10 | $530.00 | + * | February | 13 | $675.00 | + * | March | 9 | $460.00 | + */ +class Table extends Component { + constructor( props ) { + super( props ); + this.state = { + tabIndex: null, + isScrollableRight: false, + isScrollableLeft: false, + }; + this.container = createRef(); + this.sortBy = this.sortBy.bind( this ); + this.updateTableShadow = this.updateTableShadow.bind( this ); + this.getRowKey = this.getRowKey.bind( this ); + } + + componentDidMount() { + const { scrollWidth, clientWidth } = this.container.current; + const scrollable = scrollWidth > clientWidth; + /* eslint-disable react/no-did-mount-set-state */ + this.setState( { + tabIndex: scrollable ? '0' : null, + } ); + /* eslint-enable react/no-did-mount-set-state */ + this.updateTableShadow(); + window.addEventListener( 'resize', this.updateTableShadow ); + } + + componentDidUpdate() { + this.updateTableShadow(); + } + + componentWillUnmount() { + window.removeEventListener( 'resize', this.updateTableShadow ); + } + + sortBy( key ) { + const { headers, query } = this.props; + return () => { + const currentKey = + query.orderby || + get( find( headers, { defaultSort: true } ), 'key', false ); + const currentDir = + query.order || + get( + find( headers, { key: currentKey } ), + 'defaultOrder', + DESC + ); + let dir = DESC; + if ( key === currentKey ) { + dir = DESC === currentDir ? ASC : DESC; + } + this.props.onSort( key, dir ); + }; + } + + updateTableShadow() { + const table = this.container.current; + const { isScrollableRight, isScrollableLeft } = this.state; + + const scrolledToEnd = + table.scrollWidth - table.scrollLeft <= table.offsetWidth; + if ( scrolledToEnd && isScrollableRight ) { + this.setState( { isScrollableRight: false } ); + } else if ( ! scrolledToEnd && ! this.state.isScrollableRight ) { + this.setState( { isScrollableRight: true } ); + } + + const scrolledToStart = table.scrollLeft <= 0; + if ( scrolledToStart && isScrollableLeft ) { + this.setState( { isScrollableLeft: false } ); + } else if ( ! scrolledToStart && ! isScrollableLeft ) { + this.setState( { isScrollableLeft: true } ); + } + } + + getRowKey( row, index ) { + if ( this.props.rowKey && typeof this.props.rowKey === 'function' ) { + return this.props.rowKey( row, index ); + } + return index; + } + + render() { + const { + ariaHidden, + caption, + classNames, + headers, + instanceId, + query, + rowHeader, + rows, + } = this.props; + const { isScrollableRight, isScrollableLeft, tabIndex } = this.state; + const classes = classnames( 'woocommerce-table__table', classNames, { + 'is-scrollable-right': isScrollableRight, + 'is-scrollable-left': isScrollableLeft, + } ); + const sortedBy = + query.orderby || + get( find( headers, { defaultSort: true } ), 'key', false ); + const sortDir = + query.order || + get( find( headers, { key: sortedBy } ), 'defaultOrder', DESC ); + const hasData = !! rows.length; + + return ( + <div + className={ classes } + ref={ this.container } + tabIndex={ tabIndex } + aria-hidden={ ariaHidden } + aria-labelledby={ `caption-${ instanceId }` } + role="group" + onScroll={ this.updateTableShadow } + > + <table> + <caption + id={ `caption-${ instanceId }` } + className="woocommerce-table__caption screen-reader-text" + > + { caption } + { tabIndex === '0' && ( + <small> + { __( '(scroll to see more)', 'woocommerce' ) } + </small> + ) } + </caption> + <tbody> + <tr> + { headers.map( ( header, i ) => { + const { + cellClassName, + isLeftAligned, + isSortable, + isNumeric, + key, + label, + screenReaderLabel, + } = header; + const labelId = `header-${ instanceId }-${ i }`; + const thProps = { + className: classnames( + 'woocommerce-table__header', + cellClassName, + { + 'is-left-aligned': + isLeftAligned || ! isNumeric, + 'is-sortable': isSortable, + 'is-sorted': sortedBy === key, + 'is-numeric': isNumeric, + } + ), + }; + if ( isSortable ) { + thProps[ 'aria-sort' ] = 'none'; + if ( sortedBy === key ) { + thProps[ 'aria-sort' ] = + sortDir === ASC + ? 'ascending' + : 'descending'; + } + } + // We only sort by ascending if the col is already sorted descending + const iconLabel = + sortedBy === key && sortDir !== ASC + ? sprintf( + __( + 'Sort by %s in ascending order', + 'woocommerce' + ), + screenReaderLabel || label + ) + : sprintf( + __( + 'Sort by %s in descending order', + 'woocommerce' + ), + screenReaderLabel || label + ); + + const textLabel = ( + <Fragment> + <span + aria-hidden={ Boolean( + screenReaderLabel + ) } + > + { label } + </span> + { screenReaderLabel && ( + <span className="screen-reader-text"> + { screenReaderLabel } + </span> + ) } + </Fragment> + ); + + return ( + <th + role="columnheader" + scope="col" + key={ header.key || i } + { ...thProps } + > + { isSortable ? ( + <Fragment> + <Button + aria-describedby={ labelId } + onClick={ + hasData + ? this.sortBy( key ) + : noop + } + > + { sortedBy === key && + sortDir === ASC ? ( + <Icon + icon={ chevronUp } + /> + ) : ( + <Icon + icon={ chevronDown } + /> + ) } + { textLabel } + </Button> + <span + className="screen-reader-text" + id={ labelId } + > + { iconLabel } + </span> + </Fragment> + ) : ( + textLabel + ) } + </th> + ); + } ) } + </tr> + { hasData ? ( + rows.map( ( row, i ) => ( + <tr key={ this.getRowKey( row, i ) }> + { row.map( ( cell, j ) => { + const { + cellClassName, + isLeftAligned, + isNumeric, + } = headers[ j ]; + const isHeader = rowHeader === j; + const Cell = isHeader ? 'th' : 'td'; + const cellClasses = classnames( + 'woocommerce-table__item', + cellClassName, + { + 'is-left-aligned': + isLeftAligned || + ! isNumeric, + 'is-numeric': isNumeric, + 'is-sorted': + sortedBy === + headers[ j ].key, + } + ); + const cellKey = + this.getRowKey( + row, + i + ).toString() + j; + return ( + <Cell + scope={ + isHeader ? 'row' : null + } + key={ cellKey } + className={ cellClasses } + > + { getDisplay( cell ) } + </Cell> + ); + } ) } + </tr> + ) ) + ) : ( + <tr> + <td + className="woocommerce-table__empty-item" + colSpan={ headers.length } + > + { __( + 'No data to display', + 'woocommerce' + ) } + </td> + </tr> + ) } + </tbody> + </table> + </div> + ); + } +} + +Table.propTypes = { + /** + * Controls whether this component is hidden from screen readers. Used by the loading state, before there is data to read. + * Don't use this on real tables unless the table data is loaded elsewhere on the page. + */ + ariaHidden: PropTypes.bool, + /** + * A label for the content in this table + */ + caption: PropTypes.string.isRequired, + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * An array of column headers, as objects. + */ + headers: PropTypes.arrayOf( + PropTypes.shape( { + /** + * Boolean, true if this column is the default for sorting. Only one column should have this set. + */ + defaultSort: PropTypes.bool, + /** + * String, asc|desc if this column is the default for sorting. Only one column should have this set. + */ + defaultOrder: PropTypes.string, + /** + * Boolean, true if this column should be aligned to the left. + */ + isLeftAligned: PropTypes.bool, + /** + * Boolean, true if this column is a number value. + */ + isNumeric: PropTypes.bool, + /** + * Boolean, true if this column is sortable. + */ + isSortable: PropTypes.bool, + /** + * The API parameter name for this column, passed to `orderby` when sorting via API. + */ + key: PropTypes.string, + /** + * The display label for this column. + */ + label: PropTypes.node, + /** + * Boolean, true if this column should always display in the table (not shown in toggle-able list). + */ + required: PropTypes.bool, + /** + * The label used for screen readers for this column. + */ + screenReaderLabel: PropTypes.string, + } ) + ), + /** + * A function called when sortable table headers are clicked, gets the `header.key` as argument. + */ + onSort: PropTypes.func, + /** + * The query string represented in object form + */ + query: PropTypes.object, + /** + * An array of arrays of display/value object pairs. + */ + rows: PropTypes.arrayOf( + PropTypes.arrayOf( + PropTypes.shape( { + /** + * Display value, used for rendering- strings or elements are best here. + */ + display: PropTypes.node, + /** + * "Real" value used for sorting, and should be a string or number. A column with `false` value will not be sortable. + */ + value: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ] ), + } ) + ) + ).isRequired, + /** + * Which column should be the row header, defaults to the first item (`0`) (but could be set to `1`, if the first col + * is checkboxes, for example). Set to false to disable row headers. + */ + rowHeader: PropTypes.oneOfType( [ PropTypes.number, PropTypes.bool ] ), + /** + * The rowKey used for the key value on each row, a function that returns the key. + * Defaults to index. + */ + rowKey: PropTypes.func, +}; + +Table.defaultProps = { + ariaHidden: false, + headers: [], + onSort: noop, + query: {}, + rowHeader: 0, +}; + +export default withInstanceId( Table ); diff --git a/packages/js/components/src/table/test/data/table-mock-csv.js b/packages/js/components/src/table/test/data/table-mock-csv.js new file mode 100644 index 00000000000..87a9377e456 --- /dev/null +++ b/packages/js/components/src/table/test/data/table-mock-csv.js @@ -0,0 +1,3 @@ +export default `Date,Orders,Total sales,Refunds,Taxes,Shipping,Net sales +2018-10-01 00:00:00,1,411,0,0,0,411 +2018-10-02 00:00:00,0,0,,0,0,0`; diff --git a/packages/js/components/src/table/test/data/table-mock-data.js b/packages/js/components/src/table/test/data/table-mock-data.js new file mode 100644 index 00000000000..34a3dacb5eb --- /dev/null +++ b/packages/js/components/src/table/test/data/table-mock-data.js @@ -0,0 +1,70 @@ +export default [ + [ + { + display: '10/01/2018', + value: '2018-10-01 00:00:00', + }, + { + display: 1, + value: 1, + }, + { + display: '€411.00', + value: 411, + }, + { + display: '€0.00', + value: 0, + }, + { + display: '€10.00', + value: 10, + }, + { + display: '€0.00', + value: 0, + }, + { + display: '€0.00', + value: 0, + }, + { + display: '€411.00', + value: 411, + }, + ], + [ + { + display: '10/02/2018', + value: '2018-10-02 00:00:00', + }, + { + display: 0, + value: 0, + }, + { + display: '€0.00', + value: 0, + }, + { + display: '€0.00', + value: null, + }, + { + display: '€0.00', + value: 0, + }, + { + display: '€0.00', + value: 0, + }, + { + display: '€0.00', + value: 0, + }, + { + display: '€0.00', + value: 0, + }, + ], +]; diff --git a/packages/js/components/src/table/test/data/table-mock-headers.js b/packages/js/components/src/table/test/data/table-mock-headers.js new file mode 100644 index 00000000000..9353b451725 --- /dev/null +++ b/packages/js/components/src/table/test/data/table-mock-headers.js @@ -0,0 +1,37 @@ +export default [ + { + label: 'Date', + key: 'date', + required: true, + }, + { + label: 'Orders', + key: 'orders_count', + required: false, + }, + { + label: 'Total sales', + key: 'total_sales', + required: true, + }, + { + label: 'Refunds', + key: 'refunds', + }, + { + label: 'Coupons', + key: 'coupons', + }, + { + label: 'Taxes', + key: 'taxes', + }, + { + label: 'Shipping', + key: 'shipping', + }, + { + label: 'Net sales', + key: 'net_revenue', + }, +]; diff --git a/packages/js/components/src/table/test/data/table-mock-summary.js b/packages/js/components/src/table/test/data/table-mock-summary.js new file mode 100644 index 00000000000..cc0da9a18de --- /dev/null +++ b/packages/js/components/src/table/test/data/table-mock-summary.js @@ -0,0 +1,22 @@ +export default [ + { + label: 'orders', + value: 100, + }, + { + label: 'customers', + value: 20, + }, + { + label: 'products', + value: 50, + }, + { + label: 'coupons', + value: 0, + }, + { + label: 'net sales', + value: '$12345.67', + }, +]; diff --git a/packages/js/components/src/table/test/index.js b/packages/js/components/src/table/test/index.js new file mode 100644 index 00000000000..d46df160c27 --- /dev/null +++ b/packages/js/components/src/table/test/index.js @@ -0,0 +1,174 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import { toHaveClass } from '@testing-library/jest-dom/matchers'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TableCard from '../index'; +import mockHeaders from './data/table-mock-headers'; +import mockData from './data/table-mock-data'; +import mockSummary from './data/table-mock-summary'; + +expect.extend( { toHaveClass } ); + +describe( 'TableCard', () => { + it( 'should render placeholders for Table and TableSummary while loading', () => { + render( + <TableCard + title="Revenue" + headers={ mockHeaders } + isLoading={ true } + rows={ [] } + rowsPerPage={ 5 } + totalRows={ 5 } + summary={ [] } + /> + ); + + // Check Table + expect( screen.getByRole( 'group', { hidden: true } ) ).toHaveClass( + 'is-loading' + ); + + // Check TableSummary + expect( screen.getByRole( 'complementary' ) ).toHaveClass( + 'is-loading' + ); + } ); + + it( 'should render table along with summary data when row and summary data is present', () => { + render( + <TableCard + title="Revenue" + headers={ mockHeaders } + isLoading={ false } + rows={ mockData } + rowsPerPage={ 5 } + totalRows={ 5 } + summary={ mockSummary } + /> + ); + + // Check Table + expect( screen.getByRole( 'group' ) ).not.toHaveClass( 'is-loading' ); + + // Check TableSummary + expect( screen.getByRole( 'complementary' ) ).not.toHaveClass( + 'is-loading' + ); + } ); + + it( 'should not error with default callback props', () => { + render( + <TableCard + title="Revenue" + headers={ mockHeaders } + isLoading={ false } + rows={ mockData } + rowsPerPage={ 1 } + totalRows={ 5 } + summary={ mockSummary } + /> + ); + + // Trigger a query change (next page). + userEvent.click( + screen.getByLabelText( 'Next Page', { selector: 'button' } ) + ); + + // Trigger a column change. + userEvent.click( + screen.getByTitle( 'Choose which values to display', { + selector: 'button', + } ) + ); + + // We shouldn't get here if an error occurred. + expect( true ).toBe( true ); + } ); + + it( 'should render rows correctly with custom rowKey prop', () => { + render( + <TableCard + title="Revenue" + headers={ mockHeaders } + isLoading={ false } + rows={ mockData } + rowsPerPage={ 1 } + totalRows={ 5 } + rowKey={ ( row ) => row[ 1 ].value } + summary={ mockSummary } + /> + ); + + for ( const row of mockData ) { + expect( + screen.queryByText( row[ 0 ].display ) + ).toBeInTheDocument(); + } + } ); + + it( 'should render headers having class is-left-aligned if isLeftAligned is set to true', () => { + mockHeaders[ 0 ].isLeftAligned = true; + mockHeaders[ 1 ].isLeftAligned = true; + mockHeaders[ 1 ].isNumeric = true; + + render( + <TableCard + title="Revenue" + headers={ mockHeaders } + isLoading={ false } + rowsPerPage={ 5 } + totalRows={ 5 } + /> + ); + + expect( screen.getAllByRole( 'columnheader' )[ 0 ] ).toHaveClass( + 'is-left-aligned' + ); + + expect( screen.getAllByRole( 'columnheader' )[ 1 ] ).toHaveClass( + 'is-left-aligned' + ); + } ); + + it( 'should render headers not having class is-left-aligned if isLeftAligned is not set and isNumeric is true', () => { + mockHeaders[ 2 ].isNumeric = true; + + render( + <TableCard + title="Revenue" + headers={ mockHeaders } + isLoading={ false } + rowsPerPage={ 5 } + totalRows={ 5 } + /> + ); + + expect( screen.getAllByRole( 'columnheader' )[ 2 ] ).not.toHaveClass( + 'is-left-aligned' + ); + } ); + it( 'should render headers having class is-left-aligned if isLeftAligned is not set and isNumeric is false', () => { + mockHeaders[ 3 ].isNumeric = false; + + render( + <TableCard + title="Revenue" + headers={ mockHeaders } + isLoading={ false } + rowsPerPage={ 5 } + totalRows={ 5 } + /> + ); + + expect( screen.getAllByRole( 'columnheader' )[ 3 ] ).toHaveClass( + 'is-left-aligned' + ); + } ); +} ); diff --git a/packages/js/components/src/tag/README.md b/packages/js/components/src/tag/README.md new file mode 100644 index 00000000000..f8c11057bc5 --- /dev/null +++ b/packages/js/components/src/tag/README.md @@ -0,0 +1,25 @@ +Tag +=== + +This component can be used to show an item styled as a "tag", optionally with an `X` + "remove" +or with a popover that is shown on click. + + + +## Usage + +```jsx +<Tag label="My tag" id={ 1 } /> +<Tag label="Removable tag" id={ 2 } remove={ noop } /> +<Tag label="Tag with popover" popoverContents={ ( <p>This is a popover</p> ) } /> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`id` | One of type: number, string | `null` | The ID for this item, used in the remove function +`label` | String | `null` | (required) The name for this item, displayed as the tag's text +`popoverContents` | ReactNode | `null` | Contents to display on click in a popover +`remove` | Function | `null` | A function called when the remove X is clicked. If not used, no X icon will display +`screenReaderLabel` | String | `null` | A more descriptive label for screen reader users. Defaults to the `name` prop diff --git a/packages/js/components/src/tag/index.js b/packages/js/components/src/tag/index.js new file mode 100644 index 00000000000..b739ff0cd16 --- /dev/null +++ b/packages/js/components/src/tag/index.js @@ -0,0 +1,118 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { createElement, Fragment, useState } from '@wordpress/element'; +import classnames from 'classnames'; +import { Button, Popover } from '@wordpress/components'; +import { Icon, cancelCircleFilled } from '@wordpress/icons'; +import { decodeEntities } from '@wordpress/html-entities'; +import PropTypes from 'prop-types'; +import { withInstanceId } from '@wordpress/compose'; + +/** + * This component can be used to show an item styled as a "tag", optionally with an `X` + "remove" + * or with a popover that is shown on click. + * + * @param {Object} props + * @param {number|string} props.id + * @param {string} props.instanceId + * @param {string} props.label + * @param {Object} props.popoverContents + * @param {Function} props.remove + * @param {string} props.screenReaderLabel + * @param {string} props.className + * @return {Object} - + */ +const Tag = ( { + id, + instanceId, + label, + popoverContents, + remove, + screenReaderLabel, + className, +} ) => { + const [ isVisible, setIsVisible ] = useState( false ); + + screenReaderLabel = screenReaderLabel || label; + if ( ! label ) { + // A null label probably means something went wrong + // @todo Maybe this should be a loading indicator? + return null; + } + label = decodeEntities( label ); + const classes = classnames( 'woocommerce-tag', className, { + 'has-remove': !! remove, + } ); + const labelId = `woocommerce-tag__label-${ instanceId }`; + const labelTextNode = ( + <Fragment> + <span className="screen-reader-text">{ screenReaderLabel }</span> + <span aria-hidden="true">{ label }</span> + </Fragment> + ); + + return ( + <span className={ classes }> + { popoverContents ? ( + <Button + className="woocommerce-tag__text" + id={ labelId } + onClick={ () => setIsVisible( true ) } + > + { labelTextNode } + </Button> + ) : ( + <span className="woocommerce-tag__text" id={ labelId }> + { labelTextNode } + </span> + ) } + { popoverContents && isVisible && ( + <Popover onClose={ () => setIsVisible( false ) }> + { popoverContents } + </Popover> + ) } + { remove && ( + <Button + className="woocommerce-tag__remove" + onClick={ remove( id ) } + label={ sprintf( __( 'Remove %s', 'woocommerce' ), label ) } + aria-describedby={ labelId } + > + <Icon + icon={ cancelCircleFilled } + size={ 20 } + className="clear-icon" + /> + </Button> + ) } + </span> + ); +}; + +Tag.propTypes = { + /** + * The ID for this item, used in the remove function. + */ + id: PropTypes.oneOfType( [ PropTypes.number, PropTypes.string ] ), + + /** + * The name for this item, displayed as the tag's text. + */ + label: PropTypes.string.isRequired, + /** + * Contents to display on click in a popover + */ + popoverContents: PropTypes.node, + /** + * A function called when the remove X is clicked. If not used, no X icon will display. + */ + remove: PropTypes.func, + /** + * A more descriptive label for screen reader users. Defaults to the `name` prop. + */ + screenReaderLabel: PropTypes.string, +}; + +export default withInstanceId( Tag ); diff --git a/packages/js/components/src/tag/stories/index.js b/packages/js/components/src/tag/stories/index.js new file mode 100644 index 00000000000..a4a864c24bc --- /dev/null +++ b/packages/js/components/src/tag/stories/index.js @@ -0,0 +1,20 @@ +/** + * External dependencies + */ +import { Tag } from '@woocommerce/components'; + +export const Basic = () => ( + <> + <Tag label="My tag" id={ 1 } /> + <Tag label="Removable tag" id={ 2 } remove={ () => {} } /> + <Tag + label="Tag with popover" + popoverContents={ <p>This is a popover</p> } + /> + </> +); + +export default { + title: 'WooCommerce Admin/components/Tag', + component: Tag, +}; diff --git a/packages/js/components/src/tag/style.scss b/packages/js/components/src/tag/style.scss new file mode 100644 index 00000000000..d2117e2e34f --- /dev/null +++ b/packages/js/components/src/tag/style.scss @@ -0,0 +1,44 @@ +.woocommerce-tag { + display: inline-flex; + margin: 1px 4px 1px 0; + overflow: hidden; + vertical-align: middle; + + .woocommerce-tag__text, + .woocommerce-tag__remove { + display: inline-block; + line-height: 24px; + background: $gray-100; + transition: all 0.2s cubic-bezier(0.4, 1, 0.4, 1); + } + + .woocommerce-tag__text { + align-self: center; + padding: 0 $gap-smaller; + border-radius: 12px; + color: $gray-700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &.has-remove .woocommerce-tag__text { + padding: 0 $gap-smallest 0 $gap-smaller; + border-radius: 12px 0 0 12px; + } + + .woocommerce-tag__remove { + cursor: pointer; + height: inherit; + padding: 0 2px; + border-radius: 0 12px 12px 0; + color: $gray-700; + line-height: 10px; + text-indent: 0; + height: 24px; + + &:hover { + color: $gray-900; + } + } +} diff --git a/packages/js/components/src/tag/test/__snapshots__/index.js.snap b/packages/js/components/src/tag/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000..c1d732e75a0 --- /dev/null +++ b/packages/js/components/src/tag/test/__snapshots__/index.js.snap @@ -0,0 +1,543 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Tag <Tag label="foo" /> should render a tag with the label foo 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <span + class="woocommerce-tag" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-0" + > + <span + class="screen-reader-text" + > + foo + </span> + <span + aria-hidden="true" + > + foo + </span> + </span> + </span> + </div> + </body>, + "container": <div> + <span + class="woocommerce-tag" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-0" + > + <span + class="screen-reader-text" + > + foo + </span> + <span + aria-hidden="true" + > + foo + </span> + </span> + </span> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`Tag <Tag label="foo" popoverContents={ <p>This is a popover</p> } /> should render a tag with a popover 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <span + class="woocommerce-tag" + > + <button + class="components-button woocommerce-tag__text" + id="woocommerce-tag__label-2" + type="button" + > + <span + class="screen-reader-text" + > + foo + </span> + <span + aria-hidden="true" + > + foo + </span> + </button> + </span> + </div> + </body>, + "container": <div> + <span + class="woocommerce-tag" + > + <button + class="components-button woocommerce-tag__text" + id="woocommerce-tag__label-2" + type="button" + > + <span + class="screen-reader-text" + > + foo + </span> + <span + aria-hidden="true" + > + foo + </span> + </button> + </span> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`Tag <Tag label="foo" remove={ noop } /> should render a tag with a close button 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <span + class="woocommerce-tag has-remove" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-1" + > + <span + class="screen-reader-text" + > + foo + </span> + <span + aria-hidden="true" + > + foo + </span> + </span> + <button + aria-describedby="woocommerce-tag__label-1" + aria-label="Remove foo" + class="components-button woocommerce-tag__remove" + type="button" + > + <svg + aria-hidden="true" + class="clear-icon" + focusable="false" + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z" + /> + </svg> + </button> + </span> + </div> + </body>, + "container": <div> + <span + class="woocommerce-tag has-remove" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-1" + > + <span + class="screen-reader-text" + > + foo + </span> + <span + aria-hidden="true" + > + foo + </span> + </span> + <button + aria-describedby="woocommerce-tag__label-1" + aria-label="Remove foo" + class="components-button woocommerce-tag__remove" + type="button" + > + <svg + aria-hidden="true" + class="clear-icon" + focusable="false" + height="20" + viewBox="0 0 24 24" + width="20" + xmlns="http://www.w3.org/2000/svg" + > + <path + d="M12 21C16.9706 21 21 16.9706 21 12C21 7.02944 16.9706 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21ZM15.5303 8.46967C15.8232 8.76256 15.8232 9.23744 15.5303 9.53033L13.0607 12L15.5303 14.4697C15.8232 14.7626 15.8232 15.2374 15.5303 15.5303C15.2374 15.8232 14.7626 15.8232 14.4697 15.5303L12 13.0607L9.53033 15.5303C9.23744 15.8232 8.76256 15.8232 8.46967 15.5303C8.17678 15.2374 8.17678 14.7626 8.46967 14.4697L10.9393 12L8.46967 9.53033C8.17678 9.23744 8.17678 8.76256 8.46967 8.46967C8.76256 8.17678 9.23744 8.17678 9.53033 8.46967L12 10.9393L14.4697 8.46967C14.7626 8.17678 15.2374 8.17678 15.5303 8.46967Z" + /> + </svg> + </button> + </span> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`Tag <Tag label="foo" screenReaderLabel="FooBar" /> should render a tag with a screen reader label 1`] = ` +Object { + "asFragment": [Function], + "baseElement": <body> + <p + class="a11y-speak-intro-text" + hidden="hidden" + id="a11y-speak-intro-text" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + > + Notifications + </p> + <div + aria-atomic="true" + aria-live="assertive" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-assertive" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div + aria-atomic="true" + aria-live="polite" + aria-relevant="additions text" + class="a11y-speak-region" + id="a11y-speak-polite" + style="position: absolute;margin: -1px;padding: 0;height: 1px;width: 1px;overflow: hidden;clip: rect(1px, 1px, 1px, 1px);-webkit-clip-path: inset(50%);clip-path: inset(50%);border: 0;word-wrap: normal !important;" + /> + <div> + <span + class="woocommerce-tag" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-3" + > + <span + class="screen-reader-text" + > + FooBar + </span> + <span + aria-hidden="true" + > + foo + </span> + </span> + </span> + </div> + </body>, + "container": <div> + <span + class="woocommerce-tag" + > + <span + class="woocommerce-tag__text" + id="woocommerce-tag__label-3" + > + <span + class="screen-reader-text" + > + FooBar + </span> + <span + aria-hidden="true" + > + foo + </span> + </span> + </span> + </div>, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; diff --git a/packages/js/components/src/tag/test/index.js b/packages/js/components/src/tag/test/index.js new file mode 100644 index 00000000000..a5151513997 --- /dev/null +++ b/packages/js/components/src/tag/test/index.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +import { render, fireEvent } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Tag from '../'; + +const noop = () => {}; + +describe( 'Tag', () => { + test( '<Tag label="foo" /> should render a tag with the label foo', () => { + const component = render( <Tag label="foo" /> ); + expect( component ).toMatchSnapshot(); + } ); + + test( '<Tag label="foo" remove={ noop } /> should render a tag with a close button', () => { + const component = render( <Tag label="foo" remove={ noop } /> ); + expect( component ).toMatchSnapshot(); + } ); + + test( '<Tag label="foo" popoverContents={ <p>This is a popover</p> } /> should render a tag with a popover', () => { + const component = render( + <Tag label="foo" popoverContents={ <p>This is a popover</p> } /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( '<Tag label="foo" screenReaderLabel="FooBar" /> should render a tag with a screen reader label', () => { + const component = render( + <Tag label="foo" screenReaderLabel="FooBar" /> + ); + expect( component ).toMatchSnapshot(); + } ); + + test( 'Do not show popoverContents by default', () => { + const { queryByText } = render( + <Tag label="foo" popoverContents={ <p>This is a popover</p> } /> + ); + expect( queryByText( 'This is a popover' ) ).toBeNull(); + } ); + + test( 'Show popoverContents after clicking the button', () => { + const { queryByText, queryByRole } = render( + <Tag + label="foo" + instanceId="1" + popoverContents={ <p>This is a popover</p> } + /> + ); + + fireEvent.click( + queryByRole( 'button', { id: 'woocommerce-tag__label-1' } ) + ); + expect( queryByText( 'This is a popover' ) ).toBeDefined(); + } ); +} ); diff --git a/packages/js/components/src/text-control-with-affixes/README.md b/packages/js/components/src/text-control-with-affixes/README.md new file mode 100644 index 00000000000..5c2b3726e79 --- /dev/null +++ b/packages/js/components/src/text-control-with-affixes/README.md @@ -0,0 +1,38 @@ +TextControlWithAffixes +=== + +This component is essentially a wrapper (really a reimplementation) around the +TextControl component that adds support for affixes, i.e. the ability to display +a fixed part either at the beginning or at the end of the text input. + +## Usage + +```jsx +<TextControlWithAffixes + suffix="%" + label="Text field with a suffix" + value={ fourth } + onChange={ value => setState( { fourth: value } ) } +/> +<TextControlWithAffixes + prefix="$" + label="Text field with prefix and help text" + value={ fifth } + onChange={ value => setState( { fifth: value } ) } + help="This is some help text." +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`label` | String | `null` | If this property is added, a label will be generated using label property as the content +`help` | String | `null` | If this property is added, a help text will be generated using help property as the content +`type` | String | `'text'` | Type of the input element to render. Defaults to "text" +`value` | String | `null` | (required) The current value of the input +`className` | String | `null` | The class that will be added with "components-base-control" to the classes of the wrapper div. If no className is passed only components-base-control is used +`onChange` | Function | `null` | (required) A function that receives the value of the input +`prefix` | ReactNode | `null` | Markup to be inserted at the beginning of the input +`suffix` | ReactNode | `null` | Markup to be appended at the end of the input +`disabled` | Boolean | `null` | Disables the field diff --git a/packages/js/components/src/text-control-with-affixes/index.js b/packages/js/components/src/text-control-with-affixes/index.js new file mode 100644 index 00000000000..7c8b8c44394 --- /dev/null +++ b/packages/js/components/src/text-control-with-affixes/index.js @@ -0,0 +1,167 @@ +/** + * External dependencies + */ +import { createElement, Component } from '@wordpress/element'; +import { compose, withInstanceId } from '@wordpress/compose'; +import PropTypes from 'prop-types'; +import { BaseControl, withFocusOutside } from '@wordpress/components'; +import classnames from 'classnames'; + +/** + * This component is essentially a wrapper (really a reimplementation) around the + * TextControl component that adds support for affixes, i.e. the ability to display + * a fixed part either at the beginning or at the end of the text input. + */ +class TextControlWithAffixes extends Component { + constructor( props ) { + super( props ); + this.state = { + isFocused: false, + }; + } + + handleFocusOutside() { + this.setState( { isFocused: false } ); + } + + handleOnClick( event, onClick ) { + this.setState( { isFocused: true } ); + if ( typeof onClick === 'function' ) { + onClick( event ); + } + } + + render() { + const { + label, + value, + help, + className, + instanceId, + onChange, + onClick, + prefix, + suffix, + type, + disabled, + ...props + } = this.props; + const { isFocused } = this.state; + + const id = `inspector-text-control-with-affixes-${ instanceId }`; + const onChangeValue = ( event ) => onChange( event.target.value ); + const describedby = []; + if ( help ) { + describedby.push( `${ id }__help` ); + } + if ( prefix ) { + describedby.push( `${ id }__prefix` ); + } + if ( suffix ) { + describedby.push( `${ id }__suffix` ); + } + + const baseControlClasses = classnames( className, { + 'with-value': value !== '', + empty: value === '', + active: isFocused && ! disabled, + } ); + + const affixesClasses = classnames( 'text-control-with-affixes', { + 'text-control-with-prefix': prefix, + 'text-control-with-suffix': suffix, + disabled, + } ); + + return ( + <BaseControl + label={ label } + id={ id } + help={ help } + className={ baseControlClasses } + onClick={ ( event ) => this.handleOnClick( event, onClick ) } + > + <div className={ affixesClasses }> + { prefix && ( + <span + id={ `${ id }__prefix` } + className="text-control-with-affixes__prefix" + > + { prefix } + </span> + ) } + + <input + className="components-text-control__input" + type={ type } + id={ id } + value={ value } + onChange={ onChangeValue } + aria-describedby={ describedby.join( ' ' ) } + disabled={ disabled } + onFocus={ () => this.setState( { isFocused: true } ) } + { ...props } + /> + + { suffix && ( + <span + id={ `${ id }__suffix` } + className="text-control-with-affixes__suffix" + > + { suffix } + </span> + ) } + </div> + </BaseControl> + ); + } +} + +TextControlWithAffixes.defaultProps = { + type: 'text', +}; + +TextControlWithAffixes.propTypes = { + /** + * If this property is added, a label will be generated using label property as the content. + */ + label: PropTypes.string, + /** + * If this property is added, a help text will be generated using help property as the content. + */ + help: PropTypes.string, + /** + * Type of the input element to render. Defaults to "text". + */ + type: PropTypes.string, + /** + * The current value of the input. + */ + value: PropTypes.string.isRequired, + /** + * The class that will be added with "components-base-control" to the classes of the wrapper div. + * If no className is passed only components-base-control is used. + */ + className: PropTypes.string, + /** + * A function that receives the value of the input. + */ + onChange: PropTypes.func.isRequired, + /** + * Markup to be inserted at the beginning of the input. + */ + prefix: PropTypes.node, + /** + * Markup to be appended at the end of the input. + */ + suffix: PropTypes.node, + /** + * Whether or not the input is disabled. + */ + disabled: PropTypes.bool, +}; + +export default compose( [ + withInstanceId, + withFocusOutside, // this MUST be the innermost HOC as it calls handleFocusOutside +] )( TextControlWithAffixes ); diff --git a/packages/js/components/src/text-control-with-affixes/stories/index.js b/packages/js/components/src/text-control-with-affixes/stories/index.js new file mode 100644 index 00000000000..d34cb806ded --- /dev/null +++ b/packages/js/components/src/text-control-with-affixes/stories/index.js @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import { TextControlWithAffixes } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; + +const Examples = () => { + const [ state, setState ] = useState( { + first: '', + second: '', + third: '', + fourth: '', + fifth: '', + } ); + const { first, second, third, fourth, fifth } = state; + const partialUpdate = ( partial ) => { + setState( { ...state, ...partial } ); + }; + + return ( + <div> + <TextControlWithAffixes + label="Text field without affixes" + value={ first } + placeholder="Placeholder" + onChange={ ( value ) => partialUpdate( { first: value } ) } + /> + <TextControlWithAffixes + label="Disabled text field without affixes" + value={ first } + placeholder="Placeholder" + onChange={ ( value ) => partialUpdate( { first: value } ) } + disabled + /> + <TextControlWithAffixes + prefix="$" + label="Text field with a prefix" + value={ second } + onChange={ ( value ) => partialUpdate( { second: value } ) } + /> + <TextControlWithAffixes + prefix="$" + label="Disabled text field with a prefix" + value={ second } + onChange={ ( value ) => partialUpdate( { second: value } ) } + disabled + /> + <TextControlWithAffixes + prefix="Prefix" + suffix="Suffix" + label="Text field with both affixes" + value={ third } + onChange={ ( value ) => partialUpdate( { third: value } ) } + /> + <TextControlWithAffixes + prefix="Prefix" + suffix="Suffix" + label="Disabled text field with both affixes" + value={ third } + onChange={ ( value ) => partialUpdate( { third: value } ) } + disabled + /> + <TextControlWithAffixes + suffix="%" + label="Text field with a suffix" + value={ fourth } + onChange={ ( value ) => partialUpdate( { fourth: value } ) } + /> + <TextControlWithAffixes + suffix="%" + label="Disabled text field with a suffix" + value={ fourth } + onChange={ ( value ) => partialUpdate( { fourth: value } ) } + disabled + /> + <TextControlWithAffixes + prefix="$" + label="Text field with prefix and help text" + value={ fifth } + onChange={ ( value ) => partialUpdate( { fifth: value } ) } + help="This is some help text." + /> + <TextControlWithAffixes + prefix="$" + label="Disabled text field with prefix and help text" + value={ fifth } + onChange={ ( value ) => partialUpdate( { fifth: value } ) } + help="This is some help text." + disabled + /> + </div> + ); +}; + +export const Basic = () => <Examples />; + +export default { + title: 'WooCommerce Admin/components/TextControlWithAffixes', + component: TextControlWithAffixes, +}; diff --git a/packages/js/components/src/text-control-with-affixes/style.scss b/packages/js/components/src/text-control-with-affixes/style.scss new file mode 100644 index 00000000000..86428fc36e5 --- /dev/null +++ b/packages/js/components/src/text-control-with-affixes/style.scss @@ -0,0 +1,74 @@ +.text-control-with-affixes { + display: inline-flex; + flex-direction: row; + width: 100%; + + input[type='email'], + input[type='password'], + input[type='url'], + input[type='text'], + input[type='number'] { + flex-grow: 1; + margin: 0; + + &:disabled { + border-right-width: 0; + + & + .text-control-with-affixes__suffix { + border-left: 1px solid $gray-100; + } + } + } + + &.text-control-with-prefix input { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + &.text-control-with-suffix input { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +} + +.text-control-with-affixes__prefix, +.text-control-with-affixes__suffix { + position: relative; + background: $studio-white; + border-width: 1px; + border-style: solid; + border-color: $gray-700; + color: $gray-text; + padding: 7px 14px; + white-space: nowrap; + flex: 1 0 auto; + font-size: 14px; + line-height: 1.5; + + .disabled & { + background: rgba(255, 255, 255, 0.5); + border-color: rgba(222, 222, 222, 0.75); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.04); + color: rgba(51, 51, 51, 0.5); + } +} + +.text-control-with-affixes__prefix { + border-right: none; + border-radius: 4px 0 0 4px; + + & + input[type='email'], + & + input[type='password'], + & + input[type='url'], + & + input[type='text'], + & + input[type='number'] { + &:disabled { + border-left-color: $gray-100; + } + } +} + +.text-control-with-affixes__suffix { + border-left: none; + border-radius: 0 4px 4px 0; +} diff --git a/packages/js/components/src/text-control/README.md b/packages/js/components/src/text-control/README.md new file mode 100644 index 00000000000..ef3a90bcf3d --- /dev/null +++ b/packages/js/components/src/text-control/README.md @@ -0,0 +1,24 @@ +TextControl +=== + +An input field use for text inputs in forms. + +## Usage + +```jsx +<TextControl + label="Input label" + value={ value } + onChange={ value => setState( { value } ) } +/>; +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`className` | String | ``null`` | Additional CSS classes +`disabled` | Boolean | ``null`` | Disables the field +`label` | String | ``null`` | Input label used as a placeholder +`onClick` | Function | ``null`` | On click handler called when the component is clicked, passed the click event +`value` | String | ``null`` | The value of the input field diff --git a/packages/js/components/src/text-control/index.js b/packages/js/components/src/text-control/index.js new file mode 100644 index 00000000000..722bf730ea5 --- /dev/null +++ b/packages/js/components/src/text-control/index.js @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { createElement, Component } from '@wordpress/element'; +import PropTypes from 'prop-types'; +import { + TextControl as BaseComponent, + withFocusOutside, +} from '@wordpress/components'; + +/** + * An input field use for text inputs in forms. + */ +const TextControl = withFocusOutside( + class extends Component { + constructor( props ) { + super( props ); + this.state = { + isFocused: false, + }; + } + + handleFocusOutside() { + this.setState( { isFocused: false } ); + } + + handleOnClick( event, onClick ) { + this.setState( { isFocused: true } ); + if ( typeof onClick === 'function' ) { + onClick( event ); + } + } + + render() { + const { isFocused } = this.state; + const { className, onClick, ...otherProps } = this.props; + const { label, value, disabled } = otherProps; + const isEmpty = value === ''; + const isActive = isFocused && ! disabled; + + return ( + <BaseComponent + className={ classnames( + 'muriel-component', + 'muriel-input-text', + className, + { + disabled, + empty: isEmpty, + active: isActive, + 'with-value': ! isEmpty, + } + ) } + placeholder={ label } + onClick={ ( event ) => + this.handleOnClick( event, onClick ) + } + onFocus={ () => this.setState( { isFocused: true } ) } + { ...otherProps } + /> + ); + } + } +); + +TextControl.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * Disables the field. + */ + disabled: PropTypes.bool, + /** + * Input label used as a placeholder. + */ + label: PropTypes.string, + /** + * On click handler called when the component is clicked, passed the click event. + */ + onClick: PropTypes.func, + /** + * The value of the input field. + */ + value: PropTypes.string, +}; + +export default TextControl; diff --git a/packages/js/components/src/text-control/stories/index.js b/packages/js/components/src/text-control/stories/index.js new file mode 100644 index 00000000000..97febc829d0 --- /dev/null +++ b/packages/js/components/src/text-control/stories/index.js @@ -0,0 +1,29 @@ +/** + * External dependencies + */ +import { TextControl } from '@woocommerce/components'; +import { useState } from '@wordpress/element'; + +const Example = () => { + const [ value, setValue ] = useState( '' ); + + return ( + <div> + <TextControl + name="text-control" + label="Enter text here" + onChange={ ( newValue ) => setValue( newValue ) } + value={ value } + /> + <br /> + <TextControl label="Disabled field" disabled value="" /> + </div> + ); +}; + +export const Basic = () => <Example />; + +export default { + title: 'WooCommerce Admin/components/TextControl', + component: TextControl, +}; diff --git a/packages/js/components/src/text-control/style.scss b/packages/js/components/src/text-control/style.scss new file mode 100644 index 00000000000..e942478026e --- /dev/null +++ b/packages/js/components/src/text-control/style.scss @@ -0,0 +1,115 @@ +.muriel-input-text { + background: $studio-white; + border: 1px solid $studio-gray-20; + border-radius: 3px; + height: 56px; + box-shadow: none; + padding: 12px 12px 4px; + position: relative; + box-shadow: $shadow-popover; + + &:hover { + border-color: $studio-gray-40; + } + + label { + color: $studio-gray-50; + font-size: 14px; + line-height: 21px; + + &.components-base-control__label { + margin: 0; + } + } + + // @wordpress/components styles for text control target all types, so we must also do that + // to increase specificity and override the styles. + .components-text-control__input, + .components-text-control__input[type='text'], + .components-text-control__input[type='tel'], + .components-text-control__input[type='time'], + .components-text-control__input[type='url'], + .components-text-control__input[type='week'], + .components-text-control__input[type='password'], + .components-text-control__input[type='color'], + .components-text-control__input[type='date'], + .components-text-control__input[type='datetime'], + .components-text-control__input[type='datetime-local'], + .components-text-control__input[type='email'], + .components-text-control__input[type='month'], + .components-text-control__input[type='number'] { + border: 0; + box-shadow: none; + font-size: 16px; + line-height: 21px; + margin: 0; + padding: 0; + min-height: 30px; + + &:focus { + box-shadow: none; + } + } + + &.active { + box-shadow: 0 0 0 2px var(--wp-admin-theme-color); + border-color: transparent; + + input { + color: $studio-gray-80; + } + } + + &.with-value { + .components-base-control__label { + display: block; + position: relative; + top: -8px; + width: 100%; + font-size: 12px; + } + + input { + color: $studio-gray-80; + position: relative; + top: -12px; + } + } + + &.empty { + label { + display: none; + } + input { + color: $studio-gray-50; + } + } + + &.has-error { + box-shadow: none; + } + + &.disabled { + label { + display: none; + } + input { + color: $studio-gray-20; + + /* Placeholder styling: */ + &::placeholder { + /* Chrome, Firefox, Opera, Safari 10.1+ */ + color: $studio-gray-20; + opacity: 1; /* Firefox */ + } + &:-ms-input-placeholder { + /* Internet Explorer 10-11 */ + color: $studio-gray-20; + } + &::-ms-input-placeholder { + /* Microsoft Edge */ + color: $studio-gray-20; + } + } + } +} diff --git a/packages/js/components/src/timeline/README.md b/packages/js/components/src/timeline/README.md new file mode 100644 index 00000000000..f27300da7bc --- /dev/null +++ b/packages/js/components/src/timeline/README.md @@ -0,0 +1,70 @@ +Timeline +=== + +This is a timeline for displaying data, such as events, in chronological order. +It accepts `items` for the timeline content and will order the data for you. + +## Usage + +```jsx +import Timeline from './Timeline'; +import { orderByOptions, groupByOptions } from './Timeline'; +import GridIcon from 'gridicons'; + +const items = [ + { + date: new Date( 2019, 9, 28, 9, 0 ), + icon: <GridIcon icon={ 'checkmark' } />, + headline: 'A payment of $90.00 was successfully charged', + body: [ + <p key={ '1' }>{ 'Fee: $2.91 ( 2.9% + $0.30 )' }</p>, + <p key={ '2' }>{ 'Net deposit: $87.09' }</p>, + ], + }, + { + date: new Date( 2019, 9, 28, 9, 32 ), + icon: <GridIcon icon={ 'plus' } />, + headline: '$94.16 was added to your October 29, 2019 deposit', + body: [], + }, + { + date: new Date( 2019, 9, 27, 20, 9 ), + icon: <GridIcon icon={ 'checkmark' } className={ 'is-success' } />, + headline: 'A payment of $90.00 was successfully authorized', + body: [], + }, +] + +<Timeline + items={ items } + groupBy={ groupByOptions.DAY } + orderBy={ orderByOptions.ASC } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`className` | String | `''` | Additional class names that can be applied for styling purposes +`items` | Array | `[]` | An array of items to be displayed on the timeline +`orderBy` | String | `'asc'` | How the items should be ordered, either `'asc'` or `'desc'` +`groupBy` | String | `'day'` | How the items should be grouped, one of `'day'`, `'week'`, or `'month'` +`dateFormat` | String | `'F j, Y'` | PHP date format string used to format dates, see php.net/date +`clockFormat` | String | `'g:ia'` | PHP clock format string used to format times, see php.net/date + + +### `items` structure + +A list of items with properties: + +Name | Type | Default | Description +--- | --- | --- | --- +`date` | Date | Required | JavaScript Date object set to when this event happened +`icon` | Element | Required | The element used to represent the icon for this event +`headline` | Element | Required | The element used to represent the title of this event +`body` | Array | `[]` | Elements that contain details pertaining to this event +`hideTimestamp` | Bool | `false` | Allows the user to hide the timestamp associated with this event + +Icon color can be customized by adding 1 of 3 classes to the icon element: `is-success` (green), `is-warning` (yellow), and `is-error` (red) + - If no class is provided the icon will be gray diff --git a/packages/js/components/src/timeline/__mocks__/timeline-mock-data.js b/packages/js/components/src/timeline/__mocks__/timeline-mock-data.js new file mode 100644 index 00000000000..fb614f32945 --- /dev/null +++ b/packages/js/components/src/timeline/__mocks__/timeline-mock-data.js @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import GridIcon from 'gridicons'; +import { createElement } from '@wordpress/element'; + +export default [ + { + date: new Date( 2020, 0, 20, 1, 30 ), + body: [ <p key={ '1' }>{ 'p element in body' }</p>, 'string in body' ], + headline: <p>{ 'p tag in headline' }</p>, + icon: ( + <GridIcon + className={ 'is-success' } + icon={ 'checkmark' } + size={ 16 } + /> + ), + hideTimestamp: true, + }, + { + date: new Date( 2020, 0, 20, 23, 45 ), + body: [], + headline: <span>{ 'span in headline' }</span>, + icon: ( + <GridIcon + className={ 'is-warning' } + icon={ 'refresh' } + size={ 16 } + /> + ), + }, + { + date: new Date( 2020, 0, 22, 15, 13 ), + body: [ <span key={ '1' }>{ 'span in body' }</span> ], + headline: 'string in headline', + icon: ( + <GridIcon className={ 'is-error' } icon={ 'cross' } size={ 16 } /> + ), + }, + { + date: new Date( 2020, 0, 17, 1, 45 ), + headline: 'undefined body and string headline', + icon: <GridIcon icon={ 'cross' } size={ 16 } />, + }, +]; diff --git a/packages/js/components/src/timeline/index.js b/packages/js/components/src/timeline/index.js new file mode 100644 index 00000000000..bd11469a63f --- /dev/null +++ b/packages/js/components/src/timeline/index.js @@ -0,0 +1,132 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import PropTypes from 'prop-types'; +import { __ } from '@wordpress/i18n'; +import { format } from '@wordpress/date'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TimelineGroup from './timeline-group'; +import { sortByDateUsing, groupItemsUsing } from './util'; + +const Timeline = ( props ) => { + const { + className, + items, + groupBy, + orderBy, + dateFormat, + clockFormat, + } = props; + const timelineClassName = classnames( 'woocommerce-timeline', className ); + + // Early return in case no data was passed to the component. + if ( ! items || items.length === 0 ) { + return ( + <div className={ timelineClassName }> + <p className={ 'timeline_no_events' }> + { __( 'No data to display', 'woocommerce' ) } + </p> + </div> + ); + } + + const addGroupTitles = ( group ) => { + return { + ...group, + title: format( dateFormat, group.date ), + }; + }; + + return ( + <div className={ timelineClassName }> + <ul> + { items + .reduce( groupItemsUsing( groupBy ), [] ) + .map( addGroupTitles ) + .sort( sortByDateUsing( orderBy ) ) + .map( ( group ) => ( + <TimelineGroup + key={ group.date.getTime().toString() } + group={ group } + orderBy={ orderBy } + clockFormat={ clockFormat } + /> + ) ) } + </ul> + </div> + ); +}; + +Timeline.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * An array of list items. + */ + items: PropTypes.arrayOf( + PropTypes.shape( { + /** + * Date for the timeline item. + */ + date: PropTypes.instanceOf( Date ).isRequired, + /** + * Icon for the Timeline item. + */ + icon: PropTypes.element.isRequired, + /** + * Headline displayed for the list item. + */ + headline: PropTypes.oneOfType( [ + PropTypes.element, + PropTypes.string, + ] ).isRequired, + /** + * Body displayed for the list item. + */ + body: PropTypes.arrayOf( + PropTypes.oneOfType( [ PropTypes.element, PropTypes.string ] ) + ), + /** + * Allows users to toggle the timestamp on or off. + */ + hideTimestamp: PropTypes.bool, + } ) + ).isRequired, + /** + * Defines how items should be grouped together. + */ + groupBy: PropTypes.oneOf( [ 'day', 'week', 'month' ] ), + /** + * Defines how groups should be ordered. + */ + orderBy: PropTypes.oneOf( [ 'asc', 'desc' ] ), + /** + * The PHP date format string used to format dates, see php.net/date. + */ + dateFormat: PropTypes.string, + /** + * The PHP clock format string used to format times, see php.net/date. + */ + clockFormat: PropTypes.string, +}; + +Timeline.defaultProps = { + className: '', + items: [], + groupBy: 'day', + orderBy: 'desc', + /* translators: PHP date format string used to display dates, see php.net/date. */ + dateFormat: __( 'F j, Y', 'woocommerce' ), + /* translators: PHP clock format string used to display times, see php.net/date. */ + clockFormat: __( 'g:ia', 'woocommerce' ), +}; + +export { orderByOptions, groupByOptions } from './util'; +export default Timeline; diff --git a/packages/js/components/src/timeline/stories/index.js b/packages/js/components/src/timeline/stories/index.js new file mode 100644 index 00000000000..a995ea3fd18 --- /dev/null +++ b/packages/js/components/src/timeline/stories/index.js @@ -0,0 +1,107 @@ +/** + * External dependencies + */ +import { date, text } from '@storybook/addon-knobs'; +import GridIcon from 'gridicons'; + +/** + * Internal dependencies + */ +import Timeline, { orderByOptions } from '../'; + +export default { + title: 'WooCommerce Admin/components/Timeline', + component: Timeline, +}; + +export const Empty = () => <Timeline />; + +const itemDate = ( label, value ) => { + const d = date( label, value ); + return new Date( d ); +}; + +export const Filled = () => ( + <Timeline + orderBy={ orderByOptions.DESC } + items={ [ + { + date: itemDate( + 'event 1 date', + new Date( 2020, 0, 20, 1, 30 ) + ), + body: [ + <p key={ '1' }> + { text( 'event 1, first event', 'p element in body' ) } + </p>, + text( 'event 1, second event', 'string in body' ), + ], + headline: ( + <p>{ text( 'event 1, headline', 'p tag in headline' ) }</p> + ), + icon: ( + <GridIcon + className={ 'is-success' } + icon={ text( 'event 1 gridicon', 'checkmark' ) } + size={ 16 } + /> + ), + hideTimestamp: true, + }, + { + date: itemDate( + 'event 2 date', + new Date( 2020, 0, 20, 23, 45 ) + ), + body: [], + headline: ( + <span> + { text( 'event 2, headline', 'span in headline' ) } + </span> + ), + icon: ( + <GridIcon + className={ 'is-warning' } + icon={ text( 'event 2 gridicon', 'refresh' ) } + size={ 16 } + /> + ), + }, + { + date: itemDate( + 'event 3 date', + new Date( 2020, 0, 22, 15, 13 ) + ), + body: [ + <span key={ '1' }> + { text( 'event 3, second event', 'span in body' ) } + </span>, + ], + headline: text( 'event 3, headline', 'string in headline' ), + icon: ( + <GridIcon + className={ 'is-error' } + icon={ text( 'event 3 gridicon', 'cross' ) } + size={ 16 } + /> + ), + }, + { + date: itemDate( + 'event 4 date', + new Date( 2020, 0, 17, 1, 45 ) + ), + headline: text( + 'event 4, headline', + 'undefined body and string headline' + ), + icon: ( + <GridIcon + icon={ text( 'event 4 gridicon', 'cross' ) } + size={ 16 } + /> + ), + }, + ] } + /> +); diff --git a/packages/js/components/src/timeline/style.scss b/packages/js/components/src/timeline/style.scss new file mode 100644 index 00000000000..c51bff8cfe5 --- /dev/null +++ b/packages/js/components/src/timeline/style.scss @@ -0,0 +1,122 @@ + +.woocommerce-timeline { + ul { + margin: 0; + padding-left: 0; + list-style-type: none; + + li { + margin-bottom: 0; + } + } + + .woocommerce-timeline-group { + .woocommerce-timeline-group__title { + color: $studio-gray-90; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + + margin: 0 0 $gap 0; + + // Overrides the default `display: block` for p elements in the title. + // This is done to prevent soft line breaks from appearing in shorter + // titles. + display: inline-block; + } + + hr { + float: right; + width: calc(100% - #{ $gap-largest }); + + margin-bottom: $gap; + + // Color is according to design, we should probably find a suitable color variable. + border: 0.5px solid #e3dfe2; + } + } + + .woocommerce-timeline-item { + .woocommerce-timeline-item__top-border { + min-height: 16px; + border-left: 1px solid $studio-gray-10; + margin: 0 $gap-small; + } + + .woocommerce-timeline-item__title { + display: flex; + align-items: center; + flex-direction: row; + justify-content: space-between; + color: $studio-gray-80; + + * { + font-size: 16px; + } + } + + .woocommerce-timeline-item__headline { + display: flex; + align-items: center; + flex-direction: row; + margin: $gap-smaller 0; + + * { + margin: 0; + } + & > * { + padding: 0 $gap; + } + + svg { + fill: $studio-white; + padding: $gap-smallest; + background: $studio-gray-10; + border-radius: 9999px; + box-sizing: content-box; + // We hard code the size to maintain consistent styling and spacing. + width: 16px; + height: 16px; + + &.is-success { + background: $valid-green; + } + + &.is-warning { + background: $notice-yellow; + } + + &.is-error { + background: $error-red; + } + } + } + + .woocommerce-timeline-item__timestamp { + font-size: 14px; + line-height: 16px; + } + + .woocommerce-timeline-item__body { + display: flex; + flex-direction: column; + + color: $studio-gray-60; + + margin: 0 $gap-small; + padding: $gap-smaller $gap-larger; + border-left: 1px solid $studio-gray-10; + + // Make sure child elements fit tightly together. + * { + margin: 0; + font-size: 14px; + } + } + } +} + +// Hide last <hr /> element. +.woocommerce-timeline ul :last-child.woocommerce-timeline-group hr:last-child { + display: none; +} diff --git a/packages/js/components/src/timeline/test/__snapshots__/index.js.snap b/packages/js/components/src/timeline/test/__snapshots__/index.js.snap new file mode 100644 index 00000000000..2b9e3b3e77d --- /dev/null +++ b/packages/js/components/src/timeline/test/__snapshots__/index.js.snap @@ -0,0 +1,235 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Timeline Empty snapshot 1`] = ` +<div> + <div + class="woocommerce-timeline" + > + <p + class="timeline_no_events" + > + No data to display + </p> + </div> +</div> +`; + +exports[`Timeline With data snapshot 1`] = ` +<div> + <div + class="woocommerce-timeline" + > + <ul> + <li + class="woocommerce-timeline-group" + > + <p + class="woocommerce-timeline-group__title" + > + January 22, 2020 + </p> + <ul> + <li + class="woocommerce-timeline-item" + > + <div + class="woocommerce-timeline-item__top-border" + /> + <div + class="woocommerce-timeline-item__title" + > + <div + class="woocommerce-timeline-item__headline" + > + <svg + class="gridicon gridicons-cross is-error" + height="16" + viewBox="0 0 24 24" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M18.36 19.78L12 13.41l-6.36 6.37-1.42-1.42L10.59 12 4.22 5.64l1.42-1.42L12 10.59l6.36-6.36 1.41 1.41L13.41 12l6.36 6.36z" + /> + </g> + </svg> + <span> + string in headline + </span> + </div> + <span + class="woocommerce-timeline-item__timestamp" + > + 3:13pm + </span> + </div> + <div + class="woocommerce-timeline-item__body" + > + <span> + <span> + span in body + </span> + </span> + </div> + </li> + </ul> + <hr /> + </li> + <li + class="woocommerce-timeline-group" + > + <p + class="woocommerce-timeline-group__title" + > + January 20, 2020 + </p> + <ul> + <li + class="woocommerce-timeline-item" + > + <div + class="woocommerce-timeline-item__top-border" + /> + <div + class="woocommerce-timeline-item__title" + > + <div + class="woocommerce-timeline-item__headline" + > + <svg + class="gridicon gridicons-refresh is-warning" + height="16" + viewBox="0 0 24 24" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M17.91 14c-.478 2.833-2.943 5-5.91 5-3.308 0-6-2.692-6-6s2.692-6 6-6h2.172l-2.086 2.086L13.5 10.5 18 6l-4.5-4.5-1.414 1.414L14.172 5H12a8 8 0 000 16c4.079 0 7.438-3.055 7.931-7H17.91z" + /> + </g> + </svg> + <span> + <span> + span in headline + </span> + </span> + </div> + <span + class="woocommerce-timeline-item__timestamp" + > + 11:45pm + </span> + </div> + <div + class="woocommerce-timeline-item__body" + /> + </li> + <li + class="woocommerce-timeline-item" + > + <div + class="woocommerce-timeline-item__top-border" + /> + <div + class="woocommerce-timeline-item__title" + > + <div + class="woocommerce-timeline-item__headline" + > + <svg + class="gridicon gridicons-checkmark is-success" + height="16" + viewBox="0 0 24 24" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M9 19.414l-6.707-6.707 1.414-1.414L9 16.586 20.293 5.293l1.414 1.414z" + /> + </g> + </svg> + <span> + <p> + p tag in headline + </p> + </span> + </div> + <span + class="woocommerce-timeline-item__timestamp" + /> + </div> + <div + class="woocommerce-timeline-item__body" + > + <span> + <p> + p element in body + </p> + </span> + <span> + string in body + </span> + </div> + </li> + </ul> + <hr /> + </li> + <li + class="woocommerce-timeline-group" + > + <p + class="woocommerce-timeline-group__title" + > + January 17, 2020 + </p> + <ul> + <li + class="woocommerce-timeline-item" + > + <div + class="woocommerce-timeline-item__top-border" + /> + <div + class="woocommerce-timeline-item__title" + > + <div + class="woocommerce-timeline-item__headline" + > + <svg + class="gridicon gridicons-cross" + height="16" + viewBox="0 0 24 24" + width="16" + xmlns="http://www.w3.org/2000/svg" + > + <g> + <path + d="M18.36 19.78L12 13.41l-6.36 6.37-1.42-1.42L10.59 12 4.22 5.64l1.42-1.42L12 10.59l6.36-6.36 1.41 1.41L13.41 12l6.36 6.36z" + /> + </g> + </svg> + <span> + undefined body and string headline + </span> + </div> + <span + class="woocommerce-timeline-item__timestamp" + > + 1:45am + </span> + </div> + <div + class="woocommerce-timeline-item__body" + /> + </li> + </ul> + <hr /> + </li> + </ul> + </div> +</div> +`; diff --git a/packages/js/components/src/timeline/test/index.js b/packages/js/components/src/timeline/test/index.js new file mode 100644 index 00000000000..2743b38255f --- /dev/null +++ b/packages/js/components/src/timeline/test/index.js @@ -0,0 +1,112 @@ +/* eslint-disable jest/no-mocks-import */ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Timeline from '..'; +import mockData from '../__mocks__/timeline-mock-data'; +import { groupItemsUsing, sortByDateUsing } from '../util.js'; + +describe( 'Timeline', () => { + test( 'Empty snapshot', () => { + const { container } = render( <Timeline /> ); + expect( container ).toMatchSnapshot(); + } ); + + test( 'With data snapshot', () => { + const { container } = render( <Timeline items={ mockData } /> ); + expect( container ).toMatchSnapshot(); + } ); + + describe( 'Timeline utilities', () => { + test( 'Sorts correctly', () => { + const jan21 = new Date( 2020, 0, 21 ); + const jan22 = new Date( 2020, 0, 22 ); + const jan23 = new Date( 2020, 0, 23 ); + + const data = [ + { id: 0, date: jan22 }, + { id: 1, date: jan21 }, + { id: 2, date: jan23 }, + ]; + const expectedAsc = [ + { id: 1, date: jan21 }, + { id: 0, date: jan22 }, + { id: 2, date: jan23 }, + ]; + const expectedDesc = [ + { id: 2, date: jan23 }, + { id: 0, date: jan22 }, + { id: 1, date: jan21 }, + ]; + + expect( data.sort( sortByDateUsing( 'asc' ) ) ).toStrictEqual( + expectedAsc + ); + expect( data.sort( sortByDateUsing( 'desc' ) ) ).toStrictEqual( + expectedDesc + ); + } ); + + test( "Empty item list doesn't break sort", () => { + expect( [].sort( sortByDateUsing( 'asc' ) ) ).toStrictEqual( [] ); + } ); + + test( "Single item doesn't change on sort", () => { + const items = [ { date: new Date( 2020, 0, 1 ) } ]; + expect( items.sort( sortByDateUsing( 'asc' ) ) ).toBe( items ); + } ); + + test( 'Groups correctly', () => { + const jan22 = new Date( 2020, 0, 22 ); + const jan23 = new Date( 2020, 0, 23 ); + const items = [ + { id: 0, date: jan22 }, + { id: 1, date: jan23 }, + { id: 2, date: jan22 }, + ]; + const expected = [ + { + date: jan22, + items: [ + { id: 0, date: jan22 }, + { id: 2, date: jan22 }, + ], + }, + { + date: jan23, + items: [ { id: 1, date: jan23 } ], + }, + ]; + + expect( + items.reduce( groupItemsUsing( 'days' ), [] ) + ).toStrictEqual( expected ); + } ); + + test( "Empty item list doesn't break grouping", () => { + expect( [].reduce( groupItemsUsing( 'days' ), [] ) ).toStrictEqual( + [] + ); + } ); + + test( 'Single item grouped correctly', () => { + const jan22 = new Date( 2020, 0, 22 ); + const items = [ { id: 0, date: jan22 } ]; + const expected = [ + { + date: jan22, + items: [ { id: 0, date: jan22 } ], + }, + ]; + expect( + items.reduce( groupItemsUsing( 'days' ), [] ) + ).toStrictEqual( expected ); + } ); + } ); +} ); diff --git a/packages/js/components/src/timeline/timeline-group.js b/packages/js/components/src/timeline/timeline-group.js new file mode 100644 index 00000000000..6c97877ae4e --- /dev/null +++ b/packages/js/components/src/timeline/timeline-group.js @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import TimelineItem from './timeline-item'; +import { sortByDateUsing } from './util'; + +const TimelineGroup = ( props ) => { + const { group, className, orderBy, clockFormat } = props; + const groupClassName = classnames( + 'woocommerce-timeline-group', + className + ); + const itemsToTimlineItem = ( item, itemIndex ) => { + const itemKey = group.title + '-' + itemIndex; + return ( + <TimelineItem + key={ itemKey } + item={ item } + clockFormat={ clockFormat } + /> + ); + }; + + return ( + <li className={ groupClassName }> + <p className={ 'woocommerce-timeline-group__title' }> + { group.title } + </p> + <ul> + { group.items + .sort( sortByDateUsing( orderBy ) ) + .map( itemsToTimlineItem ) } + </ul> + <hr /> + </li> + ); +}; + +TimelineGroup.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * The group to render. + */ + group: PropTypes.shape( { + /** + * The group title. + */ + title: PropTypes.string, + /** + * An array of list items. + */ + items: PropTypes.arrayOf( + PropTypes.shape( { + /** + * Date for the timeline item. + */ + date: PropTypes.instanceOf( Date ).isRequired, + /** + * Icon for the Timeline item. + */ + icon: PropTypes.element.isRequired, + /** + * Headline displayed for the list item. + */ + headline: PropTypes.oneOfType( [ + PropTypes.element, + PropTypes.string, + ] ).isRequired, + /** + * Body displayed for the list item. + */ + body: PropTypes.arrayOf( + PropTypes.oneOfType( [ + PropTypes.element, + PropTypes.string, + ] ) + ), + /** + * Allows users to toggle the timestamp on or off. + */ + hideTimestamp: PropTypes.bool, + } ) + ).isRequired, + } ).isRequired, + /** + * Defines how items should be ordered. + */ + orderBy: PropTypes.oneOf( [ 'asc', 'desc' ] ), + /** + * The PHP clock format string used to format times, see php.net/date. + */ + clockFormat: PropTypes.string, +}; + +TimelineGroup.defaultProps = { + className: '', + group: { + title: '', + items: [], + }, + orderBy: 'desc', +}; + +export default TimelineGroup; diff --git a/packages/js/components/src/timeline/timeline-item.js b/packages/js/components/src/timeline/timeline-item.js new file mode 100644 index 00000000000..ecf1a829050 --- /dev/null +++ b/packages/js/components/src/timeline/timeline-item.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { format } from '@wordpress/date'; +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; + +const TimelineItem = ( props ) => { + const { item, className, clockFormat } = props; + + const itemClassName = classnames( 'woocommerce-timeline-item', className ); + const itemTimeString = format( clockFormat, item.date ); + + return ( + <li className={ itemClassName }> + <div className={ 'woocommerce-timeline-item__top-border' }></div> + <div className={ 'woocommerce-timeline-item__title' }> + <div className={ 'woocommerce-timeline-item__headline' }> + { item.icon } + <span>{ item.headline }</span> + </div> + <span className={ 'woocommerce-timeline-item__timestamp' }> + { item.hideTimestamp || false ? null : itemTimeString } + </span> + </div> + <div className={ 'woocommerce-timeline-item__body' }> + { ( item.body || [] ).map( ( bodyItem, index ) => ( + <span key={ `timeline-item-body-${ index }` }> + { bodyItem } + </span> + ) ) } + </div> + </li> + ); +}; + +TimelineItem.propTypes = { + /** + * Additional CSS classes. + */ + className: PropTypes.string, + /** + * An array of list items. + */ + item: PropTypes.shape( { + /** + * Date for the timeline item. + */ + date: PropTypes.instanceOf( Date ).isRequired, + /** + * Icon for the Timeline item. + */ + icon: PropTypes.element.isRequired, + /** + * Headline displayed for the list item. + */ + headline: PropTypes.oneOfType( [ PropTypes.element, PropTypes.string ] ) + .isRequired, + /** + * Body displayed for the list item. + */ + body: PropTypes.arrayOf( + PropTypes.oneOfType( [ PropTypes.element, PropTypes.string ] ) + ), + /** + * Allows users to toggle the timestamp on or off. + */ + hideTimestamp: PropTypes.bool, + /** + * The PHP clock format string used to format times, see php.net/date. + */ + clockFormat: PropTypes.string, + } ).isRequired, +}; + +TimelineItem.defaultProps = { + className: '', + item: {}, +}; + +export default TimelineItem; diff --git a/packages/js/components/src/timeline/util.js b/packages/js/components/src/timeline/util.js new file mode 100644 index 00000000000..5f58b71cfc9 --- /dev/null +++ b/packages/js/components/src/timeline/util.js @@ -0,0 +1,57 @@ +/** + * External dependencies + */ +import moment from 'moment'; + +const orderByOptions = { + ASC: 'asc', + DESC: 'desc', +}; + +const groupByOptions = { + DAY: 'day', + WEEK: 'week', + MONTH: 'month', +}; + +const sortAscending = ( groupA, groupB ) => + groupA.date.getTime() - groupB.date.getTime(); +const sortDescending = ( groupA, groupB ) => + groupB.date.getTime() - groupA.date.getTime(); + +const sortByDateUsing = ( orderBy ) => { + switch ( orderBy ) { + case orderByOptions.ASC: + return sortAscending; + case orderByOptions.DESC: + default: + return sortDescending; + } +}; + +const groupItemsUsing = ( groupBy ) => ( groups, newItem ) => { + // Helper functions defined to make the logic a bit more readable. + const hasSameMoment = ( group, item ) => { + return moment( group.date ).isSame( moment( item.date ), groupBy ); + }; + const groupIndexExists = ( index ) => index >= 0; + const groupForItem = groups.findIndex( ( group ) => + hasSameMoment( group, newItem ) + ); + + if ( ! groupIndexExists( groupForItem ) ) { + // Create new group for newItem. + return [ + ...groups, + { + date: newItem.date, + items: [ newItem ], + }, + ]; + } + + groups[ groupForItem ].items.push( newItem ); + return groups; +}; + +export { groupByOptions, groupItemsUsing, orderByOptions, sortByDateUsing }; diff --git a/packages/js/components/src/view-more-list/README.md b/packages/js/components/src/view-more-list/README.md new file mode 100644 index 00000000000..e5753fd95be --- /dev/null +++ b/packages/js/components/src/view-more-list/README.md @@ -0,0 +1,20 @@ +ViewMoreList +=== + +This component displays a 'X more' button that displays a list of items on a popover when clicked. + + + +## Usage + +```jsx +<ViewMoreList + items={ [ <i>Lorem</i>, <i>Ipsum</i>, <i>Dolor</i>, <i>Sit</i> ] } +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`items` | Array | `[]` | `ReactNodes` to list in the popover diff --git a/packages/js/components/src/view-more-list/index.js b/packages/js/components/src/view-more-list/index.js new file mode 100644 index 00000000000..831ed33b90b --- /dev/null +++ b/packages/js/components/src/view-more-list/index.js @@ -0,0 +1,55 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import PropTypes from 'prop-types'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Tag from '../tag'; + +/** + * This component displays a 'X more' button that displays a list of items on a popover when clicked. + * + * @param {Object} props + * @param {Array} props.items + * @return {Object} - + */ +const ViewMoreList = ( { items } ) => { + return ( + <Tag + className="woocommerce-view-more-list" + label={ sprintf( + __( '+%d more', 'woocommerce' ), + items.length - 1 + ) } + popoverContents={ + <ul className="woocommerce-view-more-list__popover"> + { items.map( ( item, i ) => ( + <li + key={ i } + className="woocommerce-view-more-list__popover__item" + > + { item } + </li> + ) ) } + </ul> + } + /> + ); +}; + +ViewMoreList.propTypes = { + /** + * Items to list in the popover + */ + items: PropTypes.arrayOf( PropTypes.node ), +}; + +ViewMoreList.defaultProps = { + items: [], +}; + +export default ViewMoreList; diff --git a/packages/js/components/src/view-more-list/stories/index.js b/packages/js/components/src/view-more-list/stories/index.js new file mode 100644 index 00000000000..068b2b46eff --- /dev/null +++ b/packages/js/components/src/view-more-list/stories/index.js @@ -0,0 +1,16 @@ +/** + * External dependencies + */ +import { ViewMoreList } from '@woocommerce/components'; + +export const Basic = () => ( + <ViewMoreList + // eslint-disable-next-line react/jsx-key + items={ [ <i>Lorem</i>, <i>Ipsum</i>, <i>Dolor</i>, <i>Sit</i> ] } + /> +); + +export default { + title: 'WooCommerce Admin/components/ViewMoreList', + component: ViewMoreList, +}; diff --git a/packages/js/components/src/view-more-list/style.scss b/packages/js/components/src/view-more-list/style.scss new file mode 100644 index 00000000000..97a811a6387 --- /dev/null +++ b/packages/js/components/src/view-more-list/style.scss @@ -0,0 +1,26 @@ + + +.woocommerce-view-more-list { + padding-left: 4px; + margin: 0 0 0 $gap-smallest; + vertical-align: middle; +} + +.woocommerce-view-more-list__popover { + margin: 0; + padding: $gap; + text-align: left; +} + +.woocommerce-view-more-list__popover__item { + display: block; + margin: $gap 0; + + &:first-child { + margin-top: 0; + } + + &:last-child { + margin-bottom: 0; + } +} diff --git a/packages/js/components/src/web-preview/README.md b/packages/js/components/src/web-preview/README.md new file mode 100644 index 00000000000..4b094258548 --- /dev/null +++ b/packages/js/components/src/web-preview/README.md @@ -0,0 +1,23 @@ +WebPreview +=== + +WebPreview component to display an iframe of another page. + +## Usage + +```jsx +<WebPreview + title="My Web Preview" + src="https://themes.woocommerce.com/?name=galleria" +/> +``` + +### Props + +Name | Type | Default | Description +--- | --- | --- | --- +`className` | String | `null` | Additional class name to style the component +`loadingContent` | ReactNode | `<Spinner />` | Content shown when iframe is still loading +`onLoad` | Function | `noop` | Function to fire when iframe content is loaded +`src` | String | `null` | (required) Iframe src to load +`title` | String | `null` | (required) Iframe title diff --git a/packages/js/components/src/web-preview/index.js b/packages/js/components/src/web-preview/index.js new file mode 100644 index 00000000000..f3514d3817d --- /dev/null +++ b/packages/js/components/src/web-preview/index.js @@ -0,0 +1,89 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { createElement, Component, createRef } from '@wordpress/element'; +import { noop } from 'lodash'; +import PropTypes from 'prop-types'; + +/** + * Internal dependencies + */ +import Spinner from '../spinner'; + +/** + * WebPreview component to display an iframe of another page. + */ +class WebPreview extends Component { + constructor( props ) { + super( props ); + + this.state = { + isLoading: true, + }; + + this.iframeRef = createRef(); + this.setLoaded = this.setLoaded.bind( this ); + } + + componentDidMount() { + this.iframeRef.current.addEventListener( 'load', this.setLoaded ); + } + + setLoaded() { + this.setState( { isLoading: false } ); + this.props.onLoad(); + } + + render() { + const { className, loadingContent, src, title } = this.props; + const { isLoading } = this.state; + + const classes = classnames( 'woocommerce-web-preview', className, { + 'is-loading': isLoading, + } ); + + return ( + <div className={ classes }> + { isLoading && loadingContent } + <div className="woocommerce-web-preview__iframe-wrapper"> + <iframe + ref={ this.iframeRef } + title={ title } + src={ src } + /> + </div> + </div> + ); + } +} + +WebPreview.propTypes = { + /** + * Additional class name to style the component. + */ + className: PropTypes.string, + /** + * Content shown when iframe is still loading. + */ + loadingContent: PropTypes.node, + /** + * Function to fire when iframe content is loaded. + */ + onLoad: PropTypes.func, + /** + * Iframe src to load. + */ + src: PropTypes.string.isRequired, + /** + * Iframe title. + */ + title: PropTypes.string.isRequired, +}; + +WebPreview.defaultProps = { + loadingContent: <Spinner />, + onLoad: noop, +}; + +export default WebPreview; diff --git a/packages/js/components/src/web-preview/stories/index.js b/packages/js/components/src/web-preview/stories/index.js new file mode 100644 index 00000000000..86b66e09af0 --- /dev/null +++ b/packages/js/components/src/web-preview/stories/index.js @@ -0,0 +1,16 @@ +/** + * External dependencies + */ +import { WebPreview } from '@woocommerce/components'; + +export const Basic = () => ( + <WebPreview + src="https://themes.woocommerce.com/?name=galleria" + title="My Web Preview" + /> +); + +export default { + title: 'WooCommerce Admin/components/WebPreview', + component: WebPreview, +}; diff --git a/packages/js/components/src/web-preview/style.scss b/packages/js/components/src/web-preview/style.scss new file mode 100644 index 00000000000..c6852ca8762 --- /dev/null +++ b/packages/js/components/src/web-preview/style.scss @@ -0,0 +1,23 @@ +.woocommerce-web-preview { + width: 100%; + display: flex; + align-items: center; + justify-content: center; + background: $studio-gray-0; + + &.is-loading { + .woocommerce-web-preview__iframe-wrapper { + display: none; + } + } + + .woocommerce-web-preview__iframe-wrapper { + width: 100%; + } + + iframe { + width: 100%; + height: 100%; + min-height: 400px; + } +} diff --git a/packages/js/components/tsconfig-cjs.json b/packages/js/components/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/components/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/components/tsconfig.json b/packages/js/components/tsconfig.json new file mode 100644 index 00000000000..1281b33ba53 --- /dev/null +++ b/packages/js/components/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module", + "declaration": true, + "declarationMap": true, + "declarationDir": "./build-types", + "composite": true + } +} \ No newline at end of file diff --git a/packages/js/components/webpack.config.js b/packages/js/components/webpack.config.js new file mode 100644 index 00000000000..5c56294e0f0 --- /dev/null +++ b/packages/js/components/webpack.config.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +const { webpackConfig } = require( '@woocommerce/style-build' ); + +module.exports = { + mode: process.env.NODE_ENV || 'development', + entry: { + 'build-style': __dirname + '/src/style.scss', + }, + output: { + path: __dirname, + }, + module: { + rules: webpackConfig.rules, + }, + plugins: webpackConfig.plugins, +}; diff --git a/packages/js/csv-export/.eslintrc.js b/packages/js/csv-export/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/csv-export/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/csv-export/.npmrc b/packages/js/csv-export/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/csv-export/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/csv-export/CHANGELOG.md b/packages/js/csv-export/CHANGELOG.md new file mode 100644 index 00000000000..40e2c1a031d --- /dev/null +++ b/packages/js/csv-export/CHANGELOG.md @@ -0,0 +1,48 @@ +# Unreleased + +# 1.5.0 + +- Update all js packages with minor/patch version changes. #8392 + +# 1.4.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 +# 1.3.2 + +- Use tab char for the CSV injection prevention. + +# 1.3.1 + +- Update dependencies. + +# 1.3.0 + +- Update to @wordpress/eslint coding standards. + +# 1.2.0 + +- Properly escape values with double quotes. +- Prevent CSV injection. + +# 1.1.2 + +- Update dependencies. + +# 1.1.1 + +- Update license to GPL-3.0-or-later. + +# 1.1.0 + +- Fix error in `getCSVRows` when there is a null or undefined column value. +- Bump dependency versions. + +# 1.0.3 + +# 1.0.2 + +# 1.0.1 + +# 1.0.0 + +- Released package diff --git a/packages/js/csv-export/README.md b/packages/js/csv-export/README.md new file mode 100644 index 00000000000..638b5a05a1a --- /dev/null +++ b/packages/js/csv-export/README.md @@ -0,0 +1,61 @@ +# CSV Export + +A set of functions to convert data into CSV values, and enable a browser download of the CSV data. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/csv-export --save +``` + +## Usage + +```js +onClick = () => { + // Create a file name based on a title and optional query. Will return a timestamped + // name, for example: revenue-2018-11-01-interval-month.csv + const name = generateCSVFileName( 'revenue', { interval: 'month' } ); + + // Create a string of CSV data, `headers` is an array of row headers, put at the top + // of the file. `rows` is a 2 dimensional array. Each array is a line in the file, + // separated by newlines. The second-level arrays are the data points in each row. + // For header format, see https://woocommerce.github.io/woocommerce-admin/#/components/table?id=headers-2 + // For rows format, see https://woocommerce.github.io/woocommerce-admin/#/components/table?id=rows-1 + const data = generateCSVDataFromTable( headers, rows ); + + // Triggers a browser UI to save a file, named the first argument, with the contents of + // the second argument. + downloadCSVFile( name, data ); +} +``` + +### generateCSVDataFromTable(headers, rows) ⇒ <code>String</code> +Generates a CSV string from table contents + +**Returns**: <code>String</code> - Table contents in a CSV format + +| Param | Type | Description | +| --- | --- | --- | +| headers | <code>Array.<Object></code> | Object with table header information | +| rows | <code>Array.Array.<Object></code> | Object with table rows information | + +### generateCSVFileName([name], [params]) ⇒ <code>String</code> +Generates a file name for CSV files based on the provided name, the current date +and the provided params, which are all appended with hyphens. + +**Returns**: <code>String</code> - Formatted file name + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| [name] | <code>String</code> | <code>''</code> | Name of the file | +| [params] | <code>Object</code> | <code>{}</code> | Object of key-values to append to the file name | + +### downloadCSVFile(fileName, content) +Downloads a CSV file with the given file name and contents + +| Param | Type | Description | +| --- | --- | --- | +| fileName | <code>String</code> | Name of the file to download | +| content | <code>String</code> | Contents of the file to download | diff --git a/packages/js/csv-export/jest.config.json b/packages/js/csv-export/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/csv-export/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/csv-export/package.json b/packages/js/csv-export/package.json new file mode 100644 index 00000000000..e9798ae1255 --- /dev/null +++ b/packages/js/csv-export/package.json @@ -0,0 +1,56 @@ +{ + "name": "@woocommerce/csv-export", + "version": "1.5.0", + "description": "WooCommerce utility library to convert data to CSV files.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "csv" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/csv-export/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "browser-filesaver": "^1.1.1", + "moment": "^2.29.1" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "start": "tsc --build --watch", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/csv-export/project.json b/packages/js/csv-export/project.json new file mode 100644 index 00000000000..3bb9bcc4fd2 --- /dev/null +++ b/packages/js/csv-export/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/csv-export", + "sourceRoot": "packages/js/csv-export/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/csv-export" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/csv-export/src/__mocks__/mock-csv-data.js b/packages/js/csv-export/src/__mocks__/mock-csv-data.js new file mode 100644 index 00000000000..950b794beaa --- /dev/null +++ b/packages/js/csv-export/src/__mocks__/mock-csv-data.js @@ -0,0 +1,2 @@ +export default `Date,Orders,Description,"Total sales",Refunds,Coupons,Taxes,Shipping,"Net sales","Negative number" +2018-04-29T00:00:00,30,"Lorem, ""ipsum""",200,19,19,100,19,200,"\t-123"`; diff --git a/packages/js/csv-export/src/__mocks__/mock-headers.js b/packages/js/csv-export/src/__mocks__/mock-headers.js new file mode 100644 index 00000000000..5c3d7a30fcc --- /dev/null +++ b/packages/js/csv-export/src/__mocks__/mock-headers.js @@ -0,0 +1,42 @@ +export default [ + { + label: 'Date', + key: 'date_start', + }, + { + label: 'Orders', + key: 'orders_count', + }, + { + label: 'Description', + key: 'description', + }, + { + label: 'Total sales', + key: 'total_sales', + }, + { + label: 'Refunds', + key: 'refunds', + }, + { + label: 'Coupons', + key: 'coupons', + }, + { + label: 'Taxes', + key: 'taxes', + }, + { + label: 'Shipping', + key: 'shipping', + }, + { + label: 'Net sales', + key: 'net_revenue', + }, + { + label: 'Negative number', + key: 'neg_num', + }, +]; diff --git a/packages/js/csv-export/src/__mocks__/mock-rows.js b/packages/js/csv-export/src/__mocks__/mock-rows.js new file mode 100644 index 00000000000..53e93006f9a --- /dev/null +++ b/packages/js/csv-export/src/__mocks__/mock-rows.js @@ -0,0 +1,44 @@ +export default [ + [ + { + display: '04/29/2018', + value: '2018-04-29T00:00:00', + }, + { + display: 'Product 30', + value: '30', + }, + { + display: 'Lorem, "ipsum"', + value: 'Lorem, "ipsum"', + }, + { + display: '€200.00', + value: 200, + }, + { + display: '€19.00', + value: 19, + }, + { + display: '€19.00', + value: 19, + }, + { + display: '€100.00', + value: 100, + }, + { + display: '€19.00', + value: 19, + }, + { + display: '€200.00', + value: 200, + }, + { + display: '-123', + value: -123, + }, + ], +]; diff --git a/packages/js/csv-export/src/index.js b/packages/js/csv-export/src/index.js new file mode 100644 index 00000000000..1720841aef4 --- /dev/null +++ b/packages/js/csv-export/src/index.js @@ -0,0 +1,102 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import { saveAs } from 'browser-filesaver'; + +function escapeCSVValue( value ) { + let stringValue = value.toString(); + + // Prevent CSV injection. + // See: http://www.contextis.com/resources/blog/comma-separated-vulnerabilities/ + // See: WC_CSV_Exporter::escape_data() + if ( [ '=', '+', '-', '@' ].includes( stringValue.charAt( 0 ) ) ) { + stringValue = '"\t' + stringValue + '"'; + } else if ( stringValue.match( /[,"\s]/ ) ) { + stringValue = '"' + stringValue.replace( /"/g, '""' ) + '"'; + } + + return stringValue; +} + +function getCSVHeaders( headers ) { + return Array.isArray( headers ) + ? headers + .map( ( header ) => escapeCSVValue( header.label ) ) + .join( ',' ) + : []; +} + +function getCSVRows( rows ) { + return Array.isArray( rows ) + ? rows + .map( ( row ) => + row + .map( ( rowItem ) => { + if ( + undefined === rowItem.value || + rowItem.value === null + ) { + return ''; + } + + return escapeCSVValue( rowItem.value ); + } ) + .join( ',' ) + ) + .join( '\n' ) + : []; +} + +/** + * Generates a CSV string from table contents + * + * @param {Array.<Object>} headers Object with table header information + * @param {Array.Array.<Object>} rows Object with table rows information + * @return {string} Table contents in a CSV format + */ +export function generateCSVDataFromTable( headers, rows ) { + return [ getCSVHeaders( headers ), getCSVRows( rows ) ] + .filter( ( text ) => text.length ) + .join( '\n' ); +} + +/** + * Generates a file name for CSV files based on the provided name, the current date + * and the provided params, which are all appended with hyphens. + * + * @param {string} [name=''] Name of the file + * @param {Object} [params={}] Object of key-values to append to the file name + * @return {string} Formatted file name + */ +export function generateCSVFileName( name = '', params = {} ) { + const fileNameSections = [ + name.toLowerCase().replace( /[^a-z0-9]/g, '-' ), + moment().format( 'YYYY-MM-DD' ), + Object.keys( params ) + .map( + ( key ) => + key.toLowerCase().replace( /[^a-z0-9]/g, '-' ) + + '-' + + decodeURIComponent( params[ key ] ) + .toLowerCase() + .replace( /[^a-z0-9]/g, '-' ) + ) + .join( '_' ), + ].filter( ( text ) => text.length ); + + return fileNameSections.join( '_' ) + '.csv'; +} + +/** + * Downloads a CSV file with the given file name and contents + * + * @param {string} fileName Name of the file to download + * @param {string} content Contents of the file to download + */ +export function downloadCSVFile( fileName, content ) { + // eslint-disable-next-line no-undef + const blob = new Blob( [ content ], { type: 'text/csv;charset=utf-8' } ); + + saveAs( blob, fileName ); +} diff --git a/packages/js/csv-export/src/test/index.js b/packages/js/csv-export/src/test/index.js new file mode 100644 index 00000000000..25a79e0fe9c --- /dev/null +++ b/packages/js/csv-export/src/test/index.js @@ -0,0 +1,105 @@ +/* eslint-disable jest/no-mocks-import */ +/** + * External dependencies + */ +import moment from 'moment'; +import { saveAs } from 'browser-filesaver'; + +/** + * Internal dependencies + */ +import { + downloadCSVFile, + generateCSVDataFromTable, + generateCSVFileName, +} from '../index'; +import mockCSVData from '../__mocks__/mock-csv-data'; +import mockHeaders from '../__mocks__/mock-headers'; +import mockRows from '../__mocks__/mock-rows'; + +jest.mock( 'browser-filesaver', () => ( { + saveAs: jest.fn(), +} ) ); + +describe( 'generateCSVDataFromTable', () => { + it( 'should not crash when parameters are not arrays', () => { + expect( generateCSVDataFromTable( null, null ) ).toBe( '' ); + } ); + + it( 'should generate a CSV string from table contents', () => { + expect( generateCSVDataFromTable( mockHeaders, mockRows ) ).toBe( + mockCSVData + ); + } ); + + it( 'should prefix tab character when the cell value starts with one of =, +, -, and @', () => { + [ '=', '+', '-', '@' ].forEach( ( val ) => { + const expected = 'value\n"\t' + val + 'test"'; + const result = generateCSVDataFromTable( + [ + { + label: 'value', + key: 'value', + }, + ], + [ + [ + { + display: 'value', + value: val + 'test', + }, + ], + ] + ); + expect( result ).toBe( expected ); + } ); + } ); +} ); + +describe( 'generateCSVFileName', () => { + it( 'should generate a file name with the date when no params are provided', () => { + const fileName = generateCSVFileName(); + expect( fileName ).toBe( moment().format( 'YYYY-MM-DD' ) + '.csv' ); + } ); + + it( 'should generate a file name with the `name` and the date', () => { + const fileName = generateCSVFileName( 'Revenue table' ); + expect( fileName ).toBe( + 'revenue-table_' + moment().format( 'YYYY-MM-DD' ) + '.csv' + ); + } ); + + it( 'should generate a file name with the `name` and `params`', () => { + const fileName = generateCSVFileName( 'Revenue table', { + orderby: 'revenue', + order: 'desc', + } ); + expect( fileName ).toBe( + 'revenue-table_' + + moment().format( 'YYYY-MM-DD' ) + + '_orderby-revenue_order-desc.csv' + ); + } ); +} ); + +describe( 'downloadCSVFile', () => { + it( "should download a CSV file name to users' browser", () => { + global.Blob = class Blob { + constructor( content, options ) { + return { + content, + options, + }; + } + }; + const fileName = 'test.csv'; + downloadCSVFile( fileName, mockCSVData ); + + // eslint-disable-next-line no-undef + const blob = new Blob( [ mockCSVData ], { + type: 'text/csv;charset=utf-8', + } ); + + expect( saveAs ).toHaveBeenCalledWith( blob, fileName ); + } ); +} ); diff --git a/packages/js/csv-export/tsconfig-cjs.json b/packages/js/csv-export/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/csv-export/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/csv-export/tsconfig.json b/packages/js/csv-export/tsconfig.json new file mode 100644 index 00000000000..6ac6ac42d21 --- /dev/null +++ b/packages/js/csv-export/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module" + } +} diff --git a/packages/js/currency/.eslintrc.js b/packages/js/currency/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/currency/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/currency/.npmrc b/packages/js/currency/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/currency/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/currency/CHANGELOG.md b/packages/js/currency/CHANGELOG.md new file mode 100644 index 00000000000..c0b834f4e6b --- /dev/null +++ b/packages/js/currency/CHANGELOG.md @@ -0,0 +1,69 @@ +# Unreleased + +# 4.0.1 + +- Update all js packages with minor/patch version changes. #8392 + +# 4.0.0 + +## Breaking changes + +- Update dependencies to support react 17. #8305 +- Drop support for IE11. #8305 + +# 3.2.1 + +- Tweak - Added `useCode` parameter to `formatAmount`, to render currency code instead of symbol. #7575 + +# 3.2.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 + +# 3.1.0 + +- Return countryInfo as an empty object if not in locale info #6188 +- Localize regional currency information for use during onboarding setup #5969 +- Update dependencies + +# 3.0.0 + +## Breaking changes + +- Currency is now a factory function instead of a class. + +- Add getCurrencyConfig method to retrieve currency config. + +- `formatCurrency` is deprecated in favor of `formatAmount`. + +# 2.0.0 + +## Breaking changes + +- Decouple from global `wcSettings` object. +- The currency package has been rewritten to export a `Currency` class instead of several utility functions. + +## Other changes + +- Remove lodash dependency. + +# 1.1.3 + +- Update dependencies. + +# 1.1.2 + +- Update license to GPL-3.0-or-later. + +# 1.1.1 + +- Change text domain on i18n functions. +- Bump dependency versions. + +# 1.1.0 + +- Format using store currency settings (instead of locale) +- Add optional currency symbol parameter + +# 1.0.0 + +- Released package diff --git a/packages/js/currency/README.md b/packages/js/currency/README.md new file mode 100644 index 00000000000..113e253a609 --- /dev/null +++ b/packages/js/currency/README.md @@ -0,0 +1,33 @@ +# Currency + +A collection of utilities to display and work with currency values. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/currency --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +```JS +import CurrencyFactory from '@woocommerce/currency'; + +const storeCurrency = CurrencyFactory(); // pass store settings into constructor. + +// Formats money with a given currency symbol. Uses site's currency settings for formatting, +// from the settings api. Defaults to symbol=`$`, precision=2, decimalSeparator=`.`, thousandSeparator=`,` +const total = storeCurrency.formatAmount( 20.923 ); // '$20.92' + +// Get the rounded decimal value of a number at the precision used for the current currency, +// from the settings api. Defaults to 2. +const total = storeCurrency.formatDecimal( '6.2892' ); // 6.29 + +// Get the string representation of a floating point number to the precision used by the current +// currency. This is different from `formatAmount` by not returning the currency symbol. +const total = storeCurrency.formatDecimalString( 1088.478 ); // '1088.48' +``` diff --git a/packages/js/currency/jest.config.json b/packages/js/currency/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/currency/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/currency/package.json b/packages/js/currency/package.json new file mode 100644 index 00000000000..71c32688cb5 --- /dev/null +++ b/packages/js/currency/package.json @@ -0,0 +1,59 @@ +{ + "name": "@woocommerce/currency", + "version": "4.0.1", + "description": "WooCommerce currency utilities.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "currency" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/currency/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@woocommerce/number": "workspace:*", + "@wordpress/deprecated": "^2.12.3", + "@wordpress/element": "^4.1.1", + "@wordpress/html-entities": "^3.3.1", + "@wordpress/i18n": "^3.20.0" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "start": "tsc --build --watch", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/currency/project.json b/packages/js/currency/project.json new file mode 100644 index 00000000000..d36d9055594 --- /dev/null +++ b/packages/js/currency/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/currency", + "sourceRoot": "packages/js/currency/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/currency" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/currency/src/index.js b/packages/js/currency/src/index.js new file mode 100644 index 00000000000..c948f6b28c4 --- /dev/null +++ b/packages/js/currency/src/index.js @@ -0,0 +1,347 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +import { sprintf } from '@wordpress/i18n'; +import { numberFormat } from '@woocommerce/number'; +import deprecated from '@wordpress/deprecated'; + +/** + * @typedef {import('@woocommerce/number').NumberConfig} NumberConfig + */ +/** + * @typedef {Object} CurrencyProps + * @property {string} code Currency ISO code. + * @property {string} symbol Symbol, can be multi-character. + * @property {string} symbolPosition Where the symbol should be relative to the amount. One of `'left' | 'right' | 'left_space | 'right_space'`. + * @typedef {NumberConfig & CurrencyProps} CurrencyConfig + */ + +/** + * + * @param {CurrencyConfig} currencySetting + * @return {Object} currency object + */ +const CurrencyFactory = function ( currencySetting ) { + let currency; + + setCurrency( currencySetting ); + + function setCurrency( setting ) { + const defaultCurrency = { + code: 'USD', + symbol: '$', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }; + const config = { ...defaultCurrency, ...setting }; + currency = { + code: config.code.toString(), + symbol: config.symbol.toString(), + symbolPosition: config.symbolPosition.toString(), + decimalSeparator: config.decimalSeparator.toString(), + priceFormat: getPriceFormat( config ), + thousandSeparator: config.thousandSeparator.toString(), + precision: parseInt( config.precision, 10 ), + }; + } + + function stripTags( str ) { + const tmp = document.createElement( 'DIV' ); + tmp.innerHTML = str; + return tmp.textContent || tmp.innerText || ''; + } + + /** + * Formats money value. + * + * @param {number|string} number number to format + * @param {boolean} [useCode=false] Set to `true` to use the currency code instead of the symbol. + * @return {?string} A formatted string. + */ + function formatAmount( number, useCode = false ) { + const formattedNumber = numberFormat( currency, number ); + + if ( formattedNumber === '' ) { + return formattedNumber; + } + + const { priceFormat, symbol, code } = currency; + + // eslint-disable-next-line @wordpress/valid-sprintf + return sprintf( priceFormat, useCode ? code : symbol, formattedNumber ); + } + + /** + * Formats money value. + * + * @deprecated + * @param {number|string} number number to format + * @return {?string} A formatted string. + */ + function formatCurrency( number ) { + deprecated( 'Currency().formatCurrency', { + version: '5.0.0', + alternative: 'Currency().formatAmount', + plugin: 'WooCommerce', + hint: '`formatAmount` accepts the same arguments as formatCurrency', + } ); + return formatAmount( number ); + } + + /** + * Get the default price format from a currency. + * + * @param {CurrencyConfig} config Currency configuration. + * @return {string} Price format. + */ + function getPriceFormat( config ) { + if ( config.priceFormat ) { + return stripTags( config.priceFormat.toString() ); + } + + switch ( config.symbolPosition ) { + case 'left': + return '%1$s%2$s'; + case 'right': + return '%2$s%1$s'; + case 'left_space': + return '%1$s %2$s'; + case 'right_space': + return '%2$s %1$s'; + } + + return '%1$s%2$s'; + } + + /** + * Get formatted data for a country from supplied locale and symbol info. + * + * @param {string} countryCode Country code. + * @param {Object} localeInfo Locale info by country code. + * @param {Object} currencySymbols Currency symbols by symbol code. + * @return {CurrencyConfig | {}} Formatted currency data for country. + */ + function getDataForCountry( + countryCode, + localeInfo = {}, + currencySymbols = {} + ) { + const countryInfo = localeInfo[ countryCode ] || {}; + const symbol = currencySymbols[ countryInfo.currency_code ]; + + if ( ! symbol ) { + return {}; + } + + return { + code: countryInfo.currency_code, + symbol: decodeEntities( symbol ), + symbolPosition: countryInfo.currency_pos, + thousandSeparator: countryInfo.thousand_sep, + decimalSeparator: countryInfo.decimal_sep, + precision: countryInfo.num_decimals, + }; + } + + return { + getCurrencyConfig: () => { + return { ...currency }; + }, + getDataForCountry, + setCurrency, + formatAmount, + formatCurrency, + getPriceFormat, + + /** + * Get the rounded decimal value of a number at the precision used for the current currency. + * This is a work-around for fraction-cents, meant to be used like `wc_format_decimal` + * + * @param {number|string} number A floating point number (or integer), or string that converts to a number + * @return {number} The original number rounded to a decimal point + */ + formatDecimal( number ) { + if ( typeof number !== 'number' ) { + number = parseFloat( number ); + } + if ( Number.isNaN( number ) ) { + return 0; + } + const { precision } = currency; + return ( + Math.round( number * Math.pow( 10, precision ) ) / + Math.pow( 10, precision ) + ); + }, + + /** + * Get the string representation of a floating point number to the precision used by the current currency. + * This is different from `formatAmount` by not returning the currency symbol. + * + * @param {number|string} number A floating point number (or integer), or string that converts to a number + * @return {string} The original number rounded to a decimal point + */ + formatDecimalString( number ) { + if ( typeof number !== 'number' ) { + number = parseFloat( number ); + } + if ( Number.isNaN( number ) ) { + return ''; + } + const { precision } = currency; + return number.toFixed( precision ); + }, + + /** + * Render a currency for display in a component. + * + * @param {number|string} number A floating point number (or integer), or string that converts to a number + * @return {Node|string} The number formatted as currency and rendered for display. + */ + render( number ) { + if ( typeof number !== 'number' ) { + number = parseFloat( number ); + } + if ( number < 0 ) { + return ( + <span className="is-negative"> + { formatAmount( number ) } + </span> + ); + } + return formatAmount( number ); + }, + }; +}; + +export default CurrencyFactory; + +/** + * Returns currency data by country/region. Contains code, symbol, position, thousands separator, decimal separator, and precision. + * + * Dev Note: When adding new currencies below, the exchange rate array should also be updated in WooCommerce Admin's `business-details.js`. + * + * @deprecated + * @return {Object} Curreny data. + */ +export function getCurrencyData() { + deprecated( 'getCurrencyData', { + version: '3.1.0', + alternative: 'CurrencyFactory.getDataForCountry', + plugin: 'WooCommerce Admin', + hint: + 'Pass in the country, locale data, and symbol info to use getDataForCountry', + } ); + + // See https://github.com/woocommerce/woocommerce-admin/issues/3101. + return { + US: { + code: 'USD', + symbol: '$', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + EU: { + code: 'EUR', + symbol: '€', + symbolPosition: 'left', + thousandSeparator: '.', + decimalSeparator: ',', + precision: 2, + }, + IN: { + code: 'INR', + symbol: '₹', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + GB: { + code: 'GBP', + symbol: '£', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + BR: { + code: 'BRL', + symbol: 'R$', + symbolPosition: 'left', + thousandSeparator: '.', + decimalSeparator: ',', + precision: 2, + }, + VN: { + code: 'VND', + symbol: '₫', + symbolPosition: 'right', + thousandSeparator: '.', + decimalSeparator: ',', + precision: 1, + }, + ID: { + code: 'IDR', + symbol: 'Rp', + symbolPosition: 'left', + thousandSeparator: '.', + decimalSeparator: ',', + precision: 0, + }, + BD: { + code: 'BDT', + symbol: '৳', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 0, + }, + PK: { + code: 'PKR', + symbol: '₨', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + RU: { + code: 'RUB', + symbol: '₽', + symbolPosition: 'right', + thousandSeparator: ' ', + decimalSeparator: ',', + precision: 2, + }, + TR: { + code: 'TRY', + symbol: '₺', + symbolPosition: 'left', + thousandSeparator: '.', + decimalSeparator: ',', + precision: 2, + }, + MX: { + code: 'MXN', + symbol: '$', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + CA: { + code: 'CAD', + symbol: '$', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + }; +} diff --git a/packages/js/currency/src/test/index.js b/packages/js/currency/src/test/index.js new file mode 100644 index 00000000000..70aa824414b --- /dev/null +++ b/packages/js/currency/src/test/index.js @@ -0,0 +1,99 @@ +/** + * Internal dependencies + */ +import Currency from '../'; + +describe( 'formatAmount', () => { + it( 'should use defaults (USD) when currency not passed in', () => { + const currency = Currency(); + expect( currency.formatAmount( 9.99 ) ).toBe( '$9.99' ); + expect( currency.formatAmount( 30 ) ).toBe( '$30.00' ); + } ); + + it( 'should return country code instead of symbol, when `useCode` is set to `true`', () => { + const currency = Currency(); + expect( currency.formatAmount( 9.99, true ) ).toBe( 'USD9.99' ); + const currency2 = Currency( { + priceFormat: '%2$s %1$s', + symbol: 'EUR', + } ); + expect( currency2.formatAmount( 30 ) ).toBe( '30.00 EUR' ); + } ); + + it( 'should uses store currency settings, not locale-based', () => { + const currency = Currency( { + code: 'JPY', + symbol: '¥', + precision: 3, + priceFormat: '%2$s%1$s', + thousandSeparator: '.', + decimalSeparator: ',', + } ); + expect( currency.formatAmount( 9.49258 ) ).toBe( '9,493¥' ); + expect( currency.formatAmount( 3000 ) ).toBe( '3.000,000¥' ); + expect( currency.formatAmount( 3.0002 ) ).toBe( '3,000¥' ); + } ); + + it( "should return empty string when given an input that isn't a number", () => { + const currency = Currency(); + expect( currency.formatAmount( 'abc' ) ).toBe( '' ); + expect( currency.formatAmount( false ) ).toBe( '' ); + expect( currency.formatAmount( null ) ).toBe( '' ); + } ); +} ); + +describe( 'currency.formatDecimal', () => { + it( 'should round a number to 2 decimal places in USD', () => { + const currency = Currency(); + expect( currency.formatDecimal( 9.49258 ) ).toBe( 9.49 ); + expect( currency.formatDecimal( 30 ) ).toBe( 30 ); + expect( currency.formatDecimal( 3.0002 ) ).toBe( 3 ); + } ); + + it( 'should round a number to 0 decimal places in JPY', () => { + const currency = Currency( { precision: 0 } ); + expect( currency.formatDecimal( 1239.88 ) ).toBe( 1240 ); + expect( currency.formatDecimal( 1500 ) ).toBe( 1500 ); + expect( currency.formatDecimal( 33715.02 ) ).toBe( 33715 ); + } ); + + it( 'should correctly convert and round a string', () => { + const currency = Currency(); + expect( currency.formatDecimal( '19.80' ) ).toBe( 19.8 ); + } ); + + it( "should return 0 when given an input that isn't a number", () => { + const currency = Currency(); + expect( currency.formatDecimal( 'abc' ) ).toBe( 0 ); + expect( currency.formatDecimal( false ) ).toBe( 0 ); + expect( currency.formatDecimal( null ) ).toBe( 0 ); + } ); +} ); + +describe( 'currency.formatDecimalString', () => { + it( 'should round a number to 2 decimal places in USD', () => { + const currency = Currency(); + expect( currency.formatDecimalString( 9.49258 ) ).toBe( '9.49' ); + expect( currency.formatDecimalString( 30 ) ).toBe( '30.00' ); + expect( currency.formatDecimalString( 3.0002 ) ).toBe( '3.00' ); + } ); + + it( 'should round a number to 0 decimal places in JPY', () => { + const currency = Currency( { precision: 0 } ); + expect( currency.formatDecimalString( 1239.88 ) ).toBe( '1240' ); + expect( currency.formatDecimalString( 1500 ) ).toBe( '1500' ); + expect( currency.formatDecimalString( 33715.02 ) ).toBe( '33715' ); + } ); + + it( 'should correctly convert and round a string', () => { + const currency = Currency(); + expect( currency.formatDecimalString( '19.80' ) ).toBe( '19.80' ); + } ); + + it( "should return empty string when given an input that isn't a number", () => { + const currency = Currency(); + expect( currency.formatDecimalString( 'abc' ) ).toBe( '' ); + expect( currency.formatDecimalString( false ) ).toBe( '' ); + expect( currency.formatDecimalString( null ) ).toBe( '' ); + } ); +} ); diff --git a/packages/js/currency/tsconfig-cjs.json b/packages/js/currency/tsconfig-cjs.json new file mode 100644 index 00000000000..04a2967c800 --- /dev/null +++ b/packages/js/currency/tsconfig-cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "declaration": true, + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/currency/tsconfig.json b/packages/js/currency/tsconfig.json new file mode 100644 index 00000000000..1d13e90e9a6 --- /dev/null +++ b/packages/js/currency/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "declaration": true, + "rootDir": "src", + "outDir": "build-module" + } +} diff --git a/packages/js/customer-effort-score/.eslintrc.js b/packages/js/customer-effort-score/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/customer-effort-score/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/customer-effort-score/.npmrc b/packages/js/customer-effort-score/.npmrc new file mode 100644 index 00000000000..9cf9495031e --- /dev/null +++ b/packages/js/customer-effort-score/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/packages/js/customer-effort-score/CHANGELOG.md b/packages/js/customer-effort-score/CHANGELOG.md new file mode 100644 index 00000000000..8c01aa70b7d --- /dev/null +++ b/packages/js/customer-effort-score/CHANGELOG.md @@ -0,0 +1,17 @@ +# Unreleased + +# 2.0.1 + +- Update all js packages with minor/patch version changes. #8392 + +# 2.0.0 + +- Update dependencies to support react 17 #8305 + +# 1.1.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 + +# 1.0.0 + +- Initial release. diff --git a/packages/js/customer-effort-score/README.md b/packages/js/customer-effort-score/README.md new file mode 100644 index 00000000000..61aad89db1a --- /dev/null +++ b/packages/js/customer-effort-score/README.md @@ -0,0 +1,79 @@ +# Customer Effort Score + +WooCommerce utility to measuring user satisfaction. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/customer-effort-score --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +### CustomerEffortScore component + +`CustomerEffortScore` is a React component that can be used to implement your +own effort score survey, providing your own logging infrastructure. + +This creates a wrapper component around `CustomerEffortScore` which simply logs +responses to the console: + +```js +import CustomerEffortScore from '@woocommerce/customer-effort-score'; + +export function CustomerEffortScoreConsole( { label } ) { + const onNoticeShown = () => console.log( 'onNoticeShown' ); + const onModalShown = () => console.log( 'onModalShown' ); + const onNoticeDismissed = () => console.log( 'onNoticeDismissed' ); + const recordScore = ( score, comments ) => console.log( { score, comments } ); + + return ( + <CustomerEffortScore + recordScoreCallback={ recordScore } + label={ label } + onNoticeShownCallback={ onNoticeShown } + onNoticeDismissedCallback={ onNoticeDismissed } + onModalShownCallback={ onModalShown } + icon={ + <span + style={ { height: 21, width: 21 } } + role="img" + aria-label="Pencil icon" + > + ✏️ + </span> + } + /> + ); +}; +``` + +Use this wrapper component in your code like this: + +```js +const MyComponent = function() { + const [ ceses, setCeses ] = useState( [] ); + + const addCES = () => { + setCeses( + ceses.concat( + <CustomerEffortScoreConsole + label={ `survey ${ceses.length + 1}` } + key={ ceses.length + 1 } + /> + ) + ); + }; + + return ( + <> + { ceses } + <button onClick={ addCES }>Show new survey</button> + </> + ); +}; +``` diff --git a/packages/js/customer-effort-score/jest.config.json b/packages/js/customer-effort-score/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/customer-effort-score/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/customer-effort-score/package.json b/packages/js/customer-effort-score/package.json new file mode 100644 index 00000000000..f56ea4b593f --- /dev/null +++ b/packages/js/customer-effort-score/package.json @@ -0,0 +1,80 @@ +{ + "name": "@woocommerce/customer-effort-score", + "version": "2.0.1", + "description": "WooCommerce utility to measure user effort.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/customer-effort-score/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@woocommerce/experimental": "workspace:*", + "@wordpress/components": "^19.5.0", + "@wordpress/compose": "^5.1.2", + "@wordpress/data": "^6.3.0", + "@wordpress/element": "^4.1.1", + "@wordpress/i18n": "^4.3.1", + "@wordpress/notices": "^3.3.2", + "classnames": "^2.3.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.2" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@testing-library/react": "^12.1.3", + "@types/prop-types": "^15.7.4", + "@types/wordpress__components": "^9.8.6", + "@woocommerce/style-build": "workspace:*", + "@wordpress/browserslist-config": "^4.1.1", + "@wordpress/eslint-plugin": "^11.0.0", + "concurrently": "^7.0.0", + "css-loader": "^3.6.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "postcss-loader": "^3.0.0", + "rimraf": "^3.0.2", + "sass-loader": "^10.2.1", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2", + "webpack": "^5.70.0", + "webpack-cli": "^3.3.12" + }, + "peerDependencies": { + "react": "^17.0.0", + "react-dom": "^17.0.0" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "pnpm run build:js && pnpm run build:css", + "build:js": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "build:css": "webpack", + "start": "concurrently \"tsc --build --watch\" \"webpack --watch\"", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/customer-effort-score/project.json b/packages/js/customer-effort-score/project.json new file mode 100644 index 00000000000..1b5455db24e --- /dev/null +++ b/packages/js/customer-effort-score/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/customer-effort-score", + "sourceRoot": "packages/js/customer-effort-score/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/customer-effort-score" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file 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 new file mode 100644 index 00000000000..109febcf7cd --- /dev/null +++ b/packages/js/customer-effort-score/src/customer-feedback-modal/index.tsx @@ -0,0 +1,157 @@ +/** + * External dependencies + */ +import { createElement, useState } from '@wordpress/element'; +import PropTypes from 'prop-types'; +import { + Button, + Modal, + RadioControl, + TextareaControl, +} from '@wordpress/components'; +import { Text } from '@woocommerce/experimental'; +import { __ } from '@wordpress/i18n'; + +/** + * Provides a modal requesting customer feedback. + * + * A label is displayed in the modal asking the customer to score the + * difficulty completing a task. A group of radio buttons, styled with + * emoji facial expressions, are used to provide a score between 1 and 5. + * + * A low score triggers a comments field to appear. + * + * Upon completion, the score and comments is sent to a callback function. + * + * @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. + */ +function CustomerFeedbackModal( { + recordScoreCallback, + label, +}: { + recordScoreCallback: ( score: number, comments: string ) => void; + label: 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 [ score, setScore ] = useState( NaN ); + const [ comments, setComments ] = useState( '' ); + const [ showNoScoreMessage, setShowNoScoreMessage ] = useState( false ); + const [ isOpen, setOpen ] = useState( true ); + + const closeModal = () => setOpen( false ); + + const onRadioControlChange = ( value: string ) => { + const valueAsInt = parseInt( value, 10 ); + setScore( valueAsInt ); + setShowNoScoreMessage( ! Number.isInteger( valueAsInt ) ); + }; + + const sendScore = () => { + if ( ! Number.isInteger( score ) ) { + setShowNoScoreMessage( true ); + return; + } + setOpen( false ); + recordScoreCallback( score, comments ); + }; + + if ( ! isOpen ) { + return null; + } + + return ( + <Modal + className="woocommerce-customer-effort-score" + title={ __( 'Please share your feedback', 'woocommerce' ) } + onRequestClose={ closeModal } + shouldCloseOnClickOutside={ false } + > + <Text + variant="subtitle.small" + as="p" + weight="600" + size="14" + lineHeight="20px" + > + { label } + </Text> + + <div className="woocommerce-customer-effort-score__selection"> + <RadioControl + selected={ score.toString( 10 ) } + options={ options } + onChange={ onRadioControlChange } + /> + </div> + + { ( score === 1 || score === 2 ) && ( + <div className="woocommerce-customer-effort-score__comments"> + <TextareaControl + label={ __( 'Comments (Optional)', 'woocommerce' ) } + help={ __( + 'Your feedback will go to the WooCommerce development team', + 'woocommerce' + ) } + value={ comments } + onChange={ ( value: string ) => setComments( value ) } + rows={ 5 } + /> + </div> + ) } + + { showNoScoreMessage && ( + <div + className="woocommerce-customer-effort-score__errors" + role="alert" + > + <Text variant="body" as="p"> + { __( + 'Please provide feedback by selecting an option above.', + 'woocommerce' + ) } + </Text> + </div> + ) } + + <div className="woocommerce-customer-effort-score__buttons"> + <Button isTertiary onClick={ closeModal } name="cancel"> + { __( 'Cancel', 'woocommerce' ) } + </Button> + <Button isPrimary onClick={ sendScore } name="send"> + { __( 'Send', 'woocommerce' ) } + </Button> + </div> + </Modal> + ); +} + +CustomerFeedbackModal.propTypes = { + recordScoreCallback: PropTypes.func.isRequired, + label: PropTypes.string.isRequired, +}; + +export default CustomerFeedbackModal; diff --git a/packages/js/customer-effort-score/src/customer-feedback-modal/test/index.js b/packages/js/customer-effort-score/src/customer-feedback-modal/test/index.js new file mode 100644 index 00000000000..429ccd75034 --- /dev/null +++ b/packages/js/customer-effort-score/src/customer-feedback-modal/test/index.js @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import CustomerFeedbackModal from '../index'; + +const mockRecordScoreCallback = jest.fn(); + +describe( 'CustomerFeedbackModal', () => { + it( 'should close modal when cancel button pressed', async () => { + render( + <CustomerFeedbackModal + recordScoreCallback={ mockRecordScoreCallback } + label="Testing" + /> + ); + + // Wait for the modal to render. + await screen.findByRole( 'dialog' ); + + // Press cancel button. + fireEvent.click( screen.getByRole( 'button', { name: /cancel/i } ) ); + + expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument(); + } ); + + it( 'should halt with an error when submitting without a score', async () => { + render( + <CustomerFeedbackModal + recordScoreCallback={ mockRecordScoreCallback } + label="Testing" + /> + ); + + await screen.findByRole( 'dialog' ); // Wait for the modal to render. + + fireEvent.click( screen.getByRole( 'button', { name: /send/i } ) ); // Press send button. + + // Wait for error message. + await screen.findByRole( 'alert' ); + + expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument(); + } ); + + it( 'should disable the comments field initially', async () => { + render( + <CustomerFeedbackModal + recordScoreCallback={ mockRecordScoreCallback } + label="Testing" + /> + ); + + // Wait for the modal to render. + await screen.findByRole( 'dialog' ); + + expect( + screen.queryByLabelText( 'Comments (Optional)' ) + ).not.toBeInTheDocument(); + } ); + + it.each( [ 'Very difficult', 'Somewhat difficult' ] )( + 'should toggle the comments field when %s is selected', + async ( labelText ) => { + render( + <CustomerFeedbackModal + recordScoreCallback={ mockRecordScoreCallback } + label="Testing" + /> + ); + + // Wait for the modal to render. + await screen.findByRole( 'dialog' ); + + // Select the option. + fireEvent.click( screen.getByLabelText( labelText ) ); + + // Wait for comments field to show. + await screen.findByLabelText( 'Comments (Optional)' ); + + // Select neutral score. + fireEvent.click( screen.getByLabelText( 'Neutral' ) ); + + // Wait for comments field to hide. + await waitFor( () => { + expect( + screen.queryByLabelText( 'Comments (Optional)' ) + ).not.toBeInTheDocument(); + } ); + } + ); +} ); diff --git a/packages/js/customer-effort-score/src/index.js b/packages/js/customer-effort-score/src/index.js new file mode 100644 index 00000000000..6563247067d --- /dev/null +++ b/packages/js/customer-effort-score/src/index.js @@ -0,0 +1,124 @@ +/** + * External dependencies + */ +import { createElement, useState, useEffect } from '@wordpress/element'; +import PropTypes from 'prop-types'; +import { __ } from '@wordpress/i18n'; +import { compose } from '@wordpress/compose'; +import { withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import CustomerFeedbackModal from './customer-feedback-modal'; + +const noop = () => {}; + +/** + * Use `CustomerEffortScore` to gather a customer effort score. + * + * NOTE: This should live in @woocommerce/customer-effort-score to allow + * reuse. + * + * @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 {Function} props.createNotice Create a notice (snackbar). + * @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 {Object} props.icon Icon (React component) to be shown on the notice. + */ +export function CustomerEffortScore( { + recordScoreCallback, + label, + createNotice, + onNoticeShownCallback = noop, + onNoticeDismissedCallback = noop, + onModalShownCallback = noop, + icon, +} ) { + const [ shouldCreateNotice, setShouldCreateNotice ] = useState( true ); + const [ visible, setVisible ] = useState( false ); + + useEffect( () => { + if ( ! shouldCreateNotice ) { + return; + } + + createNotice( 'success', label, { + actions: [ + { + label: __( 'Give feedback', 'woocommerce' ), + onClick: () => { + setVisible( true ); + onModalShownCallback(); + }, + }, + ], + icon, + explicitDismiss: true, + onDismiss: onNoticeDismissedCallback, + } ); + + setShouldCreateNotice( false ); + + onNoticeShownCallback(); + }, [ shouldCreateNotice ] ); + + if ( shouldCreateNotice ) { + return null; + } + + if ( ! visible ) { + return null; + } + + return ( + <CustomerFeedbackModal + label={ label } + recordScoreCallback={ recordScoreCallback } + /> + ); +} + +CustomerEffortScore.propTypes = { + /** + * The function to call to record the score. + */ + recordScoreCallback: PropTypes.func.isRequired, + /** + * The label displayed in the modal. + */ + label: PropTypes.string.isRequired, + /** + * Create a notice (snackbar). + */ + createNotice: PropTypes.func.isRequired, + /** + * The function to call when the notice is shown. + */ + onNoticeShownCallback: PropTypes.func, + /** + * The function to call when the notice is dismissed. + */ + onNoticeDismissedCallback: PropTypes.func, + /** + * The function to call when the modal is shown. + */ + onModalShownCallback: PropTypes.func, + /** + * Icon (React component) to be displayed. + */ + icon: PropTypes.element, +}; + +export default compose( + withDispatch( ( dispatch ) => { + const { createNotice } = dispatch( 'core/notices2' ); + + return { + createNotice, + }; + } ) +)( CustomerEffortScore ); diff --git a/packages/js/customer-effort-score/src/style.scss b/packages/js/customer-effort-score/src/style.scss new file mode 100644 index 00000000000..8c65b7005bf --- /dev/null +++ b/packages/js/customer-effort-score/src/style.scss @@ -0,0 +1,102 @@ +.woocommerce-customer-effort-score__selection { + margin: 1em 0; + + .components-base-control__field { + display: flex; + flex-direction: column; + margin: 0 auto; + color: var(--wp-admin-theme-color); + + @include breakpoint( '>600px' ) { + flex-direction: row; + } + } + + .components-radio-control__option { + &:not(:last-child) { + // Override package component style. + margin-bottom: 0; + margin-right: 4px; + } + + input { + // Hide the radio input but keep it accessibile. + position: absolute; + opacity: 0; + } + + label { + display: block; + text-align: center; + box-sizing: border-box; + width: 9em; + height: 100%; + padding: 1em 0.5em; + font-size: 0.9em; + + &:hover { + background-color: $studio-wordpress-blue-0; + } + } + + input:focus + label { + outline: 2px solid $studio-wordpress-blue; + background-color: peach; + color: $studio-blue-60; + } + + input:checked + label { + outline: 2px solid $studio-wordpress-blue; + background-color: $studio-wordpress-blue-0; + } + + // Replace the hidden radio input with emoji. + label::before { + display: block; + font-size: 24px; + text-align: center; + margin: 1em 0; + color: $studio-wordpress-blue; + } + + input[value='1'] + label::before { + content: '😞'; + } + + input[value='2'] + label::before { + content: '🙁'; + } + + input[value='3'] + label::before { + content: '😐'; + } + + input[value='4'] + label::before { + content: '🙂'; + } + + input[value='5'] + label::before { + content: '😁'; + } + } +} + +.woocommerce-customer-effort-score__comments { + label { + display: block; + color: inherit; + font-weight: bold; + } + + textarea { + width: 100%; + } +} + +.woocommerce-customer-effort-score__buttons { + text-align: right; + + .components-button { + margin-left: 1em; + } +} diff --git a/packages/js/customer-effort-score/src/test/index.js b/packages/js/customer-effort-score/src/test/index.js new file mode 100644 index 00000000000..fb016334a81 --- /dev/null +++ b/packages/js/customer-effort-score/src/test/index.js @@ -0,0 +1,107 @@ +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { CustomerEffortScore } from '../index'; + +const noop = () => {}; + +describe( 'CustomerEffortScore', () => { + it( 'should call createNotice with appropriate parameters', async () => { + const mockCreateNotice = jest.fn(); + const icon = <span>icon</span>; + + render( + <CustomerEffortScore + createNotice={ mockCreateNotice } + recordScoreCallback={ noop } + label={ 'label' } + onNoticeDismissedCallback={ noop } + icon={ icon } + /> + ); + + expect( mockCreateNotice ).toHaveBeenCalledWith( + // Notice status. + expect.any( String ), + // Notice message. + 'label', + // Notice options. + expect.objectContaining( { + icon, + onDismiss: noop, + } ) + ); + } ); + + it( 'should not call createNotice on rerender', async () => { + const mockCreateNotice = jest.fn(); + + const { rerender } = render( + <CustomerEffortScore + createNotice={ mockCreateNotice } + recordScoreCallback={ noop } + label={ 'label' } + /> + ); + + // Simulate rerender by changing label prop. + rerender( + <CustomerEffortScore + createNotice={ mockCreateNotice } + recordScoreCallback={ noop } + label={ 'label2' } + /> + ); + + expect( mockCreateNotice ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'should not show dialog if no action is taken', async () => { + render( + <CustomerEffortScore + createNotice={ noop } + recordScoreCallback={ noop } + label={ 'label' } + /> + ); + + const dialog = screen.queryByRole( 'dialog' ); + expect( dialog ).toBeNull(); + } ); + + it( 'should show dialog if "Give feedback" callback is run', async () => { + const mockOnModalShownCallback = jest.fn(); + const createNotice = ( ...args ) => { + // We're only interested in the 3rd argument. + const { actions } = args[ 2 ]; + + // Assuming the first action is the "Give feedback" action, + // manually call callback. + const callback = actions[ 0 ].onClick; + if ( typeof callback === 'function' ) { + callback(); + } + + // Modal shown callback should also be called. + expect( mockOnModalShownCallback ).toHaveBeenCalled(); + }; + + render( + <CustomerEffortScore + createNotice={ createNotice } + recordScoreCallback={ noop } + label={ 'label' } + onModalShownCallback={ mockOnModalShownCallback } + /> + ); + + const dialog = screen.queryByRole( 'dialog' ); + expect( dialog ).not.toBeNull(); + } ); +} ); diff --git a/packages/js/customer-effort-score/tsconfig-cjs.json b/packages/js/customer-effort-score/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/customer-effort-score/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/customer-effort-score/tsconfig.json b/packages/js/customer-effort-score/tsconfig.json new file mode 100644 index 00000000000..6ac6ac42d21 --- /dev/null +++ b/packages/js/customer-effort-score/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module" + } +} diff --git a/packages/js/customer-effort-score/typings/index.d.ts b/packages/js/customer-effort-score/typings/index.d.ts new file mode 100644 index 00000000000..9384f37ca19 --- /dev/null +++ b/packages/js/customer-effort-score/typings/index.d.ts @@ -0,0 +1 @@ +declare module '@woocommerce/experimental'; diff --git a/packages/js/customer-effort-score/webpack.config.js b/packages/js/customer-effort-score/webpack.config.js new file mode 100644 index 00000000000..5c56294e0f0 --- /dev/null +++ b/packages/js/customer-effort-score/webpack.config.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +const { webpackConfig } = require( '@woocommerce/style-build' ); + +module.exports = { + mode: process.env.NODE_ENV || 'development', + entry: { + 'build-style': __dirname + '/src/style.scss', + }, + output: { + path: __dirname, + }, + module: { + rules: webpackConfig.rules, + }, + plugins: webpackConfig.plugins, +}; diff --git a/packages/js/data/.eslintrc.js b/packages/js/data/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/data/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/data/.npmrc b/packages/js/data/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/data/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/data/CHANGELOG.md b/packages/js/data/CHANGELOG.md new file mode 100644 index 00000000000..80703cd633a --- /dev/null +++ b/packages/js/data/CHANGELOG.md @@ -0,0 +1,107 @@ +# Unreleased + +- Update dependency `@wordpress/hooks` to ^3.5.0 +- Add `is_offline` attribute for `Plugin` type. #32467 +- Added Typescript type declarations. #32615 +- Update type definitions. #32683, #32695, #32698, #32712 + - Make `isResolving` param `args` optional. + - Update `Plugin` type to reflect the latest changes. + - Maps "raw" payment `ActionDispatchers` to the registered actions. + - Export `getTaskListsByIds`, `getTaskLists`, `getTaskList`, `getFreeExtensions` onboarding selector types + - Update `TaskType` & `TaskListType` types + - Export `InstallPluginsResponse` type +- Convert `use-user-preferences.js` to TS. #32695 + +## Breaking change + +- Remove `PaymentMethodsState` type. Use `Plugin` instead. #32683 +# 3.1.0 + +- Add "moment" to peerDependencies. #8349 +- Update all js packages with minor/patch version changes. #8392 +- Fix type errors. #8392 +# 3.0.0 + +## Breaking changes + +- Update dependencies to support react 17. #8305 +- Drop support for IE11. #8305 + +# 2.0.0 + +## Breaking changes + +- Fix the batch fetch logic for the options data store. #7587 +- Add backwards compability for old function format. #7688 +- Add console warning for inbox note contents exceeding 320 characters and add dompurify dependency. #7869 +- Fix race condition in data package's options module. #7947 +- Remove dev dependency `@woocommerce/wc-admin-settings`. #8057 +- Update plugins data store actions #8042 +- Add `defaultDateRange` parameter to `getRequestQuery` #8189 +- Change `getLocale` selector parameter from country to id #8123 +- Add countries data store #8119 +- Rename `is_visible` to `can_view` #7918 +- Replace old task list option calls with data store selectors #7820 +- Remove task status endpoint #7841 +- Add country validation to subscription inclusion #7777 +- Move some of the deprecated tasks #7761 +- Change how `getTasksFromDeprecatedFilter` works #7749 +- Add query args for removeAllNotes #7743 +- Removed some attributes from `TasksStatusState` #7736 +- Add an endpoint and method for actioning tasks #7746 +- Add show/hide behavior for task list API #7733 +- Add optimistic task completion and cache invalidation #7722 +- Add extended task list support to the new REST api task lists #7730 +- Migrate tasks to task API #7699 +- Revert `searchItemsByString` to use selector param again #7682 +- Add hide task list endpoint and data actions #7578 +- Add task list components to consume task list REST API #7556 +- Add Newsletter Signup to onboarding data store #7601 +- Add task selectors and actions to onboarding data store #7545 +- Add super admin check to preloaded user data #7489 +- Add free extensions data store #7420 +- Add `isPluginsRequesting` selector #7383 +- Add options and change selector param for `searchItemsByString`. #7385 +- Change select to selector param for `searchItemsByString`. #7355 +- Change item data store's `getItems` selector #7395 + +# 1.4.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 + +# 1.3.2 + +- Add fallback for the select/dispatch data-controls for older WP versions. #7204 +- Fix error parsing of plugin data package. #7164 +- Update dependencies + +# 1.3.1 + +- Fix, state md5 as npm dependency. #7087 + +# 1.3.0 + +- Fix parsing bad JSON string data in useUserPreferences hook. #6819 +- Removed allowed keys list for adding woocommerce_meta data. #6889 + +# 1.2.0 + +- Add management of persisted queries to navigation data store. +- Add TypeScript support. +- Generate MD5 hashes without bundling all of Node crypto. #5768 +- Fix HoC-wrapped components from being named "Anonymous". #5898 +- Reduce Unnecessary Re-renders in Revenue Report. #5986 +- Add useUser hook. #6365 +- Fix bug in useSettings that causes an infinite loop. #6540 + +# 1.1.1 + +- Remove usage of wc-admin alias `@woocommerce/wc-admin-settings`. + +# 1.1.0 + +- Add export, import, items, notes, reports, and reviews data stores. + +# 1.0.0 + +- Released package diff --git a/packages/js/data/README.md b/packages/js/data/README.md new file mode 100644 index 00000000000..17815fba6dc --- /dev/null +++ b/packages/js/data/README.md @@ -0,0 +1,37 @@ +# Data + +WooCommerce Admin data store and utilities. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/data --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +```JS +import { SETTINGS_STORE_NAME } from '@woocommerce/data'; +import { useSelect } from '@wordpress/data'; + +function MySettings() { + const settings = useSelect( select => { + return select( SETTINGS_STORE_NAME ).getSettings(); + } ); + return ( + <ul> + { settings.map( setting => ( + <li>{ setting.name }</li> + ) ) } + </ul> + ); +} + +// Rendered in the application: +// +// <MySettings /> +``` diff --git a/packages/js/data/jest.config.json b/packages/js/data/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/data/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/data/package.json b/packages/js/data/package.json new file mode 100644 index 00000000000..592adc87fe7 --- /dev/null +++ b/packages/js/data/package.json @@ -0,0 +1,83 @@ +{ + "name": "@woocommerce/data", + "version": "3.1.0", + "description": "WooCommerce Admin data store and utilities", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "data" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/data/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "types": "build-types", + "react-native": "src/index", + "dependencies": { + "@woocommerce/date": "workspace:*", + "@woocommerce/navigation": "workspace:*", + "@wordpress/api-fetch": "^6.0.1", + "@wordpress/compose": "^5.1.2", + "@wordpress/core-data": "^4.1.2", + "@wordpress/data": "^6.3.0", + "@wordpress/data-controls": "^2.3.2", + "@wordpress/deprecated": "^3.3.1", + "@wordpress/element": "^4.1.1", + "@wordpress/hooks": "^3.5.0", + "@wordpress/i18n": "^4.3.1", + "@wordpress/url": "^3.4.1", + "dompurify": "^2.3.6", + "md5": "^2.3.0", + "qs": "^6.10.3", + "rememo": "^4.0.0" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@automattic/data-stores": "^2.0.1", + "@babel/core": "^7.17.5", + "@babel/runtime": "^7.17.2", + "@testing-library/react": "^12.1.3", + "@testing-library/react-hooks": "^7.0.2", + "@types/wordpress__core-data": "^2.4.5", + "@types/wordpress__data-controls": "^2.2.0", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "peerDependencies": { + "@wordpress/core-data": "^4.1.0", + "moment": "^2.18.1", + "react": "^17.0.0", + "react-dom": "^17.0.0" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "start": "tsc --build --watch", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/data/project.json b/packages/js/data/project.json new file mode 100644 index 00000000000..ff9c52018f3 --- /dev/null +++ b/packages/js/data/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/data", + "sourceRoot": "packages/js/data/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/data" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/data/src/constants.ts b/packages/js/data/src/constants.ts new file mode 100644 index 00000000000..82764e56bac --- /dev/null +++ b/packages/js/data/src/constants.ts @@ -0,0 +1,28 @@ +export const JETPACK_NAMESPACE = '/jetpack/v4'; +export const NAMESPACE = '/wc-analytics'; +export const WC_ADMIN_NAMESPACE = '/wc-admin'; +export const WCS_NAMESPACE = '/wc/v1'; // WCS endpoints like Stripe are not avaiable on later /wc versions + +// WordPress & WooCommerce both set a hard limit of 100 for the per_page parameter +export const MAX_PER_PAGE = 100; + +export const SECOND = 1000; +export const MINUTE = 60 * SECOND; +export const HOUR = 60 * MINUTE; +export const DAY = 24 * HOUR; +export const WEEK = 7 * DAY; +export const MONTH = ( 365 * DAY ) / 12; + +export const DEFAULT_REQUIREMENT = { + timeout: 1 * MINUTE, + freshness: 30 * MINUTE, +}; + +export const DEFAULT_ACTIONABLE_STATUSES = [ 'processing', 'on-hold' ]; + +export const QUERY_DEFAULTS = { + pageSize: 25, + period: 'month', + compare: 'previous_year', + noteTypes: [ 'info', 'marketing', 'survey', 'warning' ], +}; diff --git a/packages/js/data/src/controls.js b/packages/js/data/src/controls.js new file mode 100644 index 00000000000..81ccac7cab2 --- /dev/null +++ b/packages/js/data/src/controls.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { controls as dataControls } from '@wordpress/data-controls'; + +import apiFetch from '@wordpress/api-fetch'; + +export const fetchWithHeaders = ( options ) => { + return { + type: 'FETCH_WITH_HEADERS', + options, + }; +}; + +const controls = { + ...dataControls, + FETCH_WITH_HEADERS( { options } ) { + return apiFetch( { ...options, parse: false } ) + .then( ( response ) => { + return Promise.all( [ + response.headers, + response.status, + response.json(), + ] ); + } ) + .then( ( [ headers, status, data ] ) => ( { + headers, + status, + data, + } ) ); + }, +}; + +export default controls; diff --git a/packages/js/data/src/countries/action-types.ts b/packages/js/data/src/countries/action-types.ts new file mode 100644 index 00000000000..68547991c51 --- /dev/null +++ b/packages/js/data/src/countries/action-types.ts @@ -0,0 +1,8 @@ +export enum TYPES { + GET_LOCALES_ERROR = 'GET_LOCALES_ERROR', + GET_LOCALES_SUCCESS = 'GET_LOCALES_SUCCESS', + GET_COUNTRIES_ERROR = 'GET_COUNTRIES_ERROR', + GET_COUNTRIES_SUCCESS = 'GET_COUNTRIES_SUCCESS', +} + +export default TYPES; diff --git a/packages/js/data/src/countries/actions.ts b/packages/js/data/src/countries/actions.ts new file mode 100644 index 00000000000..82cfef1351f --- /dev/null +++ b/packages/js/data/src/countries/actions.ts @@ -0,0 +1,34 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { Locales, Country } from './types'; +import { RestApiError } from '../types'; + +export function getLocalesSuccess( locales: Locales ) { + return { + type: TYPES.GET_LOCALES_SUCCESS, + locales, + }; +} + +export function getLocalesError( error: RestApiError ) { + return { + type: TYPES.GET_LOCALES_ERROR, + error, + }; +} + +export function getCountriesSuccess( countries: Country[] ) { + return { + type: TYPES.GET_COUNTRIES_SUCCESS, + countries, + }; +} + +export function getCountriesError( error: RestApiError ) { + return { + type: TYPES.GET_COUNTRIES_ERROR, + error, + }; +} diff --git a/packages/js/data/src/countries/constants.ts b/packages/js/data/src/countries/constants.ts new file mode 100644 index 00000000000..43aa63eca2f --- /dev/null +++ b/packages/js/data/src/countries/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'wc/admin/countries'; diff --git a/packages/js/data/src/countries/index.ts b/packages/js/data/src/countries/index.ts new file mode 100644 index 00000000000..d4ddfc132ff --- /dev/null +++ b/packages/js/data/src/countries/index.ts @@ -0,0 +1,25 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; +import { controls } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const COUNTRIES_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/countries/reducer.ts b/packages/js/data/src/countries/reducer.ts new file mode 100644 index 00000000000..5850a966f84 --- /dev/null +++ b/packages/js/data/src/countries/reducer.ts @@ -0,0 +1,60 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { CountriesState, Locales, Country } from './types'; + +const reducer = ( + state: CountriesState = { + errors: {}, + locales: {}, + countries: [], + }, + { + type, + error, + locales, + countries, + }: { + type: string; + error: string; + locales: Locales; + countries: Country[]; + } +): CountriesState => { + switch ( type ) { + case TYPES.GET_LOCALES_SUCCESS: + state = { + ...state, + locales, + }; + break; + case TYPES.GET_LOCALES_ERROR: + state = { + ...state, + errors: { + ...state.errors, + locales: error, + }, + }; + break; + case TYPES.GET_COUNTRIES_SUCCESS: + state = { + ...state, + countries, + }; + break; + case TYPES.GET_COUNTRIES_ERROR: + state = { + ...state, + errors: { + ...state.errors, + countries: error, + }, + }; + break; + } + return state; +}; + +export default reducer; diff --git a/packages/js/data/src/countries/resolvers.ts b/packages/js/data/src/countries/resolvers.ts new file mode 100644 index 00000000000..6a384500de7 --- /dev/null +++ b/packages/js/data/src/countries/resolvers.ts @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { apiFetch, select } from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { + getLocalesSuccess, + getLocalesError, + getCountriesSuccess, + getCountriesError, +} from './actions'; +import { NAMESPACE } from '../constants'; +import { Locales, Country } from './types'; +import { STORE_NAME } from './constants'; +import { RestApiError } from '../types'; + +const resolveSelect = + controls && controls.resolveSelect ? controls.resolveSelect : select; + +export function* getLocale() { + yield resolveSelect( STORE_NAME, 'getLocales' ); +} + +export function* getLocales() { + try { + const url = NAMESPACE + '/data/countries/locales'; + const results: Locales = yield apiFetch( { + path: url, + method: 'GET', + } ); + + return getLocalesSuccess( results ); + } catch ( error ) { + return getLocalesError( error as RestApiError ); + } +} + +export function* getCountry() { + yield resolveSelect( STORE_NAME, 'getCountries' ); +} + +export function* getCountries() { + try { + const url = NAMESPACE + '/data/countries'; + const results: Country[] = yield apiFetch( { + path: url, + method: 'GET', + } ); + + return getCountriesSuccess( results ); + } catch ( error ) { + return getCountriesError( error as RestApiError ); + } +} diff --git a/packages/js/data/src/countries/selectors.ts b/packages/js/data/src/countries/selectors.ts new file mode 100644 index 00000000000..13372f8e5b5 --- /dev/null +++ b/packages/js/data/src/countries/selectors.ts @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ +import { CountriesState } from './types'; + +export const getLocales = ( state: CountriesState ) => { + return state.locales; +}; + +export const getLocale = ( state: CountriesState, id: string ) => { + const country = id.split( ':' )[ 0 ]; + return state.locales[ country ]; +}; + +export const getCountries = ( state: CountriesState ) => { + return state.countries; +}; + +export const getCountry = ( state: CountriesState, code: string ) => { + return state.countries.find( ( country ) => country.code === code ); +}; diff --git a/packages/js/data/src/countries/types.ts b/packages/js/data/src/countries/types.ts new file mode 100644 index 00000000000..b3d64e64164 --- /dev/null +++ b/packages/js/data/src/countries/types.ts @@ -0,0 +1,42 @@ +export type SettingProperties = { + label?: string; + label_class?: string[]; + placeholder?: string; + class?: string[]; + autocomplete?: string; + priority?: number; + required?: boolean; + type?: string; +}; + +export type Locale = { + address_1?: SettingProperties; + address_2?: SettingProperties; + city?: SettingProperties; + company?: SettingProperties; + first_name?: SettingProperties; + last_name?: SettingProperties; + postcode?: SettingProperties; + state?: SettingProperties; +}; + +export type Country = { + code: string; + name: string; + states: Array< { + code: string; + name: string; + } >; +}; + +export type Locales = { + [ key: string ]: Locale; +}; + +export type CountriesState = { + errors: { + [ key: string ]: string; + }; + locales: Locales; + countries: Country[]; +}; diff --git a/packages/js/data/src/export/action-types.js b/packages/js/data/src/export/action-types.js new file mode 100644 index 00000000000..aae48cdd806 --- /dev/null +++ b/packages/js/data/src/export/action-types.js @@ -0,0 +1,8 @@ +const TYPES = { + START_EXPORT: 'START_EXPORT', + SET_EXPORT_ID: 'SET_EXPORT_ID', + SET_ERROR: 'SET_ERROR', + SET_IS_REQUESTING: 'SET_IS_REQUESTING', +}; + +export default TYPES; diff --git a/packages/js/data/src/export/actions.js b/packages/js/data/src/export/actions.js new file mode 100644 index 00000000000..4938af84971 --- /dev/null +++ b/packages/js/data/src/export/actions.js @@ -0,0 +1,64 @@ +/** + * Internal dependencies + */ +import { fetchWithHeaders } from '../controls'; +import TYPES from './action-types'; +import { NAMESPACE } from '../constants'; + +export function setExportId( exportType, exportArgs, exportId ) { + return { + type: TYPES.SET_EXPORT_ID, + exportType, + exportArgs, + exportId, + }; +} + +export function setIsRequesting( selector, selectorArgs, isRequesting ) { + return { + type: TYPES.SET_IS_REQUESTING, + selector, + selectorArgs, + isRequesting, + }; +} + +export function setError( selector, selectorArgs, error ) { + return { + type: TYPES.SET_ERROR, + selector, + selectorArgs, + error, + }; +} + +export function* startExport( type, args ) { + yield setIsRequesting( 'startExport', { type, args }, true ); + + try { + const response = yield fetchWithHeaders( { + path: `${ NAMESPACE }/reports/${ type }/export`, + method: 'POST', + data: { + report_args: args, + email: true, + }, + } ); + + yield setIsRequesting( 'startExport', { type, args }, false ); + + const { export_id: exportId, message } = response.data; + + if ( exportId ) { + yield setExportId( type, args, exportId ); + } else { + throw new Error( message ); + } + + return response.data; + } catch ( error ) { + yield setError( 'startExport', { type, args }, error.message ); + yield setIsRequesting( 'startExport', { type, args }, false ); + throw error; + } +} diff --git a/packages/js/data/src/export/constants.ts b/packages/js/data/src/export/constants.ts new file mode 100644 index 00000000000..4d6b20d281b --- /dev/null +++ b/packages/js/data/src/export/constants.ts @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +export const STORE_NAME = 'wc/admin/export'; diff --git a/packages/js/data/src/export/index.js b/packages/js/data/src/export/index.js new file mode 100644 index 00000000000..e23abf293d9 --- /dev/null +++ b/packages/js/data/src/export/index.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import controls from '../controls'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, +} ); + +export const EXPORT_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/export/reducer.js b/packages/js/data/src/export/reducer.js new file mode 100644 index 00000000000..c0abfcd60b2 --- /dev/null +++ b/packages/js/data/src/export/reducer.js @@ -0,0 +1,74 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { hashExportArgs } from './utils'; + +const exportReducer = ( + state = { + errors: {}, + requesting: {}, + exportMeta: {}, + exportIds: {}, + }, + { + error, + exportArgs, + exportId, + exportType, + isRequesting, + selector, + selectorArgs, + type, + } +) => { + switch ( type ) { + case TYPES.SET_IS_REQUESTING: + return { + ...state, + requesting: { + ...state.requesting, + [ selector ]: { + ...state.requesting[ selector ], + [ hashExportArgs( selectorArgs ) ]: isRequesting, + }, + }, + }; + case TYPES.SET_EXPORT_ID: + return { + ...state, + exportMeta: { + ...state.exportMeta, + [ exportId ]: { + exportType, + exportArgs, + }, + }, + exportIds: { + ...state.exportIds, + [ exportType ]: { + ...state.exportIds[ exportType ], + [ hashExportArgs( { + type: exportType, + args: exportArgs, + } ) ]: exportId, + }, + }, + }; + case TYPES.SET_ERROR: + return { + ...state, + errors: { + ...state.errors, + [ selector ]: { + ...state.errors[ selector ], + [ hashExportArgs( selectorArgs ) ]: error, + }, + }, + }; + default: + return state; + } +}; + +export default exportReducer; diff --git a/packages/js/data/src/export/selectors.js b/packages/js/data/src/export/selectors.js new file mode 100644 index 00000000000..fd593d2efd8 --- /dev/null +++ b/packages/js/data/src/export/selectors.js @@ -0,0 +1,25 @@ +/** + * Internal dependencies + */ +import { hashExportArgs } from './utils'; + +export const isExportRequesting = ( state, selector, selectorArgs ) => { + return Boolean( + state.requesting[ selector ] && + state.requesting[ selector ][ hashExportArgs( selectorArgs ) ] + ); +}; + +export const getExportId = ( state, exportType, exportArgs ) => { + return ( + state.exportIds[ exportType ] && + state.exportIds[ exportType ][ hashExportArgs( exportArgs ) ] + ); +}; + +export const getError = ( state, selector, selectorArgs ) => { + return ( + state.errors[ selector ] && + state.errors[ selector ][ hashExportArgs( selectorArgs ) ] + ); +}; diff --git a/packages/js/data/src/export/test/reducer.js b/packages/js/data/src/export/test/reducer.js new file mode 100644 index 00000000000..2513f6c6675 --- /dev/null +++ b/packages/js/data/src/export/test/reducer.js @@ -0,0 +1,96 @@ +/** + * @jest-environment node + */ + +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import TYPES from '../action-types'; +import { hashExportArgs } from '../utils'; + +const defaultState = { + errors: {}, + requesting: {}, + exportMeta: {}, + exportIds: {}, +}; + +describe( 'export reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( 'should handle SET_IS_REQUESTING', () => { + const selectorArgs = { + type: 'orders', + args: { + after: '2020-01-01T00:00:00', + before: '2019-12-31T23:59:59', + status_is: 'pending', + }, + }; + const state = reducer( defaultState, { + type: TYPES.SET_IS_REQUESTING, + selector: 'startExport', + selectorArgs, + isRequesting: true, + } ); + + expect( + state.requesting.startExport[ hashExportArgs( selectorArgs ) ] + ).toBe( true ); + } ); + + it( 'should handle SET_EXPORT_ID', () => { + const exportType = 'orders'; + const exportArgs = { + after: '2020-01-01T00:00:00', + before: '2019-12-31T23:59:59', + status_is: 'pending', + }; + const hashArgs = { + type: exportType, + args: exportArgs, + }; + const exportId = '15967352870671'; + const state = reducer( defaultState, { + type: TYPES.SET_EXPORT_ID, + exportType, + exportArgs, + exportId, + } ); + + expect( + state.exportIds[ exportType ][ hashExportArgs( hashArgs ) ] + ).toBe( exportId ); + expect( state.exportMeta[ exportId ] ).toEqual( { + exportType, + exportArgs, + } ); + } ); + + it( 'should handle SET_ERROR', () => { + const selectorArgs = { + type: 'orders', + args: { + after: '2020-01-01T00:00:00', + before: '2019-12-31T23:59:59', + status_is: 'pending', + }, + }; + const error = 'There is no data to export for the given request.'; + const state = reducer( defaultState, { + type: TYPES.SET_ERROR, + selector: 'startExport', + selectorArgs, + error, + } ); + + expect( + state.errors.startExport[ hashExportArgs( selectorArgs ) ] + ).toBe( error ); + } ); +} ); diff --git a/packages/js/data/src/export/utils.js b/packages/js/data/src/export/utils.js new file mode 100644 index 00000000000..0ed0b0726b6 --- /dev/null +++ b/packages/js/data/src/export/utils.js @@ -0,0 +1,13 @@ +/** + * External dependencies + */ +import md5 from 'md5'; + +/** + * Internal dependencies + */ +import { getResourceName } from '../utils'; + +export const hashExportArgs = ( args ) => { + return md5( getResourceName( 'export', args ) ); +}; diff --git a/packages/js/data/src/import/action-types.js b/packages/js/data/src/import/action-types.js new file mode 100644 index 00000000000..7804671eb76 --- /dev/null +++ b/packages/js/data/src/import/action-types.js @@ -0,0 +1,11 @@ +const TYPES = { + SET_IMPORT_DATE: 'SET_IMPORT_DATE', + SET_IMPORT_ERROR: 'SET_IMPORT_ERROR', + SET_IMPORT_PERIOD: 'SET_IMPORT_PERIOD', + SET_IMPORT_STARTED: 'SET_IMPORT_STARTED', + SET_IMPORT_STATUS: 'SET_IMPORT_STATUS', + SET_IMPORT_TOTALS: 'SET_IMPORT_TOTALS', + SET_SKIP_IMPORTED: 'SET_SKIP_IMPORTED', +}; + +export default TYPES; diff --git a/packages/js/data/src/import/actions.js b/packages/js/data/src/import/actions.js new file mode 100644 index 00000000000..e3f030f8746 --- /dev/null +++ b/packages/js/data/src/import/actions.js @@ -0,0 +1,71 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; + +export function setImportStarted( activeImport ) { + return { + type: TYPES.SET_IMPORT_STARTED, + activeImport, + }; +} + +export function setImportPeriod( date, dateModified ) { + if ( ! dateModified ) { + return { + type: TYPES.SET_IMPORT_PERIOD, + date, + }; + } + return { + type: TYPES.SET_IMPORT_DATE, + date, + }; +} + +export function setSkipPrevious( skipPrevious ) { + return { + type: TYPES.SET_SKIP_IMPORTED, + skipPrevious, + }; +} + +export function setImportStatus( query, importStatus ) { + return { + type: TYPES.SET_IMPORT_STATUS, + importStatus, + query, + }; +} + +export function setImportTotals( query, importTotals ) { + return { + type: TYPES.SET_IMPORT_TOTALS, + importTotals, + query, + }; +} + +export function setImportError( query, error ) { + return { + type: TYPES.SET_IMPORT_ERROR, + error, + query, + }; +} + +export function* updateImportation( path, importStarted = false ) { + yield setImportStarted( importStarted ); + try { + const response = yield apiFetch( { path, method: 'POST' } ); + return response; + } catch ( error ) { + yield setImportError( path, error ); + throw error; + } +} diff --git a/packages/js/data/src/import/constants.ts b/packages/js/data/src/import/constants.ts new file mode 100644 index 00000000000..ea3b564d927 --- /dev/null +++ b/packages/js/data/src/import/constants.ts @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +export const STORE_NAME = 'wc/admin/import'; diff --git a/packages/js/data/src/import/index.js b/packages/js/data/src/import/index.js new file mode 100644 index 00000000000..b6f6175fd6c --- /dev/null +++ b/packages/js/data/src/import/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; +import { controls } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const IMPORT_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/import/reducer.js b/packages/js/data/src/import/reducer.js new file mode 100644 index 00000000000..b09c5cd1f15 --- /dev/null +++ b/packages/js/data/src/import/reducer.js @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import moment from 'moment'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; + +const reducer = ( + state = { + activeImport: false, + importStatus: {}, + importTotals: {}, + errors: {}, + lastImportStartTimestamp: 0, + period: { + date: moment().format( __( 'MM/DD/YYYY', 'woocommerce' ) ), + label: 'all', + }, + skipPrevious: true, + }, + { + type, + query, + importStatus, + importTotals, + activeImport, + date, + error, + skipPrevious, + } +) => { + switch ( type ) { + case TYPES.SET_IMPORT_STARTED: + state = { + ...state, + activeImport, + lastImportStartTimestamp: activeImport + ? Date.now() + : state.lastImportStartTimestamp, + }; + break; + case TYPES.SET_IMPORT_PERIOD: + state = { + ...state, + period: { + ...state.period, + label: date, + }, + activeImport: false, + }; + break; + case TYPES.SET_IMPORT_DATE: + state = { + ...state, + period: { + date, + label: 'custom', + }, + activeImport: false, + }; + break; + case TYPES.SET_SKIP_IMPORTED: + state = { + ...state, + skipPrevious, + activeImport: false, + }; + break; + case TYPES.SET_IMPORT_STATUS: + state = { + ...state, + importStatus: { + ...state.importStatus, + [ JSON.stringify( query ) ]: importStatus, + }, + errors: { + ...state.errors, + [ JSON.stringify( query ) ]: false, + }, + }; + break; + case TYPES.SET_IMPORT_TOTALS: + state = { + ...state, + importTotals: { + ...state.importTotals, + [ JSON.stringify( query ) ]: importTotals, + }, + }; + break; + case TYPES.SET_IMPORT_ERROR: + state = { + ...state, + errors: { + ...state.errors, + [ JSON.stringify( query ) ]: error, + }, + }; + break; + } + return state; +}; + +export default reducer; diff --git a/packages/js/data/src/import/resolvers.js b/packages/js/data/src/import/resolvers.js new file mode 100644 index 00000000000..29c5c52cd3d --- /dev/null +++ b/packages/js/data/src/import/resolvers.js @@ -0,0 +1,38 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; +import { apiFetch } from '@wordpress/data-controls'; +import { omit } from 'lodash'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from '../constants'; +import { setImportError, setImportStatus, setImportTotals } from './actions'; + +export function* getImportStatus( query ) { + try { + const url = addQueryArgs( + `${ NAMESPACE }/reports/import/status`, + omit( query, [ 'timestamp' ] ) + ); + const response = yield apiFetch( { path: url } ); + yield setImportStatus( query, response ); + } catch ( error ) { + yield setImportError( query, error ); + } +} + +export function* getImportTotals( query ) { + try { + const url = addQueryArgs( + `${ NAMESPACE }/reports/import/totals`, + query + ); + const response = yield apiFetch( { path: url } ); + yield setImportTotals( query, response ); + } catch ( error ) { + yield setImportError( query, error ); + } +} diff --git a/packages/js/data/src/import/selectors.js b/packages/js/data/src/import/selectors.js new file mode 100644 index 00000000000..ee33226d6d1 --- /dev/null +++ b/packages/js/data/src/import/selectors.js @@ -0,0 +1,30 @@ +export const getImportStarted = ( state ) => { + const { activeImport, lastImportStartTimestamp } = state; + return { activeImport, lastImportStartTimestamp } || {}; +}; + +export const getFormSettings = ( state ) => { + const { period, skipPrevious } = state; + return { period, skipPrevious } || {}; +}; + +export const getImportStatus = ( state, query ) => { + const stringifiedQuery = JSON.stringify( query ); + return state.importStatus[ stringifiedQuery ] || {}; +}; + +export const getImportTotals = ( state, query ) => { + const { importTotals, lastImportStartTimestamp } = state; + const stringifiedQuery = JSON.stringify( query ); + return ( + { + ...importTotals[ stringifiedQuery ], + lastImportStartTimestamp, + } || {} + ); +}; + +export const getImportError = ( state, query ) => { + const stringifiedQuery = JSON.stringify( query ); + return state.errors[ stringifiedQuery ] || false; +}; diff --git a/packages/js/data/src/import/test/reducer.js b/packages/js/data/src/import/test/reducer.js new file mode 100644 index 00000000000..dfe88238343 --- /dev/null +++ b/packages/js/data/src/import/test/reducer.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ +import moment from 'moment'; + +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import TYPES from '../action-types'; + +const defaultState = { + activeImport: false, + importStatus: {}, + importTotals: {}, + errors: {}, + lastImportStartTimestamp: 0, + period: { + date: moment().format( 'MM/DD/YYYY' ), + label: 'all', + }, + skipPrevious: true, +}; + +describe( 'import reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( 'should handle SET_IMPORT_STATUS', () => { + const query = Date.now(); + const state = reducer( defaultState, { + type: TYPES.SET_IMPORT_STATUS, + query, + importStatus: { is_importing: false }, + } ); + const stringifiedQuery = JSON.stringify( query ); + expect( state.importStatus ).toHaveProperty( stringifiedQuery ); + expect( state.importStatus[ stringifiedQuery ].is_importing ).toEqual( + false + ); + } ); + + it( 'should handle SET_IMPORT_TOTALS', () => { + const query = { days: 90, skip_existing: true }; + const state = reducer( defaultState, { + type: TYPES.SET_IMPORT_TOTALS, + query, + importTotals: { + customers: 1, + orders: 6, + }, + } ); + const stringifiedQuery = JSON.stringify( query ); + + expect( state.importTotals ).toHaveProperty( stringifiedQuery ); + expect( state.importTotals[ stringifiedQuery ].customers ).toEqual( 1 ); + expect( state.importTotals[ stringifiedQuery ].orders ).toEqual( 6 ); + } ); + + it( 'should handle SET_IMPORT_STARTED', () => { + const activeImport = true; + const state = reducer( defaultState, { + type: TYPES.SET_IMPORT_STARTED, + activeImport, + } ); + + expect( state.activeImport ).toBeTruthy(); + expect( state.lastImportStartTimestamp > 0 ).toBeTruthy(); + } ); + + it( 'should handle SET_IMPORT_DATE', () => { + const date = '08/04/2020'; + const state = reducer( defaultState, { + type: TYPES.SET_IMPORT_DATE, + date, + } ); + + expect( state.period.date ).toEqual( date ); + expect( state.period.label ).toEqual( 'custom' ); + expect( state.activeImport ).toEqual( false ); + } ); + + it( 'should handle SET_IMPORT_PERIOD', () => { + defaultState.activeImport = true; + const date = '08/04/2020'; + const state = reducer( defaultState, { + type: TYPES.SET_IMPORT_PERIOD, + date, + } ); + expect( state.period.label ).toEqual( date ); + expect( state.activeImport ).toEqual( false ); + } ); + + it( 'should handle SET_SKIP_IMPORTED', () => { + const skipPrevious = false; + defaultState.activeImport = true; + const state = reducer( defaultState, { + type: TYPES.SET_SKIP_IMPORTED, + skipPrevious, + } ); + expect( state.skipPrevious ).toEqual( false ); + expect( state.activeImport ).toEqual( false ); + } ); + + it( 'should handle SET_IMPORT_ERROR', () => { + const query = 'test-import-error'; + const state = reducer( defaultState, { + type: TYPES.SET_IMPORT_ERROR, + query, + error: { code: 'error' }, + } ); + const stringifiedQuery = JSON.stringify( query ); + + expect( state.errors[ stringifiedQuery ].code ).toBe( 'error' ); + } ); +} ); diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts new file mode 100644 index 00000000000..1d9fbe86d29 --- /dev/null +++ b/packages/js/data/src/index.ts @@ -0,0 +1,144 @@ +/** + * External dependencies + */ +import '@wordpress/core-data'; + +/** + * Internal dependencies + */ +import type { REVIEWS_STORE_NAME } from './reviews'; +import type { SETTINGS_STORE_NAME } from './settings'; +import type { PLUGINS_STORE_NAME } from './plugins'; +import type { ONBOARDING_STORE_NAME } from './onboarding'; +import type { USER_STORE_NAME } from './user'; +import type { OPTIONS_STORE_NAME } from './options'; +import type { NAVIGATION_STORE_NAME } from './navigation'; +import type { NOTES_STORE_NAME } from './notes'; +import type { REPORTS_STORE_NAME } from './reports'; +import type { ITEMS_STORE_NAME } from './items'; +import type { COUNTRIES_STORE_NAME } from './countries'; +import type { PAYMENT_GATEWAYS_STORE_NAME } from './payment-gateways'; +import { OnboardingSelectors } from './onboarding/selectors'; +import { PaymentSelectors } from './payment-gateways/selectors'; +import { WPDataSelectors } from './types'; +import { PluginSelectors } from './plugins/selectors'; + +export * from './types'; +export { SETTINGS_STORE_NAME } from './settings'; +export { withSettingsHydration } from './settings/with-settings-hydration'; +export { useSettings } from './settings/use-settings'; + +export { PLUGINS_STORE_NAME } from './plugins'; +export type { Plugin } from './plugins/types'; +export type { InstallPluginsResponse } from './plugins/actions'; +export { ActionDispatchers as PluginsStoreActions } from './plugins/actions'; +export { pluginNames } from './plugins/constants'; +export { withPluginsHydration } from './plugins/with-plugins-hydration'; + +export { ONBOARDING_STORE_NAME } from './onboarding'; +export { withOnboardingHydration } from './onboarding/with-onboarding-hydration'; +export { getVisibleTasks } from './onboarding/utils'; +export type { TaskType, TaskListType } from './onboarding/types'; + +export { USER_STORE_NAME } from './user'; +export { withCurrentUserHydration } from './user/with-current-user-hydration'; +export { useUser } from './user/use-user'; +export { useUserPreferences } from './user/use-user-preferences'; + +export { OPTIONS_STORE_NAME } from './options'; +export { + withOptionsHydration, + useOptionsHydration, +} from './options/with-options-hydration'; + +export { REVIEWS_STORE_NAME } from './reviews'; + +export { NOTES_STORE_NAME } from './notes'; + +export { REPORTS_STORE_NAME } from './reports'; + +export { ITEMS_STORE_NAME } from './items'; +export { getLeaderboard, searchItemsByString } from './items/utils'; + +export { COUNTRIES_STORE_NAME } from './countries'; + +export { NAVIGATION_STORE_NAME } from './navigation'; +export { withNavigationHydration } from './navigation/with-navigation-hydration'; + +export { PAYMENT_GATEWAYS_STORE_NAME } from './payment-gateways'; + +export { + getFilterQuery, + getSummaryNumbers, + getReportTableData, + getReportTableQuery, + getReportChartData, + getTooltipValueFormat, +} from './reports/utils'; + +export { + MAX_PER_PAGE, + QUERY_DEFAULTS, + NAMESPACE, + WC_ADMIN_NAMESPACE, + WCS_NAMESPACE, + SECOND, + MINUTE, + HOUR, + DAY, + WEEK, + MONTH, +} from './constants'; + +export { EXPORT_STORE_NAME } from './export'; + +export { IMPORT_STORE_NAME } from './import'; + +export type WCDataStoreName = + | typeof REVIEWS_STORE_NAME + | typeof SETTINGS_STORE_NAME + | typeof PLUGINS_STORE_NAME + | typeof ONBOARDING_STORE_NAME + | typeof USER_STORE_NAME + | typeof OPTIONS_STORE_NAME + | typeof NAVIGATION_STORE_NAME + | typeof NOTES_STORE_NAME + | typeof REPORTS_STORE_NAME + | typeof ITEMS_STORE_NAME + | typeof COUNTRIES_STORE_NAME + | typeof PAYMENT_GATEWAYS_STORE_NAME; + +// 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. +export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME + ? WPDataSelectors + : T extends typeof SETTINGS_STORE_NAME + ? WPDataSelectors + : T extends typeof PLUGINS_STORE_NAME + ? PluginSelectors + : T extends typeof ONBOARDING_STORE_NAME + ? OnboardingSelectors + : T extends typeof PAYMENT_GATEWAYS_STORE_NAME + ? PaymentSelectors + : T extends typeof USER_STORE_NAME + ? WPDataSelectors + : T extends typeof OPTIONS_STORE_NAME + ? WPDataSelectors + : T extends typeof NAVIGATION_STORE_NAME + ? WPDataSelectors + : T extends typeof NOTES_STORE_NAME + ? WPDataSelectors + : T extends typeof REPORTS_STORE_NAME + ? WPDataSelectors + : T extends typeof ITEMS_STORE_NAME + ? WPDataSelectors + : T extends typeof COUNTRIES_STORE_NAME + ? WPDataSelectors + : never; + +export interface WCDataSelector { + < T extends WCDataStoreName >( storeName: T ): WCSelectorType< T >; +} +export * from './onboarding/selectors'; +export * from './onboarding/types'; +export * from './countries/types'; diff --git a/packages/js/data/src/items/action-types.js b/packages/js/data/src/items/action-types.js new file mode 100644 index 00000000000..4bea03c6d13 --- /dev/null +++ b/packages/js/data/src/items/action-types.js @@ -0,0 +1,8 @@ +const TYPES = { + SET_ITEM: 'SET_ITEM', + SET_ITEMS: 'SET_ITEMS', + SET_ITEMS_TOTAL_COUNT: 'SET_ITEMS_TOTAL_COUNT', + SET_ERROR: 'SET_ERROR', +}; + +export default TYPES; diff --git a/packages/js/data/src/items/actions.js b/packages/js/data/src/items/actions.js new file mode 100644 index 00000000000..71662a367f9 --- /dev/null +++ b/packages/js/data/src/items/actions.js @@ -0,0 +1,100 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { NAMESPACE, WC_ADMIN_NAMESPACE } from '../constants'; + +export function setItem( itemType, id, item ) { + return { + type: TYPES.SET_ITEM, + id, + item, + itemType, + }; +} + +export function setItems( itemType, query, items, totalCount ) { + return { + type: TYPES.SET_ITEMS, + items, + itemType, + query, + totalCount, + }; +} + +export function setItemsTotalCount( itemType, query, totalCount ) { + return { + type: TYPES.SET_ITEMS_TOTAL_COUNT, + itemType, + query, + totalCount, + }; +} + +export function setError( itemType, query, error ) { + return { + type: TYPES.SET_ERROR, + itemType, + query, + error, + }; +} + +export function* updateProductStock( product, quantity ) { + const updatedProduct = { ...product, stock_quantity: quantity }; + const { id, parent_id: parentId, type } = updatedProduct; + + // Optimistically update product stock. + yield setItem( 'products', id, updatedProduct ); + + let url = NAMESPACE; + + switch ( type ) { + case 'variation': + url += `/products/${ parentId }/variations/${ id }`; + break; + case 'variable': + case 'simple': + default: + url += `/products/${ id }`; + } + try { + yield apiFetch( { + path: url, + method: 'PUT', + data: updatedProduct, + } ); + return true; + } catch ( error ) { + // Update failed, return product back to original state. + yield setItem( 'products', id, product ); + yield setError( 'products', id, error ); + return false; + } +} + +export function* createProductFromTemplate( itemFields, query ) { + try { + const url = addQueryArgs( + `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/create_product_from_template`, + query || {} + ); + const newItem = yield apiFetch( { + path: url, + method: 'POST', + data: itemFields, + } ); + yield setItem( 'products', newItem.id, newItem ); + return newItem; + } catch ( error ) { + yield setError( 'createProductFromTemplate', query, error ); + throw error; + } +} diff --git a/packages/js/data/src/items/constants.ts b/packages/js/data/src/items/constants.ts new file mode 100644 index 00000000000..dbe32f31d2e --- /dev/null +++ b/packages/js/data/src/items/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'wc/admin/items'; diff --git a/packages/js/data/src/items/index.js b/packages/js/data/src/items/index.js new file mode 100644 index 00000000000..7b3ca7e73fa --- /dev/null +++ b/packages/js/data/src/items/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import controls from '../controls'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const ITEMS_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/items/reducer.js b/packages/js/data/src/items/reducer.js new file mode 100644 index 00000000000..60707994250 --- /dev/null +++ b/packages/js/data/src/items/reducer.js @@ -0,0 +1,79 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { getResourceName } from '../utils'; +import { getTotalCountResourceName } from './utils'; + +const reducer = ( + state = { + items: {}, + errors: {}, + data: {}, + }, + { type, id, itemType, query, item, items, totalCount, error } +) => { + switch ( type ) { + case TYPES.SET_ITEM: + const itemData = state.data[ itemType ] || {}; + return { + ...state, + data: { + ...state.data, + [ itemType ]: { + ...itemData, + [ id ]: { + ...( itemData[ id ] || {} ), + ...item, + }, + }, + }, + }; + case TYPES.SET_ITEMS: + const ids = []; + const nextItems = items.reduce( ( result, theItem ) => { + ids.push( theItem.id ); + result[ theItem.id ] = theItem; + return result; + }, {} ); + const resourceName = getResourceName( itemType, query ); + return { + ...state, + items: { + ...state.items, + [ resourceName ]: { data: ids }, + }, + data: { + ...state.data, + [ itemType ]: { + ...state.data[ itemType ], + ...nextItems, + }, + }, + }; + case TYPES.SET_ITEMS_TOTAL_COUNT: + const totalResourceName = getTotalCountResourceName( + itemType, + query + ); + return { + ...state, + items: { + ...state.items, + [ totalResourceName ]: totalCount, + }, + }; + case TYPES.SET_ERROR: + return { + ...state, + errors: { + ...state.errors, + [ getResourceName( itemType, query ) ]: error, + }, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/packages/js/data/src/items/resolvers.js b/packages/js/data/src/items/resolvers.js new file mode 100644 index 00000000000..1d140f9b4b1 --- /dev/null +++ b/packages/js/data/src/items/resolvers.js @@ -0,0 +1,59 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; +import { apiFetch } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from '../constants'; +import { setError, setItems, setItemsTotalCount } from './actions'; +import { fetchWithHeaders } from '../controls'; + +function* request( itemType, query ) { + const endpoint = + itemType === 'categories' ? 'products/categories' : itemType; + const url = addQueryArgs( `${ NAMESPACE }/${ endpoint }`, query ); + const isUnboundedRequest = query.per_page === -1; + const fetch = isUnboundedRequest ? apiFetch : fetchWithHeaders; + const response = yield fetch( { + path: url, + method: 'GET', + } ); + + if ( isUnboundedRequest ) { + return { items: response, totalCount: response.length }; + } + const totalCount = parseInt( response.headers.get( 'x-wp-total' ), 10 ); + + return { items: response.data, totalCount }; +} + +export function* getItems( itemType, query ) { + try { + const { items, totalCount } = yield request( itemType, query ); + yield setItemsTotalCount( itemType, query, totalCount ); + yield setItems( itemType, query, items ); + } catch ( error ) { + yield setError( itemType, query, error ); + } +} + +export function* getReviewsTotalCount( itemType, query ) { + yield getItemsTotalCount( itemType, query ); +} + +export function* getItemsTotalCount( itemType, query ) { + try { + const totalsQuery = { + ...query, + page: 1, + per_page: 1, + }; + const { totalCount } = yield request( itemType, totalsQuery ); + yield setItemsTotalCount( itemType, query, totalCount ); + } catch ( error ) { + yield setError( itemType, query, error ); + } +} diff --git a/packages/js/data/src/items/selectors.js b/packages/js/data/src/items/selectors.js new file mode 100644 index 00000000000..5e019fe4334 --- /dev/null +++ b/packages/js/data/src/items/selectors.js @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; + +/** + * Internal dependencies + */ +import { getResourceName } from '../utils'; +import { getTotalCountResourceName } from './utils'; + +export const getItems = createSelector( + ( state, itemType, query, defaultValue = new Map() ) => { + const resourceName = getResourceName( itemType, query ); + const ids = + state.items[ resourceName ] && state.items[ resourceName ].data; + if ( ! ids ) { + return defaultValue; + } + return ids.reduce( ( map, id ) => { + map.set( id, state.data[ itemType ][ id ] ); + return map; + }, new Map() ); + }, + ( state, itemType, query ) => { + const resourceName = getResourceName( itemType, query ); + return [ state.items[ resourceName ] ]; + } +); + +export const getItemsTotalCount = ( + state, + itemType, + query, + defaultValue = 0 +) => { + const resourceName = getTotalCountResourceName( itemType, query ); + const totalCount = state.items.hasOwnProperty( resourceName ) + ? state.items[ resourceName ] + : defaultValue; + return totalCount; +}; + +export const getItemsError = ( state, itemType, query ) => { + const resourceName = getResourceName( itemType, query ); + return state.errors[ resourceName ]; +}; diff --git a/packages/js/data/src/items/test/reducer.js b/packages/js/data/src/items/test/reducer.js new file mode 100644 index 00000000000..e7c73e40677 --- /dev/null +++ b/packages/js/data/src/items/test/reducer.js @@ -0,0 +1,146 @@ +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import TYPES from '../action-types'; +import { getResourceName } from '../../utils'; +import { getTotalCountResourceName } from '../utils'; + +const defaultState = { + items: {}, + errors: {}, + data: {}, +}; + +describe( 'items reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( 'should handle SET_ITEM', () => { + const itemType = 'guyisms'; + const initialState = { + items: { + [ itemType ]: { + data: [ 1, 2 ], + }, + 'total-guyisms:{}': 2, + }, + errors: {}, + data: { + [ itemType ]: { + 1: { id: 1, title: 'Donkey', status: 'flavortown' }, + 2: { id: 2, title: 'Sauce', status: 'flavortown' }, + }, + }, + }; + const update = { + id: 2, + status: 'bomb dot com', + }; + + const state = reducer( initialState, { + type: TYPES.SET_ITEM, + id: update.id, + item: update, + itemType, + } ); + + expect( state.items ).toEqual( initialState.items ); + expect( state.errors ).toEqual( initialState.errors ); + + expect( state.data[ itemType ][ '1' ] ).toEqual( + initialState.data[ itemType ][ '1' ] + ); + expect( state.data[ itemType ][ '2' ].id ).toEqual( + initialState.data[ itemType ][ '2' ].id + ); + expect( state.data[ itemType ][ '2' ].title ).toEqual( + initialState.data[ itemType ][ '2' ].title + ); + expect( state.data[ itemType ][ '2' ].status ).toEqual( update.status ); + } ); + + it( 'should handle SET_ITEMS', () => { + const items = [ + { id: 1, title: 'Yum!' }, + { id: 2, title: 'Dynamite!' }, + ]; + const totalCount = 45; + const query = { status: 'flavortown' }; + const itemType = 'BBQ'; + const state = reducer( defaultState, { + type: TYPES.SET_ITEMS, + items, + itemType, + query, + totalCount, + } ); + + const resourceName = getResourceName( itemType, query ); + + expect( state.items[ resourceName ].data ).toHaveLength( 2 ); + expect( state.items[ resourceName ].data.includes( 1 ) ).toBeTruthy(); + expect( state.items[ resourceName ].data.includes( 2 ) ).toBeTruthy(); + + expect( state.data[ itemType ][ '1' ] ).toBe( items[ 0 ] ); + expect( state.data[ itemType ][ '2' ] ).toBe( items[ 1 ] ); + } ); + + it( 'should handle SET_ITEMS_TOTAL_COUNT', () => { + const itemType = 'BBQ'; + const initialQuery = { + status: 'flavortown', + page: 1, + per_page: 1, + _fields: [ 'id' ], + }; + const resourceName = getTotalCountResourceName( + itemType, + initialQuery + ); + const initialState = { + items: { + [ resourceName ]: 1, + }, + }; + + // Additional coverage for getTotalCountResourceName(). + const similarQueryForTotals = { + status: 'flavortown', + page: 2, + per_page: 10, + _fields: [ 'id', 'title', 'status' ], + }; + + const state = reducer( initialState, { + type: TYPES.SET_ITEMS_TOTAL_COUNT, + itemType, + query: similarQueryForTotals, + totalCount: 2, + } ); + + expect( state ).toEqual( { + items: { + [ resourceName ]: 2, + }, + } ); + } ); + + it( 'should handle SET_ERROR', () => { + const query = { status: 'flavortown' }; + const itemType = 'BBQ'; + const resourceName = getResourceName( itemType, query ); + const error = 'Baaam!'; + const state = reducer( defaultState, { + type: TYPES.SET_ERROR, + itemType, + query, + error, + } ); + + expect( state.errors[ resourceName ] ).toBe( error ); + } ); +} ); diff --git a/packages/js/data/src/items/test/utils.js b/packages/js/data/src/items/test/utils.js new file mode 100644 index 00000000000..c4450acae34 --- /dev/null +++ b/packages/js/data/src/items/test/utils.js @@ -0,0 +1,46 @@ +/** + * Internal dependencies + */ +import { getTotalCountResourceName } from '../utils'; + +describe( 'getTotalCountResourceName()', () => { + it( "Ignores query params that don't affect total counts", () => { + const fullQuery = { + page: 2, + per_page: 10, + _fields: [ 'id', 'title', 'status', 'image', 'quantity', 'price' ], + status: 'publish', + }; + + const slimQuery = { + page: 1, + per_page: 1, + _fields: [ 'id' ], + status: 'publish', + }; + + expect( getTotalCountResourceName( 'test', fullQuery ) ).toEqual( + getTotalCountResourceName( 'test', slimQuery ) + ); + } ); + + it( 'Accounts for query params that do affect total counts', () => { + const firstQuery = { + page: 2, + per_page: 10, + _fields: [ 'id', 'title', 'status', 'image', 'quantity', 'price' ], + status: 'publish', + }; + + const secondQuery = { + page: 1, + per_page: 1, + _fields: [ 'id' ], + status: 'draft', + }; + + expect( getTotalCountResourceName( 'test', firstQuery ) ).not.toEqual( + getTotalCountResourceName( 'test', secondQuery ) + ); + } ); +} ); diff --git a/packages/js/data/src/items/utils.js b/packages/js/data/src/items/utils.js new file mode 100644 index 00000000000..085270c0624 --- /dev/null +++ b/packages/js/data/src/items/utils.js @@ -0,0 +1,121 @@ +/** + * External dependencies + */ +import { appendTimestamp, getCurrentDates } from '@woocommerce/date'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import { getResourceName } from '../utils'; + +/** + * Returns leaderboard data to render a leaderboard table. + * + * @param {Object} options arguments + * @param {string} options.id Leaderboard ID + * @param {number} options.per_page Per page limit + * @param {Object} options.persisted_query Persisted query passed to endpoint + * @param {Object} options.query Query parameters in the url + * @param {Object} options.select Instance of @wordpress/select + * @param {string} options.defaultDateRange User specified default date range. + * @return {Object} Object containing leaderboard responses. + */ +export function getLeaderboard( options ) { + const endpoint = 'leaderboards'; + const { + per_page: perPage, + persisted_query: persistedQuery, + query, + select, + filterQuery, + } = options; + const { getItems, getItemsError, isResolving } = select( STORE_NAME ); + const response = { + isRequesting: false, + isError: false, + rows: [], + }; + + const datesFromQuery = getCurrentDates( query, options.defaultDateRange ); + const leaderboardQuery = { + ...filterQuery, + after: appendTimestamp( datesFromQuery.primary.after, 'start' ), + before: appendTimestamp( datesFromQuery.primary.before, 'end' ), + per_page: perPage, + persisted_query: JSON.stringify( persistedQuery ), + }; + + // Disable eslint rule requiring `getItems` to be defined below because the next two statements + // depend on `getItems` to have been called. + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const leaderboards = getItems( endpoint, leaderboardQuery ); + + if ( isResolving( 'getItems', [ endpoint, leaderboardQuery ] ) ) { + return { ...response, isRequesting: true }; + } else if ( getItemsError( endpoint, leaderboardQuery ) ) { + return { ...response, isError: true }; + } + + const leaderboard = leaderboards.get( options.id ); + return { ...response, rows: leaderboard?.rows }; +} +/** + * Returns items based on a search query. + * + * @param {Object} selector Instance of @wordpress/select response + * @param {string} endpoint Report API Endpoint + * @param {string[]} search Array of search strings. + * @param {Object} options Query options. + * @return {Object} Object containing API request information and the matching items. + */ +export function searchItemsByString( + selector, + endpoint, + search, + options = {} +) { + const { getItems, getItemsError, isResolving } = selector; + + const items = {}; + let isRequesting = false; + let isError = false; + search.forEach( ( searchWord ) => { + const query = { + search: searchWord, + per_page: 10, + ...options, + }; + const newItems = getItems( endpoint, query ); + newItems.forEach( ( item, id ) => { + items[ id ] = item; + } ); + if ( isResolving( 'getItems', [ endpoint, query ] ) ) { + isRequesting = true; + } + if ( getItemsError( endpoint, query ) ) { + isError = true; + } + } ); + + return { items, isRequesting, isError }; +} + +/** + * Generate a resource name for item totals count. + * + * It omits query parameters from the identifier that don't affect + * totals values like pagination and response field filtering. + * + * @param {string} itemType Item type for totals count. + * @param {Object} query Query for item totals count. + * @return {string} Resource name for item totals. + */ +export function getTotalCountResourceName( itemType, query ) { + // Disable eslint rule because we're using this spread to omit properties + // that don't affect item totals count results. + // eslint-disable-next-line no-unused-vars, camelcase + const { _fields, page, per_page, ...totalsQuery } = query; + + return getResourceName( 'total-' + itemType, totalsQuery ); +} diff --git a/packages/js/data/src/navigation/action-types.js b/packages/js/data/src/navigation/action-types.js new file mode 100644 index 00000000000..2735b2e892d --- /dev/null +++ b/packages/js/data/src/navigation/action-types.js @@ -0,0 +1,16 @@ +const TYPES = { + ADD_MENU_ITEMS: 'ADD_MENU_ITEMS', + SET_MENU_ITEMS: 'SET_MENU_ITEMS', + ON_HISTORY_CHANGE: 'ON_HISTORY_CHANGE', + ADD_FAVORITE_FAILURE: 'ADD_FAVORITE_FAILURE', + ADD_FAVORITE_REQUEST: 'ADD_FAVORITE_REQUEST', + ADD_FAVORITE_SUCCESS: 'ADD_FAVORITE_SUCCESS', + GET_FAVORITES_FAILURE: 'GET_FAVORITES_FAILURE', + GET_FAVORITES_REQUEST: 'GET_FAVORITES_REQUEST', + GET_FAVORITES_SUCCESS: 'GET_FAVORITES_SUCCESS', + REMOVE_FAVORITE_FAILURE: 'REMOVE_FAVORITE_FAILURE', + REMOVE_FAVORITE_REQUEST: 'REMOVE_FAVORITE_REQUEST', + REMOVE_FAVORITE_SUCCESS: 'REMOVE_FAVORITE_SUCCESS', +}; + +export default TYPES; diff --git a/packages/js/data/src/navigation/actions.js b/packages/js/data/src/navigation/actions.js new file mode 100644 index 00000000000..a4c748c65db --- /dev/null +++ b/packages/js/data/src/navigation/actions.js @@ -0,0 +1,156 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { getPersistedQuery } from '@woocommerce/navigation'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { WC_ADMIN_NAMESPACE } from '../constants'; + +export function setMenuItems( menuItems ) { + return { + type: TYPES.SET_MENU_ITEMS, + menuItems, + }; +} + +export function addMenuItems( menuItems ) { + return { + type: TYPES.ADD_MENU_ITEMS, + menuItems, + }; +} + +export function getFavoritesFailure( error ) { + return { + type: TYPES.GET_FAVORITES_FAILURE, + error, + }; +} + +export function getFavoritesRequest( favorites ) { + return { + type: TYPES.GET_FAVORITES_REQUEST, + favorites, + }; +} + +export function getFavoritesSuccess( favorites ) { + return { + type: TYPES.GET_FAVORITES_SUCCESS, + favorites, + }; +} + +export function addFavoriteRequest( favorite ) { + return { + type: TYPES.ADD_FAVORITE_REQUEST, + favorite, + }; +} + +export function addFavoriteFailure( favorite, error ) { + return { + type: TYPES.ADD_FAVORITE_FAILURE, + favorite, + error, + }; +} + +export function addFavoriteSuccess( favorite ) { + return { + type: TYPES.ADD_FAVORITE_SUCCESS, + favorite, + }; +} + +export function removeFavoriteRequest( favorite ) { + return { + type: TYPES.REMOVE_FAVORITE_REQUEST, + favorite, + }; +} + +export function removeFavoriteFailure( favorite, error ) { + return { + type: TYPES.REMOVE_FAVORITE_FAILURE, + favorite, + error, + }; +} + +export function removeFavoriteSuccess( favorite, error ) { + return { + type: TYPES.REMOVE_FAVORITE_SUCCESS, + favorite, + error, + }; +} + +export function* onLoad() { + yield onHistoryChange(); +} + +export function* onHistoryChange() { + const persistedQuery = getPersistedQuery(); + + if ( ! Object.keys( persistedQuery ).length ) { + return null; + } + + yield { + type: TYPES.ON_HISTORY_CHANGE, + persistedQuery, + }; +} + +export function* addFavorite( favorite ) { + yield addFavoriteRequest( favorite ); + + try { + const results = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/navigation/favorites/me`, + method: 'POST', + data: { + item_id: favorite, + }, + } ); + + if ( results ) { + yield addFavoriteSuccess( favorite ); + return results; + } + + throw new Error(); + } catch ( error ) { + yield addFavoriteFailure( favorite, error ); + throw new Error(); + } +} + +export function* removeFavorite( favorite ) { + yield removeFavoriteRequest( favorite ); + + try { + const results = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/navigation/favorites/me`, + method: 'DELETE', + data: { + item_id: favorite, + }, + } ); + + if ( results ) { + yield removeFavoriteSuccess( favorite ); + return results; + } + + throw new Error(); + } catch ( error ) { + yield removeFavoriteFailure( favorite, error ); + throw new Error(); + } +} diff --git a/packages/js/data/src/navigation/constants.ts b/packages/js/data/src/navigation/constants.ts new file mode 100644 index 00000000000..cc7f6a71b7f --- /dev/null +++ b/packages/js/data/src/navigation/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'woocommerce-navigation'; diff --git a/packages/js/data/src/navigation/dispatchers.js b/packages/js/data/src/navigation/dispatchers.js new file mode 100644 index 00000000000..62cc877f149 --- /dev/null +++ b/packages/js/data/src/navigation/dispatchers.js @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { dispatch } from '@wordpress/data'; +import { addHistoryListener } from '@woocommerce/navigation'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +export default async () => { + const { onLoad, onHistoryChange } = dispatch( STORE_NAME ); + + await onLoad(); + + addHistoryListener( async () => { + setTimeout( async () => { + await onHistoryChange(); + }, 0 ); + } ); +}; diff --git a/packages/js/data/src/navigation/index.js b/packages/js/data/src/navigation/index.js new file mode 100644 index 00000000000..c18c238591c --- /dev/null +++ b/packages/js/data/src/navigation/index.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import { controls } from '@wordpress/data-controls'; +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import reducer from './reducer'; +import * as resolvers from './resolvers'; +import initDispatchers from './dispatchers'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + resolvers, + selectors, +} ); + +initDispatchers(); +export const NAVIGATION_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/navigation/reducer.js b/packages/js/data/src/navigation/reducer.js new file mode 100644 index 00000000000..39b7f46009e --- /dev/null +++ b/packages/js/data/src/navigation/reducer.js @@ -0,0 +1,151 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; + +const reducer = ( + state = { + error: null, + menuItems: [], + favorites: [], + requesting: {}, + persistedQuery: {}, + }, + { type, error, favorite, favorites, menuItems, persistedQuery } +) => { + switch ( type ) { + case TYPES.SET_MENU_ITEMS: + state = { + ...state, + menuItems, + }; + break; + case TYPES.ADD_MENU_ITEMS: + state = { + ...state, + menuItems: [ ...state.menuItems, ...menuItems ], + }; + break; + case TYPES.ON_HISTORY_CHANGE: + state = { + ...state, + persistedQuery, + }; + break; + case TYPES.GET_FAVORITES_FAILURE: + state = { + ...state, + requesting: { + ...state.requesting, + getFavorites: false, + }, + }; + break; + case TYPES.GET_FAVORITES_REQUEST: + state = { + ...state, + requesting: { + ...state.requesting, + getFavorites: true, + }, + }; + break; + case TYPES.GET_FAVORITES_SUCCESS: + state = { + ...state, + favorites, + requesting: { + ...state.requesting, + getFavorites: false, + }, + }; + break; + case TYPES.ADD_FAVORITE_FAILURE: + state = { + ...state, + error, + requesting: { + ...state.requesting, + addFavorite: false, + }, + }; + break; + case TYPES.ADD_FAVORITE_REQUEST: + state = { + ...state, + requesting: { + ...state.requesting, + addFavorite: true, + }, + }; + break; + case TYPES.ADD_FAVORITE_SUCCESS: + const newFavorites = ! state.favorites.includes( favorite ) + ? [ ...state.favorites, favorite ] + : state.favorites; + + state = { + ...state, + favorites: newFavorites, + menuItems: state.menuItems.map( ( item ) => { + if ( item.id === favorite ) { + return { + ...item, + menuId: 'favorites', + }; + } + return item; + } ), + requesting: { + ...state.requesting, + addFavorite: false, + }, + }; + break; + case TYPES.REMOVE_FAVORITE_FAILURE: + state = { + ...state, + requesting: { + ...state.requesting, + error, + removeFavorite: false, + }, + }; + break; + case TYPES.REMOVE_FAVORITE_REQUEST: + state = { + ...state, + requesting: { + ...state.requesting, + removeFavorite: true, + }, + }; + break; + case TYPES.REMOVE_FAVORITE_SUCCESS: + const filteredFavorites = state.favorites.filter( + ( f ) => f !== favorite + ); + + state = { + ...state, + favorites: filteredFavorites, + menuItems: state.menuItems.map( ( item ) => { + if ( item.id === favorite ) { + return { + ...item, + menuId: 'plugins', + }; + } + return item; + } ), + requesting: { + ...state.requesting, + removeFavorite: false, + }, + }; + break; + } + return state; +}; + +export default reducer; diff --git a/packages/js/data/src/navigation/resolvers.js b/packages/js/data/src/navigation/resolvers.js new file mode 100644 index 00000000000..0f87a184290 --- /dev/null +++ b/packages/js/data/src/navigation/resolvers.js @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { + getFavoritesFailure, + getFavoritesRequest, + getFavoritesSuccess, +} from './actions'; +import { WC_ADMIN_NAMESPACE } from '../constants'; + +export function* getFavorites() { + yield getFavoritesRequest(); + + try { + const results = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/navigation/favorites/me`, + } ); + + if ( results ) { + yield getFavoritesSuccess( results ); + return; + } + + throw new Error(); + } catch ( error ) { + yield getFavoritesFailure( error ); + throw new Error(); + } +} diff --git a/packages/js/data/src/navigation/selectors.js b/packages/js/data/src/navigation/selectors.js new file mode 100644 index 00000000000..1579631eb97 --- /dev/null +++ b/packages/js/data/src/navigation/selectors.js @@ -0,0 +1,28 @@ +/** + * External dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +const MENU_ITEMS_HOOK = 'woocommerce_navigation_menu_items'; + +export const getMenuItems = ( state ) => { + /** + * Navigation Menu Items. + * + * @filter woocommerce_navigation_menu_items + * @param {Array.<Object>} menuItems Array of Navigation menu items. + */ + return applyFilters( MENU_ITEMS_HOOK, state.menuItems ); +}; + +export const getFavorites = ( state ) => { + return state.favorites || []; +}; + +export const isNavigationRequesting = ( state, selector ) => { + return state.requesting[ selector ] || false; +}; + +export const getPersistedQuery = ( state ) => { + return state.persistedQuery || {}; +}; diff --git a/packages/js/data/src/navigation/test/reducer.js b/packages/js/data/src/navigation/test/reducer.js new file mode 100644 index 00000000000..ccb6fbe6cbb --- /dev/null +++ b/packages/js/data/src/navigation/test/reducer.js @@ -0,0 +1,121 @@ +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import TYPES from '../action-types'; + +const defaultState = { + error: null, + menuItems: [], + favorites: [], + requesting: {}, + persistedQuery: {}, +}; + +describe( 'navigation reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( "should set a menu's items", () => { + const state = reducer( defaultState, { + type: TYPES.SET_MENU_ITEMS, + menuItems: [ + { + id: 'menu-item-1', + title: 'Menu Item 1', + menuId: 'primary', + }, + { + id: 'menu-item-2', + title: 'Menu Item 2', + menuId: 'primary', + }, + { + id: 'menu-item-3', + title: 'Menu Item 3', + menuId: 'secondary', + }, + ], + } ); + + expect( state.menuItems.length ).toBe( 3 ); + expect( state.menuItems[ 0 ].id ).toBe( 'menu-item-1' ); + expect( state.menuItems[ 1 ].id ).toBe( 'menu-item-2' ); + expect( state.menuItems[ 2 ].id ).toBe( 'menu-item-3' ); + } ); + + it( 'should add menu items', () => { + const state = reducer( + { + menuItems: [ + { + id: 'menu-item-1', + title: 'Menu Item 1', + menuId: 'primary', + }, + ], + }, + { + type: TYPES.ADD_MENU_ITEMS, + menuItems: [ + { + id: 'menu-item-2', + title: 'Menu Item 2', + menuId: 'primary', + }, + ], + } + ); + + expect( state.menuItems.length ).toBe( 2 ); + expect( state.menuItems[ 0 ].id ).toBe( 'menu-item-1' ); + expect( state.menuItems[ 1 ].id ).toBe( 'menu-item-2' ); + } ); + + it( 'should set the favorites', () => { + const favorites = [ 'favorite1', 'favorite2' ]; + const state = reducer( defaultState, { + type: TYPES.GET_FAVORITES_SUCCESS, + favorites, + } ); + + expect( state.favorites ).toEqual( favorites ); + } ); + + it( 'should add a favorite', () => { + const state = reducer( + { + ...defaultState, + favorites: [ 'favorite1', 'favorite2' ], + }, + { + type: TYPES.ADD_FAVORITE_SUCCESS, + favorite: 'favorite3', + } + ); + + expect( state.favorites ).toEqual( [ + 'favorite1', + 'favorite2', + 'favorite3', + ] ); + } ); + + it( 'should remove a favorite', () => { + const state = reducer( + { + ...defaultState, + favorites: [ 'favorite1', 'favorite2' ], + }, + { + type: TYPES.REMOVE_FAVORITE_SUCCESS, + favorite: 'favorite2', + } + ); + + expect( state.favorites ).toEqual( [ 'favorite1' ] ); + } ); +} ); diff --git a/packages/js/data/src/navigation/with-navigation-hydration.js b/packages/js/data/src/navigation/with-navigation-hydration.js new file mode 100644 index 00000000000..60d5341614d --- /dev/null +++ b/packages/js/data/src/navigation/with-navigation-hydration.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { createElement, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +/** + * Higher-order component used to hydrate navigation data. + * + * @param {Object} data Data object with menu items and site information. + */ +export const withNavigationHydration = ( data ) => + createHigherOrderComponent( + ( OriginalComponent ) => ( props ) => { + const dataRef = useRef( data ); + + useSelect( ( select, registry ) => { + if ( ! dataRef.current ) { + return; + } + + const { isResolving, hasFinishedResolution } = select( + STORE_NAME + ); + const { + startResolution, + finishResolution, + setMenuItems, + } = registry.dispatch( STORE_NAME ); + + if ( + ! isResolving( 'getMenuItems' ) && + ! hasFinishedResolution( 'getMenuItems' ) + ) { + startResolution( 'getMenuItems', [] ); + setMenuItems( dataRef.current.menuItems ); + finishResolution( 'getMenuItems', [] ); + } + } ); + + return <OriginalComponent { ...props } />; + }, + 'withNavigationHydration' + ); diff --git a/packages/js/data/src/notes/action-types.js b/packages/js/data/src/notes/action-types.js new file mode 100644 index 00000000000..cadeb2cb871 --- /dev/null +++ b/packages/js/data/src/notes/action-types.js @@ -0,0 +1,10 @@ +const TYPES = { + SET_ERROR: 'SET_ERROR', + SET_NOTE: 'SET_NOTE', + SET_NOTE_IS_UPDATING: 'SET_NOTE_IS_UPDATING', + SET_NOTES: 'SET_NOTES', + SET_NOTES_QUERY: 'SET_NOTES_QUERY', + SET_IS_REQUESTING: 'SET_IS_REQUESTING', +}; + +export default TYPES; diff --git a/packages/js/data/src/notes/actions.js b/packages/js/data/src/notes/actions.js new file mode 100644 index 00000000000..5fefc15f901 --- /dev/null +++ b/packages/js/data/src/notes/actions.js @@ -0,0 +1,154 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from '../constants'; +import TYPES from './action-types'; + +export function* triggerNoteAction( noteId, actionId ) { + yield setIsRequesting( 'triggerNoteAction', true ); + + const url = `${ NAMESPACE }/admin/notes/${ noteId }/action/${ actionId }`; + try { + const result = yield apiFetch( { path: url, method: 'POST' } ); + yield updateNote( noteId, result ); + yield setIsRequesting( 'triggerNoteAction', false ); + } catch ( error ) { + yield setError( 'triggerNoteAction', error ); + yield setIsRequesting( 'triggerNoteAction', false ); + throw new Error(); + } +} + +export function* removeNote( noteId ) { + yield setIsRequesting( 'removeNote', true ); + yield setNoteIsUpdating( noteId, true ); + + try { + const url = `${ NAMESPACE }/admin/notes/delete/${ noteId }`; + const response = yield apiFetch( { path: url, method: 'DELETE' } ); + yield setNote( noteId, response ); + yield setIsRequesting( 'removeNote', false ); + return response; + } catch ( error ) { + yield setError( 'removeNote', error ); + yield setIsRequesting( 'removeNote', false ); + yield setNoteIsUpdating( noteId, false ); + throw new Error(); + } +} + +export function* removeAllNotes( query = {} ) { + yield setIsRequesting( 'removeAllNotes', true ); + + try { + const url = addQueryArgs( + `${ NAMESPACE }/admin/notes/delete/all`, + query + ); + const notes = yield apiFetch( { path: url, method: 'DELETE' } ); + yield setNotes( notes ); + yield setIsRequesting( 'removeAllNotes', false ); + return notes; + } catch ( error ) { + yield setError( 'removeAllNotes', error ); + yield setIsRequesting( 'removeAllNotes', false ); + throw new Error(); + } +} + +export function* batchUpdateNotes( noteIds, noteFields ) { + yield setIsRequesting( 'batchUpdateNotes', true ); + + try { + const url = `${ NAMESPACE }/admin/notes/update`; + const notes = yield apiFetch( { + path: url, + method: 'PUT', + data: { + noteIds, + ...noteFields, + }, + } ); + yield setNotes( notes ); + yield setIsRequesting( 'batchUpdateNotes', false ); + } catch ( error ) { + yield setError( 'updateNote', error ); + yield setIsRequesting( 'batchUpdateNotes', false ); + throw new Error(); + } +} + +export function* updateNote( noteId, noteFields ) { + yield setIsRequesting( 'updateNote', true ); + yield setNoteIsUpdating( noteId, true ); + + try { + const url = `${ NAMESPACE }/admin/notes/${ noteId }`; + const note = yield apiFetch( { + path: url, + method: 'PUT', + data: noteFields, + } ); + yield setNote( noteId, note ); + yield setIsRequesting( 'updateNote', false ); + yield setNoteIsUpdating( noteId, false ); + } catch ( error ) { + yield setError( 'updateNote', error ); + yield setIsRequesting( 'updateNote', false ); + yield setNoteIsUpdating( noteId, false ); + throw new Error(); + } +} + +export function setNote( noteId, noteFields ) { + return { + type: TYPES.SET_NOTE, + noteId, + noteFields, + }; +} + +export function setNoteIsUpdating( noteId, isUpdating ) { + return { + type: TYPES.SET_NOTE_IS_UPDATING, + noteId, + isUpdating, + }; +} + +export function setNotes( notes ) { + return { + type: TYPES.SET_NOTES, + notes, + }; +} + +export function setNotesQuery( query, noteIds ) { + return { + type: TYPES.SET_NOTES_QUERY, + query, + noteIds, + }; +} + +export function setError( selector, error ) { + return { + type: TYPES.SET_ERROR, + error, + selector, + }; +} + +export function setIsRequesting( selector, isRequesting ) { + return { + type: TYPES.SET_IS_REQUESTING, + selector, + isRequesting, + }; +} diff --git a/packages/js/data/src/notes/constants.ts b/packages/js/data/src/notes/constants.ts new file mode 100644 index 00000000000..ef7d5ccec2a --- /dev/null +++ b/packages/js/data/src/notes/constants.ts @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +export const STORE_NAME = 'wc/admin/notes'; diff --git a/packages/js/data/src/notes/index.js b/packages/js/data/src/notes/index.js new file mode 100644 index 00000000000..8ba4f91be9d --- /dev/null +++ b/packages/js/data/src/notes/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; +import { controls } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const NOTES_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/notes/reducer.js b/packages/js/data/src/notes/reducer.js new file mode 100644 index 00000000000..ebfede29baf --- /dev/null +++ b/packages/js/data/src/notes/reducer.js @@ -0,0 +1,91 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; + +const notesReducer = ( + state = { + errors: {}, + noteQueries: {}, + notes: {}, + requesting: {}, + }, + { + error, + isRequesting, + isUpdating, + noteFields, + noteId, + noteIds, + notes, + query, + selector, + type, + } +) => { + switch ( type ) { + case TYPES.SET_NOTES: + state = { + ...state, + notes: { + ...state.notes, + ...notes.reduce( ( acc, item ) => { + acc[ item.id ] = item; + return acc; + }, {} ), + }, + }; + break; + case TYPES.SET_NOTES_QUERY: + state = { + ...state, + noteQueries: { + ...state.noteQueries, + [ JSON.stringify( query ) ]: noteIds, + }, + }; + break; + case TYPES.SET_ERROR: + state = { + ...state, + errors: { + ...state.errors, + [ selector ]: error, + }, + }; + break; + case TYPES.SET_NOTE: + state = { + ...state, + notes: { + ...state.notes, + [ noteId ]: noteFields, + }, + }; + break; + case TYPES.SET_NOTE_IS_UPDATING: + state = { + ...state, + notes: { + ...state.notes, + [ noteId ]: { + ...state.notes[ noteId ], + isUpdating, + }, + }, + }; + break; + case TYPES.SET_IS_REQUESTING: + state = { + ...state, + requesting: { + ...state.requesting, + [ selector ]: isRequesting, + }, + }; + break; + } + return state; +}; + +export default notesReducer; diff --git a/packages/js/data/src/notes/resolvers.js b/packages/js/data/src/notes/resolvers.js new file mode 100644 index 00000000000..9271928fd33 --- /dev/null +++ b/packages/js/data/src/notes/resolvers.js @@ -0,0 +1,67 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { addQueryArgs } from '@wordpress/url'; +import { apiFetch } from '@wordpress/data-controls'; +import { sanitize } from 'dompurify'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from '../constants'; +import { setNotes, setNotesQuery, setError } from './actions'; + +let notesExceededWarningShown = false; + +export function* getNotes( query = {} ) { + const url = addQueryArgs( `${ NAMESPACE }/admin/notes`, query ); + + try { + const notes = yield apiFetch( { + path: url, + } ); + + if ( ! notesExceededWarningShown ) { + const noteNames = notes.reduce( ( filtered, note ) => { + const content = sanitize( note.content, { + ALLOWED_TAGS: [], + } ); + if ( content.length > 320 ) { + filtered.push( note.name ); + } + return filtered; + }, [] ); + + if ( noteNames.length ) { + /* eslint-disable no-console */ + console.warn( + sprintf( + /* translators: %s = link to developer blog */ + __( + 'WooCommerce Admin will soon limit inbox note contents to 320 characters. For more information, please visit %s. The following notes currently exceeds that limit:', + 'woocommerce' + ), + 'https://developer.woocommerce.com/?p=10749' + ) + + '\n' + + noteNames + .map( ( name, idx ) => { + return ` ${ idx + 1 }. ${ name }`; + } ) + .join( '\n' ) + ); + /* eslint-enable no-console */ + notesExceededWarningShown = true; + } + } + + yield setNotes( notes ); + yield setNotesQuery( + query, + notes.map( ( note ) => note.id ) + ); + } catch ( error ) { + yield setError( 'getNotes', error ); + } +} diff --git a/packages/js/data/src/notes/selectors.js b/packages/js/data/src/notes/selectors.js new file mode 100644 index 00000000000..fae37edcbc4 --- /dev/null +++ b/packages/js/data/src/notes/selectors.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; + +export const getNotes = createSelector( + ( state, query ) => { + const noteIds = state.noteQueries[ JSON.stringify( query ) ] || []; + return noteIds.map( ( id ) => state.notes[ id ] ); + }, + ( state, query ) => [ + state.noteQueries[ JSON.stringify( query ) ], + state.notes, + ] +); + +export const getNotesError = ( state, selector ) => { + return state.errors[ selector ] || false; +}; + +export const isNotesRequesting = ( state, selector ) => { + return state.requesting[ selector ] || false; +}; diff --git a/packages/js/data/src/notes/test/reducer.js b/packages/js/data/src/notes/test/reducer.js new file mode 100644 index 00000000000..0d9855acac4 --- /dev/null +++ b/packages/js/data/src/notes/test/reducer.js @@ -0,0 +1,130 @@ +/** + * @jest-environment node + */ + +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import TYPES from '../action-types'; + +const defaultState = { errors: {}, noteQueries: {}, notes: {}, requesting: {} }; + +describe( 'notes reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( 'should handle SET_ERROR', () => { + const state = reducer( defaultState, { + type: TYPES.SET_ERROR, + selector: 'getNotes', + error: '404', + } ); + + expect( state.errors.updateNote ).toBeUndefined(); + expect( state.errors.getNotes ).toBe( '404' ); + } ); + + it( 'should handle SET_NOTE', () => { + const state = reducer( defaultState, { + type: TYPES.SET_NOTE, + noteId: 21, + noteFields: { + field1: 'value1', + }, + } ); + + expect( state.notes[ 21 ].field1 ).toBe( 'value1' ); + } ); + + it( 'should handle SET_NOTE on an existing note', () => { + const state = reducer( + { + ...defaultState, + notes: { + 10: { + field1: 'old-value', + }, + }, + }, + { + type: TYPES.SET_NOTE, + noteId: 10, + noteFields: { + field1: 'updated-value', + }, + } + ); + + expect( state.notes[ 10 ].field1 ).toBe( 'updated-value' ); + } ); + + it( 'should handle SET_NOTE_IS_UPDATING', () => { + const state = reducer( + { + ...defaultState, + notes: { + 10: {}, + }, + }, + { + type: TYPES.SET_NOTE_IS_UPDATING, + noteId: 10, + isUpdating: true, + } + ); + + expect( state.notes[ 10 ].isUpdating ).toBe( true ); + } ); + + it( 'should handle SET_NOTES', () => { + const state = reducer( + { + ...defaultState, + notes: { + 10: { + id: 10, + title: 'Initial note', + }, + }, + }, + { + type: TYPES.SET_NOTES, + notes: [ + { + id: 5, + title: 'Notes on notes on notes', + }, + { + id: 22, + title: 'Update now!', + }, + ], + } + ); + + expect( state.notes[ 10 ].title ).toBe( 'Initial note' ); + expect( state.notes[ 5 ].title ).toBe( 'Notes on notes on notes' ); + expect( state.notes[ 22 ].title ).toBe( 'Update now!' ); + } ); + + it( 'should handle SET_NOTES_QUERY', () => { + const query = { + page: 1, + status: 'unactioned', + }; + + const state = reducer( defaultState, { + type: TYPES.SET_NOTES_QUERY, + query, + noteIds: [ 10, 22, 5 ], + } ); + + expect( state.noteQueries[ JSON.stringify( query ) ] ).toContain( 10 ); + expect( state.noteQueries[ JSON.stringify( query ) ] ).toContain( 22 ); + expect( state.noteQueries[ JSON.stringify( query ) ] ).toContain( 5 ); + } ); +} ); diff --git a/packages/js/data/src/notes/test/selectors.js b/packages/js/data/src/notes/test/selectors.js new file mode 100644 index 00000000000..445d431558f --- /dev/null +++ b/packages/js/data/src/notes/test/selectors.js @@ -0,0 +1,122 @@ +/** + * Internal dependencies + */ +import { getNotes } from '../selectors'; + +describe( 'getNotes', () => { + it( 'should return an empty array by default', () => { + const state = { + noteQueries: {}, + }; + + expect( getNotes( state, { param: 'test' } ) ).toEqual( [] ); + } ); + + it( 'should return results with records', () => { + const query = { param: 'test' }; + const state = { + noteQueries: { + [ JSON.stringify( query ) ]: [ 1, 2 ], + }, + notes: { + 1: { name: 'test' }, + 2: { name: 'another' }, + }, + }; + expect( getNotes( state, query ) ).toEqual( [ + { name: 'test' }, + { name: 'another' }, + ] ); + } ); + + it( 'should return the same instance with the same arguments, and if original query not updated', () => { + const query = { param: 'test' }; + let state = { + noteQueries: { + [ JSON.stringify( query ) ]: [ 1, 2 ], + }, + notes: { + 1: { name: 'test' }, + 2: { name: 'another' }, + }, + }; + + const firstCall = getNotes( state, query ); + + // Simulate update states + state = { + ...state, + noteQueries: { + ...state.noteQueries, + randomQuery: [ 3, 4 ], + }, + }; + + const secondCall = getNotes( state, query ); + + expect( firstCall ).toBe( secondCall ); + } ); + + it( 'should return updated instance if a note is updated', () => { + const query = { param: 'test' }; + let state = { + noteQueries: { + [ JSON.stringify( query ) ]: [ 1, 2 ], + }, + notes: { + 1: { name: 'test' }, + 2: { name: 'another' }, + }, + }; + + const firstCall = getNotes( state, query ); + + // Simulate update states + state = { + ...state, + notes: { + ...state.notes, + 1: { + ...state.notes[ 1 ], + updated: true, + }, + }, + }; + + const secondCall = getNotes( state, query ); + + expect( firstCall ).not.toBe( secondCall ); + expect( firstCall[ 0 ].updated ).not.toBeDefined(); + expect( secondCall[ 0 ].updated ).toBe( true ); + } ); + + it( 'should return updated instance if query is updated', () => { + const query = { param: 'test' }; + let state = { + noteQueries: { + [ JSON.stringify( query ) ]: [ 1, 2 ], + }, + notes: { + 1: { name: 'test' }, + 2: { name: 'another' }, + }, + }; + + const firstCall = getNotes( state, query ); + + // Simulate update states + state = { + ...state, + noteQueries: { + ...state.noteQueries, + [ JSON.stringify( query ) ]: [ 1 ], + }, + }; + + const secondCall = getNotes( state, query ); + + expect( firstCall ).not.toBe( secondCall ); + expect( firstCall.length ).toBe( 2 ); + expect( secondCall.length ).toBe( 1 ); + } ); +} ); diff --git a/packages/js/data/src/onboarding/action-types.js b/packages/js/data/src/onboarding/action-types.js new file mode 100644 index 00000000000..b492840fd6b --- /dev/null +++ b/packages/js/data/src/onboarding/action-types.js @@ -0,0 +1,39 @@ +const TYPES = { + SET_ERROR: 'SET_ERROR', + SET_IS_REQUESTING: 'SET_IS_REQUESTING', + SET_PROFILE_ITEMS: 'SET_PROFILE_ITEMS', + SET_EMAIL_PREFILL: 'SET_EMAIL_PREFILL', + GET_PAYMENT_METHODS_SUCCESS: 'GET_PAYMENT_METHODS_SUCCESS', + GET_PRODUCT_TYPES_SUCCESS: 'GET_PRODUCT_TYPES_SUCCESS', + GET_PRODUCT_TYPES_ERROR: 'GET_PRODUCT_TYPES_ERROR', + GET_FREE_EXTENSIONS_ERROR: 'GET_FREE_EXTENSIONS_ERROR', + GET_FREE_EXTENSIONS_SUCCESS: 'GET_FREE_EXTENSIONS_SUCCESS', + GET_TASK_LISTS_ERROR: 'GET_TASK_LISTS_ERROR', + GET_TASK_LISTS_SUCCESS: 'GET_TASK_LISTS_SUCCESS', + DISMISS_TASK_ERROR: 'DISMISS_TASK_ERROR', + DISMISS_TASK_REQUEST: 'DISMISS_TASK_REQUEST', + DISMISS_TASK_SUCCESS: 'DISMISS_TASK_SUCCESS', + UNDO_DISMISS_TASK_ERROR: 'UNDO_DISMISS_TASK_ERROR', + UNDO_DISMISS_TASK_REQUEST: 'UNDO_DISMISS_TASK_REQUEST', + UNDO_DISMISS_TASK_SUCCESS: 'UNDO_DISMISS_TASK_SUCCESS', + SNOOZE_TASK_ERROR: 'SNOOZE_TASK_ERROR', + SNOOZE_TASK_REQUEST: 'SNOOZE_TASK_REQUEST', + SNOOZE_TASK_SUCCESS: 'SNOOZE_TASK_SUCCESS', + UNDO_SNOOZE_TASK_ERROR: 'UNDO_SNOOZE_TASK_ERROR', + UNDO_SNOOZE_TASK_REQUEST: 'UNDO_SNOOZE_TASK_REQUEST', + UNDO_SNOOZE_TASK_SUCCESS: 'UNDO_SNOOZE_TASK_SUCCESS', + HIDE_TASK_LIST_ERROR: 'HIDE_TASK_LIST_ERROR', + HIDE_TASK_LIST_REQUEST: 'HIDE_TASK_LIST_REQUEST', + HIDE_TASK_LIST_SUCCESS: 'HIDE_TASK_LIST_SUCCESS', + UNHIDE_TASK_LIST_ERROR: 'UNHIDE_TASK_LIST_ERROR', + UNHIDE_TASK_LIST_REQUEST: 'UNHIDE_TASK_LIST_REQUEST', + UNHIDE_TASK_LIST_SUCCESS: 'UNHIDE_TASK_LIST_SUCCESS', + OPTIMISTICALLY_COMPLETE_TASK_REQUEST: + 'OPTIMISTICALLY_COMPLETE_TASK_REQUEST', + ACTION_TASK_ERROR: 'ACTION_TASK_ERROR', + ACTION_TASK_REQUEST: 'ACTION_TASK_REQUEST', + ACTION_TASK_SUCCESS: 'ACTION_TASK_SUCCESS', + VISITED_TASK: 'VISITED_TASK', +}; + +export default TYPES; diff --git a/packages/js/data/src/onboarding/actions.js b/packages/js/data/src/onboarding/actions.js new file mode 100644 index 00000000000..5ee1161493e --- /dev/null +++ b/packages/js/data/src/onboarding/actions.js @@ -0,0 +1,424 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { WC_ADMIN_NAMESPACE } from '../constants'; +import { DeprecatedTasks } from './deprecated-tasks'; + +export function getFreeExtensionsError( error ) { + return { + type: TYPES.GET_FREE_EXTENSIONS_ERROR, + error, + }; +} + +export function getFreeExtensionsSuccess( freeExtensions ) { + return { + type: TYPES.GET_FREE_EXTENSIONS_SUCCESS, + freeExtensions, + }; +} + +export function setError( selector, error ) { + return { + type: TYPES.SET_ERROR, + selector, + error, + }; +} + +export function setIsRequesting( selector, isRequesting ) { + return { + type: TYPES.SET_IS_REQUESTING, + selector, + isRequesting, + }; +} + +export function setProfileItems( profileItems, replace = false ) { + return { + type: TYPES.SET_PROFILE_ITEMS, + profileItems, + replace, + }; +} + +export function getTaskListsError( error ) { + return { + type: TYPES.GET_TASK_LISTS_ERROR, + error, + }; +} + +export function getTaskListsSuccess( taskLists ) { + return { + type: TYPES.GET_TASK_LISTS_SUCCESS, + taskLists, + }; +} + +export function snoozeTaskError( taskId, error ) { + return { + type: TYPES.SNOOZE_TASK_ERROR, + taskId, + error, + }; +} + +export function snoozeTaskRequest( taskId ) { + return { + type: TYPES.SNOOZE_TASK_REQUEST, + taskId, + }; +} + +export function snoozeTaskSuccess( task ) { + return { + type: TYPES.SNOOZE_TASK_SUCCESS, + task, + }; +} + +export function undoSnoozeTaskError( taskId, error ) { + return { + type: TYPES.UNDO_SNOOZE_TASK_ERROR, + taskId, + error, + }; +} + +export function undoSnoozeTaskRequest( taskId ) { + return { + type: TYPES.UNDO_SNOOZE_TASK_REQUEST, + taskId, + }; +} + +export function undoSnoozeTaskSuccess( task ) { + return { + type: TYPES.UNDO_SNOOZE_TASK_SUCCESS, + task, + }; +} + +export function dismissTaskError( taskId, error ) { + return { + type: TYPES.DISMISS_TASK_ERROR, + taskId, + error, + }; +} + +export function dismissTaskRequest( taskId ) { + return { + type: TYPES.DISMISS_TASK_REQUEST, + taskId, + }; +} + +export function dismissTaskSuccess( task ) { + return { + type: TYPES.DISMISS_TASK_SUCCESS, + task, + }; +} + +export function undoDismissTaskError( taskId, error ) { + return { + type: TYPES.UNDO_DISMISS_TASK_ERROR, + taskId, + error, + }; +} + +export function undoDismissTaskRequest( taskId ) { + return { + type: TYPES.UNDO_DISMISS_TASK_REQUEST, + taskId, + }; +} + +export function undoDismissTaskSuccess( task ) { + return { + type: TYPES.UNDO_DISMISS_TASK_SUCCESS, + task, + }; +} + +export function hideTaskListError( taskListId, error ) { + return { + type: TYPES.HIDE_TASK_LIST_ERROR, + taskListId, + error, + }; +} + +export function hideTaskListRequest( taskListId ) { + return { + type: TYPES.HIDE_TASK_LIST_REQUEST, + taskListId, + }; +} + +export function hideTaskListSuccess( taskList ) { + return { + type: TYPES.HIDE_TASK_LIST_SUCCESS, + taskList, + }; +} + +export function unhideTaskListError( taskListId, error ) { + return { + type: TYPES.UNHIDE_TASK_LIST_ERROR, + taskListId, + error, + }; +} + +export function unhideTaskListRequest( taskListId ) { + return { + type: TYPES.UNHIDE_TASK_LIST_REQUEST, + taskListId, + }; +} + +export function unhideTaskListSuccess( taskList ) { + return { + type: TYPES.UNHIDE_TASK_LIST_SUCCESS, + taskList, + }; +} + +export function optimisticallyCompleteTaskRequest( taskId ) { + return { + type: TYPES.OPTIMISTICALLY_COMPLETE_TASK_REQUEST, + taskId, + }; +} + +export function visitedTask( taskId ) { + return { + type: TYPES.VISITED_TASK, + taskId, + }; +} + +export function setPaymentMethods( paymentMethods ) { + return { + type: TYPES.GET_PAYMENT_METHODS_SUCCESS, + paymentMethods, + }; +} + +export function setEmailPrefill( email ) { + return { + type: TYPES.SET_EMAIL_PREFILL, + emailPrefill: email, + }; +} + +export function actionTaskError( taskId, error ) { + return { + type: TYPES.ACTION_TASK_ERROR, + taskId, + error, + }; +} + +export function actionTaskRequest( taskId ) { + return { + type: TYPES.ACTION_TASK_REQUEST, + taskId, + }; +} + +export function actionTaskSuccess( task ) { + return { + type: TYPES.ACTION_TASK_SUCCESS, + task, + }; +} + +export function getProductTypesSuccess( productTypes ) { + return { + type: TYPES.GET_PRODUCT_TYPES_SUCCESS, + productTypes, + }; +} + +export function getProductTypesError( error ) { + return { + type: TYPES.GET_PRODUCT_TYPES_ERROR, + error, + }; +} + +export function* updateProfileItems( items ) { + yield setIsRequesting( 'updateProfileItems', true ); + yield setError( 'updateProfileItems', null ); + + try { + const results = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/onboarding/profile`, + method: 'POST', + data: items, + } ); + + if ( results && results.status === 'success' ) { + yield setProfileItems( items ); + yield setIsRequesting( 'updateProfileItems', false ); + return results; + } + + throw new Error(); + } catch ( error ) { + yield setError( 'updateProfileItems', error ); + yield setIsRequesting( 'updateProfileItems', false ); + throw error; + } +} + +export function* snoozeTask( id ) { + yield snoozeTaskRequest( id ); + + try { + const task = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/snooze`, + method: 'POST', + } ); + + yield snoozeTaskSuccess( + DeprecatedTasks.possiblyPruneTaskData( task, [ + 'isSnoozed', + 'isDismissed', + 'snoozedUntil', + ] ) + ); + } catch ( error ) { + yield snoozeTaskError( id, error ); + throw new Error(); + } +} + +export function* undoSnoozeTask( id ) { + yield undoSnoozeTaskRequest( id ); + + try { + const task = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/undo_snooze`, + method: 'POST', + } ); + + yield undoSnoozeTaskSuccess( + DeprecatedTasks.possiblyPruneTaskData( task, [ + 'isSnoozed', + 'isDismissed', + 'snoozedUntil', + ] ) + ); + } catch ( error ) { + yield undoSnoozeTaskError( id, error ); + throw new Error(); + } +} + +export function* dismissTask( id ) { + yield dismissTaskRequest( id ); + + try { + const task = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/dismiss`, + method: 'POST', + } ); + + yield dismissTaskSuccess( + DeprecatedTasks.possiblyPruneTaskData( task, [ + 'isDismissed', + 'isSnoozed', + ] ) + ); + } catch ( error ) { + yield dismissTaskError( id, error ); + throw new Error(); + } +} + +export function* undoDismissTask( id ) { + yield undoDismissTaskRequest( id ); + + try { + const task = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/undo_dismiss`, + method: 'POST', + } ); + + yield undoDismissTaskSuccess( + DeprecatedTasks.possiblyPruneTaskData( task, [ + 'isDismissed', + 'isSnoozed', + ] ) + ); + } catch ( error ) { + yield undoDismissTaskError( id, error ); + throw new Error(); + } +} + +export function* hideTaskList( id ) { + yield hideTaskListRequest( id ); + + try { + const taskList = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/hide`, + method: 'POST', + } ); + + yield hideTaskListSuccess( taskList ); + } catch ( error ) { + yield hideTaskListError( id, error ); + throw new Error(); + } +} + +export function* unhideTaskList( id ) { + yield unhideTaskListRequest( id ); + + try { + const taskList = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/unhide`, + method: 'POST', + } ); + + yield unhideTaskListSuccess( taskList ); + } catch ( error ) { + yield unhideTaskListError( id, error ); + throw new Error(); + } +} + +export function* optimisticallyCompleteTask( id ) { + yield optimisticallyCompleteTaskRequest( id ); +} + +export function* actionTask( id ) { + yield actionTaskRequest( id ); + + try { + const task = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/onboarding/tasks/${ id }/action`, + method: 'POST', + } ); + + yield actionTaskSuccess( + DeprecatedTasks.possiblyPruneTaskData( task, [ 'isActioned' ] ) + ); + } catch ( error ) { + yield actionTaskError( id, error ); + throw new Error(); + } +} diff --git a/packages/js/data/src/onboarding/constants.ts b/packages/js/data/src/onboarding/constants.ts new file mode 100644 index 00000000000..3b6bd1fb2a4 --- /dev/null +++ b/packages/js/data/src/onboarding/constants.ts @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +export const STORE_NAME = 'wc/admin/onboarding'; diff --git a/packages/js/data/src/onboarding/deprecated-tasks.js b/packages/js/data/src/onboarding/deprecated-tasks.js new file mode 100644 index 00000000000..50958b5d736 --- /dev/null +++ b/packages/js/data/src/onboarding/deprecated-tasks.js @@ -0,0 +1,117 @@ +/** + * External dependencies + */ +import { applyFilters } from '@wordpress/hooks'; +import { parse } from 'qs'; +import deprecated from '@wordpress/deprecated'; + +function getQuery() { + const searchString = window.location && window.location.search; + if ( ! searchString ) { + return {}; + } + + const search = searchString.substring( 1 ); + return parse( search ); +} + +/** + * A simple class to handle deprecated tasks using the woocommerce_admin_onboarding_task_list filter. + */ +export class DeprecatedTasks { + constructor() { + /** + * **Deprecated** Filter Onboarding tasks. + * + * @filter woocommerce_admin_onboarding_task_list + * @deprecated + * @param {Array} tasks Array of tasks. + * @param {Array} query Url query parameters. + */ + this.filteredTasks = applyFilters( + 'woocommerce_admin_onboarding_task_list', + [], + getQuery() + ); + if ( this.filteredTasks && this.filteredTasks.length > 0 ) { + deprecated( 'woocommerce_admin_onboarding_task_list', { + version: '2.10.0', + alternative: 'TaskLists::add_task()', + plugin: '@woocommerce/data', + } ); + } + this.tasks = this.filteredTasks.reduce( ( org, task ) => { + return { + ...org, + [ task.key ]: task, + }; + }, {} ); + } + + hasDeprecatedTasks() { + return this.filteredTasks.length > 0; + } + + getPostData() { + return this.hasDeprecatedTasks() + ? { + extended_tasks: this.filteredTasks.map( ( task ) => ( { + title: task.title, + content: task.content, + additional_info: task.additionalInfo, + time: task.time, + level: task.level ? parseInt( task.level, 10 ) : 3, + list_id: task.type || 'extended', + can_view: task.visible, + id: task.key, + is_snoozeable: task.allowRemindMeLater, + is_dismissable: task.isDismissable, + is_complete: task.completed, + } ) ), + } + : null; + } + + mergeDeprecatedCallbackFunctions( taskLists ) { + if ( this.filteredTasks.length > 0 ) { + for ( const taskList of taskLists ) { + // Merge any extended task list items, to keep the callback functions around. + taskList.tasks = taskList.tasks.map( ( task ) => { + if ( this.tasks && this.tasks[ task.id ] ) { + return { + ...this.tasks[ task.id ], + ...task, + isDeprecated: true, + }; + } + return task; + } ); + } + } + return taskLists; + } + + /** + * Used to keep backwards compatibility with the extended task list filter on the client. + * This can be removed after version WC Admin 2.10 (see deprecated notice in resolvers.js). + * + * @param {Object} task the returned task object. + * @param {Array} keys to keep in the task object. + * @return {Object} task with the keys specified. + */ + static possiblyPruneTaskData( task, keys ) { + if ( ! task.time && ! task.title ) { + // client side task + return keys.reduce( + ( simplifiedTask, key ) => { + return { + ...simplifiedTask, + [ key ]: task[ key ], + }; + }, + { id: task.id } + ); + } + return task; + } +} diff --git a/packages/js/data/src/onboarding/index.js b/packages/js/data/src/onboarding/index.js new file mode 100644 index 00000000000..287263d2713 --- /dev/null +++ b/packages/js/data/src/onboarding/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; +import { controls } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const ONBOARDING_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/onboarding/reducer.js b/packages/js/data/src/onboarding/reducer.js new file mode 100644 index 00000000000..499b890dda1 --- /dev/null +++ b/packages/js/data/src/onboarding/reducer.js @@ -0,0 +1,429 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; + +export const defaultState = { + errors: {}, + freeExtensions: [], + profileItems: { + business_extensions: null, + completed: null, + industry: null, + number_employees: null, + other_platform: null, + other_platform_name: null, + product_count: null, + product_types: null, + revenue: null, + selling_venues: null, + setup_client: null, + skipped: null, + theme: null, + wccom_connected: null, + is_agree_marketing: null, + store_email: null, + }, + emailPrefill: '', + paymentMethods: [], + productTypes: [], + requesting: {}, + taskLists: {}, +}; + +const getUpdatedTaskLists = ( taskLists, args ) => { + return Object.keys( taskLists ).reduce( + ( lists, taskListId ) => { + return { + ...lists, + [ taskListId ]: { + ...taskLists[ taskListId ], + tasks: taskLists[ taskListId ].tasks.map( ( task ) => { + if ( args.id === task.id ) { + return { + ...task, + ...args, + }; + } + return task; + } ), + }, + }; + }, + { ...taskLists } + ); +}; + +const onboarding = ( + state = defaultState, + { + freeExtensions, + type, + profileItems, + emailPrefill, + paymentMethods, + productTypes, + replace, + error, + isRequesting, + selector, + task, + taskId, + taskListId, + taskList, + taskLists, + } +) => { + switch ( type ) { + case TYPES.SET_PROFILE_ITEMS: + return { + ...state, + profileItems: replace + ? profileItems + : { ...state.profileItems, ...profileItems }, + }; + case TYPES.SET_EMAIL_PREFILL: + return { + ...state, + emailPrefill, + }; + case TYPES.SET_ERROR: + return { + ...state, + errors: { + ...state.errors, + [ selector ]: error, + }, + }; + case TYPES.SET_IS_REQUESTING: + return { + ...state, + requesting: { + ...state.requesting, + [ selector ]: isRequesting, + }, + }; + case TYPES.GET_PAYMENT_METHODS_SUCCESS: + return { + ...state, + paymentMethods, + }; + case TYPES.GET_PRODUCT_TYPES_SUCCESS: + return { + ...state, + productTypes, + }; + case TYPES.GET_PRODUCT_TYPES_ERROR: + return { + ...state, + errors: { + ...state.errors, + productTypes: error, + }, + }; + case TYPES.GET_FREE_EXTENSIONS_ERROR: + return { + ...state, + errors: { + ...state.errors, + getFreeExtensions: error, + }, + }; + case TYPES.GET_FREE_EXTENSIONS_SUCCESS: + return { + ...state, + freeExtensions, + }; + case TYPES.GET_TASK_LISTS_ERROR: + return { + ...state, + errors: { + ...state.errors, + getTaskLists: error, + }, + }; + case TYPES.GET_TASK_LISTS_SUCCESS: + return { + ...state, + taskLists: taskLists.reduce( ( lists, list ) => { + return { + ...lists, + [ list.id ]: list, + }; + }, state.taskLists || {} ), + }; + case TYPES.DISMISS_TASK_ERROR: + return { + ...state, + errors: { + ...state.errors, + dismissTask: error, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isDismissed: false, + } ), + }; + case TYPES.DISMISS_TASK_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + dismissTask: true, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isDismissed: true, + } ), + }; + case TYPES.DISMISS_TASK_SUCCESS: + return { + ...state, + requesting: { + ...state.requesting, + dismissTask: false, + }, + taskLists: getUpdatedTaskLists( state.taskLists, task ), + }; + case TYPES.UNDO_DISMISS_TASK_ERROR: + return { + ...state, + errors: { + ...state.errors, + undoDismissTask: error, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isDismissed: true, + } ), + }; + case TYPES.UNDO_DISMISS_TASK_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + undoDismissTask: true, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isDismissed: false, + } ), + }; + case TYPES.UNDO_DISMISS_TASK_SUCCESS: + return { + ...state, + requesting: { + ...state.requesting, + undoDismissTask: false, + }, + taskLists: getUpdatedTaskLists( state.taskLists, task ), + }; + case TYPES.SNOOZE_TASK_ERROR: + return { + ...state, + errors: { + ...state.errors, + snoozeTask: error, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isSnoozed: false, + } ), + }; + case TYPES.SNOOZE_TASK_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + snoozeTask: true, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isSnoozed: true, + } ), + }; + case TYPES.SNOOZE_TASK_SUCCESS: + return { + ...state, + requesting: { + ...state.requesting, + snoozeTask: false, + }, + taskLists: getUpdatedTaskLists( state.taskLists, task ), + }; + case TYPES.UNDO_SNOOZE_TASK_ERROR: + return { + ...state, + errors: { + ...state.errors, + undoSnoozeTask: error, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isSnoozed: true, + } ), + }; + case TYPES.UNDO_SNOOZE_TASK_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + undoSnoozeTask: true, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isSnoozed: false, + } ), + }; + case TYPES.UNDO_SNOOZE_TASK_SUCCESS: + return { + ...state, + requesting: { + ...state.requesting, + undoSnoozeTask: false, + }, + taskLists: getUpdatedTaskLists( state.taskLists, task ), + }; + case TYPES.HIDE_TASK_LIST_ERROR: + return { + ...state, + errors: { + ...state.errors, + hideTaskList: error, + }, + taskLists: { + ...state.taskLists, + [ taskListId ]: { + ...state.taskLists[ taskListId ], + isHidden: false, + isVisible: true, + }, + }, + }; + case TYPES.HIDE_TASK_LIST_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + hideTaskList: true, + }, + taskLists: { + ...state.taskLists, + [ taskListId ]: { + ...state.taskLists[ taskListId ], + isHidden: true, + isVisible: false, + }, + }, + }; + case TYPES.HIDE_TASK_LIST_SUCCESS: + return { + ...state, + requesting: { + ...state.requesting, + hideTaskList: false, + }, + taskLists: { + ...state.taskLists, + [ taskListId ]: taskList, + }, + }; + case TYPES.UNHIDE_TASK_LIST_ERROR: + return { + ...state, + errors: { + ...state.errors, + unhideTaskList: error, + }, + taskLists: { + ...state.taskLists, + [ taskListId ]: { + ...state.taskLists[ taskListId ], + isHidden: true, + isVisible: false, + }, + }, + }; + case TYPES.UNHIDE_TASK_LIST_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + unhideTaskList: true, + }, + taskLists: { + ...state.taskLists, + [ taskListId ]: { + ...state.taskLists[ taskListId ], + isHidden: false, + isVisible: true, + }, + }, + }; + case TYPES.UNHIDE_TASK_LIST_SUCCESS: + return { + ...state, + requesting: { + ...state.requesting, + unhideTaskList: false, + }, + taskLists: { + ...state.taskLists, + [ taskListId ]: taskList, + }, + }; + case TYPES.OPTIMISTICALLY_COMPLETE_TASK_REQUEST: + return { + ...state, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isComplete: true, + } ), + }; + case TYPES.VISITED_TASK: + return { + ...state, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isVisited: true, + } ), + }; + case TYPES.ACTION_TASK_ERROR: + return { + ...state, + errors: { + ...state.errors, + actionTask: error, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isActioned: false, + } ), + }; + case TYPES.ACTION_TASK_REQUEST: + return { + ...state, + requesting: { + ...state.requesting, + actionTask: true, + }, + taskLists: getUpdatedTaskLists( state.taskLists, { + id: taskId, + isActioned: true, + } ), + }; + case TYPES.ACTION_TASK_SUCCESS: + return { + ...state, + requesting: { + ...state.requesting, + actionTask: false, + }, + taskLists: getUpdatedTaskLists( state.taskLists, task ), + }; + default: + return state; + } +}; + +export default onboarding; diff --git a/packages/js/data/src/onboarding/resolvers.js b/packages/js/data/src/onboarding/resolvers.js new file mode 100644 index 00000000000..466a2109625 --- /dev/null +++ b/packages/js/data/src/onboarding/resolvers.js @@ -0,0 +1,123 @@ +/** + * External dependencies + */ +import { apiFetch, select } from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import { WC_ADMIN_NAMESPACE } from '../constants'; +import { + getFreeExtensionsError, + getFreeExtensionsSuccess, + getTaskListsError, + getTaskListsSuccess, + setProfileItems, + setError, + setPaymentMethods, + setEmailPrefill, + getProductTypesSuccess, + getProductTypesError, +} from './actions'; +import { DeprecatedTasks } from './deprecated-tasks'; + +const resolveSelect = + controls && controls.resolveSelect ? controls.resolveSelect : select; + +export function* getProfileItems() { + try { + const results = yield apiFetch( { + path: WC_ADMIN_NAMESPACE + '/onboarding/profile', + method: 'GET', + } ); + + yield setProfileItems( results, true ); + } catch ( error ) { + yield setError( 'getProfileItems', error ); + } +} + +export function* getEmailPrefill() { + try { + const results = yield apiFetch( { + path: + WC_ADMIN_NAMESPACE + + '/onboarding/profile/experimental_get_email_prefill', + method: 'GET', + } ); + + yield setEmailPrefill( results.email ); + } catch ( error ) { + yield setError( 'getEmailPrefill', error ); + } +} + +export function* getTaskLists() { + const deprecatedTasks = new DeprecatedTasks(); + try { + const results = yield apiFetch( { + path: WC_ADMIN_NAMESPACE + '/onboarding/tasks', + method: deprecatedTasks.hasDeprecatedTasks() ? 'POST' : 'GET', + data: deprecatedTasks.getPostData(), + } ); + + deprecatedTasks.mergeDeprecatedCallbackFunctions( results ); + + yield getTaskListsSuccess( results ); + } catch ( error ) { + yield getTaskListsError( error ); + } +} + +export function* getTaskListsByIds() { + yield resolveSelect( STORE_NAME, 'getTaskLists' ); +} + +export function* getTaskList() { + yield resolveSelect( STORE_NAME, 'getTaskLists' ); +} + +export function* getTask() { + yield resolveSelect( STORE_NAME, 'getTaskLists' ); +} + +export function* getPaymentGatewaySuggestions() { + try { + const results = yield apiFetch( { + path: WC_ADMIN_NAMESPACE + '/payment-gateway-suggestions', + method: 'GET', + } ); + + yield setPaymentMethods( results ); + } catch ( error ) { + yield setError( 'getPaymentGatewaySuggestions', error ); + } +} + +export function* getFreeExtensions() { + try { + const results = yield apiFetch( { + path: WC_ADMIN_NAMESPACE + '/onboarding/free-extensions', + method: 'GET', + } ); + + yield getFreeExtensionsSuccess( results ); + } catch ( error ) { + yield getFreeExtensionsError( error ); + } +} + +export function* getProductTypes() { + try { + const results = yield apiFetch( { + path: WC_ADMIN_NAMESPACE + '/onboarding/product-types', + method: 'GET', + } ); + + yield getProductTypesSuccess( results ); + } catch ( error ) { + yield getProductTypesError( error ); + } +} diff --git a/packages/js/data/src/onboarding/selectors.ts b/packages/js/data/src/onboarding/selectors.ts new file mode 100644 index 00000000000..2d8f2253cee --- /dev/null +++ b/packages/js/data/src/onboarding/selectors.ts @@ -0,0 +1,208 @@ +/** + * External dependencies + */ +import createSelector from 'rememo'; + +/** + * Internal dependencies + */ +import { TaskType, TaskListType } from './types'; +import { WPDataSelectors } from '../types'; +import { Plugin } from '../plugins/types'; + +export const getFreeExtensions = ( + state: OnboardingState +): ExtensionList[] => { + return state.freeExtensions || []; +}; + +export const getProfileItems = ( + state: OnboardingState +): ProfileItemsState | Record< string, never > => { + return state.profileItems || {}; +}; + +const EMPTY_ARRAY: Product[] = []; + +export const getTaskLists = createSelector( + ( state: OnboardingState ): TaskListType[] => { + return Object.values( state.taskLists ); + }, + ( state: OnboardingState ) => [ state.taskLists ] +); + +export const getTaskListsByIds = createSelector( + ( state: OnboardingState, ids: string[] ): TaskListType[] => { + return ids.map( ( id ) => state.taskLists[ id ] ); + }, + ( state: OnboardingState, ids: string[] ) => + ids.map( ( id ) => state.taskLists[ id ] ) +); + +export const getTaskList = ( + state: OnboardingState, + selector: string +): TaskListType | undefined => { + return state.taskLists[ selector ]; +}; + +export const getTask = ( + state: OnboardingState, + selector: string +): TaskType | undefined => { + return Object.keys( state.taskLists ).reduce( + ( value: TaskType | undefined, listId: string ) => { + return ( + value || + state.taskLists[ listId ].tasks.find( + ( task ) => task.id === selector + ) + ); + }, + undefined + ); +}; + +export const getPaymentGatewaySuggestions = ( + state: OnboardingState +): Plugin[] => { + return state.paymentMethods || []; +}; + +export const getOnboardingError = ( + state: OnboardingState, + selector: string +): unknown | false => { + return state.errors[ selector ] || false; +}; + +export const isOnboardingRequesting = ( + state: OnboardingState, + selector: string +): boolean => { + return state.requesting[ selector ] || false; +}; + +export const getEmailPrefill = ( state: OnboardingState ): string => { + return state.emailPrefill || ''; +}; + +export const getProductTypes = ( state: OnboardingState ): Product[] => { + return state.productTypes || EMPTY_ARRAY; +}; + +// Types +export type OnboardingSelectors = { + getProfileItems: () => ReturnType< typeof getProfileItems >; + getPaymentGatewaySuggestions: () => ReturnType< + typeof getPaymentGatewaySuggestions + >; + getOnboardingError: () => ReturnType< typeof getOnboardingError >; + isOnboardingRequesting: () => ReturnType< typeof isOnboardingRequesting >; + getTaskListsByIds: ( + ids: string[] + ) => ReturnType< typeof getTaskListsByIds >; + getTaskLists: () => ReturnType< typeof getTaskLists >; + getTaskList: ( id: string ) => ReturnType< typeof getTaskList >; + getFreeExtensions: () => ReturnType< typeof getFreeExtensions >; +} & WPDataSelectors; + +export type OnboardingState = { + freeExtensions: ExtensionList[]; + profileItems: ProfileItemsState; + taskLists: Record< string, TaskListType >; + paymentMethods: Plugin[]; + productTypes: Product[]; + emailPrefill: string; + // TODO clarify what the error record's type is + errors: Record< string, unknown >; + requesting: Record< string, boolean >; +}; + +export type Industry = { + slug: string; +}; + +export type ProductCount = '0' | '1-10' | '11-100' | '101 - 1000' | '1000+'; + +export type ProductTypeSlug = + | 'physical' + | 'bookings' + | 'download' + | 'memberships' + | 'product-add-ons' + | 'product-bundles' + | 'subscriptions'; + +export type OtherPlatformSlug = + | 'shopify' + | 'bigcommerce' + | 'wix' + | 'amazon' + | 'ebay' + | 'etsy' + | 'squarespace' + | 'other'; + +export type RevenueTypeSlug = + | 'none' + | 'rather-not-say' + | 'up-to-2500' + | '2500-10000' + | '10000-50000' + | '50000-250000' + | 'more-than-250000'; + +export type ProfileItemsState = { + business_extensions: [ ] | null; + completed: boolean | null; + industry: Industry[] | null; + number_employees: string | null; + other_platform: OtherPlatformSlug | null; + other_platform_name: string | null; + product_count: ProductCount | null; + product_types: ProductTypeSlug[] | null; + revenue: RevenueTypeSlug | null; + selling_venues: string | null; + setup_client: boolean | null; + skipped: boolean | null; + theme: string | null; + wccom_connected: boolean | null; + is_agree_marketing: boolean | null; + store_email: string | null; +}; + +export type FieldLocale = { + locale: string; + label: string; +}; + +export type MethodFields = { + name: string; + option?: string; + label?: string; + locales?: FieldLocale[]; + type?: string; + value?: string; +}; + +export type Product = { + default?: boolean; + label: string; + product?: number; +}; + +export type ExtensionList = { + key: string; + title: string; + plugins: Extension[]; +}; + +export type Extension = { + description: string; + key: string; + image_url: string; + manage_url: string; + name: string; + slug: string; +}; diff --git a/packages/js/data/src/onboarding/test/reducer.js b/packages/js/data/src/onboarding/test/reducer.js new file mode 100644 index 00000000000..d7b2efbff84 --- /dev/null +++ b/packages/js/data/src/onboarding/test/reducer.js @@ -0,0 +1,91 @@ +/** + * @jest-environment node + */ + +/** + * Internal dependencies + */ +import reducer, { defaultState } from '../reducer'; +import TYPES from '../action-types'; + +describe( 'plugins reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( defaultState ); + } ); + + it( 'should handle SET_PROFILE_ITEMS', () => { + const state = reducer( + { + profileItems: { previousItem: 'value' }, + }, + { + type: TYPES.SET_PROFILE_ITEMS, + profileItems: { propertyName: 'value' }, + } + ); + + expect( state.profileItems ).toHaveProperty( 'previousItem' ); + expect( state.profileItems ).toHaveProperty( 'propertyName' ); + expect( state.profileItems.propertyName ).toBe( 'value' ); + } ); + + it( 'should handle SET_PROFILE_ITEMS with replace', () => { + const state = reducer( + { + profileItems: { previousItem: 'value' }, + }, + { + type: TYPES.SET_PROFILE_ITEMS, + profileItems: { propertyName: 'value' }, + replace: true, + } + ); + + expect( state.profileItems ).not.toHaveProperty( 'previousItem' ); + expect( state.profileItems ).toHaveProperty( 'propertyName' ); + expect( state.profileItems.propertyName ).toBe( 'value' ); + } ); + + it( 'should handle GET_PAYMENT_METHODS_SUCCESS', () => { + const state = reducer( + { + paymentMethods: [ { previousItem: 'value' } ], + }, + { + type: TYPES.GET_PAYMENT_METHODS_SUCCESS, + paymentMethods: [ { newItem: 'changed' } ], + } + ); + + expect( state.paymentMethods[ 0 ] ).not.toHaveProperty( + 'previousItem' + ); + expect( state.paymentMethods[ 0 ] ).toHaveProperty( 'newItem' ); + expect( state.paymentMethods[ 0 ].newItem ).toBe( 'changed' ); + } ); + + it( 'should handle SET_ERROR', () => { + const state = reducer( defaultState, { + type: TYPES.SET_ERROR, + selector: 'getProfileItems', + error: { code: 'error' }, + } ); + + /* eslint-disable dot-notation */ + expect( state.errors[ 'getProfileItems' ].code ).toBe( 'error' ); + /* eslint-enable dot-notation */ + } ); + + it( 'should handle SET_IS_REQUESTING', () => { + const state = reducer( defaultState, { + type: TYPES.SET_IS_REQUESTING, + selector: 'updateProfileItems', + isRequesting: true, + } ); + + /* eslint-disable dot-notation */ + expect( state.requesting[ 'updateProfileItems' ] ).toBeTruthy(); + /* eslint-enable dot-notation */ + } ); +} ); diff --git a/packages/js/data/src/onboarding/types.ts b/packages/js/data/src/onboarding/types.ts new file mode 100644 index 00000000000..0f51b826452 --- /dev/null +++ b/packages/js/data/src/onboarding/types.ts @@ -0,0 +1,53 @@ +export type TaskType = { + actionLabel?: string; + actionUrl?: string; + content: string; + id: string; + parentId: string; + isComplete: boolean; + isDismissable: boolean; + isDismissed: boolean; + isSnoozed: boolean; + isVisible: boolean; + isSnoozeable: boolean; + isDisabled: boolean; + snoozedUntil: number; + time: string; + title: string; + isVisited: boolean; + additionalInfo: string; + canView: boolean; + isActioned: boolean; + eventPrefix: string; + level: number; + additionalData?: { + woocommerceTaxCountries?: string[]; + taxJarActivated?: boolean; + avalaraActivated?: boolean; + }; +}; + +export type TaskListSection = { + id: string; + title: string; + description: string; + image: string; + tasks: string[]; + isComplete: boolean; +}; + +export type TaskListType = { + id: string; + title: string; + isHidden: boolean; + isVisible: boolean; + isComplete: boolean; + tasks: TaskType[]; + eventPrefix: string; + displayProgressHeader: boolean; + keepCompletedTaskList: 'yes' | 'no'; + sections?: TaskListSection[]; + isToggleable?: boolean; + isCollapsible?: boolean; + isExpandable?: boolean; +}; diff --git a/packages/js/data/src/onboarding/utils.ts b/packages/js/data/src/onboarding/utils.ts new file mode 100644 index 00000000000..11f44817f72 --- /dev/null +++ b/packages/js/data/src/onboarding/utils.ts @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ +import { TaskType } from './types'; + +/** + * Filters tasks to only visible tasks, taking in account snoozed tasks. + */ +export function getVisibleTasks( tasks: TaskType[] ) { + const nowTimestamp = Date.now(); + return tasks.filter( + ( task ) => + ! task.isDismissed && + ( ! task.isSnoozed || task.snoozedUntil < nowTimestamp ) + ); +} diff --git a/packages/js/data/src/onboarding/with-onboarding-hydration.js b/packages/js/data/src/onboarding/with-onboarding-hydration.js new file mode 100644 index 00000000000..b36032bee38 --- /dev/null +++ b/packages/js/data/src/onboarding/with-onboarding-hydration.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { createElement, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +export const withOnboardingHydration = ( data ) => { + let hydratedProfileItems = false; + + return createHigherOrderComponent( + ( OriginalComponent ) => ( props ) => { + const onboardingRef = useRef( data ); + + useSelect( ( select, registry ) => { + if ( ! onboardingRef.current ) { + return; + } + + const { isResolving, hasFinishedResolution } = select( + STORE_NAME + ); + const { + startResolution, + finishResolution, + setProfileItems, + } = registry.dispatch( STORE_NAME ); + + const { profileItems } = onboardingRef.current; + + if ( + profileItems && + ! hydratedProfileItems && + ! isResolving( 'getProfileItems', [] ) && + ! hasFinishedResolution( 'getProfileItems', [] ) + ) { + startResolution( 'getProfileItems', [] ); + setProfileItems( profileItems, true ); + finishResolution( 'getProfileItems', [] ); + + hydratedProfileItems = true; + } + }, [] ); + + return <OriginalComponent { ...props } />; + }, + 'withOnboardingHydration' + ); +}; diff --git a/packages/js/data/src/options/action-types.js b/packages/js/data/src/options/action-types.js new file mode 100644 index 00000000000..e7765ef2e86 --- /dev/null +++ b/packages/js/data/src/options/action-types.js @@ -0,0 +1,9 @@ +const TYPES = { + RECEIVE_OPTIONS: 'RECEIVE_OPTIONS', + SET_IS_REQUESTING: 'SET_IS_REQUESTING', + SET_IS_UPDATING: 'SET_IS_UPDATING', + SET_REQUESTING_ERROR: 'SET_REQUESTING_ERROR', + SET_UPDATING_ERROR: 'SET_UPDATING_ERROR', +}; + +export default TYPES; diff --git a/packages/js/data/src/options/actions.js b/packages/js/data/src/options/actions.js new file mode 100644 index 00000000000..70b9f6931d9 --- /dev/null +++ b/packages/js/data/src/options/actions.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { WC_ADMIN_NAMESPACE } from '../constants'; + +export function receiveOptions( options ) { + return { + type: TYPES.RECEIVE_OPTIONS, + options, + }; +} + +export function setRequestingError( error, name ) { + return { + type: TYPES.SET_REQUESTING_ERROR, + error, + name, + }; +} + +export function setUpdatingError( error ) { + return { + type: TYPES.SET_UPDATING_ERROR, + error, + }; +} + +export function setIsUpdating( isUpdating ) { + return { + type: TYPES.SET_IS_UPDATING, + isUpdating, + }; +} + +export function* updateOptions( data ) { + yield setIsUpdating( true ); + yield receiveOptions( data ); + + try { + const results = yield apiFetch( { + path: WC_ADMIN_NAMESPACE + '/options', + method: 'POST', + data, + } ); + + yield setIsUpdating( false ); + return { success: true, ...results }; + } catch ( error ) { + yield setUpdatingError( error ); + return { success: false, ...error }; + } +} diff --git a/packages/js/data/src/options/constants.ts b/packages/js/data/src/options/constants.ts new file mode 100644 index 00000000000..b6d1b4cbe5f --- /dev/null +++ b/packages/js/data/src/options/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'wc/admin/options'; diff --git a/packages/js/data/src/options/controls.js b/packages/js/data/src/options/controls.js new file mode 100644 index 00000000000..f849a01ca47 --- /dev/null +++ b/packages/js/data/src/options/controls.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import { controls as dataControls } from '@wordpress/data-controls'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { WC_ADMIN_NAMESPACE } from '../constants'; + +let optionNames = []; +const fetches = {}; + +export const batchFetch = ( optionName ) => { + return { + type: 'BATCH_FETCH', + optionName, + }; +}; + +export const controls = { + ...dataControls, + BATCH_FETCH( { optionName } ) { + optionNames.push( optionName ); + + return new Promise( ( resolve ) => { + setTimeout( function () { + if ( fetches[ optionName ] ) { + return fetches[ optionName ].then( ( result ) => { + resolve( result ); + } ); + } + + // Get unique option names. + const names = [ ...new Set( optionNames ) ].join( ',' ); + // Send request for a group of options. + const url = WC_ADMIN_NAMESPACE + '/options?options=' + names; + const fetch = apiFetch( { path: url } ); + fetch.then( ( result ) => resolve( result ) ); + optionNames.forEach( ( option ) => { + fetches[ option ] = fetch; + fetches[ option ].then( () => { + // Delete the fetch after to allow wp data to handle cache invalidation. + delete fetches[ option ]; + } ); + } ); + + // Clear option names after we've sent the request for a group of options. + optionNames = []; + }, 1 ); + } ); + }, +}; diff --git a/packages/js/data/src/options/index.js b/packages/js/data/src/options/index.js new file mode 100644 index 00000000000..8e017be45b3 --- /dev/null +++ b/packages/js/data/src/options/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import { controls } from './controls'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const OPTIONS_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/options/reducer.js b/packages/js/data/src/options/reducer.js new file mode 100644 index 00000000000..759f3ab32ec --- /dev/null +++ b/packages/js/data/src/options/reducer.js @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; + +const optionsReducer = ( + state = { isUpdating: false, requestingErrors: {} }, + { type, options, error, isUpdating, name } +) => { + switch ( type ) { + case TYPES.RECEIVE_OPTIONS: + state = { + ...state, + ...options, + }; + break; + case TYPES.SET_IS_UPDATING: + state = { + ...state, + isUpdating, + }; + break; + case TYPES.SET_REQUESTING_ERROR: + state = { + ...state, + requestingErrors: { + [ name ]: error, + }, + }; + break; + case TYPES.SET_UPDATING_ERROR: + state = { + ...state, + error, + updatingError: error, + isUpdating: false, + }; + break; + } + return state; +}; + +export default optionsReducer; diff --git a/packages/js/data/src/options/resolvers.js b/packages/js/data/src/options/resolvers.js new file mode 100644 index 00000000000..ec1103ea16a --- /dev/null +++ b/packages/js/data/src/options/resolvers.js @@ -0,0 +1,19 @@ +/** + * Internal dependencies + */ +import { receiveOptions, setRequestingError } from './actions'; +import { batchFetch } from './controls'; + +/** + * Request an option value. + * + * @param {string} name - Option name + */ +export function* getOption( name ) { + try { + const result = yield batchFetch( name ); + yield receiveOptions( result ); + } catch ( error ) { + yield setRequestingError( error, name ); + } +} diff --git a/packages/js/data/src/options/selectors.js b/packages/js/data/src/options/selectors.js new file mode 100644 index 00000000000..1883494c8b0 --- /dev/null +++ b/packages/js/data/src/options/selectors.js @@ -0,0 +1,37 @@ +/** + * Get option from state tree. + * + * @param {Object} state - Reducer state + * @param {Array} name - Option name + */ +export const getOption = ( state, name ) => { + return state[ name ]; +}; + +/** + * Determine if an options request resulted in an error. + * + * @param {Object} state - Reducer state + * @param {string} name - Option name + */ +export const getOptionsRequestingError = ( state, name ) => { + return state.requestingErrors[ name ] || false; +}; + +/** + * Determine if options are being updated. + * + * @param {Object} state - Reducer state + */ +export const isOptionsUpdating = ( state ) => { + return state.isUpdating || false; +}; + +/** + * Determine if an options update resulted in an error. + * + * @param {Object} state - Reducer state + */ +export const getOptionsUpdatingError = ( state ) => { + return state.updatingError || false; +}; diff --git a/packages/js/data/src/options/test/reducer.js b/packages/js/data/src/options/test/reducer.js new file mode 100644 index 00000000000..950e118a897 --- /dev/null +++ b/packages/js/data/src/options/test/reducer.js @@ -0,0 +1,63 @@ +/** + * @jest-environment node + */ + +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import TYPES from '../action-types'; + +const defaultState = { isUpdating: false, requestingErrors: {} }; + +describe( 'options reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( 'should handle RECEIVE_OPTIONS', () => { + const state = reducer( defaultState, { + type: TYPES.RECEIVE_OPTIONS, + options: { test_option: 'abc' }, + } ); + + /* eslint-disable dot-notation */ + expect( state.requestingErrors[ 'test_option' ] ).toBeUndefined(); + expect( state[ 'test_option' ] ).toBe( 'abc' ); + /* eslint-enable dot-notation */ + } ); + + it( 'should handle SET_REQUESTING_ERROR', () => { + const state = reducer( defaultState, { + type: TYPES.SET_REQUESTING_ERROR, + error: 'My bad', + name: 'test_option', + } ); + + /* eslint-disable dot-notation */ + expect( state.requestingErrors[ 'test_option' ] ).toBe( 'My bad' ); + expect( state[ 'test_option' ] ).toBeUndefined(); + /* eslint-enable dot-notation */ + } ); + + it( 'should handle SET_UPDATING_ERROR', () => { + const state = reducer( defaultState, { + type: TYPES.SET_UPDATING_ERROR, + error: 'My bad', + } ); + + expect( state.updatingError ).toBe( 'My bad' ); + expect( state.isUpdating ).toBe( false ); + } ); + + it( 'should handle SET_IS_UPDATING', () => { + const state = reducer( defaultState, { + type: TYPES.SET_IS_UPDATING, + isUpdating: true, + } ); + + expect( state.isUpdating ).toBe( true ); + } ); +} ); diff --git a/packages/js/data/src/options/test/with-options-hydration.js b/packages/js/data/src/options/test/with-options-hydration.js new file mode 100644 index 00000000000..750cee08e2f --- /dev/null +++ b/packages/js/data/src/options/test/with-options-hydration.js @@ -0,0 +1,96 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { useSelect } from '@wordpress/data'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { + useOptionsHydration, + withOptionsHydration, +} from '../with-options-hydration'; + +jest.mock( '@wordpress/data', () => ( { + ...jest.requireActual( '@wordpress/data' ), + useSelect: jest.fn(), +} ) ); + +const optionData = { + option: 'val', + option2: 'val2', + option3: 'val3', +}; +const TestHookComponent = () => { + useOptionsHydration( optionData ); + + return <div></div>; +}; + +const TestHigherOrderComponent = withOptionsHydration( optionData )( () => ( + <div></div> +) ); + +describe( 'withOptionsHydration', () => { + const isResolvingMock = jest.fn(); + const hasFinishedMock = jest.fn(); + const startResolutionMock = jest.fn(); + const receiveOptionsMock = jest.fn(); + beforeEach( () => { + useSelect.mockImplementation( ( callback ) => { + callback( + () => ( { + isResolving: isResolvingMock, + hasFinishedResolution: hasFinishedMock, + } ), + { + dispatch: () => ( { + startResolution: startResolutionMock, + finishResolution: jest.fn(), + receiveOptions: receiveOptionsMock, + } ), + } + ); + } ); + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + it.each( [ + [ 'useOptionsHydration', TestHookComponent ], + [ 'withOptionsHydration', TestHigherOrderComponent ], + ] )( + '%s should call receiveOptions and startResolution when options have not been received yet', + ( name, Comp ) => { + isResolvingMock.mockReturnValue( false ); + hasFinishedMock.mockReturnValue( false ); + render( <Comp /> ); + expect( receiveOptionsMock ).toHaveBeenLastCalledWith( { + option3: 'val3', + } ); + expect( receiveOptionsMock ).toHaveBeenCalledTimes( 3 ); + expect( startResolutionMock ).toHaveBeenCalledTimes( 3 ); + } + ); + + it.each( [ + [ 'useOptionsHydration', TestHookComponent ], + [ 'withOptionsHydration', TestHigherOrderComponent ], + ] )( + '%s should not call receiveOptions and startResolution when options have been received', + ( name, Comp ) => { + isResolvingMock.mockReturnValue( false ); + hasFinishedMock.mockReturnValue( true ); + render( <Comp /> ); + expect( receiveOptionsMock ).not.toHaveBeenLastCalledWith( { + option3: 'val3', + } ); + expect( receiveOptionsMock ).toHaveBeenCalledTimes( 0 ); + expect( startResolutionMock ).toHaveBeenCalledTimes( 0 ); + } + ); +} ); diff --git a/packages/js/data/src/options/with-options-hydration.js b/packages/js/data/src/options/with-options-hydration.js new file mode 100644 index 00000000000..2a63512e96d --- /dev/null +++ b/packages/js/data/src/options/with-options-hydration.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { createElement, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +export const useOptionsHydration = ( data ) => { + const dataRef = useRef( data ); + + useSelect( ( select, registry ) => { + if ( ! dataRef.current ) { + return; + } + + const { isResolving, hasFinishedResolution } = select( STORE_NAME ); + const { + startResolution, + finishResolution, + receiveOptions, + } = registry.dispatch( STORE_NAME ); + const names = Object.keys( dataRef.current ); + + names.forEach( ( name ) => { + if ( + ! isResolving( 'getOption', [ name ] ) && + ! hasFinishedResolution( 'getOption', [ name ] ) + ) { + startResolution( 'getOption', [ name ] ); + receiveOptions( { [ name ]: dataRef.current[ name ] } ); + finishResolution( 'getOption', [ name ] ); + } + } ); + }, [] ); +}; + +export const withOptionsHydration = ( data ) => + createHigherOrderComponent( + ( OriginalComponent ) => ( props ) => { + useOptionsHydration( data ); + + return <OriginalComponent { ...props } />; + }, + 'withOptionsHydration' + ); diff --git a/packages/js/data/src/payment-gateways/action-types.ts b/packages/js/data/src/payment-gateways/action-types.ts new file mode 100644 index 00000000000..5c7d71aa311 --- /dev/null +++ b/packages/js/data/src/payment-gateways/action-types.ts @@ -0,0 +1,13 @@ +export enum ACTION_TYPES { + GET_PAYMENT_GATEWAYS_REQUEST = 'GET_PAYMENT_GATEWAYS_REQUEST', + GET_PAYMENT_GATEWAYS_SUCCESS = 'GET_PAYMENT_GATEWAYS_SUCCESS', + GET_PAYMENT_GATEWAYS_ERROR = 'GET_PAYMENT_GATEWAYS_ERROR', + + UPDATE_PAYMENT_GATEWAY_REQUEST = 'UPDATE_PAYMENT_GATEWAY_REQUEST', + UPDATE_PAYMENT_GATEWAY_SUCCESS = 'UPDATE_PAYMENT_GATEWAY_SUCCESS', + UPDATE_PAYMENT_GATEWAY_ERROR = 'UPDATE_PAYMENT_GATEWAY_ERROR', + + GET_PAYMENT_GATEWAY_REQUEST = 'GET_PAYMENT_GATEWAY_REQUEST', + GET_PAYMENT_GATEWAY_SUCCESS = 'GET_PAYMENT_GATEWAY_SUCCESS', + GET_PAYMENT_GATEWAY_ERROR = 'GET_PAYMENT_GATEWAY_ERROR', +} diff --git a/packages/js/data/src/payment-gateways/actions.ts b/packages/js/data/src/payment-gateways/actions.ts new file mode 100644 index 00000000000..a9b6f2d97a7 --- /dev/null +++ b/packages/js/data/src/payment-gateways/actions.ts @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { ACTION_TYPES } from './action-types'; +import { API_NAMESPACE } from './constants'; +import { PaymentGateway } from './types'; +import { RestApiError } from '../types'; + +export function getPaymentGatewaysRequest(): { + type: ACTION_TYPES.GET_PAYMENT_GATEWAYS_REQUEST; +} { + return { + type: ACTION_TYPES.GET_PAYMENT_GATEWAYS_REQUEST, + }; +} + +export function getPaymentGatewaysSuccess( + paymentGateways: PaymentGateway[] +): { + type: ACTION_TYPES.GET_PAYMENT_GATEWAYS_SUCCESS; + paymentGateways: PaymentGateway[]; +} { + return { + type: ACTION_TYPES.GET_PAYMENT_GATEWAYS_SUCCESS, + paymentGateways, + }; +} + +export function getPaymentGatewaysError( + error: RestApiError +): { + type: ACTION_TYPES.GET_PAYMENT_GATEWAYS_ERROR; + error: RestApiError; +} { + return { + type: ACTION_TYPES.GET_PAYMENT_GATEWAYS_ERROR, + error, + }; +} + +export function getPaymentGatewayRequest(): { + type: ACTION_TYPES.GET_PAYMENT_GATEWAY_REQUEST; +} { + return { + type: ACTION_TYPES.GET_PAYMENT_GATEWAY_REQUEST, + }; +} + +export function getPaymentGatewayError( + error: RestApiError +): { + type: ACTION_TYPES.GET_PAYMENT_GATEWAY_ERROR; + error: RestApiError; +} { + return { + type: ACTION_TYPES.GET_PAYMENT_GATEWAY_ERROR, + error, + }; +} + +export function getPaymentGatewaySuccess( + paymentGateway: PaymentGateway +): { + type: ACTION_TYPES.GET_PAYMENT_GATEWAY_SUCCESS; + paymentGateway: PaymentGateway; +} { + return { + type: ACTION_TYPES.GET_PAYMENT_GATEWAY_SUCCESS, + paymentGateway, + }; +} + +export function updatePaymentGatewaySuccess( + paymentGateway: PaymentGateway +): { + type: ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_SUCCESS; + paymentGateway: PaymentGateway; +} { + return { + type: ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_SUCCESS, + paymentGateway, + }; +} +export function updatePaymentGatewayRequest(): { + type: ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_REQUEST; +} { + return { + type: ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_REQUEST, + }; +} + +export function updatePaymentGatewayError( + error: RestApiError +): { + type: ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_ERROR; + error: RestApiError; +} { + return { + type: ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_ERROR, + error, + }; +} + +export function* updatePaymentGateway( + id: string, + data: Partial< PaymentGateway > +) { + try { + yield updatePaymentGatewayRequest(); + const response: PaymentGateway = yield apiFetch( { + method: 'PUT', + path: API_NAMESPACE + '/payment_gateways/' + id, + body: JSON.stringify( data ), + } ); + + if ( response && response.id === id ) { + // Update the already loaded payment gateway list with the new data + yield updatePaymentGatewaySuccess( response ); + return response; + } + } catch ( e ) { + yield updatePaymentGatewayError( e as RestApiError ); + throw e; + } +} + +export type Actions = + | ReturnType< typeof updatePaymentGateway > + | ReturnType< typeof updatePaymentGatewayRequest > + | ReturnType< typeof updatePaymentGatewaySuccess > + | ReturnType< typeof getPaymentGatewaysRequest > + | ReturnType< typeof getPaymentGatewaysSuccess > + | ReturnType< typeof getPaymentGatewaysError > + | ReturnType< typeof getPaymentGatewayRequest > + | ReturnType< typeof getPaymentGatewaySuccess > + | ReturnType< typeof getPaymentGatewayError > + | ReturnType< typeof updatePaymentGatewayRequest > + | ReturnType< typeof updatePaymentGatewayError >; diff --git a/packages/js/data/src/payment-gateways/constants.ts b/packages/js/data/src/payment-gateways/constants.ts new file mode 100644 index 00000000000..5a13095d36e --- /dev/null +++ b/packages/js/data/src/payment-gateways/constants.ts @@ -0,0 +1,2 @@ +export const STORE_KEY = 'wc/payment-gateways'; +export const API_NAMESPACE = 'wc/v3'; diff --git a/packages/js/data/src/payment-gateways/index.ts b/packages/js/data/src/payment-gateways/index.ts new file mode 100644 index 00000000000..8e9d743b6b0 --- /dev/null +++ b/packages/js/data/src/payment-gateways/index.ts @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { registerStore } from '@wordpress/data'; +import { controls } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import * as selectors from './selectors'; +import reducer from './reducer'; +import { STORE_KEY } from './constants'; + +export const PAYMENT_GATEWAYS_STORE_NAME = STORE_KEY; + +registerStore( STORE_KEY, { + actions, + selectors, + resolvers, + controls, + reducer, +} ); diff --git a/packages/js/data/src/payment-gateways/reducer.ts b/packages/js/data/src/payment-gateways/reducer.ts new file mode 100644 index 00000000000..208ecb4d31c --- /dev/null +++ b/packages/js/data/src/payment-gateways/reducer.ts @@ -0,0 +1,99 @@ +/** + * Internal dependencies + */ +import { ACTION_TYPES } from './action-types'; +import { PluginsState, PaymentGateway } from './types'; +import { Actions } from './actions'; + +function updatePaymentGatewayList( + state: PluginsState, + paymentGateway: PaymentGateway +): PluginsState { + const targetIndex = state.paymentGateways.findIndex( + ( gateway ) => gateway.id === paymentGateway.id + ); + + if ( targetIndex === -1 ) { + return { + ...state, + paymentGateways: [ ...state.paymentGateways, paymentGateway ], + isUpdating: false, + }; + } + + return { + ...state, + paymentGateways: [ + ...state.paymentGateways.slice( 0, targetIndex ), + paymentGateway, + ...state.paymentGateways.slice( targetIndex + 1 ), + ], + isUpdating: false, + }; +} + +const reducer = ( + state: PluginsState = { + paymentGateways: [], + isUpdating: false, + errors: {}, + }, + payload?: Actions +): PluginsState => { + if ( payload && 'type' in payload ) { + switch ( payload.type ) { + case ACTION_TYPES.GET_PAYMENT_GATEWAYS_REQUEST: + case ACTION_TYPES.GET_PAYMENT_GATEWAY_REQUEST: + return state; + case ACTION_TYPES.GET_PAYMENT_GATEWAYS_SUCCESS: + return { + ...state, + paymentGateways: payload.paymentGateways, + }; + case ACTION_TYPES.GET_PAYMENT_GATEWAYS_ERROR: + return { + ...state, + errors: { + ...state.errors, + getPaymentGateways: payload.error, + }, + }; + case ACTION_TYPES.GET_PAYMENT_GATEWAY_ERROR: + return { + ...state, + errors: { + ...state.errors, + getPaymentGateway: payload.error, + }, + }; + case ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_REQUEST: + return { + ...state, + isUpdating: true, + }; + case ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_SUCCESS: + return updatePaymentGatewayList( + state, + payload.paymentGateway + ); + case ACTION_TYPES.GET_PAYMENT_GATEWAY_SUCCESS: + return updatePaymentGatewayList( + state, + payload.paymentGateway + ); + + case ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_ERROR: + return { + ...state, + errors: { + ...state.errors, + updatePaymentGateway: payload.error, + }, + isUpdating: false, + }; + } + } + return state; +}; + +export default reducer; diff --git a/packages/js/data/src/payment-gateways/resolvers.ts b/packages/js/data/src/payment-gateways/resolvers.ts new file mode 100644 index 00000000000..13d763c7a2a --- /dev/null +++ b/packages/js/data/src/payment-gateways/resolvers.ts @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { + apiFetch, + dispatch as depreciatedDispatch, +} from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { + getPaymentGatewaysSuccess, + getPaymentGatewaySuccess, + getPaymentGatewaysError, + getPaymentGatewayError, + getPaymentGatewayRequest, + getPaymentGatewaysRequest, +} from './actions'; + +import { API_NAMESPACE, STORE_KEY } from './constants'; +import { PaymentGateway } from './types'; +import { RestApiError } from '../types'; + +// Can be removed in WP 5.9. +const dispatch = + controls && controls.dispatch ? controls.dispatch : depreciatedDispatch; + +export function* getPaymentGateways() { + yield getPaymentGatewaysRequest(); + + try { + const response: Array< PaymentGateway > = yield apiFetch( { + path: API_NAMESPACE + '/payment_gateways', + } ); + yield getPaymentGatewaysSuccess( response ); + for ( let i = 0; i < response.length; i++ ) { + yield dispatch( + STORE_KEY, + 'finishResolution', + 'getPaymentGateway', + [ response[ i ].id ] + ); + } + } catch ( e ) { + yield getPaymentGatewaysError( e as RestApiError ); + } +} + +export function* getPaymentGateway( id: string ) { + yield getPaymentGatewayRequest(); + + try { + const response: PaymentGateway = yield apiFetch( { + path: API_NAMESPACE + '/payment_gateways/' + id, + } ); + + if ( response && response.id ) { + yield getPaymentGatewaySuccess( response ); + return response; + } + } catch ( e ) { + yield getPaymentGatewayError( e as RestApiError ); + } +} diff --git a/packages/js/data/src/payment-gateways/selectors.ts b/packages/js/data/src/payment-gateways/selectors.ts new file mode 100644 index 00000000000..df7bc7f2e9f --- /dev/null +++ b/packages/js/data/src/payment-gateways/selectors.ts @@ -0,0 +1,37 @@ +/** + * Internal dependencies + */ +import { PaymentGateway, PluginsState } from './types'; +import { RestApiError, WPDataSelector, WPDataSelectors } from '../types'; + +export function getPaymentGateway( + state: PluginsState, + id: string +): PaymentGateway | undefined { + return state.paymentGateways.find( + ( paymentGateway ) => paymentGateway.id === id + ); +} + +export function getPaymentGateways( + state: PluginsState +): Array< PaymentGateway > { + return state.paymentGateways; +} + +export function getPaymentGatewayError( + state: PluginsState, + selector: string +): RestApiError | null { + return state.errors[ selector ] || null; +} + +export function isPaymentGatewayUpdating( state: PluginsState ): boolean { + return state.isUpdating || false; +} + +export type PaymentSelectors = { + getPaymentGateway: WPDataSelector< typeof getPaymentGateway >; + getPaymentGateways: WPDataSelector< typeof getPaymentGateways >; + isPaymentGatewayUpdating: WPDataSelector< typeof isPaymentGatewayUpdating >; +} & WPDataSelectors; diff --git a/packages/js/data/src/payment-gateways/test-helpers/stub.ts b/packages/js/data/src/payment-gateways/test-helpers/stub.ts new file mode 100644 index 00000000000..71a9a6d3629 --- /dev/null +++ b/packages/js/data/src/payment-gateways/test-helpers/stub.ts @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ +import { PaymentGateway } from '../types'; + +export const paymentGatewaysStub: PaymentGateway[] = [ + { + id: 'bacs', + title: 'direct bank', + description: 'description', + order: '', + enabled: false, + method_title: 'Direct bank transfer', + method_description: 'method description', + method_supports: [ 'products' ], + settings: { + title: { + id: 'title', + label: 'Title', + description: + 'This controls the title which the user sees during checkout.', + type: 'text', + value: 'direct bank', + default: 'Direct bank transfer', + tip: + 'This controls the title which the user sees during checkout.', + placeholder: '', + }, + }, + }, + { + id: 'test', + title: 'test', + description: 'test', + order: 0, + enabled: false, + method_title: 'test', + method_description: 'method description', + method_supports: [ 'products' ], + settings: { + title: { + id: 'title', + label: 'Title', + description: + 'This controls the title which the user sees during checkout.', + type: 'text', + value: 'direct bank', + default: 'Direct bank transfer', + tip: + 'This controls the title which the user sees during checkout.', + placeholder: '', + }, + }, + }, +]; diff --git a/packages/js/data/src/payment-gateways/test/reducer.ts b/packages/js/data/src/payment-gateways/test/reducer.ts new file mode 100644 index 00000000000..293e7f91798 --- /dev/null +++ b/packages/js/data/src/payment-gateways/test/reducer.ts @@ -0,0 +1,101 @@ +/** + * @jest-environment node + */ + +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import { ACTION_TYPES } from '../action-types'; +import { PluginsState } from '../types'; +import { paymentGatewaysStub } from '../test-helpers/stub'; + +const defaultState: PluginsState = { + paymentGateways: [], + isUpdating: false, + errors: {}, +}; + +const restApiError = { + code: 'error code', + data: { + status: 400, + }, + message: 'error message', +}; + +describe( 'plugins reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( 'should handle UPDATE_PAYMENT_GATEWAY_REQUEST', () => { + const state = reducer( defaultState, { + type: ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_REQUEST, + } ); + + expect( state.isUpdating ).toBe( true ); + } ); + + it( 'should handle GET_PAYMENT_GATEWAYS_ERROR', () => { + const state = reducer( defaultState, { + type: ACTION_TYPES.GET_PAYMENT_GATEWAYS_ERROR, + error: restApiError, + } ); + + expect( state.errors.getPaymentGateways ).toBe( restApiError ); + } ); + + it( 'should handle GET_PAYMENT_GATEWAY_ERROR', () => { + const state = reducer( defaultState, { + type: ACTION_TYPES.GET_PAYMENT_GATEWAY_ERROR, + error: restApiError, + } ); + + expect( state.errors.getPaymentGateway ).toBe( restApiError ); + } ); + + it( 'should handle UPDATE_PAYMENT_GATEWAY_ERROR', () => { + const state = reducer( defaultState, { + type: ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_ERROR, + error: restApiError, + } ); + + expect( state.errors.updatePaymentGateway ).toBe( restApiError ); + expect( state.isUpdating ).toBe( false ); + } ); + + it( 'should handle GET_PAYMENT_GATEWAYS_SUCCESS', () => { + const state = reducer( defaultState, { + type: ACTION_TYPES.GET_PAYMENT_GATEWAYS_SUCCESS, + paymentGateways: paymentGatewaysStub, + } ); + + expect( state.paymentGateways ).toHaveLength( 2 ); + expect( state.paymentGateways ).toBe( paymentGatewaysStub ); + } ); + + it( 'should replace an existing payment gateway on UPDATE_PAYMENT_GATEWAY_SUCCESS', () => { + const updatedPaymentGateway = { + ...paymentGatewaysStub[ 1 ], + description: 'update test', + }; + const state = reducer( + { + ...defaultState, + paymentGateways: paymentGatewaysStub, + }, + { + type: ACTION_TYPES.UPDATE_PAYMENT_GATEWAY_SUCCESS, + paymentGateway: updatedPaymentGateway, + } + ); + + expect( state.paymentGateways[ 1 ].id ).toBe( + paymentGatewaysStub[ 1 ].id + ); + expect( state.paymentGateways[ 1 ].description ).toBe( 'update test' ); + } ); +} ); diff --git a/packages/js/data/src/payment-gateways/types.ts b/packages/js/data/src/payment-gateways/types.ts new file mode 100644 index 00000000000..d8017aabfe8 --- /dev/null +++ b/packages/js/data/src/payment-gateways/types.ts @@ -0,0 +1,33 @@ +/** + * Internal dependencies + */ +import { RestApiError } from '../types'; + +export type SettingDefinition = { + default: string; + description: string; + id: string; + label: string; + placeholder: string; + tip: string; + type: string; + value: string; +}; + +export type PaymentGateway = { + id: string; + title: string; + description: string; + order: number | ''; + enabled: boolean; + method_title: string; + method_description: string; + method_supports: string[]; + settings: Record< string, SettingDefinition >; +}; + +export type PluginsState = { + paymentGateways: PaymentGateway[]; + isUpdating: boolean; + errors: Record< string, RestApiError >; +}; diff --git a/packages/js/data/src/plugins/action-types.ts b/packages/js/data/src/plugins/action-types.ts new file mode 100644 index 00000000000..2df914b2dbd --- /dev/null +++ b/packages/js/data/src/plugins/action-types.ts @@ -0,0 +1,10 @@ +export enum ACTION_TYPES { + UPDATE_ACTIVE_PLUGINS = 'UPDATE_ACTIVE_PLUGINS', + UPDATE_INSTALLED_PLUGINS = 'UPDATE_INSTALLED_PLUGINS', + SET_IS_REQUESTING = 'SET_IS_REQUESTING', + SET_ERROR = 'SET_ERROR', + UPDATE_JETPACK_CONNECTION = 'UPDATE_JETPACK_CONNECTION', + UPDATE_JETPACK_CONNECT_URL = 'UPDATE_JETPACK_CONNECT_URL', + SET_PAYPAL_ONBOARDING_STATUS = 'SET_PAYPAL_ONBOARDING_STATUS', + SET_RECOMMENDED_PLUGINS = 'SET_RECOMMENDED_PLUGINS', +} diff --git a/packages/js/data/src/plugins/actions.ts b/packages/js/data/src/plugins/actions.ts new file mode 100644 index 00000000000..d0708da2259 --- /dev/null +++ b/packages/js/data/src/plugins/actions.ts @@ -0,0 +1,424 @@ +/** + * External dependencies + */ +import { + apiFetch, + select, + dispatch as depreciatedDispatch, +} from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; +import { _n, sprintf } from '@wordpress/i18n'; +import { DispatchFromMap } from '@automattic/data-stores'; + +/** + * Internal dependencies + */ +import { pluginNames, STORE_NAME } from './constants'; +import { ACTION_TYPES as TYPES } from './action-types'; +import { WC_ADMIN_NAMESPACE } from '../constants'; +import { WPError } from '../types'; +import { + PaypalOnboardingStatus, + PluginNames, + SelectorKeysWithActions, + RecommendedTypes, +} from './types'; + +// Can be removed in WP 5.9, wp.data is supported in >5.7. +const dispatch = + controls && controls.dispatch ? controls.dispatch : depreciatedDispatch; +const resolveSelect = + controls && controls.resolveSelect ? controls.resolveSelect : select; + +type PluginsResponse< PluginData > = { + data: PluginData; + errors: WPError< PluginNames >; + success: boolean; + message: string; +} & Response; + +export type InstallPluginsResponse = PluginsResponse< { + installed: string[]; + results: Record< string, boolean >; + install_time?: Record< string, number >; +} >; + +type ActivatePluginsResponse = PluginsResponse< { + activated: string[]; + active: string[]; +} >; + +function isWPError( + error: WPError< PluginNames > | Error | string +): error is WPError< PluginNames > { + return ( error as WPError ).errors !== undefined; +} + +class PluginError extends Error { + constructor( message: string, public data: unknown ) { + super( message ); + } +} + +export function formatErrors( + response: WPError< PluginNames > | Error | string +): string { + if ( isWPError( response ) ) { + // Replace the slug with a plugin name if a constant exists. + ( Object.keys( response.errors ) as PluginNames[] ).forEach( + ( plugin ) => { + response.errors[ plugin ] = response.errors[ plugin ].map( + ( pluginError ) => { + return pluginNames[ plugin ] + ? pluginError.replace( + `\`${ plugin }\``, + pluginNames[ plugin ] + ) + : pluginError; + } + ); + } + ); + } else if ( typeof response === 'string' ) { + return response; + } else { + return response.message; + } + return ''; +} + +const formatErrorMessage = ( + pluginErrors: Record< PluginNames, string[] >, + actionType = 'install' +) => { + return sprintf( + /* translators: %(actionType): install or activate (the plugin). %(pluginName): a plugin slug (e.g. woocommerce-services). %(error): a single error message or in plural a comma separated error message list.*/ + _n( + 'Could not %(actionType)s %(pluginName)s plugin, %(error)s', + 'Could not %(actionType)s the following plugins: %(pluginName)s with these Errors: %(error)s', + Object.keys( pluginErrors ).length || 1, + 'woocommerce' + ), + { + actionType, + pluginName: Object.keys( pluginErrors ).join( ', ' ), + error: Object.values( pluginErrors ).join( ', \n' ), + } + ); +}; + +export function updateActivePlugins( + active: string[], + replace = false +): { type: TYPES.UPDATE_ACTIVE_PLUGINS; active: string[]; replace?: boolean } { + return { + type: TYPES.UPDATE_ACTIVE_PLUGINS, + active, + replace, + }; +} + +export function updateInstalledPlugins( + installed: string[], + replace = false +): { + type: TYPES.UPDATE_INSTALLED_PLUGINS; + installed: string[]; + replace?: boolean; +} { + return { + type: TYPES.UPDATE_INSTALLED_PLUGINS, + installed, + replace, + }; +} + +export function setIsRequesting( + selector: SelectorKeysWithActions, + isRequesting: boolean +): { + type: TYPES.SET_IS_REQUESTING; + selector: SelectorKeysWithActions; + isRequesting: boolean; +} { + return { + type: TYPES.SET_IS_REQUESTING, + selector, + isRequesting, + }; +} + +export function setError( + selector: SelectorKeysWithActions, + error: Partial< Record< PluginNames, string[] > > +): { + type: TYPES.SET_ERROR; + selector: SelectorKeysWithActions; + error: Partial< Record< PluginNames, string[] > >; +} { + return { + type: TYPES.SET_ERROR, + selector, + error, + }; +} + +export function updateIsJetpackConnected( + jetpackConnection: boolean +): { + type: TYPES.UPDATE_JETPACK_CONNECTION; + jetpackConnection: boolean; +} { + return { + type: TYPES.UPDATE_JETPACK_CONNECTION, + jetpackConnection, + }; +} + +export function updateJetpackConnectUrl( + redirectUrl: string, + jetpackConnectUrl: string +): { + type: TYPES.UPDATE_JETPACK_CONNECT_URL; + redirectUrl: string; + jetpackConnectUrl: string; +} { + return { + type: TYPES.UPDATE_JETPACK_CONNECT_URL, + jetpackConnectUrl, + redirectUrl, + }; +} + +export function* installPlugins( plugins: string[] ) { + yield setIsRequesting( 'installPlugins', true ); + + try { + const results: InstallPluginsResponse = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/plugins/install`, + method: 'POST', + data: { plugins: plugins.join( ',' ) }, + } ); + + if ( results.data.installed.length ) { + yield updateInstalledPlugins( results.data.installed ); + } + + if ( Object.keys( results.errors.errors ).length ) { + throw results.errors.errors; + } + + yield setIsRequesting( 'installPlugins', false ); + + return results; + } catch ( error ) { + if ( error instanceof Error && plugins.length === 1 ) { + // Incase of a network error + error = { [ plugins[ 0 ] ]: error.message }; + } + const errors = error as WPError[ 'errors' ]; + yield setError( 'installPlugins', errors ); + throw new PluginError( formatErrorMessage( errors ), errors ); + } +} + +export function* activatePlugins( plugins: string[] ) { + yield setIsRequesting( 'activatePlugins', true ); + + try { + const results: ActivatePluginsResponse = yield apiFetch( { + path: `${ WC_ADMIN_NAMESPACE }/plugins/activate`, + method: 'POST', + data: { plugins: plugins.join( ',' ) }, + } ); + + if ( results.data.activated.length ) { + yield updateActivePlugins( results.data.activated ); + } + + if ( Object.keys( results.errors.errors ).length ) { + throw results.errors.errors; + } + + yield setIsRequesting( 'activatePlugins', false ); + + return results; + } catch ( error ) { + if ( error instanceof Error && plugins.length === 1 ) { + // Incase of a network error + error = { [ plugins[ 0 ] ]: error.message }; + } + const errors = error as WPError[ 'errors' ]; + yield setError( 'installPlugins', errors ); + throw new PluginError( formatErrorMessage( errors ), errors ); + } +} + +export function* installAndActivatePlugins( plugins: string[] ) { + try { + const installations: InstallPluginsResponse = yield dispatch( + STORE_NAME, + 'installPlugins', + plugins + ); + const activations: InstallPluginsResponse = yield dispatch( + STORE_NAME, + 'activatePlugins', + plugins + ); + return { + ...activations, + data: { + ...activations.data, + ...installations.data, + }, + }; + } catch ( error ) { + throw error; + } +} + +export const createErrorNotice = ( errorMessage: string ) => { + return dispatch( 'core/notices', 'createNotice', 'error', errorMessage ); +}; + +export function* connectToJetpack( + getAdminLink: ( endpoint: string ) => string +) { + const url: string = yield resolveSelect( + STORE_NAME, + 'getJetpackConnectUrl', + { + redirect_url: getAdminLink( 'admin.php?page=wc-admin' ), + } + ); + const error: string = yield resolveSelect( + STORE_NAME, + 'getPluginsError', + 'getJetpackConnectUrl' + ); + + if ( error ) { + throw new Error( error ); + } else { + return url; + } +} + +export function* installJetpackAndConnect( + errorAction: ( errorMesage: string ) => void, + getAdminLink: ( endpoint: string ) => string +) { + try { + yield dispatch( STORE_NAME, 'installPlugins', [ 'jetpack' ] ); + yield dispatch( STORE_NAME, 'activatePlugins', [ 'jetpack' ] ); + + const url: string = yield dispatch( + STORE_NAME, + 'connectToJetpack', + getAdminLink + ); + window.location.href = url; + } catch ( error ) { + if ( error instanceof Error ) { + yield errorAction( error.message ); + } else { + throw error; + } + } +} + +export function* connectToJetpackWithFailureRedirect( + failureRedirect: string, + errorAction: ( errorMesage: string ) => void, + getAdminLink: ( endpoint: string ) => string +) { + try { + const url: string = yield dispatch( + STORE_NAME, + 'connectToJetpack', + getAdminLink + ); + window.location.href = url; + } catch ( error ) { + if ( error instanceof Error ) { + yield errorAction( error.message ); + } else { + throw error; + } + window.location.href = failureRedirect; + } +} + +export function setPaypalOnboardingStatus( + status: Partial< PaypalOnboardingStatus > +): { + type: TYPES.SET_PAYPAL_ONBOARDING_STATUS; + paypalOnboardingStatus: Partial< PaypalOnboardingStatus >; +} { + return { + type: TYPES.SET_PAYPAL_ONBOARDING_STATUS, + paypalOnboardingStatus: status, + }; +} + +export function setRecommendedPlugins( + type: string, + plugins: Plugin[] +): { + type: TYPES.SET_RECOMMENDED_PLUGINS; + recommendedType: string; + plugins: Plugin[]; +} { + return { + type: TYPES.SET_RECOMMENDED_PLUGINS, + recommendedType: type, + plugins, + }; +} + +const SUPPORTED_TYPES = [ 'payments' ]; +export function* dismissRecommendedPlugins( type: RecommendedTypes ) { + if ( ! SUPPORTED_TYPES.includes( type ) ) { + return []; + } + const plugins: Plugin[] = yield resolveSelect( + STORE_NAME, + 'getRecommendedPlugins', + type + ); + yield setRecommendedPlugins( type, [] ); + + let success: boolean; + try { + const url = WC_ADMIN_NAMESPACE + '/payment-gateway-suggestions/dismiss'; + success = yield apiFetch( { + path: url, + method: 'POST', + } ); + } catch ( error ) { + success = false; + } + if ( ! success ) { + // Reset recommended plugins + yield setRecommendedPlugins( type, plugins ); + } + return success; +} + +export type Actions = + | ReturnType< typeof updateActivePlugins > + | ReturnType< typeof updateInstalledPlugins > + | ReturnType< typeof setIsRequesting > + | ReturnType< typeof setError > + | ReturnType< typeof updateIsJetpackConnected > + | ReturnType< typeof updateJetpackConnectUrl > + | ReturnType< typeof setPaypalOnboardingStatus > + | ReturnType< typeof setRecommendedPlugins >; + +// Types +export type ActionDispatchers = DispatchFromMap< { + installJetpackAndConnect: typeof installJetpackAndConnect; + installAndActivatePlugins: typeof installAndActivatePlugins; + dismissRecommendedPlugins: typeof dismissRecommendedPlugins; +} >; diff --git a/packages/js/data/src/plugins/constants.ts b/packages/js/data/src/plugins/constants.ts new file mode 100644 index 00000000000..50fbb1985f2 --- /dev/null +++ b/packages/js/data/src/plugins/constants.ts @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +export const STORE_NAME = 'wc/admin/plugins'; +export const PAYPAL_NAMESPACE = '/wc-paypal/v1'; + +/** + * Plugin slugs and names as key/value pairs. + */ +export const pluginNames = { + 'facebook-for-woocommerce': __( 'Facebook for WooCommerce', 'woocommerce' ), + jetpack: __( 'Jetpack', 'woocommerce' ), + 'klarna-checkout-for-woocommerce': __( + 'Klarna Checkout for WooCommerce', + 'woocommerce' + ), + 'klarna-payments-for-woocommerce': __( + 'Klarna Payments for WooCommerce', + 'woocommerce' + ), + 'mailchimp-for-woocommerce': __( + 'Mailchimp for WooCommerce', + 'woocommerce' + ), + 'creative-mail-by-constant-contact': __( + 'Creative Mail for WooCommerce', + 'woocommerce' + ), + 'woocommerce-gateway-paypal-express-checkout': __( + 'WooCommerce PayPal', + 'woocommerce' + ), + 'woocommerce-gateway-stripe': __( 'WooCommerce Stripe', 'woocommerce' ), + 'woocommerce-payfast-gateway': __( 'WooCommerce PayFast', 'woocommerce' ), + 'woocommerce-payments': __( 'WooCommerce Payments', 'woocommerce' ), + 'woocommerce-services': __( 'WooCommerce Shipping & Tax', 'woocommerce' ), + 'woocommerce-services:shipping': __( + 'WooCommerce Shipping & Tax', + 'woocommerce' + ), + 'woocommerce-services:tax': __( + 'WooCommerce Shipping & Tax', + 'woocommerce' + ), + 'woocommerce-shipstation-integration': __( + 'WooCommerce ShipStation Gateway', + 'woocommerce' + ), + 'woocommerce-mercadopago': __( + 'Mercado Pago payments for WooCommerce', + 'woocommerce' + ), + 'google-listings-and-ads': __( 'Google Listings and Ads', 'woocommerce' ), + 'woo-razorpay': __( 'Razorpay', 'woocommerce' ), + mailpoet: __( 'MailPoet', 'woocommerce' ), + 'pinterest-for-woocommerce': __( + 'Pinterest for WooCommerce', + 'woocommerce' + ), +}; diff --git a/packages/js/data/src/plugins/index.ts b/packages/js/data/src/plugins/index.ts new file mode 100644 index 00000000000..3e18474c8a4 --- /dev/null +++ b/packages/js/data/src/plugins/index.ts @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { registerStore } from '@wordpress/data'; +import { controls } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const PLUGINS_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/plugins/reducer.ts b/packages/js/data/src/plugins/reducer.ts new file mode 100644 index 00000000000..29758e89e4e --- /dev/null +++ b/packages/js/data/src/plugins/reducer.ts @@ -0,0 +1,125 @@ +/** + * External dependencies + */ +import { concat } from 'lodash'; + +/** + * Internal dependencies + */ +import { ACTION_TYPES as TYPES } from './action-types'; +import { Actions } from './actions'; +import { PluginsState } from './types'; + +const plugins = ( + state: PluginsState = { + active: [], + installed: [], + requesting: {}, + errors: {}, + jetpackConnectUrls: {}, + recommended: {}, + }, + payload?: Actions +): PluginsState => { + if ( payload && 'type' in payload ) { + switch ( payload.type ) { + case TYPES.UPDATE_ACTIVE_PLUGINS: + state = { + ...state, + active: payload.replace + ? payload.active + : ( concat( + state.active, + payload.active + ) as string[] ), + requesting: { + ...state.requesting, + getActivePlugins: false, + activatePlugins: false, + }, + errors: { + ...state.errors, + getActivePlugins: false, + activatePlugins: false, + }, + }; + break; + case TYPES.UPDATE_INSTALLED_PLUGINS: + state = { + ...state, + installed: payload.replace + ? payload.installed + : ( concat( + state.installed, + payload.installed + ) as string[] ), + requesting: { + ...state.requesting, + getInstalledPlugins: false, + installPlugins: false, + }, + errors: { + ...state.errors, + getInstalledPlugins: false, + installPlugin: false, + }, + }; + break; + case TYPES.SET_IS_REQUESTING: + state = { + ...state, + requesting: { + ...state.requesting, + [ payload.selector ]: payload.isRequesting, + }, + }; + break; + case TYPES.SET_ERROR: + state = { + ...state, + requesting: { + ...state.requesting, + [ payload.selector ]: false, + }, + errors: { + ...state.errors, + [ payload.selector ]: payload.error, + }, + }; + break; + case TYPES.UPDATE_JETPACK_CONNECTION: + state = { + ...state, + jetpackConnection: payload.jetpackConnection, + }; + break; + case TYPES.UPDATE_JETPACK_CONNECT_URL: + state = { + ...state, + jetpackConnectUrls: { + ...state.jetpackConnectUrls, + [ payload.redirectUrl ]: payload.jetpackConnectUrl, + }, + }; + break; + case TYPES.SET_PAYPAL_ONBOARDING_STATUS: + state = { + ...state, + paypalOnboardingStatus: payload.paypalOnboardingStatus, + }; + break; + case TYPES.SET_RECOMMENDED_PLUGINS: + state = { + ...state, + recommended: { + ...state.recommended, + [ payload.recommendedType ]: payload.plugins, + }, + }; + break; + } + } + return state; +}; + +export default plugins; diff --git a/packages/js/data/src/plugins/resolvers.ts b/packages/js/data/src/plugins/resolvers.ts new file mode 100644 index 00000000000..628b4ffd4d2 --- /dev/null +++ b/packages/js/data/src/plugins/resolvers.ts @@ -0,0 +1,195 @@ +/** + * External dependencies + */ +import { apiFetch, select } from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { WC_ADMIN_NAMESPACE, JETPACK_NAMESPACE } from '../constants'; +import { OPTIONS_STORE_NAME } from '../options'; +import { PAYPAL_NAMESPACE, STORE_NAME } from './constants'; +import { + setIsRequesting, + updateActivePlugins, + setError, + updateInstalledPlugins, + updateIsJetpackConnected, + updateJetpackConnectUrl, + setPaypalOnboardingStatus, + setRecommendedPlugins, +} from './actions'; +import { PaypalOnboardingStatus, RecommendedTypes } from './types'; +import { WPError } from '../types'; + +// Can be removed in WP 5.9, wp.data is supported in >5.7. +const resolveSelect = + controls && controls.resolveSelect ? controls.resolveSelect : select; +type PluginGetResponse = { + plugins: string[]; +} & Response; + +type JetpackConnectionResponse = { + isActive: boolean; +} & Response; + +type ConnectJetpackResponse = { + slug: 'jetpack'; + name: string; + connectAction: string; +} & Response; + +export function* getActivePlugins() { + yield setIsRequesting( 'getActivePlugins', true ); + try { + const url = WC_ADMIN_NAMESPACE + '/plugins/active'; + const results: PluginGetResponse = yield apiFetch( { + path: url, + method: 'GET', + } ); + + yield updateActivePlugins( results.plugins, true ); + } catch ( error ) { + yield setError( 'getActivePlugins', error as WPError[ 'errors' ] ); + } +} + +export function* getInstalledPlugins() { + yield setIsRequesting( 'getInstalledPlugins', true ); + + try { + const url = WC_ADMIN_NAMESPACE + '/plugins/installed'; + const results: PluginGetResponse = yield apiFetch( { + path: url, + method: 'GET', + } ); + + yield updateInstalledPlugins( results.plugins, true ); + } catch ( error ) { + yield setError( 'getInstalledPlugins', error as WPError[ 'errors' ] ); + } +} + +export function* isJetpackConnected() { + yield setIsRequesting( 'isJetpackConnected', true ); + + try { + const url = JETPACK_NAMESPACE + '/connection'; + const results: JetpackConnectionResponse = yield apiFetch( { + path: url, + method: 'GET', + } ); + + yield updateIsJetpackConnected( results.isActive ); + } catch ( error ) { + yield setError( 'isJetpackConnected', error as WPError[ 'errors' ] ); + } + + yield setIsRequesting( 'isJetpackConnected', false ); +} + +export function* getJetpackConnectUrl( query: { redirect_url: string } ) { + yield setIsRequesting( 'getJetpackConnectUrl', true ); + + try { + const url = addQueryArgs( + WC_ADMIN_NAMESPACE + '/plugins/connect-jetpack', + query + ); + const results: ConnectJetpackResponse = yield apiFetch( { + path: url, + method: 'GET', + } ); + + yield updateJetpackConnectUrl( + query.redirect_url, + results.connectAction + ); + } catch ( error ) { + yield setError( 'getJetpackConnectUrl', error as WPError[ 'errors' ] ); + } + + yield setIsRequesting( 'getJetpackConnectUrl', false ); +} + +function* setOnboardingStatusWithOptions() { + const options: { + merchant_email_production: string; + merchant_id_production: string; + client_id_production: string; + client_secret_production: string; + } = yield resolveSelect( + OPTIONS_STORE_NAME, + 'getOption', + 'woocommerce-ppcp-settings' + ); + const onboarded = + options.merchant_email_production && + options.merchant_id_production && + options.client_id_production && + options.client_secret_production; + yield setPaypalOnboardingStatus( { + production: { + state: onboarded ? 'onboarded' : 'unknown', + onboarded: onboarded ? true : false, + }, + } ); +} + +export function* getPaypalOnboardingStatus() { + yield setIsRequesting( 'getPaypalOnboardingStatus', true ); + + const errorData: { + data?: { status: number }; + } = yield resolveSelect( + STORE_NAME, + 'getPluginsError', + 'getPaypalOnboardingStatus' + ); + if ( errorData && errorData.data && errorData.data.status === 404 ) { + // The get-status request doesn't exist fall back to using options. + yield setOnboardingStatusWithOptions(); + } else { + try { + const url = PAYPAL_NAMESPACE + '/onboarding/get-status'; + const results: PaypalOnboardingStatus = yield apiFetch( { + path: url, + method: 'GET', + } ); + + yield setPaypalOnboardingStatus( results ); + } catch ( error ) { + yield setOnboardingStatusWithOptions(); + yield setError( + 'getPaypalOnboardingStatus', + error as WPError[ 'errors' ] + ); + } + } + + yield setIsRequesting( 'getPaypalOnboardingStatus', false ); +} + +const SUPPORTED_TYPES = [ 'payments' ]; +export function* getRecommendedPlugins( type: RecommendedTypes ) { + if ( ! SUPPORTED_TYPES.includes( type ) ) { + return []; + } + yield setIsRequesting( 'getRecommendedPlugins', true ); + + try { + const url = WC_ADMIN_NAMESPACE + '/payment-gateway-suggestions'; + const results: Plugin[] = yield apiFetch( { + path: url, + method: 'GET', + } ); + + yield setRecommendedPlugins( type, results ); + } catch ( error ) { + yield setError( 'getRecommendedPlugins', error as WPError[ 'errors' ] ); + } + + yield setIsRequesting( 'getRecommendedPlugins', false ); +} diff --git a/packages/js/data/src/plugins/selectors.ts b/packages/js/data/src/plugins/selectors.ts new file mode 100644 index 00000000000..c9f678c455c --- /dev/null +++ b/packages/js/data/src/plugins/selectors.ts @@ -0,0 +1,73 @@ +/** + * Internal dependencies + */ +import { WPDataSelector, WPDataSelectors } from '../types'; +import { + PluginsState, + RecommendedTypes, + SelectorKeysWithActions, +} from './types'; + +export const getActivePlugins = ( state: PluginsState ) => { + return state.active || []; +}; + +export const getInstalledPlugins = ( state: PluginsState ) => { + return state.installed || []; +}; + +export const isPluginsRequesting = ( + state: PluginsState, + selector: SelectorKeysWithActions +) => { + return state.requesting[ selector ] || false; +}; + +export const getPluginsError = ( + state: PluginsState, + selector: SelectorKeysWithActions +) => { + return state.errors[ selector ] || false; +}; + +export const isJetpackConnected = ( state: PluginsState ) => + state.jetpackConnection; + +export const getJetpackConnectUrl = ( + state: PluginsState, + query: { redirect_url: string } +) => { + return state.jetpackConnectUrls[ query.redirect_url ]; +}; + +export const getPluginInstallState = ( + state: PluginsState, + plugin: string +) => { + if ( state.active.includes( plugin ) ) { + return 'activated'; + } else if ( state.installed.includes( plugin ) ) { + return 'installed'; + } + + return 'unavailable'; +}; + +export const getPaypalOnboardingStatus = ( state: PluginsState ) => + state.paypalOnboardingStatus; + +export const getRecommendedPlugins = ( + state: PluginsState, + type: RecommendedTypes +) => { + return state.recommended[ type ]; +}; + +// Types +export type PluginSelectors = { + getActivePlugins: WPDataSelector< typeof getActivePlugins >; + getInstalledPlugins: WPDataSelector< typeof getInstalledPlugins >; + getRecommendedPlugins: WPDataSelector< typeof getRecommendedPlugins >; + isJetpackConnected: WPDataSelector< typeof isJetpackConnected >; + isPluginsRequesting: WPDataSelector< typeof isPluginsRequesting >; +} & WPDataSelectors; diff --git a/packages/js/data/src/plugins/test/actions.ts b/packages/js/data/src/plugins/test/actions.ts new file mode 100644 index 00000000000..88c7c6392e7 --- /dev/null +++ b/packages/js/data/src/plugins/test/actions.ts @@ -0,0 +1,151 @@ +/** + * @jest-environment node + */ + +jest.mock( '@wordpress/data-controls', () => ( { + apiFetch: jest.fn(), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + controls: { + dispatch: jest.fn(), + select: jest.fn(), + }, +} ) ); + +/** + * External dependencies + */ +import { controls } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { + installJetpackAndConnect, + connectToJetpackWithFailureRedirect, +} from '../actions'; +import { STORE_NAME } from '../constants'; + +// Tests run faster in node env, and we just need access to the window global for this test +global.window = { + location: { + href: '', + } as Location, +} as Window & typeof globalThis; + +function getAdminLink( path: string ) { + return path; +} + +describe( 'installJetPackAndConnect', () => { + beforeEach( () => { + jest.clearAllMocks(); + } ); + + it( 'installs jetpack, then activates it', () => { + const installer = installJetpackAndConnect( () => '', getAdminLink ); + + // Run to first yield + installer.next(); + + // Run the install + installer.next(); + + expect( controls.dispatch ).toHaveBeenCalledWith( + STORE_NAME, + 'installPlugins', + [ 'jetpack' ] + ); + + // Run the activate + installer.next(); + + expect( controls.dispatch ).toHaveBeenCalledWith( + STORE_NAME, + 'activatePlugins', + [ 'jetpack' ] + ); + } ); + + it( 'calls the passed error handler if an exception is thrown into the generator', () => { + const errorHandler = jest.fn(); + const installer = installJetpackAndConnect( + errorHandler, + getAdminLink + ); + + // Run to first yield + installer.next(); + + // Throw error into generator + installer.throw( new Error( 'Failed!' ) ); + + expect( errorHandler ).toHaveBeenCalledWith( 'Failed!' ); + } ); + + it( 'redirects to the connect url if there are no errors', () => { + const installer = installJetpackAndConnect( jest.fn(), getAdminLink ); + + // Run to yield any errors from getJetpackConnectUrl + installer.next(); + installer.next(); + installer.next(); + installer.next( 'https://example.com' ); + installer.next(); + + expect( global.window.location.href ).toBe( 'https://example.com' ); + } ); +} ); + +describe( 'connectToJetpack', () => { + it( 'redirects to the failure url if there is an error', () => { + const connect = connectToJetpackWithFailureRedirect( + 'https://example.com/failure', + jest.fn(), + getAdminLink + ); + + connect.next(); + connect.throw( new Error( 'Failed' ) ); + connect.next(); + + expect( global.window.location.href ).toBe( + 'https://example.com/failure' + ); + } ); + + it( 'redirects to the jetpack url if there is no error', () => { + const connect = connectToJetpackWithFailureRedirect( + 'https://example.com/failure', + jest.fn(), + getAdminLink + ); + + connect.next(); + connect.next( 'https://example.com/success' ); + connect.next(); + connect.next(); + + expect( global.window.location.href ).toBe( + 'https://example.com/success' + ); + } ); + + it( 'calls the passed error handler if an exception is thrown into the generator', () => { + const errorHandler = jest.fn(); + const connect = connectToJetpackWithFailureRedirect( + '', + errorHandler, + getAdminLink + ); + + // Run to first yield + connect.next(); + + // Throw error into generator + connect.throw( new Error( 'Failed!' ) ); + + expect( errorHandler ).toHaveBeenCalledWith( 'Failed!' ); + } ); +} ); diff --git a/packages/js/data/src/plugins/test/reducer.ts b/packages/js/data/src/plugins/test/reducer.ts new file mode 100644 index 00000000000..65ba5980313 --- /dev/null +++ b/packages/js/data/src/plugins/test/reducer.ts @@ -0,0 +1,177 @@ +/** + * @jest-environment node + */ + +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import { ACTION_TYPES as TYPES } from '../action-types'; +import { PluginsState } from '../types'; +import { Actions } from '../actions'; + +const defaultState: PluginsState = { + active: [], + installed: [], + requesting: {}, + errors: {}, + jetpackConnectUrls: {}, + recommended: {}, +}; + +describe( 'plugins reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( 'should handle UPDATE_ACTIVE_PLUGINS with replace', () => { + const state = reducer( + { + ...defaultState, + active: [ 'plugins', 'to', 'overwrite' ], + }, + { + type: TYPES.UPDATE_ACTIVE_PLUGINS, + active: [ 'jetpack' ], + replace: true, + } as Actions + ); + + /* eslint-disable dot-notation */ + + expect( state.requesting[ 'getActivePlugins' ] ).toBe( false ); + expect( state.errors[ 'getActivePlugins' ] ).toBe( false ); + /* eslint-enable dot-notation */ + + expect( state.active ).toHaveLength( 1 ); + expect( state.active[ 0 ] ).toBe( 'jetpack' ); + } ); + + it( 'should handle UPDATE_ACTIVE_PLUGINS with active plugins', () => { + const state = reducer( + { + ...defaultState, + active: [ 'jetpack' ], + installed: [ 'jetpack' ], + requesting: {}, + errors: {}, + }, + { + type: TYPES.UPDATE_ACTIVE_PLUGINS, + active: [ 'woocommerce-services' ], + } as Actions + ); + + /* eslint-disable dot-notation */ + + expect( state.requesting[ 'getActivePlugins' ] ).toBe( false ); + expect( state.errors[ 'getActivePlugins' ] ).toBe( false ); + /* eslint-enable dot-notation */ + + expect( state.active ).toHaveLength( 2 ); + expect( state.active[ 1 ] ).toBe( 'woocommerce-services' ); + } ); + + it( 'should handle UPDATE_INSTALLED_PLUGINS with replace', () => { + const state = reducer( + { + ...defaultState, + active: [ 'plugins', 'to', 'overwrite' ], + }, + { + type: TYPES.UPDATE_INSTALLED_PLUGINS, + installed: [ 'jetpack' ], + replace: true, + } as Actions + ); + + /* eslint-disable dot-notation */ + + expect( state.requesting[ 'getInstalledPlugins' ] ).toBe( false ); + expect( state.errors[ 'getInstalledPlugins' ] ).toBe( false ); + /* eslint-enable dot-notation */ + + expect( state.installed ).toHaveLength( 1 ); + expect( state.installed[ 0 ] ).toBe( 'jetpack' ); + } ); + + it( 'should handle UPDATE_INSTALLED_PLUGINS with installed plugins', () => { + const state = reducer( + { + ...defaultState, + active: [ 'jetpack' ], + installed: [ 'jetpack' ], + requesting: {}, + errors: {}, + }, + { + type: TYPES.UPDATE_INSTALLED_PLUGINS, + installed: [ 'woocommerce-services' ], + } as Actions + ); + + /* eslint-disable dot-notation */ + + expect( state.requesting[ 'getInstalledPlugins' ] ).toBe( false ); + expect( state.errors[ 'getInstalledPlugins' ] ).toBe( false ); + /* eslint-enable dot-notation */ + + expect( state.installed ).toHaveLength( 2 ); + expect( state.installed[ 1 ] ).toBe( 'woocommerce-services' ); + } ); + + it( 'should handle SET_IS_REQUESTING', () => { + const state = reducer( defaultState, { + type: TYPES.SET_IS_REQUESTING, + selector: 'getInstalledPlugins', + isRequesting: true, + } as Actions ); + + /* eslint-disable dot-notation */ + + expect( state.requesting[ 'getInstalledPlugins' ] ).toBeTruthy(); + /* eslint-enable dot-notation */ + } ); + + it( 'should handle SET_ERROR', () => { + const state = reducer( defaultState, { + type: TYPES.SET_ERROR, + selector: 'getInstalledPlugins', + error: { jetpack: [ 'error' ] }, + } as Actions ); + + /* eslint-disable dot-notation */ + + expect( + ( state.errors[ 'getInstalledPlugins' ] as Record< + string, + string[] + > ).jetpack[ 0 ] + ).toBe( 'error' ); + expect( state.requesting[ 'getInstalledPlugins' ] ).toBe( false ); + /* eslint-enable dot-notation */ + } ); + + it( 'should handle UPDATE_JETPACK_CONNECTION', () => { + const state = reducer( defaultState, { + type: TYPES.UPDATE_JETPACK_CONNECTION, + jetpackConnection: true, + } as Actions ); + + expect( state.jetpackConnection ).toBe( true ); + } ); + + it( 'should handle UPDATE_JETPACK_CONNECT_URL', () => { + const state = reducer( defaultState, { + type: TYPES.UPDATE_JETPACK_CONNECT_URL, + jetpackConnectUrl: 'http://connect.com', + redirectUrl: 'http://redirect.com', + } as Actions ); + + expect( state.jetpackConnectUrls[ 'http://redirect.com' ] ).toBe( + 'http://connect.com' + ); + } ); +} ); diff --git a/packages/js/data/src/plugins/types.ts b/packages/js/data/src/plugins/types.ts new file mode 100644 index 00000000000..c1f62fb7afd --- /dev/null +++ b/packages/js/data/src/plugins/types.ts @@ -0,0 +1,63 @@ +/** + * Internal dependencies + */ +import { pluginNames } from './constants'; + +export type RecommendedTypes = 'payments'; + +export type PluginNames = keyof typeof pluginNames; + +export type SelectorKeysWithActions = + | 'getActivePlugins' + | 'getInstalledPlugins' + | 'getRecommendedPlugins' + | 'installPlugins' + | 'activatePlugins' + | 'isJetpackConnected' + | 'getJetpackConnectUrl' + | 'getPaypalOnboardingStatus'; + +export type PluginsState = { + active: string[]; + installed: string[]; + requesting: Partial< Record< SelectorKeysWithActions, boolean > >; + jetpackConnectUrls: Record< string, unknown >; + jetpackConnection?: boolean; + recommended: Partial< Record< RecommendedTypes, Plugin[] > >; + paypalOnboardingStatus?: Partial< PaypalOnboardingStatus >; + // TODO clarify what the error record's type is + errors: Record< string, unknown >; +}; + +export type Plugin = { + id: string; + content: string; + plugins: string[]; + title: string; + category_additional: string[]; + category_other: string[]; + image: string; + image_72x72?: string; + square_image?: string; + recommendation_priority?: number; + is_visible?: boolean; + is_local_partner?: boolean; + is_offline?: boolean; + actionText?: string; + recommended?: boolean; +}; + +type PaypalOnboardingState = 'unknown' | 'start' | 'progressive' | 'onboarded'; +export type PaypalOnboardingStatus = { + environment: string; + onboarded: boolean; + state: PaypalOnboardingState; + sandbox: { + state: PaypalOnboardingState; + onboarded: boolean; + }; + production: { + state: PaypalOnboardingState; + onboarded: boolean; + }; +}; diff --git a/packages/js/data/src/plugins/with-plugins-hydration.tsx b/packages/js/data/src/plugins/with-plugins-hydration.tsx new file mode 100644 index 00000000000..70620b35522 --- /dev/null +++ b/packages/js/data/src/plugins/with-plugins-hydration.tsx @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { createElement, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import { WCDataSelector, WPDataActions } from '../'; +import * as actions from './actions'; + +type PluginHydrationData = { + installedPlugins: string[]; + activePlugins: string[]; + jetpackStatus?: { isActive: boolean }; +}; +export const withPluginsHydration = ( data: PluginHydrationData ) => + createHigherOrderComponent( + ( OriginalComponent: React.ComponentType ) => ( + props: Record< string, unknown > + ) => { + const dataRef = useRef( data ); + + useSelect( + ( + select: WCDataSelector, + registry: { + dispatch: ( + store: string + ) => typeof actions & WPDataActions; + } + ) => { + if ( ! dataRef.current ) { + return; + } + + const { isResolving, hasFinishedResolution } = select( + STORE_NAME + ); + const { + startResolution, + finishResolution, + updateActivePlugins, + updateInstalledPlugins, + updateIsJetpackConnected, + } = registry.dispatch( STORE_NAME ); + + if ( + ! isResolving( 'getActivePlugins', [] ) && + ! hasFinishedResolution( 'getActivePlugins', [] ) + ) { + startResolution( 'getActivePlugins', [] ); + startResolution( 'getInstalledPlugins', [] ); + startResolution( 'isJetpackConnected', [] ); + updateActivePlugins( + dataRef.current.activePlugins, + true + ); + updateInstalledPlugins( + dataRef.current.installedPlugins, + true + ); + updateIsJetpackConnected( + dataRef.current.jetpackStatus && + dataRef.current.jetpackStatus.isActive + ? true + : false + ); + finishResolution( 'getActivePlugins', [] ); + finishResolution( 'getInstalledPlugins', [] ); + finishResolution( 'isJetpackConnected', [] ); + } + }, + [] + ); + + return <OriginalComponent { ...props } />; + }, + 'withPluginsHydration' + ); diff --git a/packages/js/data/src/reports/action-types.js b/packages/js/data/src/reports/action-types.js new file mode 100644 index 00000000000..5ca88da5207 --- /dev/null +++ b/packages/js/data/src/reports/action-types.js @@ -0,0 +1,8 @@ +const TYPES = { + SET_ITEM_ERROR: 'SET_ITEM_ERROR', + SET_STAT_ERROR: 'SET_STAT_ERROR', + SET_REPORT_ITEMS: 'SET_REPORT_ITEMS', + SET_REPORT_STATS: 'SET_REPORT_STATS', +}; + +export default TYPES; diff --git a/packages/js/data/src/reports/actions.js b/packages/js/data/src/reports/actions.js new file mode 100644 index 00000000000..b6449e659f0 --- /dev/null +++ b/packages/js/data/src/reports/actions.js @@ -0,0 +1,45 @@ +/** + * Internal dependencies + */ +import { getResourceName } from '../utils'; +import TYPES from './action-types'; + +export function setReportItemsError( endpoint, query, error ) { + const resourceName = getResourceName( endpoint, query ); + + return { + type: TYPES.SET_ITEM_ERROR, + resourceName, + error, + }; +} + +export function setReportItems( endpoint, query, items ) { + const resourceName = getResourceName( endpoint, query ); + + return { + type: TYPES.SET_REPORT_ITEMS, + resourceName, + items, + }; +} + +export function setReportStats( endpoint, query, stats ) { + const resourceName = getResourceName( endpoint, query ); + + return { + type: TYPES.SET_REPORT_STATS, + resourceName, + stats, + }; +} + +export function setReportStatsError( endpoint, query, error ) { + const resourceName = getResourceName( endpoint, query ); + + return { + type: TYPES.SET_STAT_ERROR, + resourceName, + error, + }; +} diff --git a/packages/js/data/src/reports/constants.ts b/packages/js/data/src/reports/constants.ts new file mode 100644 index 00000000000..fdf8db22cf4 --- /dev/null +++ b/packages/js/data/src/reports/constants.ts @@ -0,0 +1,4 @@ +/** + * Internal dependencies + */ +export const STORE_NAME = 'wc/admin/reports'; diff --git a/packages/js/data/src/reports/index.js b/packages/js/data/src/reports/index.js new file mode 100644 index 00000000000..0172e6a3cdc --- /dev/null +++ b/packages/js/data/src/reports/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import controls from '../controls'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const REPORTS_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/reports/reducer.js b/packages/js/data/src/reports/reducer.js new file mode 100644 index 00000000000..562aea0dc23 --- /dev/null +++ b/packages/js/data/src/reports/reducer.js @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; + +const reports = ( + state = { + itemErrors: {}, + items: {}, + statErrors: {}, + stats: {}, + }, + { type, items, stats, error, resourceName } +) => { + switch ( type ) { + case TYPES.SET_REPORT_ITEMS: + return { + ...state, + items: { ...state.items, [ resourceName ]: items }, + }; + case TYPES.SET_REPORT_STATS: + return { + ...state, + stats: { ...state.stats, [ resourceName ]: stats }, + }; + case TYPES.SET_ITEM_ERROR: + return { + ...state, + itemErrors: { + ...state.itemErrors, + [ resourceName ]: error, + }, + }; + case TYPES.SET_STAT_ERROR: + return { + ...state, + statErrors: { + ...state.statErrors, + [ resourceName ]: error, + }, + }; + default: + return state; + } +}; + +export default reports; diff --git a/packages/js/data/src/reports/resolvers.js b/packages/js/data/src/reports/resolvers.js new file mode 100644 index 00000000000..80224a3eaed --- /dev/null +++ b/packages/js/data/src/reports/resolvers.js @@ -0,0 +1,75 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { fetchWithHeaders } from '../controls'; +import { NAMESPACE } from '../constants'; +import { + setReportItemsError, + setReportStatsError, + setReportItems, + setReportStats, +} from './actions'; + +export function* getReportItems( endpoint, query ) { + const fetchArgs = { + parse: false, + path: addQueryArgs( `${ NAMESPACE }/reports/${ endpoint }`, query ), + }; + + try { + const response = yield fetchWithHeaders( fetchArgs ); + const data = response.data; + const totalResults = parseInt( + response.headers.get( 'x-wp-total' ), + 10 + ); + const totalPages = parseInt( + response.headers.get( 'x-wp-totalpages' ), + 10 + ); + + yield setReportItems( endpoint, query, { + data, + totalResults, + totalPages, + } ); + } catch ( error ) { + yield setReportItemsError( endpoint, query, error ); + } +} + +export function* getReportStats( endpoint, query ) { + const fetchArgs = { + parse: false, + path: addQueryArgs( + `${ NAMESPACE }/reports/${ endpoint }/stats`, + query + ), + }; + + try { + const response = yield fetchWithHeaders( fetchArgs ); + const data = response.data; + const totalResults = parseInt( + response.headers.get( 'x-wp-total' ), + 10 + ); + const totalPages = parseInt( + response.headers.get( 'x-wp-totalpages' ), + 10 + ); + + yield setReportStats( endpoint, query, { + data, + totalResults, + totalPages, + } ); + } catch ( error ) { + yield setReportStatsError( endpoint, query, error ); + } +} diff --git a/packages/js/data/src/reports/selectors.js b/packages/js/data/src/reports/selectors.js new file mode 100644 index 00000000000..6ac94167cc1 --- /dev/null +++ b/packages/js/data/src/reports/selectors.js @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import { getResourceName } from '../utils'; + +const EMPTY_OBJECT = {}; + +export const getReportItemsError = ( state, endpoint, query ) => { + const resourceName = getResourceName( endpoint, query ); + return state.itemErrors[ resourceName ] || false; +}; + +export const getReportItems = ( state, endpoint, query ) => { + const resourceName = getResourceName( endpoint, query ); + return state.items[ resourceName ] || EMPTY_OBJECT; +}; + +export const getReportStats = ( state, endpoint, query ) => { + const resourceName = getResourceName( endpoint, query ); + return state.stats[ resourceName ] || EMPTY_OBJECT; +}; + +export const getReportStatsError = ( state, endpoint, query ) => { + const resourceName = getResourceName( endpoint, query ); + return state.statErrors[ resourceName ] || false; +}; diff --git a/packages/js/data/src/reports/test/reducer.js b/packages/js/data/src/reports/test/reducer.js new file mode 100644 index 00000000000..9303f407840 --- /dev/null +++ b/packages/js/data/src/reports/test/reducer.js @@ -0,0 +1,72 @@ +/** + * @jest-environment node + */ + +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import TYPES from '../action-types'; + +const defaultState = { + itemErrors: {}, + items: {}, + statErrors: {}, + stats: {}, +}; + +describe( 'reports reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( 'should handle SET_REPORT_ITEMS', () => { + const state = reducer( defaultState, { + type: TYPES.SET_REPORT_ITEMS, + resourceName: 'test-resource-items', + items: [ 1, 2 ], + } ); + + expect( state.items ).toHaveProperty( 'test-resource-items' ); + expect( state.items[ 'test-resource-items' ] ).toContain( 1 ); + expect( state.items[ 'test-resource-items' ] ).toContain( 2 ); + } ); + + it( 'should handle SET_REPORT_STATS', () => { + const state = reducer( defaultState, { + type: TYPES.SET_REPORT_STATS, + resourceName: 'test-resource-stats', + stats: [ 3, 4 ], + } ); + + expect( state.stats ).toHaveProperty( 'test-resource-stats' ); + expect( state.stats[ 'test-resource-stats' ] ).toContain( 3 ); + expect( state.stats[ 'test-resource-stats' ] ).toContain( 4 ); + } ); + + it( 'should handle SET_ITEM_ERROR', () => { + const state = reducer( defaultState, { + type: TYPES.SET_ITEM_ERROR, + resourceName: 'test-resource-items', + error: { code: 'error' }, + } ); + + expect( state.itemErrors[ 'test-resource-items' ].code ).toBe( + 'error' + ); + } ); + + it( 'should handle SET_STAT_ERROR', () => { + const state = reducer( defaultState, { + type: TYPES.SET_STAT_ERROR, + resourceName: 'test-resource-stats', + error: { code: 'error' }, + } ); + + expect( state.statErrors[ 'test-resource-stats' ].code ).toBe( + 'error' + ); + } ); +} ); diff --git a/packages/js/data/src/reports/utils.js b/packages/js/data/src/reports/utils.js new file mode 100644 index 00000000000..367391c4827 --- /dev/null +++ b/packages/js/data/src/reports/utils.js @@ -0,0 +1,558 @@ +/** + * External dependencies + */ +import { find, forEach, isNull, get, includes, memoize } from 'lodash'; +import moment from 'moment'; +import { + appendTimestamp, + getCurrentDates, + getIntervalForQuery, +} from '@woocommerce/date'; +import { + flattenFilters, + getActiveFiltersFromQuery, + getQueryFromActiveFilters, +} from '@woocommerce/navigation'; +import deprecated from '@wordpress/deprecated'; + +/** + * Internal dependencies + */ +import * as reportsUtils from './utils'; +import { MAX_PER_PAGE, QUERY_DEFAULTS } from '../constants'; +import { STORE_NAME } from './constants'; +import { getResourceName } from '../utils'; + +/** + * Add filters and advanced filters values to a query object. + * + * @param {Object} options arguments + * @param {string} options.endpoint Report API Endpoint + * @param {Object} options.query Query parameters in the url + * @param {Array} options.limitBy Properties used to limit the results. It will be used in the API call to send the IDs. + * @param {Array} [options.filters] config filters + * @param {Object} [options.advancedFilters] config advanced filters + * @return {Object} A query object with the values from filters and advanced fitlters applied. + */ +export function getFilterQuery( options ) { + const { + endpoint, + query, + limitBy, + filters = [], + advancedFilters = {}, + } = options; + if ( query.search ) { + const limitProperties = limitBy || [ endpoint ]; + return limitProperties.reduce( ( result, limitProperty ) => { + result[ limitProperty ] = query[ limitProperty ]; + return result; + }, {} ); + } + + return filters + .map( ( filter ) => + getQueryFromConfig( filter, advancedFilters, query ) + ) + .reduce( + ( result, configQuery ) => Object.assign( result, configQuery ), + {} + ); +} + +// Some stats endpoints don't have interval data, so they can ignore after/before params and omit that part of the response. +const noIntervalEndpoints = [ 'stock', 'customers' ]; + +/** + * Add timestamp to advanced filter parameters involving date. The api + * expects a timestamp for these values similar to `before` and `after`. + * + * @param {Object} config - advancedFilters config object. + * @param {Object} activeFilter - an active filter. + * @return {Object} - an active filter with timestamp added to date values. + */ +export function timeStampFilterDates( config, activeFilter ) { + const advancedFilterConfig = config.filters[ activeFilter.key ]; + if ( get( advancedFilterConfig, [ 'input', 'component' ] ) !== 'Date' ) { + return activeFilter; + } + + const { rule, value } = activeFilter; + const timeOfDayMap = { + after: 'start', + before: 'end', + }; + // If the value is an array, it signifies "between" values which must have a timestamp + // appended to each value. + if ( Array.isArray( value ) ) { + const [ after, before ] = value; + return Object.assign( {}, activeFilter, { + value: [ + appendTimestamp( moment( after ), timeOfDayMap.after ), + appendTimestamp( moment( before ), timeOfDayMap.before ), + ], + } ); + } + + return Object.assign( {}, activeFilter, { + value: appendTimestamp( moment( value ), timeOfDayMap[ rule ] ), + } ); +} + +export function getQueryFromConfig( config, advancedFilters, query ) { + const queryValue = query[ config.param ]; + + if ( ! queryValue ) { + return {}; + } + + if ( queryValue === 'advanced' ) { + const activeFilters = getActiveFiltersFromQuery( + query, + advancedFilters.filters + ); + + if ( activeFilters.length === 0 ) { + return {}; + } + + const filterQuery = getQueryFromActiveFilters( + activeFilters.map( ( filter ) => + timeStampFilterDates( advancedFilters, filter ) + ), + {}, + advancedFilters.filters + ); + + return { + match: query.match || 'all', + ...filterQuery, + }; + } + + const filter = find( flattenFilters( config.filters ), { + value: queryValue, + } ); + + if ( ! filter ) { + return {}; + } + + if ( filter.settings && filter.settings.param ) { + const { param } = filter.settings; + + if ( query[ param ] ) { + return { + [ param ]: query[ param ], + }; + } + + return {}; + } + + return { + [ config.param ]: queryValue, + }; +} + +/** + * Returns true if a report object is empty. + * + * @param {Object} report Report to check + * @param {string} endpoint Endpoint slug + * @return {boolean} True if report is data is empty. + */ +export function isReportDataEmpty( report, endpoint ) { + if ( ! report ) { + return true; + } + if ( ! report.data ) { + return true; + } + if ( ! report.data.totals || isNull( report.data.totals ) ) { + return true; + } + + const checkIntervals = ! includes( noIntervalEndpoints, endpoint ); + if ( + checkIntervals && + ( ! report.data.intervals || report.data.intervals.length === 0 ) + ) { + return true; + } + return false; +} + +/** + * Constructs and returns a query associated with a Report data request. + * + * @param {Object} options arguments + * @param {string} options.endpoint Report API Endpoint + * @param {string} options.dataType 'primary' or 'secondary'. + * @param {Object} options.query Query parameters in the url. + * @param {Array} options.limitBy Properties used to limit the results. It will be used in the API call to send the IDs. + * @param {string} options.defaultDateRange User specified default date range. + * @return {Object} data request query parameters. + */ +function getRequestQuery( options ) { + const { endpoint, dataType, query, fields, defaultDateRange } = options; + const datesFromQuery = getCurrentDates( query, defaultDateRange ); + const interval = getIntervalForQuery( query, defaultDateRange ); + const filterQuery = getFilterQuery( options ); + const end = datesFromQuery[ dataType ].before; + + const noIntervals = includes( noIntervalEndpoints, endpoint ); + return noIntervals + ? { ...filterQuery, fields } + : { + order: 'asc', + interval, + per_page: MAX_PER_PAGE, + after: appendTimestamp( + datesFromQuery[ dataType ].after, + 'start' + ), + before: appendTimestamp( end, 'end' ), + segmentby: query.segmentby, + fields, + ...filterQuery, + }; +} + +/** + * Returns summary number totals needed to render a report page. + * + * @param {Object} options arguments + * @param {string} options.endpoint Report API Endpoint + * @param {Object} options.query Query parameters in the url + * @param {Object} options.select Instance of @wordpress/select + * @param {Array} options.limitBy Properties used to limit the results. It will be used in the API call to send the IDs. + * @param {string} options.defaultDateRange User specified default date range. + * @return {Object} Object containing summary number responses. + */ +export function getSummaryNumbers( options ) { + const { endpoint, select } = options; + const { getReportStats, getReportStatsError, isResolving } = select( + STORE_NAME + ); + const response = { + isRequesting: false, + isError: false, + totals: { + primary: null, + secondary: null, + }, + }; + + const primaryQuery = getRequestQuery( { ...options, dataType: 'primary' } ); + + // Disable eslint rule requiring `getReportStats` to be defined below because the next two statements + // depend on `getReportStats` to have been called. + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const primary = getReportStats( endpoint, primaryQuery ); + + if ( isResolving( 'getReportStats', [ endpoint, primaryQuery ] ) ) { + return { ...response, isRequesting: true }; + } else if ( getReportStatsError( endpoint, primaryQuery ) ) { + return { ...response, isError: true }; + } + + const primaryTotals = + ( primary && primary.data && primary.data.totals ) || null; + + const secondaryQuery = getRequestQuery( { + ...options, + dataType: 'secondary', + } ); + + // Disable eslint rule requiring `getReportStats` to be defined below because the next two statements + // depend on `getReportStats` to have been called. + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const secondary = getReportStats( endpoint, secondaryQuery ); + + if ( isResolving( 'getReportStats', [ endpoint, secondaryQuery ] ) ) { + return { ...response, isRequesting: true }; + } else if ( getReportStatsError( endpoint, secondaryQuery ) ) { + return { ...response, isError: true }; + } + + const secondaryTotals = + ( secondary && secondary.data && secondary.data.totals ) || null; + + return { + ...response, + totals: { primary: primaryTotals, secondary: secondaryTotals }, + }; +} + +/** + * Static responses object to avoid returning new references each call. + */ +const reportChartDataResponses = { + requesting: { + isEmpty: false, + isError: false, + isRequesting: true, + data: { + totals: {}, + intervals: [], + }, + }, + error: { + isEmpty: false, + isError: true, + isRequesting: false, + data: { + totals: {}, + intervals: [], + }, + }, + empty: { + isEmpty: true, + isError: false, + isRequesting: false, + data: { + totals: {}, + intervals: [], + }, + }, +}; + +const EMPTY_ARRAY = []; + +/** + * Cache helper for returning the full chart dataset after multiple + * requests. Memoized on the request query (string), only called after + * all the requests have resolved successfully. + */ +const getReportChartDataResponse = memoize( + ( requestString, totals, intervals ) => ( { + isEmpty: false, + isError: false, + isRequesting: false, + data: { totals, intervals }, + } ), + ( requestString, totals, intervals ) => + [ requestString, totals.length, intervals.length ].join( ':' ) +); + +/** + * Returns all of the data needed to render a chart with summary numbers on a report page. + * + * @param {Object} options arguments + * @param {string} options.endpoint Report API Endpoint + * @param {string} options.dataType 'primary' or 'secondary' + * @param {Object} options.query Query parameters in the url + * @param {Object} options.selector Instance of @wordpress/select response + * @param {Object} options.select (Depreciated) Instance of @wordpress/select + * @param {Array} options.limitBy Properties used to limit the results. It will be used in the API call to send the IDs. + * @param {string} options.defaultDateRange User specified default date range. + * @return {Object} Object containing API request information (response, fetching, and error details) + */ +export function getReportChartData( options ) { + const { endpoint } = options; + let reportSelectors = options.selector; + if ( options.select && ! options.selector ) { + deprecated( 'option.select', { + version: '1.7.0', + hint: + 'You can pass the report selectors through option.selector now.', + } ); + reportSelectors = options.select( STORE_NAME ); + } + const { + getReportStats, + getReportStatsError, + isResolving, + } = reportSelectors; + + const requestQuery = getRequestQuery( options ); + // Disable eslint rule requiring `stats` to be defined below because the next two if statements + // depend on `getReportStats` to have been called. + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const stats = getReportStats( endpoint, requestQuery ); + + if ( isResolving( 'getReportStats', [ endpoint, requestQuery ] ) ) { + return reportChartDataResponses.requesting; + } + + if ( getReportStatsError( endpoint, requestQuery ) ) { + return reportChartDataResponses.error; + } + + if ( isReportDataEmpty( stats, endpoint ) ) { + return reportChartDataResponses.empty; + } + + const totals = ( stats && stats.data && stats.data.totals ) || null; + let intervals = + ( stats && stats.data && stats.data.intervals ) || EMPTY_ARRAY; + + // If we have more than 100 results for this time period, + // we need to make additional requests to complete the response. + if ( stats.totalResults > MAX_PER_PAGE ) { + let isFetching = true; + let isError = false; + const pagedData = []; + const totalPages = Math.ceil( stats.totalResults / MAX_PER_PAGE ); + let pagesFetched = 1; + + for ( let i = 2; i <= totalPages; i++ ) { + const nextQuery = { ...requestQuery, page: i }; + const _data = getReportStats( endpoint, nextQuery ); + if ( isResolving( 'getReportStats', [ endpoint, nextQuery ] ) ) { + continue; + } + if ( getReportStatsError( endpoint, nextQuery ) ) { + isError = true; + isFetching = false; + break; + } + + pagedData.push( _data ); + pagesFetched++; + + if ( pagesFetched === totalPages ) { + isFetching = false; + break; + } + } + + if ( isFetching ) { + return reportChartDataResponses.requesting; + } else if ( isError ) { + return reportChartDataResponses.error; + } + + forEach( pagedData, function ( _data ) { + if ( + _data.data && + _data.data.intervals && + Array.isArray( _data.data.intervals ) + ) { + intervals = intervals.concat( _data.data.intervals ); + } + } ); + } + + return getReportChartDataResponse( + getResourceName( endpoint, requestQuery ), + totals, + intervals + ); +} + +/** + * Returns a formatting function or string to be used by d3-format + * + * @param {string} type Type of number, 'currency', 'number', 'percent', 'average' + * @param {Function} formatAmount format currency function + * @return {string|Function} returns a number format based on the type or an overriding formatting function + */ +export function getTooltipValueFormat( type, formatAmount ) { + switch ( type ) { + case 'currency': + return formatAmount; + case 'percent': + return '.0%'; + case 'number': + return ','; + case 'average': + return ',.2r'; + default: + return ','; + } +} + +/** + * Returns query needed for a request to populate a table. + * + * @param {Object} options arguments + * @param {Object} options.query Query parameters in the url + * @param {Object} options.tableQuery Query parameters specific for that endpoint + * @param {string} options.defaultDateRange User specified default date range. + * @return {Object} Object Table data response + */ +export function getReportTableQuery( options ) { + const { query, tableQuery = {} } = options; + const filterQuery = getFilterQuery( options ); + const datesFromQuery = getCurrentDates( query, options.defaultDateRange ); + + const noIntervals = includes( noIntervalEndpoints, options.endpoint ); + + return { + orderby: query.orderby || 'date', + order: query.order || 'desc', + after: noIntervals + ? undefined + : appendTimestamp( datesFromQuery.primary.after, 'start' ), + before: noIntervals + ? undefined + : appendTimestamp( datesFromQuery.primary.before, 'end' ), + page: query.paged || 1, + per_page: query.per_page || QUERY_DEFAULTS.pageSize, + ...filterQuery, + ...tableQuery, + }; +} + +/** + * Returns table data needed to render a report page. + * + * @param {Object} options arguments + * @param {string} options.endpoint Report API Endpoint + * @param {Object} options.query Query parameters in the url + * @param {Object} options.selector Instance of @wordpress/select response + * @param {Object} options.select (depreciated) Instance of @wordpress/select + * @param {Object} options.tableQuery Query parameters specific for that endpoint + * @param {string} options.defaultDateRange User specified default date range. + * @return {Object} Object Table data response + */ +export function getReportTableData( options ) { + const { endpoint } = options; + let reportSelectors = options.selector; + if ( options.select && ! options.selector ) { + deprecated( 'option.select', { + version: '1.7.0', + hint: + 'You can pass the report selectors through option.selector now.', + } ); + reportSelectors = options.select( STORE_NAME ); + } + const { + getReportItems, + getReportItemsError, + hasFinishedResolution, + } = reportSelectors; + + const tableQuery = reportsUtils.getReportTableQuery( options ); + const response = { + query: tableQuery, + isRequesting: false, + isError: false, + items: { + data: [], + totalResults: 0, + }, + }; + + // Disable eslint rule requiring `items` to be defined below because the next two if statements + // depend on `getReportItems` to have been called. + // eslint-disable-next-line @wordpress/no-unused-vars-before-return + const items = getReportItems( endpoint, tableQuery ); + + const queryResolved = hasFinishedResolution( 'getReportItems', [ + endpoint, + tableQuery, + ] ); + + if ( ! queryResolved ) { + return { ...response, isRequesting: true }; + } + + if ( getReportItemsError( endpoint, tableQuery ) ) { + return { ...response, isError: true }; + } + + return { ...response, items }; +} diff --git a/packages/js/data/src/reviews/action-types.js b/packages/js/data/src/reviews/action-types.js new file mode 100644 index 00000000000..7e2269fe981 --- /dev/null +++ b/packages/js/data/src/reviews/action-types.js @@ -0,0 +1,8 @@ +const TYPES = { + UPDATE_REVIEWS: 'UPDATE_REVIEWS', + SET_REVIEW: 'SET_REVIEW', + SET_ERROR: 'SET_ERROR', + SET_REVIEW_IS_UPDATING: 'SET_REVIEW_IS_UPDATING', +}; + +export default TYPES; diff --git a/packages/js/data/src/reviews/actions.js b/packages/js/data/src/reviews/actions.js new file mode 100644 index 00000000000..ef17604e531 --- /dev/null +++ b/packages/js/data/src/reviews/actions.js @@ -0,0 +1,82 @@ +/** + * External dependencies + */ +import { apiFetch } from '@wordpress/data-controls'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { NAMESPACE } from '../constants'; + +export function updateReviews( query, reviews, totalCount ) { + return { + type: TYPES.UPDATE_REVIEWS, + reviews, + query, + totalCount, + }; +} + +export function* updateReview( reviewId, reviewFields, query ) { + yield setReviewIsUpdating( reviewId, true ); + + try { + const url = addQueryArgs( + `${ NAMESPACE }/products/reviews/${ reviewId }`, + query || {} + ); + const review = yield apiFetch( { + path: url, + method: 'PUT', + data: reviewFields, + } ); + yield setReview( reviewId, review ); + yield setReviewIsUpdating( reviewId, false ); + } catch ( error ) { + yield setError( 'updateReview', error ); + yield setReviewIsUpdating( reviewId, false ); + throw new Error(); + } +} + +export function* deleteReview( reviewId ) { + yield setReviewIsUpdating( reviewId, true ); + + try { + const url = `${ NAMESPACE }/products/reviews/${ reviewId }`; + const response = yield apiFetch( { path: url, method: 'DELETE' } ); + yield setReview( reviewId, response ); + yield setReviewIsUpdating( reviewId, false ); + return response; + } catch ( error ) { + yield setError( 'deleteReview', error ); + yield setReviewIsUpdating( reviewId, false ); + throw new Error(); + } +} + +export function setReviewIsUpdating( reviewId, isUpdating ) { + return { + type: TYPES.SET_REVIEW_IS_UPDATING, + reviewId, + isUpdating, + }; +} + +export function setReview( reviewId, reviewData ) { + return { + type: TYPES.SET_REVIEW, + reviewId, + reviewData, + }; +} + +export function setError( query, error ) { + return { + type: TYPES.SET_ERROR, + query, + error, + }; +} diff --git a/packages/js/data/src/reviews/constants.ts b/packages/js/data/src/reviews/constants.ts new file mode 100644 index 00000000000..56ed28c95b8 --- /dev/null +++ b/packages/js/data/src/reviews/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'wc/admin/reviews'; diff --git a/packages/js/data/src/reviews/index.js b/packages/js/data/src/reviews/index.js new file mode 100644 index 00000000000..8bad9ead046 --- /dev/null +++ b/packages/js/data/src/reviews/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import controls from '../controls'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const REVIEWS_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/reviews/reducer.js b/packages/js/data/src/reviews/reducer.js new file mode 100644 index 00000000000..0a4b06f2e6d --- /dev/null +++ b/packages/js/data/src/reviews/reducer.js @@ -0,0 +1,77 @@ +/** + * Internal dependencies + */ +import TYPES from './action-types'; + +const reducer = ( + state = { + reviews: {}, + errors: {}, + data: {}, + }, + { + type, + query, + reviews, + reviewId, + reviewData, + totalCount, + error, + isUpdating, + } +) => { + switch ( type ) { + case TYPES.UPDATE_REVIEWS: + const ids = []; + const nextReviews = reviews.reduce( ( result, review ) => { + ids.push( review.id ); + result[ review.id ] = { + ...( state.data[ review.id ] || {} ), + ...review, + }; + return result; + }, {} ); + return { + ...state, + reviews: { + ...state.reviews, + [ JSON.stringify( query ) ]: { data: ids, totalCount }, + }, + data: { + ...state.data, + ...nextReviews, + }, + }; + case TYPES.SET_REVIEW: + return { + ...state, + data: { + ...state.data, + [ reviewId ]: reviewData, + }, + }; + case TYPES.SET_ERROR: + return { + ...state, + errors: { + ...state.errors, + [ JSON.stringify( query ) ]: error, + }, + }; + case TYPES.SET_REVIEW_IS_UPDATING: + return { + ...state, + data: { + ...state.data, + [ reviewId ]: { + ...state.data[ reviewId ], + isUpdating, + }, + }, + }; + default: + return state; + } +}; + +export default reducer; diff --git a/packages/js/data/src/reviews/resolvers.js b/packages/js/data/src/reviews/resolvers.js new file mode 100644 index 00000000000..3ff2103b106 --- /dev/null +++ b/packages/js/data/src/reviews/resolvers.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from '../constants'; +import { setError, updateReviews } from './actions'; +import { fetchWithHeaders } from '../controls'; + +export function* getReviews( query ) { + try { + const url = addQueryArgs( `${ NAMESPACE }/products/reviews`, query ); + const response = yield fetchWithHeaders( { + path: url, + method: 'GET', + } ); + + const totalCount = parseInt( response.headers.get( 'x-wp-total' ), 10 ); + yield updateReviews( query, response.data, totalCount ); + } catch ( error ) { + yield setError( query, error ); + } +} + +export function* getReviewsTotalCount( query ) { + yield getReviews( query ); +} diff --git a/packages/js/data/src/reviews/selectors.js b/packages/js/data/src/reviews/selectors.js new file mode 100644 index 00000000000..f969579e99e --- /dev/null +++ b/packages/js/data/src/reviews/selectors.js @@ -0,0 +1,21 @@ +export const getReviews = ( state, query ) => { + const stringifiedQuery = JSON.stringify( query ); + const ids = + ( state.reviews[ stringifiedQuery ] && + state.reviews[ stringifiedQuery ].data ) || + []; + return ids.map( ( id ) => state.data[ id ] ); +}; + +export const getReviewsTotalCount = ( state, query ) => { + const stringifiedQuery = JSON.stringify( query ); + return ( + state.reviews[ stringifiedQuery ] && + state.reviews[ stringifiedQuery ].totalCount + ); +}; + +export const getReviewsError = ( state, query ) => { + const stringifiedQuery = JSON.stringify( query ); + return state.errors[ stringifiedQuery ]; +}; diff --git a/packages/js/data/src/reviews/test/reducer.js b/packages/js/data/src/reviews/test/reducer.js new file mode 100644 index 00000000000..18732816b19 --- /dev/null +++ b/packages/js/data/src/reviews/test/reducer.js @@ -0,0 +1,146 @@ +/** + * @jest-environment node + */ + +/** + * Internal dependencies + */ +import reducer from '../reducer'; +import TYPES from '../action-types'; + +const defaultState = { + reviews: {}, + errors: {}, + data: {}, +}; + +describe( 'reviews reducer', () => { + it( 'should return a default state', () => { + const state = reducer( undefined, {} ); + expect( state ).toEqual( defaultState ); + expect( state ).not.toBe( defaultState ); + } ); + + it( 'should handle UPDATE_REVIEWS', () => { + const reviews = [ + { id: 1, review: 'Yum!' }, + { id: 2, review: 'Dynamite!' }, + ]; + const totalCount = 45; + const query = { status: 'flavortown' }; + const state = reducer( defaultState, { + type: TYPES.UPDATE_REVIEWS, + reviews, + query, + totalCount, + } ); + + const stringifiedQuery = JSON.stringify( query ); + + expect( state.reviews[ stringifiedQuery ].data ).toHaveLength( 2 ); + expect( + state.reviews[ stringifiedQuery ].data.includes( 1 ) + ).toBeTruthy(); + expect( + state.reviews[ stringifiedQuery ].data.includes( 2 ) + ).toBeTruthy(); + + expect( state.reviews[ stringifiedQuery ].totalCount ).toBe( 45 ); + expect( state.data[ '1' ] ).toEqual( reviews[ 0 ] ); + expect( state.data[ '2' ] ).toEqual( reviews[ 1 ] ); + } ); + + it( 'should handle UPDATE_REVIEWS with _fields, only update updated fields', () => { + const reviews = [ { id: 1 }, { id: 2 } ]; + const totalCount = 45; + const query = { status: 'flavortown', _fields: [ 'id' ] }; + const state = reducer( + { + ...defaultState, + data: { + 1: { id: 1, review: 'Yum!' }, + 2: { id: 2, review: 'Dynamite!' }, + }, + }, + { + type: TYPES.UPDATE_REVIEWS, + reviews, + query, + totalCount, + } + ); + + const stringifiedQuery = JSON.stringify( query ); + + expect( state.reviews[ stringifiedQuery ].data ).toHaveLength( 2 ); + expect( + state.reviews[ stringifiedQuery ].data.includes( 1 ) + ).toBeTruthy(); + expect( + state.reviews[ stringifiedQuery ].data.includes( 2 ) + ).toBeTruthy(); + + expect( state.reviews[ stringifiedQuery ].totalCount ).toBe( 45 ); + expect( state.data[ '1' ].review ).toEqual( 'Yum!' ); + expect( state.data[ '2' ].review ).toEqual( 'Dynamite!' ); + } ); + + it( 'should handle SET_ERROR', () => { + const query = { status: 'flavortown' }; + const error = 'Baaam!'; + const state = reducer( defaultState, { + type: TYPES.SET_ERROR, + query, + error, + } ); + + const stringifiedQuery = JSON.stringify( query ); + expect( state.errors[ stringifiedQuery ] ).toBe( error ); + } ); + + it( 'should handle SET_REVIEW', () => { + const state = reducer( + { + ...defaultState, + data: { + 4: { title: 'test' }, + }, + }, + { + type: TYPES.SET_REVIEW, + reviewId: 4, + reviewData: { + title: 'test updated', + }, + } + ); + + expect( state.data[ 4 ].title ).toEqual( 'test updated' ); + } ); + + it( 'should handle SET_REVIEW_IS_UPDATING', () => { + const state = reducer( + { + ...defaultState, + data: { + 4: { title: 'test' }, + }, + }, + { + type: TYPES.SET_REVIEW_IS_UPDATING, + reviewId: 4, + isUpdating: true, + } + ); + + expect( state.data[ 4 ].isUpdating ).toEqual( true ); + + const newstate = reducer( state, { + type: TYPES.SET_REVIEW_IS_UPDATING, + reviewId: 4, + isUpdating: false, + } ); + + expect( newstate.data[ 4 ].isUpdating ).toEqual( false ); + } ); +} ); diff --git a/packages/js/data/src/settings/action-types.js b/packages/js/data/src/settings/action-types.js new file mode 100644 index 00000000000..58830191f16 --- /dev/null +++ b/packages/js/data/src/settings/action-types.js @@ -0,0 +1,9 @@ +const TYPES = { + UPDATE_SETTINGS_FOR_GROUP: 'UPDATE_SETTINGS_FOR_GROUP', + UPDATE_ERROR_FOR_GROUP: 'UPDATE_ERROR_FOR_GROUP', + CLEAR_SETTINGS: 'CLEAR_SETTINGS', + SET_IS_REQUESTING: 'SET_IS_REQUESTING', + CLEAR_IS_DIRTY: 'CLEAR_IS_DIRTY', +}; + +export default TYPES; diff --git a/packages/js/data/src/settings/actions.js b/packages/js/data/src/settings/actions.js new file mode 100644 index 00000000000..d2ec2922a26 --- /dev/null +++ b/packages/js/data/src/settings/actions.js @@ -0,0 +1,119 @@ +/** + * External dependencies + */ + +import { __ } from '@wordpress/i18n'; +import { apiFetch, select } from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; +import { concat } from 'lodash'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from '../constants'; +import { STORE_NAME } from './constants'; +import TYPES from './action-types'; + +// Can be removed in WP 5.9, wp.data is supported in >5.7. +const resolveSelect = + controls && controls.resolveSelect ? controls.resolveSelect : select; + +export function updateSettingsForGroup( group, data, time = new Date() ) { + return { + type: TYPES.UPDATE_SETTINGS_FOR_GROUP, + group, + data, + time, + }; +} + +export function updateErrorForGroup( group, data, error, time = new Date() ) { + return { + type: TYPES.UPDATE_ERROR_FOR_GROUP, + group, + data, + error, + time, + }; +} + +export function setIsRequesting( group, isRequesting ) { + return { + type: TYPES.SET_IS_REQUESTING, + group, + isRequesting, + }; +} + +export function clearIsDirty( group ) { + return { + type: TYPES.CLEAR_IS_DIRTY, + group, + }; +} + +// allows updating and persisting immediately in one action. +export function* updateAndPersistSettingsForGroup( group, data ) { + yield updateSettingsForGroup( group, data ); + yield* persistSettingsForGroup( group ); +} + +// this would replace setSettingsForGroup +export function* persistSettingsForGroup( group ) { + // first dispatch the is persisting action + yield setIsRequesting( group, true ); + // get all dirty keys with select control + const dirtyKeys = yield resolveSelect( STORE_NAME, 'getDirtyKeys', group ); + // if there is nothing dirty, bail + if ( dirtyKeys.length === 0 ) { + yield setIsRequesting( group, false ); + return; + } + + // get data slice for keys + const dirtyData = yield resolveSelect( + STORE_NAME, + 'getSettingsForGroup', + group, + dirtyKeys + ); + const url = `${ NAMESPACE }/settings/${ group }/batch`; + + const update = dirtyKeys.reduce( ( updates, key ) => { + const u = Object.keys( dirtyData[ key ] ).map( ( k ) => { + return { id: k, value: dirtyData[ key ][ k ] }; + } ); + return concat( updates, u ); + }, [] ); + try { + const results = yield apiFetch( { + path: url, + method: 'POST', + data: { update }, + } ); + + yield setIsRequesting( group, false ); + + if ( ! results ) { + throw new Error( + __( + 'There was a problem updating your settings.', + 'woocommerce' + ) + ); + } + + // remove dirtyKeys from map - note we're only doing this if there is no error. + yield clearIsDirty( group ); + } catch ( e ) { + yield updateErrorForGroup( group, null, e ); + yield setIsRequesting( group, false ); + throw e; + } +} + +export function clearSettings() { + return { + type: TYPES.CLEAR_SETTINGS, + }; +} diff --git a/packages/js/data/src/settings/constants.ts b/packages/js/data/src/settings/constants.ts new file mode 100644 index 00000000000..12a988f0e70 --- /dev/null +++ b/packages/js/data/src/settings/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'wc/admin/settings'; diff --git a/packages/js/data/src/settings/index.js b/packages/js/data/src/settings/index.js new file mode 100644 index 00000000000..52efcd292dd --- /dev/null +++ b/packages/js/data/src/settings/index.js @@ -0,0 +1,25 @@ +/** + * External dependencies + */ + +import { registerStore } from '@wordpress/data'; +import { controls } from '@wordpress/data-controls'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; +import * as selectors from './selectors'; +import * as actions from './actions'; +import * as resolvers from './resolvers'; +import reducer from './reducer'; + +registerStore( STORE_NAME, { + reducer, + actions, + controls, + selectors, + resolvers, +} ); + +export const SETTINGS_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/settings/reducer.js b/packages/js/data/src/settings/reducer.js new file mode 100644 index 00000000000..3707229e443 --- /dev/null +++ b/packages/js/data/src/settings/reducer.js @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { union } from 'lodash'; + +/** + * Internal dependencies + */ +import TYPES from './action-types'; +import { getResourceName } from '../utils'; + +const updateGroupDataInNewState = ( + newState, + { group, groupIds, data, time, error } +) => { + groupIds.forEach( ( id ) => { + newState[ getResourceName( group, id ) ] = { + data: data[ id ], + lastReceived: time, + error, + }; + } ); + return newState; +}; + +const receiveSettings = ( + state = {}, + { type, group, data, error, time, isRequesting } +) => { + const newState = {}; + switch ( type ) { + case TYPES.SET_IS_REQUESTING: + state = { + ...state, + [ group ]: { + ...state[ group ], + isRequesting, + }, + }; + break; + case TYPES.CLEAR_IS_DIRTY: + state = { + ...state, + [ group ]: { + ...state[ group ], + dirty: [], + }, + }; + break; + case TYPES.UPDATE_SETTINGS_FOR_GROUP: + case TYPES.UPDATE_ERROR_FOR_GROUP: + const groupIds = data ? Object.keys( data ) : []; + if ( data === null ) { + state = { + ...state, + [ group ]: { + data: state[ group ] ? state[ group ].data : [], + error, + lastReceived: time, + }, + }; + } else { + state = { + ...state, + [ group ]: { + data: + state[ group ] && state[ group ].data + ? [ ...state[ group ].data, ...groupIds ] + : groupIds, + error, + lastReceived: time, + isRequesting: false, + dirty: + state[ group ] && state[ group ].dirty + ? union( state[ group ].dirty, groupIds ) + : groupIds, + }, + ...updateGroupDataInNewState( newState, { + group, + groupIds, + data, + time, + error, + } ), + }; + } + break; + case TYPES.CLEAR_SETTINGS: + state = {}; + } + return state; +}; + +export default receiveSettings; diff --git a/packages/js/data/src/settings/resolvers.js b/packages/js/data/src/settings/resolvers.js new file mode 100644 index 00000000000..d7ea4df2d8f --- /dev/null +++ b/packages/js/data/src/settings/resolvers.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { + apiFetch, + dispatch as depreciatedDispatch, +} from '@wordpress/data-controls'; +import { controls } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from '../constants'; +import { STORE_NAME } from './constants'; +import { updateSettingsForGroup, updateErrorForGroup } from './actions'; + +// Can be removed in WP 5.9. +const dispatch = + controls && controls.dispatch ? controls.dispatch : depreciatedDispatch; + +function settingsToSettingsResource( settings ) { + return settings.reduce( ( resource, setting ) => { + resource[ setting.id ] = setting.value; + return resource; + }, {} ); +} + +export function* getSettings( group ) { + yield dispatch( STORE_NAME, 'setIsRequesting', group, true ); + + try { + const url = NAMESPACE + '/settings/' + group; + const results = yield apiFetch( { + path: url, + method: 'GET', + } ); + + const resource = settingsToSettingsResource( results ); + + return updateSettingsForGroup( group, { [ group ]: resource } ); + } catch ( error ) { + return updateErrorForGroup( group, null, error.message ); + } +} + +export function* getSettingsForGroup( group ) { + return getSettings( group ); +} diff --git a/packages/js/data/src/settings/selectors.js b/packages/js/data/src/settings/selectors.js new file mode 100644 index 00000000000..3b13c13924c --- /dev/null +++ b/packages/js/data/src/settings/selectors.js @@ -0,0 +1,98 @@ +/** + * Internal dependencies + */ +import { getResourceName, getResourcePrefix } from '../utils'; + +export const getSettingsGroupNames = ( state ) => { + const groupNames = new Set( + Object.keys( state ).map( ( resourceName ) => { + return getResourcePrefix( resourceName ); + } ) + ); + return [ ...groupNames ]; +}; + +export const getSettings = ( state, group ) => { + const settings = {}; + const settingIds = ( state[ group ] && state[ group ].data ) || []; + if ( settingIds.length === 0 ) { + return settings; + } + settingIds.forEach( ( id ) => { + settings[ id ] = state[ getResourceName( group, id ) ].data; + } ); + return settings; +}; + +export const getDirtyKeys = ( state, group ) => { + return state[ group ].dirty || []; +}; + +export const getIsDirty = ( state, group, keys = [] ) => { + const dirtyMap = getDirtyKeys( state, group ); + // if empty array bail + if ( dirtyMap.length === 0 ) { + return false; + } + // if at least one of the keys is in the dirty map then the state is dirty + // meaning it hasn't been persisted. + return keys.some( ( key ) => dirtyMap.includes( key ) ); +}; + +export const getSettingsForGroup = ( state, group, keys ) => { + const allSettings = getSettings( state, group ); + return keys.reduce( ( accumulator, key ) => { + accumulator[ key ] = allSettings[ key ] || {}; + return accumulator; + }, {} ); +}; + +export const isUpdateSettingsRequesting = ( state, group ) => { + return state[ group ] && Boolean( state[ group ].isRequesting ); +}; + +/** + * Retrieves a setting value from the setting store. + * + * @param {Object} state State param added by wp.data. + * @param {string} group The settings group. + * @param {string} name The identifier for the setting. + * @param {*} [fallback=false] The value to use as a fallback + * if the setting is not in the + * state. + * @param {Function} [filter=( val ) => val] A callback for filtering the + * value before it's returned. + * Receives both the found value + * (if it exists for the key) and + * the provided fallback arg. + * + * @return {*} The value present in the settings state for the given + * name. + */ +export function getSetting( + state, + group, + name, + fallback = false, + filter = ( val ) => val +) { + const resourceName = getResourceName( group, name ); + const value = + ( state[ resourceName ] && state[ resourceName ].data ) || fallback; + return filter( value, fallback ); +} + +export const getLastSettingsErrorForGroup = ( state, group ) => { + const settingsIds = state[ group ].data; + if ( settingsIds.length === 0 ) { + return state[ group ].error; + } + return [ ...settingsIds ].pop().error; +}; + +export const getSettingsError = ( state, group, id ) => { + if ( ! id ) { + return ( state[ group ] && state[ group ].error ) || false; + } + return state[ getResourceName( group, id ) ].error || false; +}; diff --git a/packages/js/data/src/settings/use-settings.js b/packages/js/data/src/settings/use-settings.js new file mode 100644 index 00000000000..11fabfd8740 --- /dev/null +++ b/packages/js/data/src/settings/use-settings.js @@ -0,0 +1,66 @@ +/** + * External dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +export const useSettings = ( group, settingsKeys = [] ) => { + const { + requestedSettings, + settingsError, + isRequesting, + isDirty, + } = useSelect( + ( select ) => { + const { + getLastSettingsErrorForGroup, + getSettingsForGroup, + getIsDirty, + isUpdateSettingsRequesting, + } = select( STORE_NAME ); + return { + requestedSettings: getSettingsForGroup( group, settingsKeys ), + settingsError: Boolean( getLastSettingsErrorForGroup( group ) ), + isRequesting: isUpdateSettingsRequesting( group ), + isDirty: getIsDirty( group, settingsKeys ), + }; + }, + [ group, ...settingsKeys.sort() ] + ); + const { + persistSettingsForGroup, + updateAndPersistSettingsForGroup, + updateSettingsForGroup, + } = useDispatch( STORE_NAME ); + const updateSettings = useCallback( + ( name, data ) => { + updateSettingsForGroup( group, { [ name ]: data } ); + }, + [ group ] + ); + const persistSettings = useCallback( () => { + // this action would simply persist all settings marked as dirty in the + // store state and then remove the dirty record in the isDirtyMap + persistSettingsForGroup( group ); + }, [ group ] ); + const updateAndPersistSettings = useCallback( + ( name, data ) => { + updateAndPersistSettingsForGroup( group, { [ name ]: data } ); + }, + [ group ] + ); + return { + settingsError, + isRequesting, + isDirty, + ...requestedSettings, + persistSettings, + updateAndPersistSettings, + updateSettings, + }; +}; diff --git a/packages/js/data/src/settings/with-settings-hydration.js b/packages/js/data/src/settings/with-settings-hydration.js new file mode 100644 index 00000000000..91d5b20e041 --- /dev/null +++ b/packages/js/data/src/settings/with-settings-hydration.js @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { createElement, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +export const withSettingsHydration = ( group, settings ) => + createHigherOrderComponent( + ( OriginalComponent ) => ( props ) => { + const settingsRef = useRef( settings ); + + useSelect( ( select, registry ) => { + if ( ! settingsRef.current ) { + return; + } + + const { isResolving, hasFinishedResolution } = select( + STORE_NAME + ); + const { + startResolution, + finishResolution, + updateSettingsForGroup, + clearIsDirty, + } = registry.dispatch( STORE_NAME ); + + if ( + ! isResolving( 'getSettings', [ group ] ) && + ! hasFinishedResolution( 'getSettings', [ group ] ) + ) { + startResolution( 'getSettings', [ group ] ); + updateSettingsForGroup( group, settingsRef.current ); + clearIsDirty( group ); + finishResolution( 'getSettings', [ group ] ); + } + }, [] ); + + return <OriginalComponent { ...props } />; + }, + 'withSettingsHydration' + ); diff --git a/packages/js/data/src/types/api.ts b/packages/js/data/src/types/api.ts new file mode 100644 index 00000000000..6c8b0cf3a63 --- /dev/null +++ b/packages/js/data/src/types/api.ts @@ -0,0 +1,9 @@ +export interface RestApiErrorData { + status?: number; +} + +export type RestApiError = { + code: string; + data?: RestApiErrorData; + message: string; +}; diff --git a/packages/js/data/src/types/index.ts b/packages/js/data/src/types/index.ts new file mode 100644 index 00000000000..e642ee48728 --- /dev/null +++ b/packages/js/data/src/types/index.ts @@ -0,0 +1,3 @@ +export * from './wp-data'; +export * from './rule-processor'; +export * from './api'; diff --git a/packages/js/data/src/types/rule-processor.ts b/packages/js/data/src/types/rule-processor.ts new file mode 100644 index 00000000000..c4ba88df52d --- /dev/null +++ b/packages/js/data/src/types/rule-processor.ts @@ -0,0 +1,45 @@ +export type RuleProcessor = { + type: RuleType; + value?: string | number | boolean; + default?: string | number | boolean; + index?: string; + operation?: RuleOperation; + status?: string; + operand?: RuleProcessor; + operands?: RuleProcessor[] | RuleProcessor[][]; + option_name?: string; + plugin?: string; + plugins?: string[]; + publish_after?: string; +}; + +export type RuleType = + | 'plugins_activated' + | 'publish_after_time' + | 'publish_before_time' + | 'not' + | 'or' + | 'fail' + | 'pass' + | 'plugin_version' + | 'stored_state' + | 'order_count' + | 'wcadmin_active_for' + | 'product_count' + | 'onboarding_profile' + | 'is_ecommerce' + | 'base_location_country' + | 'base_location_state' + | 'note_status' + | 'option' + | 'wca_updated'; + +export type RuleOperation = + | '=' + | '<' + | '<=' + | '>' + | '>=' + | '!=' + | 'contains' + | '!contains'; diff --git a/packages/js/data/src/types/wp-data.ts b/packages/js/data/src/types/wp-data.ts new file mode 100644 index 00000000000..22e04f270f2 --- /dev/null +++ b/packages/js/data/src/types/wp-data.ts @@ -0,0 +1,27 @@ +// Type for the basic selectors built into @wordpress/data, note these +// types define the interface for the public selectors, so state is not an +// argument. +export type WPDataSelectors = { + hasStartedResolution: ( selector: string, args?: string[] ) => boolean; + hasFinishedResolution: ( selector: string, args?: string[] ) => boolean; + isResolving: ( selector: string, args?: string[] ) => boolean; +}; + +export type WPDataActions = { + startResolution: ( selector: string, args?: string[] ) => void; + finishResolution: ( selector: string, args?: string[] ) => void; +}; + +// Omitting state from selector parameter +export type WPDataSelector< T > = T extends ( + state: infer S, + ...args: infer A +) => infer R + ? ( ...args: A ) => R + : T; + +export type WPError< ErrorKey extends string = string, ErrorData = unknown > = { + errors: Record< ErrorKey, string[] >; + error_data?: Record< ErrorKey, ErrorData >; + additional_data?: Record< ErrorKey, ErrorData[] >; +}; diff --git a/packages/js/data/src/use-select-with-refresh.js b/packages/js/data/src/use-select-with-refresh.js new file mode 100644 index 00000000000..99d5e5fe8ff --- /dev/null +++ b/packages/js/data/src/use-select-with-refresh.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { useEffect, useRef } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +const useInterval = ( callback, interval ) => { + const savedCallback = useRef(); + useEffect( () => { + savedCallback.current = callback; + }, [ callback ] ); + useEffect( () => { + const handler = ( ...args ) => savedCallback.current( ...args ); + if ( interval !== null ) { + const id = setInterval( handler, interval ); + return () => clearInterval( id ); + } + }, [ interval ] ); +}; + +export const useSelectWithRefresh = ( + mapSelectToProps, + invalidationCallback, + interval, + dependencies +) => { + const result = useSelect( mapSelectToProps, dependencies ); + useInterval( invalidationCallback, interval ); + return result; +}; diff --git a/packages/js/data/src/user/constants.ts b/packages/js/data/src/user/constants.ts new file mode 100644 index 00000000000..7f89c15aa9d --- /dev/null +++ b/packages/js/data/src/user/constants.ts @@ -0,0 +1 @@ +export const STORE_NAME = 'core'; diff --git a/packages/js/data/src/user/index.js b/packages/js/data/src/user/index.js new file mode 100644 index 00000000000..7c67b693e2d --- /dev/null +++ b/packages/js/data/src/user/index.js @@ -0,0 +1,6 @@ +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +export const USER_STORE_NAME = STORE_NAME; diff --git a/packages/js/data/src/user/test/use-user-preferences.js b/packages/js/data/src/user/test/use-user-preferences.js new file mode 100644 index 00000000000..ccfb1aa139f --- /dev/null +++ b/packages/js/data/src/user/test/use-user-preferences.js @@ -0,0 +1,310 @@ +/** + * External dependencies + */ +import { act, renderHook } from '@testing-library/react-hooks'; +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { useUserPreferences } from '../use-user-preferences'; + +describe( 'useUserPreferences() hook', () => { + it( 'isRequesting is false before resolution has started', () => { + registerStore( 'core', { + reducer: () => ( {} ), + selectors: { + getEntity: jest.fn().mockReturnValue( undefined ), + getCurrentUser: jest.fn().mockReturnValue( {} ), + getLastEntitySaveError: jest.fn().mockReturnValue( {} ), + hasStartedResolution: jest.fn().mockReturnValue( false ), + hasFinishedResolution: jest.fn().mockReturnValue( false ), + }, + actions: { + receiveCurrentUser: jest.fn(), + saveUser: jest.fn(), + }, + } ); + + const { result } = renderHook( () => useUserPreferences() ); + + expect( result.current.isRequesting ).toBe( false ); + } ); + + it( 'isRequesting is false after resolution has ended', () => { + registerStore( 'core', { + reducer: () => ( {} ), + selectors: { + getEntity: jest.fn().mockReturnValue( undefined ), + getCurrentUser: jest.fn().mockReturnValue( {} ), + getLastEntitySaveError: jest.fn().mockReturnValue( {} ), + hasStartedResolution: jest.fn().mockReturnValue( true ), + hasFinishedResolution: jest.fn().mockReturnValue( true ), + }, + actions: { + receiveCurrentUser: jest.fn(), + saveUser: jest.fn(), + }, + } ); + + const { result } = renderHook( () => useUserPreferences() ); + + expect( result.current.isRequesting ).toBe( false ); + } ); + + it( 'isRequesting is true after resolution has started', () => { + registerStore( 'core', { + reducer: () => ( {} ), + selectors: { + getEntity: jest.fn().mockReturnValue( undefined ), + getCurrentUser: jest.fn().mockReturnValue( {} ), + getLastEntitySaveError: jest.fn().mockReturnValue( {} ), + hasStartedResolution: jest.fn().mockReturnValue( true ), + hasFinishedResolution: jest.fn().mockReturnValue( false ), + }, + actions: { + receiveCurrentUser: jest.fn(), + saveUser: jest.fn(), + }, + } ); + + const { result } = renderHook( () => useUserPreferences() ); + + expect( result.current.isRequesting ).toBe( true ); + } ); + + it( 'Returns woocommerce_meta (JSON decoded) at root level', () => { + registerStore( 'core', { + reducer: () => ( {} ), + selectors: { + getEntity: jest.fn().mockReturnValue( undefined ), + getCurrentUser: jest.fn().mockReturnValue( { + woocommerce_meta: { + dashboard_chart_type: '"line"', + dashboard_sections: + '[{"key":"leaderboards","title":"Leaderboards","isVisible":true,"icon":"editor-ol","hiddenBlocks":["coupons","customers"]}]', + revenue_report_columns: + '["coupons","taxes","shipping"]', + }, + } ), + getLastEntitySaveError: jest.fn().mockReturnValue( {} ), + hasStartedResolution: jest.fn().mockReturnValue( true ), + hasFinishedResolution: jest.fn().mockReturnValue( true ), + }, + actions: { + receiveCurrentUser: jest.fn(), + saveUser: jest.fn(), + }, + } ); + + const { result } = renderHook( () => useUserPreferences() ); + + expect( result.current.dashboard_chart_type ).toBe( 'line' ); + expect( result.current.dashboard_sections ).toMatchObject( [ + { + hiddenBlocks: [ 'coupons', 'customers' ], + icon: 'editor-ol', + isVisible: true, + key: 'leaderboards', + title: 'Leaderboards', + }, + ] ); + expect( result.current.revenue_report_columns ).toEqual( [ + 'coupons', + 'taxes', + 'shipping', + ] ); + } ); + + it( 'Handles no valid meta keys', async () => { + registerStore( 'core', { + reducer: () => ( {} ), + selectors: { + getEntity: jest.fn().mockReturnValue( undefined ), + getCurrentUser: jest.fn().mockReturnValue( { + id: 1, + } ), + getLastEntitySaveError: jest.fn().mockReturnValue( {} ), + hasStartedResolution: jest.fn().mockReturnValue( true ), + hasFinishedResolution: jest.fn().mockReturnValue( true ), + }, + actions: { + receiveCurrentUser: jest.fn(), + saveUser: jest.fn(), + }, + } ); + + const { result } = renderHook( () => useUserPreferences() ); + + expect( typeof result.current.updateUserPreferences ).toBe( + 'function' + ); + + // Passing an array, not an object. + const updateResult = await result.current.updateUserPreferences( [] ); + + expect( updateResult ).toMatchObject( { + error: new Error( 'Invalid woocommerce_meta data for update.' ), + updatedUser: undefined, + } ); + } ); + + it( 'Saves user preferences', async () => { + const saveUser = jest.fn().mockReturnValue( { + // HACK alert! + // This `type` property prevents the 'Actions may not have an undefined "type" property' error. + // I tried to create this mock function as a generator, but it's not being called the + // same way under test and didn't work. + // Having to do this also prevents testing the saveUser() error condition. + type: 'BOGUS_ACTION_HERE', + id: 1, + woocommerce_meta: { + revenue_report_columns: '["shipping"]', + }, + } ); + + const receiveCurrentUser = jest.fn().mockReturnValue( { + type: 'RECEIVE_CURRENT_USER', + currentUser: { + id: 1, + woocommerce_meta: { + revenue_report_columns: '["shipping"]', + }, + }, + } ); + + registerStore( 'core', { + reducer: () => ( {} ), + selectors: { + getEntity: jest.fn().mockReturnValue( undefined ), + getCurrentUser: jest.fn().mockReturnValue( { + id: 1, + } ), + getLastEntitySaveError: jest.fn().mockReturnValue( {} ), + hasStartedResolution: jest.fn().mockReturnValue( true ), + hasFinishedResolution: jest.fn().mockReturnValue( true ), + }, + actions: { + receiveCurrentUser, + saveUser, + }, + } ); + + const { result } = renderHook( () => useUserPreferences() ); + + expect( typeof result.current.updateUserPreferences ).toBe( + 'function' + ); + + await act( async () => { + const updateResult = await result.current.updateUserPreferences( { + revenue_report_columns: [ 'shipping' ], + } ); + + expect( saveUser ).toHaveBeenCalledWith( { + id: 1, + woocommerce_meta: { revenue_report_columns: '["shipping"]' }, + } ); + expect( receiveCurrentUser ).toHaveBeenCalled(); + expect( updateResult ).toMatchObject( { + updatedUser: { + id: 1, + woocommerce_meta: { + revenue_report_columns: [ 'shipping' ], + }, + }, + } ); + } ); + } ); + + it( 'Polyfills saveUser() on older versions of WordPress', async () => { + const receiveCurrentUser = jest.fn().mockReturnValue( { + type: 'RECEIVE_CURRENT_USER', + currentUser: { + id: 1, + woocommerce_meta: { + revenue_report_columns: '["shipping"]', + }, + }, + } ); + const addEntities = jest.fn().mockReturnValue( { + type: 'BOGUG_ADD_ENTITIES', + } ); + const saveEntityRecord = jest.fn().mockReturnValue( { + type: 'BOGUG_SAVE_ENTITY_RECORD', + } ); + registerStore( 'core', { + reducer: () => ( {} ), + selectors: { + getCurrentUser: jest.fn().mockReturnValue( { + id: 1, + } ), + getEntity: jest + .fn() + .mockReturnValueOnce( undefined ) + .mockReturnValueOnce( { name: 'user', kind: 'root' } ), + getEntityRecord: jest.fn().mockReturnValue( { + id: 1, + woocommerce_meta: { + revenue_report_columns: '["shipping"]', + }, + } ), + getLastEntitySaveError: jest.fn().mockReturnValue( {} ), + hasStartedResolution: jest.fn().mockReturnValue( true ), + hasFinishedResolution: jest.fn().mockReturnValue( true ), + }, + actions: { + addEntities, + receiveCurrentUser, + saveEntityRecord, + // saveUser() left undefined to simulate WP 5.3.x. + }, + } ); + + const { result } = renderHook( () => useUserPreferences() ); + + await act( async () => { + const firstResult = await result.current.updateUserPreferences( { + revenue_report_columns: [ 'shipping' ], + } ); + + // First calls should register the User entity. + expect( addEntities ).toHaveBeenCalledWith( [ + { + name: 'user', + kind: 'root', + baseURL: '/wp/v2/users', + plural: 'users', + }, + ] ); + + expect( saveEntityRecord ).toHaveBeenCalledWith( 'root', 'user', { + id: 1, + woocommerce_meta: { revenue_report_columns: '["shipping"]' }, + } ); + expect( receiveCurrentUser ).toHaveBeenCalled(); + expect( firstResult ).toMatchObject( { + updatedUser: { + id: 1, + woocommerce_meta: { + revenue_report_columns: [ 'shipping' ], + }, + }, + } ); + + await result.current.updateUserPreferences( { + revenue_report_columns: [ 'shipping', 'taxes' ], + } ); + + // Subsequent calls should NOT register the User entity. + expect( addEntities ).toHaveBeenCalledTimes( 1 ); + + expect( saveEntityRecord ).toHaveBeenCalledWith( 'root', 'user', { + id: 1, + woocommerce_meta: { + revenue_report_columns: '["shipping","taxes"]', + }, + } ); + } ); + } ); +} ); diff --git a/packages/js/data/src/user/use-user-preferences.ts b/packages/js/data/src/user/use-user-preferences.ts new file mode 100644 index 00000000000..f334bfcdae7 --- /dev/null +++ b/packages/js/data/src/user/use-user-preferences.ts @@ -0,0 +1,236 @@ +/** + * External dependencies + */ +import { mapValues } from 'lodash'; +import { + useDispatch, + useSelect, + select as wpDataSelect, +} from '@wordpress/data'; +import schema, { Schema } from 'wordpress__core-data'; +import type { getEntityRecord } from 'wordpress__core-data/selectors'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +type UserPreferences = { + activity_panel_inbox_last_read?: string; + activity_panel_reviews_last_read?: string; + android_app_banner_dismissed?: string; + categories_report_columns?: string; + coupons_report_columns?: string; + customers_report_columns?: string; + dashboard_chart_interval?: string; + dashboard_chart_type?: string; + dashboard_leaderboard_rows?: string; + dashboard_sections?: string; + help_panel_highlight_shown?: string; + homepage_layout?: string; + homepage_stats?: string; + orders_report_columns?: string; + products_report_columns?: string; + revenue_report_columns?: string; + task_list_tracked_started_tasks?: { + [ key: string ]: number; + }; + taxes_report_columns?: string; + variations_report_columns?: string; +}; + +type WoocommerceMeta = UserPreferences & { + task_list_tracked_started_tasks?: string; +}; + +type WCUser = Schema.User & { + woocommerce_meta: WoocommerceMeta; +}; + +/** + * Retrieve and decode the user's WooCommerce meta values. + * + * @param {Object} user WP User object. + * @return {Object} User's WooCommerce preferences. + */ +const getWooCommerceMeta = ( user: WCUser ) => { + const wooMeta = user.woocommerce_meta || {}; + + const userData = mapValues( wooMeta, ( data, key ) => { + if ( ! data || data.length === 0 ) { + return ''; + } + try { + return JSON.parse( data ); + } catch ( e ) { + if ( e instanceof Error ) { + /* eslint-disable no-console */ + console.error( + `Error parsing value '${ data }' for ${ key }`, + e.message + ); + /* eslint-enable no-console */ + } else { + /* eslint-disable no-console */ + console.error( + `Unexpected Error parsing value '${ data }' for ${ key } ${ e }` + ); + /* eslint-enable no-console */ + } + return ''; + } + } ); + + return userData; +}; + +// Create wrapper for updating user's `woocommerce_meta`. +async function updateUserPrefs( + receiveCurrentUser: ( user: WCUser ) => void, + user: WCUser, + saveUser: ( userToSave: { + id: number; + woocommerce_meta: { [ key: string ]: boolean }; + } ) => WCUser, + getLastEntitySaveError: ( + kind: string, + name: string, + recordId: number + ) => unknown, + userPrefs: UserPreferences +) { + // @todo Handle unresolved getCurrentUser() here. + // Prep fields for update. + const metaData = mapValues( userPrefs, JSON.stringify ); + + if ( Object.keys( metaData ).length === 0 ) { + return { + error: new Error( 'Invalid woocommerce_meta data for update.' ), + updatedUser: undefined, + }; + } + + // Optimistically propagate new woocommerce_meta to the store for instant update. + receiveCurrentUser( { + ...user, + woocommerce_meta: { + ...user.woocommerce_meta, + ...metaData, + }, + } ); + + // Use saveUser() to update WooCommerce meta values. + const updatedUser = await saveUser( { + id: user.id, + woocommerce_meta: metaData, + } ); + + if ( undefined === updatedUser ) { + // Return the encountered error to the caller. + const error = getLastEntitySaveError( 'root', 'user', user.id ); + + return { + error, + updatedUser, + }; + } + + // Decode the WooCommerce meta after save. + const updatedUserResponse = { + ...updatedUser, + woocommerce_meta: getWooCommerceMeta( updatedUser ), + }; + + return { + updatedUser: updatedUserResponse, + }; +} + +/** + * Custom react hook for retrieving thecurrent user's WooCommerce preferences. + * + * This is a wrapper around @wordpress/core-data's getCurrentUser() and saveUser(). + */ +export const useUserPreferences = () => { + // Get our dispatch methods now - this can't happen inside the callback below. + const dispatch = useDispatch( STORE_NAME ); + const { addEntities, receiveCurrentUser, saveEntityRecord } = dispatch; + let { saveUser } = dispatch; + + const userData = useSelect( ( select: typeof wpDataSelect ) => { + const { + getCurrentUser, + getEntity, + getEntityRecord, + getLastEntitySaveError, + hasStartedResolution, + hasFinishedResolution, + } = select( STORE_NAME ); + + return { + isRequesting: + hasStartedResolution( 'getCurrentUser' ) && + ! hasFinishedResolution( 'getCurrentUser' ), + user: getCurrentUser(), + getCurrentUser, + getEntity, + getEntityRecord, + getLastEntitySaveError, + }; + } ); + + const updateUserPreferences = ( userPrefs: UserPreferences ) => { + // WP 5.3.x doesn't have the User entity defined. + if ( typeof saveUser !== 'function' ) { + // Polyfill saveUser() - wrapper of saveEntityRecord. + saveUser = async ( userToSave: { + id: string; + woocommerce_meta: { [ key: string ]: boolean }; + } ) => { + const entityDefined = Boolean( + userData.getEntity( 'root', 'user' ) + ); + if ( ! entityDefined ) { + // Add the User entity so saveEntityRecord works. + await addEntities( [ + { + name: 'user', + kind: 'root', + baseURL: '/wp/v2/users', + plural: 'users', + }, + ] ); + } + + // Fire off the save action. + await saveEntityRecord( 'root', 'user', userToSave ); + + // Respond with the updated user. + return userData.getEntityRecord( + 'root', + 'user', + userToSave.id + ); + }; + } + // Get most recent user before update. + const currentUser = userData.getCurrentUser(); + return updateUserPrefs( + receiveCurrentUser, + currentUser, + saveUser, + userData.getLastEntitySaveError, + userPrefs + ); + }; + + const userPreferences: UserPreferences = userData.user + ? getWooCommerceMeta( userData.user ) + : {}; + + return { + isRequesting: userData.isRequesting, + ...userPreferences, + updateUserPreferences, + }; +}; diff --git a/packages/js/data/src/user/use-user.js b/packages/js/data/src/user/use-user.js new file mode 100644 index 00000000000..060aff1a947 --- /dev/null +++ b/packages/js/data/src/user/use-user.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +/** + * Custom react hook for shortcut methods around user. + * + * This is a wrapper around @wordpress/core-data's getCurrentUser(). + */ +export const useUser = () => { + const userData = useSelect( ( select ) => { + const { + getCurrentUser, + hasStartedResolution, + hasFinishedResolution, + } = select( STORE_NAME ); + + return { + isRequesting: + hasStartedResolution( 'getCurrentUser' ) && + ! hasFinishedResolution( 'getCurrentUser' ), + user: getCurrentUser(), + getCurrentUser, + }; + } ); + + const currentUserCan = ( capability ) => { + if ( userData.user && userData.user.is_super_admin ) { + return true; + } + + if ( userData.user && userData.user.capabilities[ capability ] ) { + return true; + } + + return false; + }; + + return { + currentUserCan, + user: userData.user, + isRequesting: userData.isRequesting, + }; +}; diff --git a/packages/js/data/src/user/with-current-user-hydration.js b/packages/js/data/src/user/with-current-user-hydration.js new file mode 100644 index 00000000000..bfdb0708917 --- /dev/null +++ b/packages/js/data/src/user/with-current-user-hydration.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { useSelect } from '@wordpress/data'; +import { createElement, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { STORE_NAME } from './constants'; + +/** + * Higher-order component used to hydrate current user data. + * + * @param {Object} currentUser Current user object in the same format as the WP REST API returns. + */ +export const withCurrentUserHydration = ( currentUser ) => + createHigherOrderComponent( + ( OriginalComponent ) => ( props ) => { + const userRef = useRef( currentUser ); + + // Use currentUser to hydrate calls to @wordpress/core-data's getCurrentUser(). + useSelect( ( select, registry ) => { + if ( ! userRef.current ) { + return; + } + + const { isResolving, hasFinishedResolution } = select( + STORE_NAME + ); + const { + startResolution, + finishResolution, + receiveCurrentUser, + } = registry.dispatch( STORE_NAME ); + + if ( + ! isResolving( 'getCurrentUser' ) && + ! hasFinishedResolution( 'getCurrentUser' ) + ) { + startResolution( 'getCurrentUser', [] ); + receiveCurrentUser( userRef.current ); + finishResolution( 'getCurrentUser', [] ); + } + } ); + + return <OriginalComponent { ...props } />; + }, + 'withCurrentUserHydration' + ); diff --git a/packages/js/data/src/utils.js b/packages/js/data/src/utils.js new file mode 100644 index 00000000000..7fdd240dd08 --- /dev/null +++ b/packages/js/data/src/utils.js @@ -0,0 +1,26 @@ +export function getResourceName( prefix, identifier ) { + const identifierString = JSON.stringify( + identifier, + Object.keys( identifier ).sort() + ); + return `${ prefix }:${ identifierString }`; +} + +export function getResourcePrefix( resourceName ) { + const hasPrefixIndex = resourceName.indexOf( ':' ); + return hasPrefixIndex < 0 + ? resourceName + : resourceName.substring( 0, hasPrefixIndex ); +} + +export function isResourcePrefix( resourceName, prefix ) { + const resourcePrefix = getResourcePrefix( resourceName ); + return resourcePrefix === prefix; +} + +export function getResourceIdentifier( resourceName ) { + const identifierString = resourceName.substring( + resourceName.indexOf( ':' ) + 1 + ); + return JSON.parse( identifierString ); +} diff --git a/packages/js/data/tsconfig-cjs.json b/packages/js/data/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/data/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/data/tsconfig.json b/packages/js/data/tsconfig.json new file mode 100644 index 00000000000..ea9f201d401 --- /dev/null +++ b/packages/js/data/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module", + "declaration": true, + "declarationMap": true, + "declarationDir": "./build-types" + } +} diff --git a/packages/js/data/typings/index.d.ts b/packages/js/data/typings/index.d.ts new file mode 100644 index 00000000000..e51a93af6f7 --- /dev/null +++ b/packages/js/data/typings/index.d.ts @@ -0,0 +1,3 @@ +declare module '@wordpress/compose'; +declare module '@wordpress/data'; +declare module 'rememo'; diff --git a/packages/js/date/.eslintrc.js b/packages/js/date/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/date/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/date/.npmrc b/packages/js/date/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/date/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/date/CHANGELOG.md b/packages/js/date/CHANGELOG.md new file mode 100644 index 00000000000..5a44a3ccc4b --- /dev/null +++ b/packages/js/date/CHANGELOG.md @@ -0,0 +1,78 @@ +# Unreleased + +# 4.0.1 + +- Update all js packages with minor/patch version changes. #8392 + +# 4.0.0 + +## Breaking changes + +- Update dependencies to support react 17. #8305 +- Drop support for IE11. #8305 + +# 3.2.0 + +- Remove dev dependency `@woocommerce/wc-admin-settings`. #8057 +- Add "defaultDateRange" argument to "getAllowedIntervalsForQuery" for default period value. #8189 +- Add type option to `getDateFormatsForInterval` to support `getDateFormatsForIntervalPhp` feature. #8129 +- Sentence case all the things analytics #6501 +- Fix end date for last periods #6584 + +# 3.1.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 + +# 3.0.0 + +- Take into account leap year in calculating `getLastPeriod`. + +## Breaking changes + +- Move Lodash to a peer dependency. + +# 2.1.0 + +- Update to @wordpress/eslint coding standards. + +# 2.0.0 + +## Breaking changes + +- Decouple from global wcSettings object (#3278) +- Exported methods of the date package have been rewritten to accept a configuration object as their second parameter. +- `loadLocaleData` is no longer called within the date package. Consuming code must take care of that themselves. + +# 1.2.1 + +- Update dependencies. + +# 1.2.0 + +- Enhancement: gather default date settings from `wcSettings.wcAdminSettings.woocommerce_default_date_range` if they exist. +- Update license to GPL-3.0-or-later. + +# 1.0.7 + +- Change text domain on i18n functions. +- Bump dependency versions. + +# 1.0.6 + +- Removed timezone from `appendTimestamp()` output. + +# 1.0.5 + +- Fixed bug in getAllowedIntervalsForQuery() to not return `hour` for default intervals + +# 1.0.4 + +- Remove deprecated @wordpress/date::getSettings() usage. + +# 1.0.3 + +- Fix missing comma seperator in date inside tooltips. + +# 1.0.2 + +- Add `getChartTypeForQuery` function to ensure chart type is always `bar` or `line` diff --git a/packages/js/date/README.md b/packages/js/date/README.md new file mode 100644 index 00000000000..35f92337afd --- /dev/null +++ b/packages/js/date/README.md @@ -0,0 +1,449 @@ +# Date + +A collection of utilities to display and work with date values. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/date --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +The `date` package makes use of the global `window.wcSettings.timeZone`. If a timezone is set, the current and last periods will be converted from your browser's timezone to the store timezone. If none is set, these periods will be based on your browser's timezone. + +### Functions + +<dl> +<dt><a href="#appendTimestamp">appendTimestamp</a> ⇒ <code>string</code></dt> +<dd><p>Adds timestamp to a string date.</p> +</dd> +<dt><a href="#getDateValue">getDateValue</a> ⇒ <code><a href="#DateValue">DateValue</a></code></dt> +<dd><p>Get a DateValue object for a period described by a period, compare value, and start/end +dates, for custom dates.</p> +</dd> +<dt><a href="#getDateParamsFromQueryMemoized">getDateParamsFromQueryMemoized</a> ⇒ <code>Object</code></dt> +<dd><p>Memoized internal logic of getDateParamsFromQuery().</p> +</dd> +<dt><a href="#getDateParamsFromQuery">getDateParamsFromQuery</a> ⇒ <code><a href="#DateParams">DateParams</a></code></dt> +<dd><p>Add default date-related parameters to a query object</p> +</dd> +<dt><a href="#getCurrentDatesMemoized">getCurrentDatesMemoized</a> ⇒ <code>Object</code></dt> +<dd><p>Memoized internal logic of getCurrentDates().</p> +</dd> +<dt><a href="#getCurrentDates">getCurrentDates</a> ⇒ <code>Object</code></dt> +<dd><p>Get Date Value Objects for a primary and secondary date range</p> +</dd> +<dt><a href="#getDateDifferenceInDays">getDateDifferenceInDays</a> ⇒ <code>number</code></dt> +<dd><p>Calculates the date difference between two dates. Used in calculating a matching date for previous period.</p> +</dd> +<dt><a href="#getPreviousDate">getPreviousDate</a> ⇒ <code>Object</code></dt> +<dd><p>Get the previous date for either the previous period of year.</p> +</dd> +</dl> +<dl> +<dt><a href="#toMoment">toMoment(format, str)</a> ⇒ <code>Object</code> | <code>null</code></dt> +<dd><p>Convert a string to Moment object</p> +</dd> +<dt><a href="#getRangeLabel">getRangeLabel(after, before)</a> ⇒ <code>string</code></dt> +<dd><p>Given two dates, derive a string representation</p> +</dd> +<dt><a href="#getStoreTimeZoneMoment">getStoreTimeZoneMoment()</a> ⇒ <code>string</code></dt> +<dd><p>Gets the current time in the store time zone if set.</p> +</dd> +<dt><a href="#getLastPeriod">getLastPeriod(period, compare)</a> ⇒ <code><a href="#DateValue">DateValue</a></code></dt> +<dd><p>Get a DateValue object for a period prior to the current period.</p> +</dd> +<dt><a href="#getCurrentPeriod">getCurrentPeriod(period, compare)</a> ⇒ <code><a href="#DateValue">DateValue</a></code></dt> +<dd><p>Get a DateValue object for a curent period. The period begins on the first day of the period, +and ends on the current day.</p> +</dd> +<dt><a href="#getAllowedIntervalsForQuery">getAllowedIntervalsForQuery(query, defaultDateRange)</a> ⇒ <code>Array</code></dt> +<dd><p>Returns the allowed selectable intervals for a specific query.</p> +</dd> +<dt><a href="#getIntervalForQuery">getIntervalForQuery(query, defaultDateRange)</a> ⇒ <code>string</code></dt> +<dd><p>Returns the current interval to use.</p> +</dd> +<dt><a href="#getChartTypeForQuery">getChartTypeForQuery(query)</a> ⇒ <code>string</code></dt> +<dd><p>Returns the current chart type to use.</p> +</dd> +<dt><a href="#getDateFormatsForInterval">getDateFormatsForInterval(interval, [ticks], [option])</a> ⇒ <code>string</code></dt> +<dd><p>Returns date formats for the current interval.</p> +</dd> +<dt><a href="#getDateFormatsForIntervalD3">getDateFormatsForIntervalD3(interval, [ticks])</a> ⇒ <code>string</code></dt> +<dd><p>Returns d3 date formats for the current interval. +See <a href="https://github.com/d3/d3-time-format">https://github.com/d3/d3-time-format</a> for chart formats.</p> +</dd> +<dt><a href="#getDateFormatsForIntervalPhp">getDateFormatsForIntervalPhp(interval, [ticks])</a> ⇒ <code>string</code></dt> +<dd><p>Returns php date formats for the current interval. +See see <a href="https://www.php.net/manual/en/datetime.format.php">https://www.php.net/manual/en/datetime.format.php</a>.</p> +</dd> +<dt><a href="#loadLocaleData">loadLocaleData(config)</a></dt> +<dd><p>Gutenberg's moment instance is loaded with i18n values, which are +PHP date formats, ie 'LLL: "F j, Y g:i a"'. Override those with translations +of moment style js formats.</p> +</dd> +<dt><a href="#validateDateInputForRange">validateDateInputForRange(type, value, [before], [after], format)</a> ⇒ <code>Object</code></dt> +<dd><p>Validate text input supplied for a date range.</p> +</dd> +</dl> + +### Typedefs + +<dl> +<dt><a href="#DateValue">DateValue</a> : <code>Object</code></dt> +<dd><p>DateValue Object</p> +</dd> +<dt><a href="#DateParams">DateParams</a> : <code>Object</code></dt> +<dd><p>DateParams Object</p> +</dd> +<dt><a href="#validatedDate">validatedDate</a> : <code>Object</code></dt> +<dd></dd> +</dl> + +<a name="appendTimestamp"></a> + +### appendTimestamp ⇒ <code>string</code> +Adds timestamp to a string date. + +**Kind**: global constant +**Returns**: <code>string</code> - - String date with timestamp attached. + +| Param | Type | Description | +| --- | --- | --- | +| date | <code>moment.Moment</code> | Date as a moment object. | +| timeOfDay | <code>string</code> | Either `start`, `now` or `end` of the day. | + +<a name="getDateValue"></a> + +### getDateValue ⇒ [<code>DateValue</code>](#DateValue) +Get a DateValue object for a period described by a period, compare value, and start/end +dates, for custom dates. + +**Kind**: global constant +**Returns**: [<code>DateValue</code>](#DateValue) - - DateValue data about the selected period + +| Param | Type | Description | +| --- | --- | --- | +| period | <code>string</code> | the chosen period | +| compare | <code>string</code> | `previous_period` or `previous_year` | +| [after] | <code>Object</code> | after date if custom period | +| [before] | <code>Object</code> | before date if custom period | + +<a name="getDateParamsFromQueryMemoized"></a> + +### getDateParamsFromQueryMemoized ⇒ <code>Object</code> +Memoized internal logic of getDateParamsFromQuery(). + +**Kind**: global constant +**Returns**: <code>Object</code> - - date parameters derived from query parameters with added defaults + +| Param | Type | Description | +| --- | --- | --- | +| period | <code>string</code> | period value, ie `last_week` | +| compare | <code>string</code> | compare value, ie `previous_year` | +| after | <code>string</code> | date in iso date format, ie `2018-07-03` | +| before | <code>string</code> | date in iso date format, ie `2018-07-03` | +| defaultDateRange | <code>string</code> | the store's default date range | + +<a name="getDateParamsFromQuery"></a> + +### getDateParamsFromQuery ⇒ [<code>DateParams</code>](#DateParams) +Add default date-related parameters to a query object + +**Kind**: global constant +**Returns**: [<code>DateParams</code>](#DateParams) - - date parameters derived from query parameters with added defaults + +| Param | Type | Description | +| --- | --- | --- | +| query | <code>Object</code> | query object | +| query.period | <code>string</code> | period value, ie `last_week` | +| query.compare | <code>string</code> | compare value, ie `previous_year` | +| query.after | <code>string</code> | date in iso date format, ie `2018-07-03` | +| query.before | <code>string</code> | date in iso date format, ie `2018-07-03` | +| defaultDateRange | <code>string</code> | the store's default date range | + +<a name="getCurrentDatesMemoized"></a> + +### getCurrentDatesMemoized ⇒ <code>Object</code> +Memoized internal logic of getCurrentDates(). + +**Kind**: global constant +**Returns**: <code>Object</code> - - Primary and secondary DateValue objects + +| Param | Type | Description | +| --- | --- | --- | +| period | <code>string</code> | period value, ie `last_week` | +| compare | <code>string</code> | compare value, ie `previous_year` | +| primaryStart | <code>Object</code> | primary query start DateTime, in Moment instance. | +| primaryEnd | <code>Object</code> | primary query start DateTime, in Moment instance. | +| secondaryStart | <code>Object</code> | primary query start DateTime, in Moment instance. | +| secondaryEnd | <code>Object</code> | primary query start DateTime, in Moment instance. | + +<a name="getCurrentDates"></a> + +### getCurrentDates ⇒ <code>Object</code> +Get Date Value Objects for a primary and secondary date range + +**Kind**: global constant +**Returns**: <code>Object</code> - - Primary and secondary DateValue objects + +| Param | Type | Description | +| --- | --- | --- | +| query | <code>Object</code> | query object | +| query.period | <code>string</code> | period value, ie `last_week` | +| query.compare | <code>string</code> | compare value, ie `previous_year` | +| query.after | <code>string</code> | date in iso date format, ie `2018-07-03` | +| query.before | <code>string</code> | date in iso date format, ie `2018-07-03` | +| defaultDateRange | <code>string</code> | the store's default date range | + +<a name="getDateDifferenceInDays"></a> + +### getDateDifferenceInDays ⇒ <code>number</code> +Calculates the date difference between two dates. Used in calculating a matching date for previous period. + +**Kind**: global constant +**Returns**: <code>number</code> - - Difference in days. + +| Param | Type | Description | +| --- | --- | --- | +| date | <code>string</code> | Date to compare | +| date2 | <code>string</code> | Seconary date to compare | + +<a name="getPreviousDate"></a> + +### getPreviousDate ⇒ <code>Object</code> +Get the previous date for either the previous period of year. + +**Kind**: global constant +**Returns**: <code>Object</code> - - Calculated date + +| Param | Type | Description | +| --- | --- | --- | +| date | <code>string</code> | Base date | +| date1 | <code>string</code> | primary start | +| date2 | <code>string</code> | secondary start | +| compare | <code>string</code> | `previous_period` or `previous_year` | +| interval | <code>string</code> | interval | + +<a name="toMoment"></a> + +### toMoment(format, str) ⇒ <code>Object</code> \| <code>null</code> +Convert a string to Moment object + +**Kind**: global function +**Returns**: <code>Object</code> \| <code>null</code> - - Moment object representing given string + +| Param | Type | Description | +| --- | --- | --- | +| format | <code>string</code> | localized date string format | +| str | <code>string</code> | date string | + +<a name="getRangeLabel"></a> + +### getRangeLabel(after, before) ⇒ <code>string</code> +Given two dates, derive a string representation + +**Kind**: global function +**Returns**: <code>string</code> - - text value for the supplied date range + +| Param | Type | Description | +| --- | --- | --- | +| after | <code>Object</code> | start date | +| before | <code>Object</code> | end date | + +<a name="getStoreTimeZoneMoment"></a> + +### getStoreTimeZoneMoment() ⇒ <code>string</code> +Gets the current time in the store time zone if set. + +**Kind**: global function +**Returns**: <code>string</code> - - Datetime string. +<a name="getLastPeriod"></a> + +### getLastPeriod(period, compare) ⇒ [<code>DateValue</code>](#DateValue) +Get a DateValue object for a period prior to the current period. + +**Kind**: global function +**Returns**: [<code>DateValue</code>](#DateValue) - - DateValue data about the selected period + +| Param | Type | Description | +| --- | --- | --- | +| period | <code>string</code> | the chosen period | +| compare | <code>string</code> | `previous_period` or `previous_year` | + +<a name="getCurrentPeriod"></a> + +### getCurrentPeriod(period, compare) ⇒ [<code>DateValue</code>](#DateValue) +Get a DateValue object for a curent period. The period begins on the first day of the period, +and ends on the current day. + +**Kind**: global function +**Returns**: [<code>DateValue</code>](#DateValue) - - DateValue data about the selected period + +| Param | Type | Description | +| --- | --- | --- | +| period | <code>string</code> | the chosen period | +| compare | <code>string</code> | `previous_period` or `previous_year` | + +<a name="getAllowedIntervalsForQuery"></a> + +### getAllowedIntervalsForQuery(query, defaultDateRange) ⇒ <code>Array</code> +Returns the allowed selectable intervals for a specific query. + +**Kind**: global function +**Returns**: <code>Array</code> - Array containing allowed intervals. + +| Param | Type | Description | +| --- | --- | --- | +| query | <code>Object</code> | Current query | +| defaultDateRange | <code>string</code> | the store's default date range | + +<a name="getIntervalForQuery"></a> + +### getIntervalForQuery(query, defaultDateRange) ⇒ <code>string</code> +Returns the current interval to use. + +**Kind**: global function +**Returns**: <code>string</code> - Current interval. + +| Param | Type | Description | +| --- | --- | --- | +| query | <code>Object</code> | Current query | +| defaultDateRange | <code>string</code> | the store's default date range | + +<a name="getChartTypeForQuery"></a> + +### getChartTypeForQuery(query) ⇒ <code>string</code> +Returns the current chart type to use. + +**Kind**: global function +**Returns**: <code>string</code> - Current chart type. + +| Param | Type | Description | +| --- | --- | --- | +| query | <code>Object</code> | Current query | +| query.chartType | <code>string</code> | | + +<a name="getDateFormatsForInterval"></a> + +### getDateFormatsForInterval(interval, [ticks], [option]) ⇒ <code>string</code> +Returns date formats for the current interval. + +**Kind**: global function +**Returns**: <code>string</code> - Current interval. + +| Param | Type | Description | +| --- | --- | --- | +| interval | <code>string</code> | Interval to get date formats for. | +| [ticks] | <code>number</code> | Number of ticks the axis will have. | +| [option] | <code>Object</code> | Options | +| [option.type] | <code>string</code> | Date format type, d3 or php, defaults to d3. | + +<a name="getDateFormatsForIntervalD3"></a> + +### getDateFormatsForIntervalD3(interval, [ticks]) ⇒ <code>string</code> +Returns d3 date formats for the current interval. +See https://github.com/d3/d3-time-format for chart formats. + +**Kind**: global function +**Returns**: <code>string</code> - Current interval. + +| Param | Type | Description | +| --- | --- | --- | +| interval | <code>string</code> | Interval to get date formats for. | +| [ticks] | <code>number</code> | Number of ticks the axis will have. | + +<a name="getDateFormatsForIntervalPhp"></a> + +### getDateFormatsForIntervalPhp(interval, [ticks]) ⇒ <code>string</code> +Returns php date formats for the current interval. +See see https://www.php.net/manual/en/datetime.format.php. + +**Kind**: global function +**Returns**: <code>string</code> - Current interval. + +| Param | Type | Description | +| --- | --- | --- | +| interval | <code>string</code> | Interval to get date formats for. | +| [ticks] | <code>number</code> | Number of ticks the axis will have. | + +<a name="loadLocaleData"></a> + +### loadLocaleData(config) +Gutenberg's moment instance is loaded with i18n values, which are +PHP date formats, ie 'LLL: "F j, Y g:i a"'. Override those with translations +of moment style js formats. + +**Kind**: global function + +| Param | Type | Description | +| --- | --- | --- | +| config | <code>Object</code> | Locale config object, from store settings. | +| config.userLocale | <code>string</code> | | +| config.weekdaysShort | <code>Array</code> | | + +<a name="validateDateInputForRange"></a> + +### validateDateInputForRange(type, value, [before], [after], format) ⇒ <code>Object</code> +Validate text input supplied for a date range. + +**Kind**: global function +**Returns**: <code>Object</code> - validatedDate - validated date object + +| Param | Type | Description | +| --- | --- | --- | +| type | <code>string</code> | Designate beginning or end of range, eg `before` or `after`. | +| value | <code>string</code> | User input value | +| [before] | <code>Object</code> \| <code>null</code> | If already designated, the before date parameter | +| [after] | <code>Object</code> \| <code>null</code> | If already designated, the after date parameter | +| format | <code>string</code> | The expected date format in a user's locale | + +<a name="DateValue"></a> + +### DateValue : <code>Object</code> +DateValue Object + +**Kind**: global typedef +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| label | <code>string</code> | The translated value of the period. | +| range | <code>string</code> | The human readable value of a date range. | +| after | <code>moment.Moment</code> | Start of the date range. | +| before | <code>moment.Moment</code> | End of the date range. | + +<a name="DateParams"></a> + +### DateParams : <code>Object</code> +DateParams Object + +**Kind**: global typedef + +| Param | Type | Description | +| --- | --- | --- | +| after | <code>moment.Moment</code> \| <code>null</code> | If the period supplied is "custom", this is the after date | +| before | <code>moment.Moment</code> \| <code>null</code> | If the period supplied is "custom", this is the before date | + +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| period | <code>string</code> | period value, ie `last_week` | +| compare | <code>string</code> | compare valuer, ie previous_year | + +<a name="validatedDate"></a> + +### validatedDate : <code>Object</code> +**Kind**: global typedef +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| date | <code>Object</code> \| <code>null</code> | A resulting Moment date object or null, if invalid | +| error | <code>string</code> | An optional error message if date is invalid | diff --git a/packages/js/date/jest.config.json b/packages/js/date/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/date/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/date/package.json b/packages/js/date/package.json new file mode 100644 index 00000000000..743f7d34905 --- /dev/null +++ b/packages/js/date/package.json @@ -0,0 +1,62 @@ +{ + "name": "@woocommerce/date", + "version": "4.0.1", + "description": "WooCommerce date utilities.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "date" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/date/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@wordpress/date": "^4.3.1", + "@wordpress/i18n": "^4.3.1", + "moment": "^2.29.1", + "qs": "^6.10.3" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "d3-time-format": "^2.3.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "peerDependencies": { + "lodash": "^4.17.0" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "start": "tsc --build --watch", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/date/project.json b/packages/js/date/project.json new file mode 100644 index 00000000000..5c947f4112b --- /dev/null +++ b/packages/js/date/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/date", + "sourceRoot": "packages/js/date/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/date" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/date/src/index.js b/packages/js/date/src/index.js new file mode 100644 index 00000000000..8e493107b9b --- /dev/null +++ b/packages/js/date/src/index.js @@ -0,0 +1,829 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import { find, memoize } from 'lodash'; +import { __ } from '@wordpress/i18n'; +import { parse } from 'qs'; + +export const isoDateFormat = 'YYYY-MM-DD'; + +export const defaultDateTimeFormat = 'YYYY-MM-DDTHH:mm:ss'; + +/** + * DateValue Object + * + * @typedef {Object} DateValue - Describes the date range supplied by the date picker. + * @property {string} label - The translated value of the period. + * @property {string} range - The human readable value of a date range. + * @property {moment.Moment} after - Start of the date range. + * @property {moment.Moment} before - End of the date range. + */ + +/** + * DateParams Object + * + * @typedef {Object} DateParams - date parameters derived from query parameters. + * @property {string} period - period value, ie `last_week` + * @property {string} compare - compare valuer, ie previous_year + * @param {moment.Moment|null} after - If the period supplied is "custom", this is the after date + * @param {moment.Moment|null} before - If the period supplied is "custom", this is the before date + */ + +export const presetValues = [ + { value: 'today', label: __( 'Today', 'woocommerce' ) }, + { value: 'yesterday', label: __( 'Yesterday', 'woocommerce' ) }, + { value: 'week', label: __( 'Week to date', 'woocommerce' ) }, + { value: 'last_week', label: __( 'Last week', 'woocommerce' ) }, + { value: 'month', label: __( 'Month to date', 'woocommerce' ) }, + { value: 'last_month', label: __( 'Last month', 'woocommerce' ) }, + { value: 'quarter', label: __( 'Quarter to date', 'woocommerce' ) }, + { value: 'last_quarter', label: __( 'Last quarter', 'woocommerce' ) }, + { value: 'year', label: __( 'Year to date', 'woocommerce' ) }, + { value: 'last_year', label: __( 'Last year', 'woocommerce' ) }, + { value: 'custom', label: __( 'Custom', 'woocommerce' ) }, +]; + +export const periods = [ + { + value: 'previous_period', + label: __( 'Previous period', 'woocommerce' ), + }, + { + value: 'previous_year', + label: __( 'Previous year', 'woocommerce' ), + }, +]; + +/** + * Adds timestamp to a string date. + * + * @param {moment.Moment} date - Date as a moment object. + * @param {string} timeOfDay - Either `start`, `now` or `end` of the day. + * @return {string} - String date with timestamp attached. + */ +export const appendTimestamp = ( date, timeOfDay ) => { + if ( timeOfDay === 'start' ) { + return date.startOf( 'day' ).format( defaultDateTimeFormat ); + } + if ( timeOfDay === 'now' ) { + // Set seconds to 00 to avoid consecutives calls happening before the previous + // one finished. + return date.format( defaultDateTimeFormat ); + } + if ( timeOfDay === 'end' ) { + return date.endOf( 'day' ).format( defaultDateTimeFormat ); + } + throw new Error( + 'appendTimestamp requires second parameter to be either `start`, `now` or `end`' + ); +}; + +/** + * Convert a string to Moment object + * + * @param {string} format - localized date string format + * @param {string} str - date string + * @return {Object|null} - Moment object representing given string + */ +export function toMoment( format, str ) { + if ( moment.isMoment( str ) ) { + return str.isValid() ? str : null; + } + if ( typeof str === 'string' ) { + const date = moment( str, [ isoDateFormat, format ], true ); + return date.isValid() ? date : null; + } + throw new Error( 'toMoment requires a string to be passed as an argument' ); +} + +/** + * Given two dates, derive a string representation + * + * @param {Object} after - start date + * @param {Object} before - end date + * @return {string} - text value for the supplied date range + */ +export function getRangeLabel( after, before ) { + const isSameYear = after.year() === before.year(); + const isSameMonth = isSameYear && after.month() === before.month(); + const isSameDay = + isSameYear && isSameMonth && after.isSame( before, 'day' ); + const fullDateFormat = __( 'MMM D, YYYY', 'woocommerce' ); + + if ( isSameDay ) { + return after.format( fullDateFormat ); + } else if ( isSameMonth ) { + const afterDate = after.date(); + return after + .format( fullDateFormat ) + .replace( afterDate, `${ afterDate } - ${ before.date() }` ); + } else if ( isSameYear ) { + const monthDayFormat = __( 'MMM D', 'woocommerce' ); + return `${ after.format( monthDayFormat ) } - ${ before.format( + fullDateFormat + ) }`; + } + return `${ after.format( fullDateFormat ) } - ${ before.format( + fullDateFormat + ) }`; +} + +/** + * Gets the current time in the store time zone if set. + * + * @return {string} - Datetime string. + */ +export function getStoreTimeZoneMoment() { + if ( ! window.wcSettings || ! window.wcSettings.timeZone ) { + return moment(); + } + + if ( [ '+', '-' ].includes( window.wcSettings.timeZone.charAt( 0 ) ) ) { + return moment().utcOffset( window.wcSettings.timeZone ); + } + + return moment().tz( window.wcSettings.timeZone ); +} + +/** + * Get a DateValue object for a period prior to the current period. + * + * @param {string} period - the chosen period + * @param {string} compare - `previous_period` or `previous_year` + * @return {DateValue} - DateValue data about the selected period + */ +export function getLastPeriod( period, compare ) { + const primaryStart = getStoreTimeZoneMoment() + .startOf( period ) + .subtract( 1, period ); + const primaryEnd = primaryStart.clone().endOf( period ); + let secondaryStart; + let secondaryEnd; + + if ( compare === 'previous_period' ) { + if ( period === 'year' ) { + // Subtract two entire periods for years to take into account leap year + secondaryStart = moment().startOf( period ).subtract( 2, period ); + secondaryEnd = secondaryStart.clone().endOf( period ); + } else { + // Otherwise, use days in primary period to figure out how far to go back + // This is necessary for calculating weeks instead of using `endOf`. + const daysDiff = primaryEnd.diff( primaryStart, 'days' ); + secondaryEnd = primaryStart.clone().subtract( 1, 'days' ); + secondaryStart = secondaryEnd.clone().subtract( daysDiff, 'days' ); + } + } else if ( period === 'week' ) { + secondaryStart = primaryStart.clone().subtract( 1, 'years' ); + secondaryEnd = primaryEnd.clone().subtract( 1, 'years' ); + } else { + secondaryStart = primaryStart.clone().subtract( 1, 'years' ); + secondaryEnd = secondaryStart.clone().endOf( period ); + } + + // When the period is month, be sure to force end of month to take into account leap year + if ( period === 'month' ) { + secondaryEnd = secondaryEnd.clone().endOf( 'month' ); + } + + return { + primaryStart, + primaryEnd, + secondaryStart, + secondaryEnd, + }; +} + +/** + * Get a DateValue object for a curent period. The period begins on the first day of the period, + * and ends on the current day. + * + * @param {string} period - the chosen period + * @param {string} compare - `previous_period` or `previous_year` + * @return {DateValue} - DateValue data about the selected period + */ +export function getCurrentPeriod( period, compare ) { + const primaryStart = getStoreTimeZoneMoment().startOf( period ); + const primaryEnd = getStoreTimeZoneMoment(); + + const daysSoFar = primaryEnd.diff( primaryStart, 'days' ); + let secondaryStart; + let secondaryEnd; + + if ( compare === 'previous_period' ) { + secondaryStart = primaryStart.clone().subtract( 1, period ); + secondaryEnd = primaryEnd.clone().subtract( 1, period ); + } else { + secondaryStart = primaryStart.clone().subtract( 1, 'years' ); + // Set the end time to 23:59:59. + secondaryEnd = secondaryStart + .clone() + .add( daysSoFar + 1, 'days' ) + .subtract( 1, 'seconds' ); + } + return { + primaryStart, + primaryEnd, + secondaryStart, + secondaryEnd, + }; +} + +/** + * Get a DateValue object for a period described by a period, compare value, and start/end + * dates, for custom dates. + * + * @param {string} period - the chosen period + * @param {string} compare - `previous_period` or `previous_year` + * @param {Object} [after] - after date if custom period + * @param {Object} [before] - before date if custom period + * @return {DateValue} - DateValue data about the selected period + */ +const getDateValue = memoize( + ( period, compare, after, before ) => { + switch ( period ) { + case 'today': + return getCurrentPeriod( 'day', compare ); + case 'yesterday': + return getLastPeriod( 'day', compare ); + case 'week': + return getCurrentPeriod( 'week', compare ); + case 'last_week': + return getLastPeriod( 'week', compare ); + case 'month': + return getCurrentPeriod( 'month', compare ); + case 'last_month': + return getLastPeriod( 'month', compare ); + case 'quarter': + return getCurrentPeriod( 'quarter', compare ); + case 'last_quarter': + return getLastPeriod( 'quarter', compare ); + case 'year': + return getCurrentPeriod( 'year', compare ); + case 'last_year': + return getLastPeriod( 'year', compare ); + case 'custom': + const difference = before.diff( after, 'days' ); + if ( compare === 'previous_period' ) { + const secondaryEnd = after.clone().subtract( 1, 'days' ); + const secondaryStart = secondaryEnd + .clone() + .subtract( difference, 'days' ); + return { + primaryStart: after, + primaryEnd: before, + secondaryStart, + secondaryEnd, + }; + } + return { + primaryStart: after, + primaryEnd: before, + secondaryStart: after.clone().subtract( 1, 'years' ), + secondaryEnd: before.clone().subtract( 1, 'years' ), + }; + } + }, + ( period, compare, after, before ) => + [ + period, + compare, + after && after.format(), + before && before.format(), + ].join( ':' ) +); + +/** + * Memoized internal logic of getDateParamsFromQuery(). + * + * @param {string} period - period value, ie `last_week` + * @param {string} compare - compare value, ie `previous_year` + * @param {string} after - date in iso date format, ie `2018-07-03` + * @param {string} before - date in iso date format, ie `2018-07-03` + * @param {string} defaultDateRange - the store's default date range + * @return {Object} - date parameters derived from query parameters with added defaults + */ +const getDateParamsFromQueryMemoized = memoize( + ( period, compare, after, before, defaultDateRange ) => { + if ( period && compare ) { + return { + period, + compare, + after: after ? moment( after ) : null, + before: before ? moment( before ) : null, + }; + } + const queryDefaults = parse( + defaultDateRange.replace( /&/g, '&' ) + ); + + return { + period: queryDefaults.period, + compare: queryDefaults.compare, + after: queryDefaults.after ? moment( queryDefaults.after ) : null, + before: queryDefaults.before + ? moment( queryDefaults.before ) + : null, + }; + }, + ( period, compare, after, before, defaultDateRange ) => + [ period, compare, after, before, defaultDateRange ].join( ':' ) +); + +/** + * Add default date-related parameters to a query object + * + * @param {Object} query - query object + * @param {string} query.period - period value, ie `last_week` + * @param {string} query.compare - compare value, ie `previous_year` + * @param {string} query.after - date in iso date format, ie `2018-07-03` + * @param {string} query.before - date in iso date format, ie `2018-07-03` + * @param {string} defaultDateRange - the store's default date range + * @return {DateParams} - date parameters derived from query parameters with added defaults + */ +export const getDateParamsFromQuery = ( + query, + defaultDateRange = 'period=month&compare=previous_year' +) => { + const { period, compare, after, before } = query; + + return getDateParamsFromQueryMemoized( + period, + compare, + after, + before, + defaultDateRange + ); +}; + +/** + * Memoized internal logic of getCurrentDates(). + * + * @param {string} period - period value, ie `last_week` + * @param {string} compare - compare value, ie `previous_year` + * @param {Object} primaryStart - primary query start DateTime, in Moment instance. + * @param {Object} primaryEnd - primary query start DateTime, in Moment instance. + * @param {Object} secondaryStart - primary query start DateTime, in Moment instance. + * @param {Object} secondaryEnd - primary query start DateTime, in Moment instance. + * @return {{primary: DateValue, secondary: DateValue}} - Primary and secondary DateValue objects + */ +const getCurrentDatesMemoized = memoize( + ( + period, + compare, + primaryStart, + primaryEnd, + secondaryStart, + secondaryEnd + ) => ( { + primary: { + label: find( presetValues, ( item ) => item.value === period ) + .label, + range: getRangeLabel( primaryStart, primaryEnd ), + after: primaryStart, + before: primaryEnd, + }, + secondary: { + label: find( periods, ( item ) => item.value === compare ).label, + range: getRangeLabel( secondaryStart, secondaryEnd ), + after: secondaryStart, + before: secondaryEnd, + }, + } ), + ( + period, + compare, + primaryStart, + primaryEnd, + secondaryStart, + secondaryEnd + ) => + [ + period, + compare, + primaryStart && primaryStart.format(), + primaryEnd && primaryEnd.format(), + secondaryStart && secondaryStart.format(), + secondaryEnd && secondaryEnd.format(), + ].join( ':' ) +); + +/** + * Get Date Value Objects for a primary and secondary date range + * + * @param {Object} query - query object + * @param {string} query.period - period value, ie `last_week` + * @param {string} query.compare - compare value, ie `previous_year` + * @param {string} query.after - date in iso date format, ie `2018-07-03` + * @param {string} query.before - date in iso date format, ie `2018-07-03` + * @param {string} defaultDateRange - the store's default date range + * @return {{primary: DateValue, secondary: DateValue}} - Primary and secondary DateValue objects + */ +export const getCurrentDates = ( + query, + defaultDateRange = 'period=month&compare=previous_year' +) => { + const { period, compare, after, before } = getDateParamsFromQuery( + query, + defaultDateRange + ); + const { + primaryStart, + primaryEnd, + secondaryStart, + secondaryEnd, + } = getDateValue( period, compare, after, before ); + + return getCurrentDatesMemoized( + period, + compare, + primaryStart, + primaryEnd, + secondaryStart, + secondaryEnd + ); +}; + +/** + * Calculates the date difference between two dates. Used in calculating a matching date for previous period. + * + * @param {string} date - Date to compare + * @param {string} date2 - Seconary date to compare + * @return {number} - Difference in days. + */ +export const getDateDifferenceInDays = ( date, date2 ) => { + const _date = moment( date ); + const _date2 = moment( date2 ); + return _date.diff( _date2, 'days' ); +}; + +/** + * Get the previous date for either the previous period of year. + * + * @param {string} date - Base date + * @param {string} date1 - primary start + * @param {string} date2 - secondary start + * @param {string} compare - `previous_period` or `previous_year` + * @param {string} interval - interval + * @return {Object} - Calculated date + */ +export const getPreviousDate = ( date, date1, date2, compare, interval ) => { + const dateMoment = moment( date ); + + if ( compare === 'previous_year' ) { + return dateMoment.clone().subtract( 1, 'years' ); + } + + const _date1 = moment( date1 ); + const _date2 = moment( date2 ); + const difference = _date1.diff( _date2, interval ); + + return dateMoment.clone().subtract( difference, interval ); +}; + +/** + * Returns the allowed selectable intervals for a specific query. + * + * @param {Object} query Current query + * @param {string} defaultDateRange - the store's default date range + * @return {Array} Array containing allowed intervals. + */ +export function getAllowedIntervalsForQuery( + query, + defaultDateRange = 'period=&compare=previous_year' +) { + const { period } = getDateParamsFromQuery( query, defaultDateRange ); + let allowed = []; + if ( period === 'custom' ) { + const { primary } = getCurrentDates( query ); + const differenceInDays = getDateDifferenceInDays( + primary.before, + primary.after + ); + if ( differenceInDays >= 365 ) { + allowed = [ 'day', 'week', 'month', 'quarter', 'year' ]; + } else if ( differenceInDays >= 90 ) { + allowed = [ 'day', 'week', 'month', 'quarter' ]; + } else if ( differenceInDays >= 28 ) { + allowed = [ 'day', 'week', 'month' ]; + } else if ( differenceInDays >= 7 ) { + allowed = [ 'day', 'week' ]; + } else if ( differenceInDays > 1 && differenceInDays < 7 ) { + allowed = [ 'day' ]; + } else { + allowed = [ 'hour', 'day' ]; + } + } else { + switch ( period ) { + case 'today': + case 'yesterday': + allowed = [ 'hour', 'day' ]; + break; + case 'week': + case 'last_week': + allowed = [ 'day' ]; + break; + case 'month': + case 'last_month': + allowed = [ 'day', 'week' ]; + break; + case 'quarter': + case 'last_quarter': + allowed = [ 'day', 'week', 'month' ]; + break; + case 'year': + case 'last_year': + allowed = [ 'day', 'week', 'month', 'quarter' ]; + break; + default: + allowed = [ 'day' ]; + break; + } + } + return allowed; +} + +/** + * Returns the current interval to use. + * + * @param {Object} query Current query + * @param {string} defaultDateRange - the store's default date range + * @return {string} Current interval. + */ +export function getIntervalForQuery( + query, + defaultDateRange = 'period=&compare=previous_year' +) { + const allowed = getAllowedIntervalsForQuery( query, defaultDateRange ); + const defaultInterval = allowed[ 0 ]; + let current = query.interval || defaultInterval; + if ( query.interval && ! allowed.includes( query.interval ) ) { + current = defaultInterval; + } + + return current; +} + +/** + * Returns the current chart type to use. + * + * @param {Object} query Current query + * @param {string} query.chartType + * @return {string} Current chart type. + */ +export function getChartTypeForQuery( { chartType } ) { + if ( [ 'line', 'bar' ].includes( chartType ) ) { + return chartType; + } + return 'line'; +} + +export const dayTicksThreshold = 63; +export const weekTicksThreshold = 9; +export const defaultTableDateFormat = 'm/d/Y'; + +/** + * Returns date formats for the current interval. + * + * @param {string} interval Interval to get date formats for. + * @param {number} [ticks] Number of ticks the axis will have. + * @param {Object} [option] Options + * @param {string} [option.type] Date format type, d3 or php, defaults to d3. + * @return {string} Current interval. + */ +export function getDateFormatsForInterval( + interval, + ticks = 0, + option = { type: 'd3' } +) { + switch ( option.type ) { + case 'php': + return getDateFormatsForIntervalPhp( interval, ticks ); + + case 'd3': + default: + return getDateFormatsForIntervalD3( interval, ticks ); + } +} + +/** + * Returns d3 date formats for the current interval. + * See https://github.com/d3/d3-time-format for chart formats. + * + * @param {string} interval Interval to get date formats for. + * @param {number} [ticks] Number of ticks the axis will have. + * @return {string} Current interval. + */ +export function getDateFormatsForIntervalD3( interval, ticks = 0 ) { + let screenReaderFormat = '%B %-d, %Y'; + let tooltipLabelFormat = '%B %-d, %Y'; + let xFormat = '%Y-%m-%d'; + let x2Format = '%b %Y'; + let tableFormat = defaultTableDateFormat; + + switch ( interval ) { + case 'hour': + screenReaderFormat = '%_I%p %B %-d, %Y'; + tooltipLabelFormat = '%_I%p %b %-d, %Y'; + xFormat = '%_I%p'; + x2Format = '%b %-d, %Y'; + tableFormat = 'h A'; + break; + case 'day': + if ( ticks < dayTicksThreshold ) { + xFormat = '%-d'; + } else { + xFormat = '%b'; + x2Format = '%Y'; + } + break; + case 'week': + if ( ticks < weekTicksThreshold ) { + xFormat = '%-d'; + x2Format = '%b %Y'; + } else { + xFormat = '%b'; + x2Format = '%Y'; + } + // eslint-disable-next-line @wordpress/i18n-translator-comments + screenReaderFormat = __( 'Week of %B %-d, %Y', 'woocommerce' ); + // eslint-disable-next-line @wordpress/i18n-translator-comments + tooltipLabelFormat = __( 'Week of %B %-d, %Y', 'woocommerce' ); + break; + case 'quarter': + case 'month': + screenReaderFormat = '%B %Y'; + tooltipLabelFormat = '%B %Y'; + xFormat = '%b'; + x2Format = '%Y'; + break; + case 'year': + screenReaderFormat = '%Y'; + tooltipLabelFormat = '%Y'; + xFormat = '%Y'; + break; + } + + return { + screenReaderFormat, + tooltipLabelFormat, + xFormat, + x2Format, + tableFormat, + }; +} + +/** + * Returns php date formats for the current interval. + * See see https://www.php.net/manual/en/datetime.format.php. + * + * @param {string} interval Interval to get date formats for. + * @param {number} [ticks] Number of ticks the axis will have. + * @return {string} Current interval. + */ +export function getDateFormatsForIntervalPhp( interval, ticks = 0 ) { + let screenReaderFormat = 'F j, Y'; + let tooltipLabelFormat = 'F j, Y'; + let xFormat = 'Y-m-d'; + let x2Format = 'M Y'; + let tableFormat = defaultTableDateFormat; + + switch ( interval ) { + case 'hour': + screenReaderFormat = 'gA F j, Y'; + tooltipLabelFormat = 'gA M j, Y'; + xFormat = 'gA'; + x2Format = 'M j, Y'; + tableFormat = 'h A'; + break; + case 'day': + if ( ticks < dayTicksThreshold ) { + xFormat = 'j'; + } else { + xFormat = 'M'; + x2Format = 'Y'; + } + break; + case 'week': + if ( ticks < weekTicksThreshold ) { + xFormat = 'j'; + x2Format = 'M Y'; + } else { + xFormat = 'M'; + x2Format = 'Y'; + } + + // Since some alphabet letters have php associated formats, we need to escape them first. + const escapedWeekOfStr = __( 'Week of', 'woocommerce' ).replace( + /(\w)/g, + '\\$1' + ); + + screenReaderFormat = `${ escapedWeekOfStr } F j, Y`; + tooltipLabelFormat = `${ escapedWeekOfStr } F j, Y`; + break; + case 'quarter': + case 'month': + screenReaderFormat = 'F Y'; + tooltipLabelFormat = 'F Y'; + xFormat = 'M'; + x2Format = 'Y'; + break; + case 'year': + screenReaderFormat = 'Y'; + tooltipLabelFormat = 'Y'; + xFormat = 'Y'; + break; + } + + return { + screenReaderFormat, + tooltipLabelFormat, + xFormat, + x2Format, + tableFormat, + }; +} + +/** + * Gutenberg's moment instance is loaded with i18n values, which are + * PHP date formats, ie 'LLL: "F j, Y g:i a"'. Override those with translations + * of moment style js formats. + * + * @param {Object} config Locale config object, from store settings. + * @param {string} config.userLocale + * @param {Array} config.weekdaysShort + */ +export function loadLocaleData( { userLocale, weekdaysShort } ) { + // Don't update if the wp locale hasn't been set yet, like in unit tests, for instance. + if ( moment.locale() !== 'en' ) { + moment.updateLocale( userLocale, { + longDateFormat: { + L: __( 'MM/DD/YYYY', 'woocommerce' ), + LL: __( 'MMMM D, YYYY', 'woocommerce' ), + LLL: __( 'D MMMM YYYY LT', 'woocommerce' ), + LLLL: __( 'dddd, D MMMM YYYY LT', 'woocommerce' ), + LT: __( 'HH:mm', 'woocommerce' ), + }, + weekdaysMin: weekdaysShort, + } ); + } +} + +export const dateValidationMessages = { + invalid: __( 'Invalid date', 'woocommerce' ), + future: __( 'Select a date in the past', 'woocommerce' ), + startAfterEnd: __( 'Start date must be before end date', 'woocommerce' ), + endBeforeStart: __( 'Start date must be before end date', 'woocommerce' ), +}; + +/** + * @typedef {Object} validatedDate + * @property {Object|null} date - A resulting Moment date object or null, if invalid + * @property {string} error - An optional error message if date is invalid + */ + +/** + * Validate text input supplied for a date range. + * + * @param {string} type - Designate beginning or end of range, eg `before` or `after`. + * @param {string} value - User input value + * @param {Object|null} [before] - If already designated, the before date parameter + * @param {Object|null} [after] - If already designated, the after date parameter + * @param {string} format - The expected date format in a user's locale + * @return {Object} validatedDate - validated date object + */ +export function validateDateInputForRange( + type, + value, + before, + after, + format +) { + const date = toMoment( format, value ); + if ( ! date ) { + return { + date: null, + error: dateValidationMessages.invalid, + }; + } + if ( moment().isBefore( date, 'day' ) ) { + return { + date: null, + error: dateValidationMessages.future, + }; + } + if ( type === 'after' && before && date.isAfter( before, 'day' ) ) { + return { + date: null, + error: dateValidationMessages.startAfterEnd, + }; + } + if ( type === 'before' && after && date.isBefore( after, 'day' ) ) { + return { + date: null, + error: dateValidationMessages.endBeforeStart, + }; + } + return { date }; +} diff --git a/packages/js/date/src/test/index.js b/packages/js/date/src/test/index.js new file mode 100644 index 00000000000..4e5c0a35194 --- /dev/null +++ b/packages/js/date/src/test/index.js @@ -0,0 +1,1100 @@ +/** + * External dependencies + */ +import moment from 'moment'; +import { format as formatDate } from '@wordpress/date'; +import { timeFormat as d3TimeFormat } from 'd3-time-format'; +/** + * Internal dependencies + */ +import { + appendTimestamp, + toMoment, + getLastPeriod, + getCurrentPeriod, + getRangeLabel, + loadLocaleData, + getCurrentDates, + validateDateInputForRange, + dateValidationMessages, + isoDateFormat, + getDateDifferenceInDays, + getPreviousDate, + getChartTypeForQuery, + getAllowedIntervalsForQuery, + getStoreTimeZoneMoment, + getDateFormatsForIntervalPhp, + getDateFormatsForIntervalD3, + dayTicksThreshold, +} from '../'; + +jest.mock( 'moment', () => { + const m = jest.requireActual( 'moment' ); + m.prototype.tz = jest.fn().mockImplementation( () => m() ); + + return m; +} ); + +describe( 'appendTimestamp', () => { + it( 'should append `start` timestamp', () => { + expect( appendTimestamp( moment( '2018-01-01' ), 'start' ) ).toEqual( + '2018-01-01T00:00:00' + ); + } ); + + it( 'should append `now` timestamp', () => { + const nowTimestamp = moment().format( 'HH:mm:00' ); + expect( + appendTimestamp( moment( '2018-01-01 ' + nowTimestamp ), 'now' ) + ).toEqual( '2018-01-01T' + nowTimestamp ); + } ); + + it( 'should append `end` timestamp', () => { + expect( appendTimestamp( moment( '2018-01-01' ), 'end' ) ).toEqual( + '2018-01-01T23:59:59' + ); + } ); + + it( 'should throw and error if `timeOfDay` is not valid', () => { + expect( () => appendTimestamp( moment( '2018-01-01' ) ) ).toThrow( + Error + ); + } ); +} ); + +describe( 'toMoment', () => { + it( 'should pass through a valid Moment object as an argument', () => { + const now = moment(); + const myMoment = toMoment( 'YYYY', now ); + expect( myMoment ).toEqual( now ); + } ); + + it( 'should handle isoFormat dates', () => { + const myMoment = toMoment( 'YYYY', '2018-04-15' ); + expect( moment.isMoment( myMoment ) ).toBe( true ); + expect( myMoment.isValid() ).toBe( true ); + } ); + + it( 'should handle local formats', () => { + const longDate = toMoment( 'MMMM D, YYYY', 'April 15, 2018' ); + expect( moment.isMoment( longDate ) ).toBe( true ); + expect( longDate.isValid() ).toBe( true ); + expect( longDate.date() ).toBe( 15 ); + expect( longDate.month() ).toBe( 3 ); + expect( longDate.year() ).toBe( 2018 ); + + const shortDate = toMoment( 'DD/MM/YYYY', '15/04/2018' ); + expect( moment.isMoment( shortDate ) ).toBe( true ); + expect( shortDate.isValid() ).toBe( true ); + expect( shortDate.date() ).toBe( 15 ); + expect( shortDate.month() ).toBe( 3 ); + expect( shortDate.year() ).toBe( 2018 ); + } ); + + it( 'should throw on an invalid argument', () => { + const fn = () => toMoment( '', 77 ); + expect( fn ).toThrow(); + } ); + + it( 'should return null on invalid date', () => { + const invalidDate = toMoment( 'YYYY', '2018-00-00' ); + expect( invalidDate ).toBe( null ); + } ); +} ); + +describe( 'getAllowedIntervalsForQuery', () => { + it( 'should return days when query period is defined but empty', () => { + const allowedIntervals = getAllowedIntervalsForQuery( { + period: '', + compare: 'previous_year', + } ); + expect( allowedIntervals ).toEqual( [ 'day' ] ); + } ); + + it( 'should return days, hours when query period is empty but defaultDateRange is today and yesterday', () => { + const allowedIntervals = getAllowedIntervalsForQuery( + { + period: '', + compare: 'previous_year', + }, + 'period=today&compare=previous_year' + ); + expect( allowedIntervals ).toEqual( [ 'hour', 'day' ] ); + + const allowedIntervalsYesterday = getAllowedIntervalsForQuery( + { + period: '', + compare: 'previous_year', + }, + 'period=yesterday&compare=previous_year' + ); + expect( allowedIntervalsYesterday ).toEqual( [ 'hour', 'day' ] ); + } ); + + it( 'should return days and hours for today and yesterday periods', () => { + const allowedIntervalsToday = getAllowedIntervalsForQuery( { + period: 'today', + compare: 'previous_year', + } ); + expect( allowedIntervalsToday ).toEqual( [ 'hour', 'day' ] ); + + const allowedIntervalsYesterday = getAllowedIntervalsForQuery( { + period: 'yesterday', + compare: 'previous_year', + } ); + expect( allowedIntervalsYesterday ).toEqual( [ 'hour', 'day' ] ); + } ); + + it( 'should return day for week and last_week periods', () => { + const allowedIntervalsWeek = getAllowedIntervalsForQuery( { + period: 'week', + compare: 'previous_year', + } ); + expect( allowedIntervalsWeek ).toEqual( [ 'day' ] ); + + const allowedIntervalsLastWeek = getAllowedIntervalsForQuery( { + period: 'last_week', + compare: 'previous_year', + } ); + expect( allowedIntervalsLastWeek ).toEqual( [ 'day' ] ); + } ); + + it( 'should return day, week for month and last_month periods', () => { + const allowedIntervalsMonth = getAllowedIntervalsForQuery( { + period: 'month', + compare: 'previous_year', + } ); + expect( allowedIntervalsMonth ).toEqual( [ 'day', 'week' ] ); + + const allowedIntervalsLastMonth = getAllowedIntervalsForQuery( { + period: 'last_month', + compare: 'previous_year', + } ); + expect( allowedIntervalsLastMonth ).toEqual( [ 'day', 'week' ] ); + } ); + + it( 'should return day, week, month for quarter and last_quarter periods', () => { + const allowedIntervalsQuarter = getAllowedIntervalsForQuery( { + period: 'quarter', + compare: 'previous_year', + } ); + expect( allowedIntervalsQuarter ).toEqual( [ 'day', 'week', 'month' ] ); + + const allowedIntervalsLastQuarter = getAllowedIntervalsForQuery( { + period: 'last_quarter', + compare: 'previous_year', + } ); + expect( allowedIntervalsLastQuarter ).toEqual( [ + 'day', + 'week', + 'month', + ] ); + } ); + + it( 'should return day, week, month, quarter for year and last_year periods', () => { + const allowedIntervalsYear = getAllowedIntervalsForQuery( { + period: 'year', + compare: 'previous_year', + } ); + expect( allowedIntervalsYear ).toEqual( [ + 'day', + 'week', + 'month', + 'quarter', + ] ); + + const allowedIntervalsLastYear = getAllowedIntervalsForQuery( { + period: 'last_year', + compare: 'previous_year', + } ); + expect( allowedIntervalsLastYear ).toEqual( [ + 'day', + 'week', + 'month', + 'quarter', + ] ); + } ); +} ); + +describe( 'getCurrentPeriod', () => { + it( 'should return a DateValue object with correct properties', () => { + const dateValue = getCurrentPeriod( 'day', 'previous_period' ); + + expect( dateValue.primaryStart ).toBeDefined(); + expect( dateValue.primaryEnd ).toBeDefined(); + expect( dateValue.secondaryStart ).toBeDefined(); + expect( dateValue.secondaryEnd ).toBeDefined(); + } ); + + // day + const today = moment(); + const yesterday = moment().subtract( 1, 'days' ); + const todayLastYear = moment().subtract( 1, 'years' ); + + // week + const thisWeekStart = moment().startOf( 'week' ); + const lastWeekStart = thisWeekStart.clone().subtract( 1, 'week' ); + const todayLastWeek = today.clone().subtract( 1, 'week' ); + + // month + const thisMonthStart = moment().startOf( 'month' ); + const lastMonthStart = thisMonthStart.clone().subtract( 1, 'month' ); + const todayLastMonth = today.clone().subtract( 1, 'month' ); + + // quarter + const thisQuarterStart = moment().startOf( 'quarter' ); + const lastQuarterStart = thisQuarterStart.clone().subtract( 1, 'quarter' ); + const todayLastQuarter = today.clone().subtract( 1, 'quarter' ); + + // year + const thisYearStart = moment().startOf( 'year' ); + const lastYearStart = thisYearStart.clone().subtract( 1, 'year' ); + + describe( 'day', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getCurrentPeriod( 'day', 'previous_period' ); + + expect( today.isSame( dateValue.primaryStart, 'day' ) ).toBe( + true + ); + expect( today.isSame( dateValue.primaryEnd, 'day' ) ).toBe( true ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getCurrentPeriod( 'day', 'previous_period' ); + + expect( yesterday.isSame( dateValue.secondaryStart, 'day' ) ).toBe( + true + ); + expect( yesterday.isSame( dateValue.secondaryEnd, 'day' ) ).toBe( + true + ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getCurrentPeriod( 'day', 'previous_year' ); + + expect( + todayLastYear.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + todayLastYear.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + } ); + + describe( 'week', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getCurrentPeriod( 'week', 'previous_period' ); + + expect( + thisWeekStart.isSame( dateValue.primaryStart, 'day' ) + ).toBe( true ); + expect( today.isSame( dateValue.primaryEnd, 'day' ) ).toBe( true ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getCurrentPeriod( 'week', 'previous_period' ); + + expect( + lastWeekStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + todayLastWeek.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getCurrentPeriod( 'week', 'previous_year' ); + const daysSoFar = today.diff( thisWeekStart, 'days' ); + // Last year weeks are aligned by calendar date not day of week. + const thisWeekLastYearStart = thisWeekStart + .clone() + .subtract( 1, 'years' ); + const todayThisWeekLastYear = thisWeekLastYearStart + .clone() + .add( daysSoFar, 'days' ); + + expect( + thisWeekLastYearStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + todayThisWeekLastYear.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + } ); + + describe( 'month', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getCurrentPeriod( 'month', 'previous_period' ); + + expect( + thisMonthStart.isSame( dateValue.primaryStart, 'day' ) + ).toBe( true ); + expect( today.isSame( dateValue.primaryEnd, 'day' ) ).toBe( true ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getCurrentPeriod( 'month', 'previous_period' ); + + expect( + lastMonthStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + todayLastMonth.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getCurrentPeriod( 'month', 'previous_year' ); + const daysSoFar = today.diff( thisMonthStart, 'days' ); + + const thisMonthLastYearStart = thisMonthStart + .clone() + .subtract( 1, 'years' ); + const thisMonthLastYearEnd = thisMonthLastYearStart + .clone() + .add( daysSoFar, 'days' ); + + expect( + thisMonthLastYearStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + thisMonthLastYearEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + } ); + + describe( 'quarter', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getCurrentPeriod( 'quarter', 'previous_period' ); + + expect( + thisQuarterStart.isSame( dateValue.primaryStart, 'day' ) + ).toBe( true ); + expect( today.isSame( dateValue.primaryEnd, 'day' ) ).toBe( true ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getCurrentPeriod( 'quarter', 'previous_period' ); + + expect( + lastQuarterStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + todayLastQuarter.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getCurrentPeriod( 'quarter', 'previous_year' ); + const daysSoFar = today.diff( thisQuarterStart, 'days' ); + + const thisQuarterLastYearStart = thisQuarterStart + .clone() + .subtract( 1, 'years' ); + const thisQuarterLastYearEnd = thisQuarterLastYearStart + .clone() + .add( daysSoFar, 'days' ); + + expect( + thisQuarterLastYearStart.isSame( + dateValue.secondaryStart, + 'day' + ) + ).toBe( true ); + expect( + thisQuarterLastYearEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + } ); + + describe( 'year', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getCurrentPeriod( 'year', 'previous_period' ); + + expect( + thisYearStart.isSame( dateValue.primaryStart, 'day' ) + ).toBe( true ); + expect( today.isSame( dateValue.primaryEnd, 'day' ) ).toBe( true ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getCurrentPeriod( 'year', 'previous_period' ); + + expect( + lastYearStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + todayLastYear.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getCurrentPeriod( 'year', 'previous_year' ); + const daysSoFar = today.diff( thisYearStart, 'days' ); + + const lastYearEnd = lastYearStart.clone().add( daysSoFar, 'days' ); + + expect( + lastYearStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( lastYearEnd.isSame( dateValue.secondaryEnd, 'day' ) ).toBe( + true + ); + } ); + } ); +} ); + +describe( 'getLastPeriod', () => { + it( 'should return a DateValue object with correct properties', () => { + const dateValue = getLastPeriod( 'day', 'previous_period' ); + + expect( dateValue.primaryStart ).toBeDefined(); + expect( dateValue.primaryEnd ).toBeDefined(); + expect( dateValue.secondaryStart ).toBeDefined(); + expect( dateValue.secondaryEnd ).toBeDefined(); + } ); + + // day + const yesterday = moment().subtract( 1, 'days' ); + const twoDaysAgo = moment().subtract( 2, 'days' ); + const yesterdayLastYear = moment() + .subtract( 1, 'days' ) + .subtract( 1, 'years' ); + + // week + const lastWeekStart = moment().startOf( 'week' ).subtract( 1, 'week' ); + const lastWeekEnd = lastWeekStart.clone().endOf( 'week' ); + + // month + const lastMonthStart = moment().startOf( 'month' ).subtract( 1, 'month' ); + const lastMonthEnd = lastMonthStart.clone().endOf( 'month' ); + + // quarter + const lastQuarterStart = moment() + .startOf( 'quarter' ) + .subtract( 1, 'quarter' ); + const lastQuarterEnd = lastQuarterStart.clone().endOf( 'quarter' ); + + // year + const lastYearStart = moment().startOf( 'year' ).subtract( 1, 'year' ); + const lastYearEnd = lastYearStart.clone().endOf( 'year' ); + const twoYearsAgoStart = moment().startOf( 'year' ).subtract( 2, 'year' ); + + describe( 'day', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getLastPeriod( 'day', 'previous_period' ); + + expect( yesterday.isSame( dateValue.primaryStart, 'day' ) ).toBe( + true + ); + expect( yesterday.isSame( dateValue.primaryEnd, 'day' ) ).toBe( + true + ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getLastPeriod( 'day', 'previous_period' ); + + expect( twoDaysAgo.isSame( dateValue.secondaryStart, 'day' ) ).toBe( + true + ); + expect( twoDaysAgo.isSame( dateValue.secondaryEnd, 'day' ) ).toBe( + true + ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getLastPeriod( 'day', 'previous_year' ); + + expect( + yesterdayLastYear.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + yesterdayLastYear.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + } ); + + describe( 'week', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getLastPeriod( 'week', 'previous_period' ); + + expect( + lastWeekStart.isSame( dateValue.primaryStart, 'day' ) + ).toBe( true ); + expect( lastWeekEnd.isSame( dateValue.primaryEnd, 'day' ) ).toBe( + true + ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getLastPeriod( 'week', 'previous_period' ); + + const twoWeeksAgoStart = lastWeekStart + .clone() + .subtract( 1, 'week' ); + const twoWeeksAgoEnd = twoWeeksAgoStart.clone().endOf( 'week' ); + + expect( + twoWeeksAgoStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + twoWeeksAgoEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getLastPeriod( 'week', 'previous_year' ); + + // Last year weeks are aligned by calendar date not day of week. + const lastWeekLastYearStart = lastWeekStart + .clone() + .subtract( 1, 'year' ); + const lastWeekLastYearEnd = lastWeekEnd + .clone() + .subtract( 1, 'year' ); + + expect( + lastWeekLastYearStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + lastWeekLastYearEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + } ); + + describe( 'month', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getLastPeriod( 'month', 'previous_period' ); + + expect( + lastMonthStart.isSame( dateValue.primaryStart, 'day' ) + ).toBe( true ); + expect( lastMonthEnd.isSame( dateValue.primaryEnd, 'day' ) ).toBe( + true + ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getLastPeriod( 'month', 'previous_period' ); + const daysDiff = lastMonthEnd.diff( lastMonthStart, 'days' ); + + const twoMonthsAgoEnd = lastMonthStart + .clone() + .subtract( 1, 'days' ); + const twoMonthsAgoStart = twoMonthsAgoEnd + .clone() + .subtract( daysDiff, 'days' ); + + expect( + twoMonthsAgoStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + twoMonthsAgoEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getLastPeriod( 'month', 'previous_year' ); + + const lastMonthkLastYearStart = lastMonthStart + .clone() + .subtract( 1, 'year' ); + const lastMonthkLastYearEnd = lastMonthkLastYearStart + .clone() + .endOf( 'month' ); + expect( + lastMonthkLastYearStart.isSame( + dateValue.secondaryStart, + 'day' + ) + ).toBe( true ); + expect( + lastMonthkLastYearEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + + it( 'should return correct values on a leap year', () => { + // Mock the current time as a year and month after a leap year month, March 2021. + const dateNowSpy = jest + .spyOn( Date, 'now' ) + .mockImplementation( () => 1615587095000 ); + + const dateValue = getLastPeriod( 'month', 'previous_year' ); + + expect( dateValue.secondaryEnd.date() ).toBe( 29 ); + + dateNowSpy.mockRestore(); + } ); + } ); + + describe( 'quarter', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getLastPeriod( 'quarter', 'previous_period' ); + + expect( + lastQuarterStart.isSame( dateValue.primaryStart, 'day' ) + ).toBe( true ); + expect( lastQuarterEnd.isSame( dateValue.primaryEnd, 'day' ) ).toBe( + true + ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getLastPeriod( 'quarter', 'previous_period' ); + const daysDiff = lastQuarterEnd.diff( lastQuarterStart, 'days' ); + + const twoQuartersAgoEnd = lastQuarterStart + .clone() + .subtract( 1, 'days' ); + const twoQuartersAgoStart = twoQuartersAgoEnd + .clone() + .subtract( daysDiff, 'days' ); + + expect( + twoQuartersAgoStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + twoQuartersAgoEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getLastPeriod( 'quarter', 'previous_year' ); + const lastQuarterLastYearStart = lastQuarterStart + .clone() + .subtract( 1, 'year' ); + const lastQuarterLastYearEnd = lastQuarterLastYearStart + .clone() + .endOf( 'quarter' ); + + expect( + lastQuarterLastYearStart.isSame( + dateValue.secondaryStart, + 'day' + ) + ).toBe( true ); + expect( + lastQuarterLastYearEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + } ); + + describe( 'year', () => { + it( 'should return correct values for primary period', () => { + const dateValue = getLastPeriod( 'year', 'previous_period' ); + + expect( + lastYearStart.isSame( dateValue.primaryStart, 'day' ) + ).toBe( true ); + expect( lastYearEnd.isSame( dateValue.primaryEnd, 'day' ) ).toBe( + true + ); + } ); + + it( 'should return correct values for previous_period', () => { + const dateValue = getLastPeriod( 'year', 'previous_period' ); + const twoYearsAgoEnd = twoYearsAgoStart.clone().endOf( 'year' ); + + expect( + twoYearsAgoStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + twoYearsAgoEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + + it( 'should return correct values for previous_year', () => { + const dateValue = getLastPeriod( 'year', 'previous_year' ); + const twoYearsAgoEnd = twoYearsAgoStart.clone().endOf( 'year' ); + + expect( + twoYearsAgoStart.isSame( dateValue.secondaryStart, 'day' ) + ).toBe( true ); + expect( + twoYearsAgoEnd.isSame( dateValue.secondaryEnd, 'day' ) + ).toBe( true ); + } ); + } ); +} ); + +describe( 'getRangeLabel', () => { + it( 'should return correct string for dates on the same day', () => { + const label = getRangeLabel( + moment( '2018-04-15' ), + moment( '2018-04-15' ) + ); + expect( label ).toBe( 'Apr 15, 2018' ); + } ); + + it( 'should return correct string for dates in the same month', () => { + const label = getRangeLabel( + moment( '2018-04-01' ), + moment( '2018-04-15' ) + ); + expect( label ).toBe( 'Apr 1 - 15, 2018' ); + } ); + + it( 'should return correct string for dates in the same year, but different months', () => { + const label = getRangeLabel( + moment( '2018-04-01' ), + moment( '2018-05-15' ) + ); + expect( label ).toBe( 'Apr 1 - May 15, 2018' ); + } ); + + it( 'should return correct string for dates in different years', () => { + const label = getRangeLabel( + moment( '2017-04-01' ), + moment( '2018-05-15' ) + ); + expect( label ).toBe( 'Apr 1, 2017 - May 15, 2018' ); + } ); +} ); + +describe( 'loadLocaleData', () => { + const originalLocale = { + siteLocale: 'en_US', + userLocale: 'en_US', + }; + beforeEach( () => { + // Reset to default settings + loadLocaleData( originalLocale ); + } ); + + it( 'should load locale data on user locale', () => { + // initialize locale. Gutenberg normaly does this, but not in test environment. + moment.locale( 'fr_FR', {} ); + + const weekdaysShort = [ + 'dim', + 'lun', + 'mar', + 'mer', + 'jeu', + 'ven', + 'sam', + ]; + + loadLocaleData( { + userLocale: 'fr_FR', + weekdaysShort, + } ); + expect( moment.localeData().weekdaysMin() ).toEqual( weekdaysShort ); + } ); +} ); + +describe( 'getCurrentDates', () => { + it( 'should return a correctly shaped object', () => { + const query = {}; + const currentDates = getCurrentDates( query ); + + expect( currentDates.primary ).toBeDefined(); + expect( typeof currentDates.primary.label ).toBe( 'string' ); + expect( typeof currentDates.primary.range ).toBe( 'string' ); + expect( moment.isMoment( currentDates.primary.after ) ).toBe( true ); + expect( moment.isMoment( currentDates.primary.before ) ).toBe( true ); + + expect( currentDates.secondary ).toBeDefined(); + expect( typeof currentDates.secondary.label ).toBe( 'string' ); + expect( typeof currentDates.secondary.range ).toBe( 'string' ); + expect( moment.isMoment( currentDates.secondary.after ) ).toBe( true ); + expect( moment.isMoment( currentDates.secondary.before ) ).toBe( true ); + } ); + + it( 'should correctly apply default values', () => { + const query = {}; + const today = moment().format( isoDateFormat ); + const startOfMonth = moment() + .startOf( 'month' ) + .format( isoDateFormat ); + const startOfMonthYearAgo = moment() + .startOf( 'month' ) + .subtract( 1, 'year' ) + .format( isoDateFormat ); + const todayLastYear = moment() + .subtract( 1, 'year' ) + .format( isoDateFormat ); + const currentDates = getCurrentDates( query ); + + // Ensure default period is 'month' + expect( currentDates.primary.after.format( isoDateFormat ) ).toBe( + startOfMonth + ); + expect( currentDates.primary.before.format( isoDateFormat ) ).toBe( + today + ); + + // Ensure default compare is `previous_period` + expect( currentDates.secondary.after.format( isoDateFormat ) ).toBe( + startOfMonthYearAgo + ); + expect( currentDates.secondary.before.format( isoDateFormat ) ).toBe( + todayLastYear + ); + } ); +} ); + +describe( 'validateDateInputForRange', () => { + const dateFormat = 'YYYY-MM-DD'; + + it( 'should return a valid date in Moment object', () => { + const validated = validateDateInputForRange( + 'after', + '2018-04-15', + null, + null, + dateFormat + ); + expect( moment.isMoment( validated.date ) ).toBe( true ); + expect( validated.error ).toBe( undefined ); + } ); + + it( 'should return a null date on invalid date string', () => { + const validated = validateDateInputForRange( + 'after', + 'BAd-2018-Date-Format/15/4', + null, + null, + dateFormat + ); + expect( validated.date ).toBe( null ); + expect( validated.error ).toBe( dateValidationMessages.invalid ); + } ); + + it( 'should return a correct error for a date in the future', () => { + const futureDateString = moment() + .add( 1, 'months' ) + .format( dateFormat ); + const validated = validateDateInputForRange( + 'after', + futureDateString, + null, + null, + dateFormat + ); + expect( validated.date ).toBe( null ); + expect( validated.error ).toBe( dateValidationMessages.future ); + } ); + + it( 'should return a correct error for start', () => { + const futureDateString = moment() + .add( 1, 'months' ) + .format( dateFormat ); + const validated = validateDateInputForRange( + 'after', + futureDateString, + null, + null, + dateFormat + ); + expect( validated.date ).toBe( null ); + expect( validated.error ).toBe( dateValidationMessages.future ); + } ); + + it( 'should return a correct error for start after end', () => { + const end = moment().subtract( 5, 'months' ); + const value = end.clone().add( 1, 'months' ).format( dateFormat ); + const validated = validateDateInputForRange( + 'after', + value, + end, + null, + dateFormat + ); + expect( validated.date ).toBe( null ); + expect( validated.error ).toBe( dateValidationMessages.startAfterEnd ); + } ); + + it( 'should return a correct error for end after start', () => { + const start = moment().subtract( 5, 'months' ); + const value = start + .clone() + .subtract( 1, 'months' ) + .format( dateFormat ); + const validated = validateDateInputForRange( + 'before', + value, + null, + start, + dateFormat + ); + expect( validated.date ).toBe( null ); + expect( validated.error ).toBe( dateValidationMessages.endBeforeStart ); + } ); +} ); + +describe( 'getDateDifferenceInDays', () => { + it( 'should calculate the day difference between two dates', () => { + const difference = getDateDifferenceInDays( + '2018-08-22', + '2018-05-22' + ); + expect( difference ).toBe( 92 ); + } ); +} ); + +describe( 'getPreviousDate', () => { + it( 'should return valid date for previous period by days', () => { + const date = '2018-08-21'; + const primaryStart = '2018-08-25'; + const secondaryStart = '2018-08-15'; + const previousDate = getPreviousDate( + date, + primaryStart, + secondaryStart, + 'previous_period', + 'day' + ); + expect( previousDate.format( isoDateFormat ) ).toBe( '2018-08-11' ); + } ); + it( 'should return valid date for previous period by months', () => { + const date = '2018-08-21'; + const primaryStart = '2018-08-01'; + const secondaryStart = '2018-07-01'; + const previousDate = getPreviousDate( + date, + primaryStart, + secondaryStart, + 'previous_period', + 'month' + ); + expect( previousDate.format( isoDateFormat ) ).toBe( '2018-07-21' ); + } ); + it( 'should return valid date for previous year', () => { + const date = '2018-08-21'; + const primaryStart = '2018-08-01'; + const secondaryStart = '2018-07-01'; + const previousDate = getPreviousDate( + date, + primaryStart, + secondaryStart, + 'previous_year', + 'day' + ); + expect( previousDate.format( isoDateFormat ) ).toBe( '2017-08-21' ); + } ); +} ); + +describe( 'getChartTypeForQuery', () => { + it( 'should return allowed type', () => { + const query = { + chartType: 'bar', + }; + expect( getChartTypeForQuery( query ) ).toBe( 'bar' ); + } ); + + it( 'should default to line', () => { + expect( getChartTypeForQuery( {} ) ).toBe( 'line' ); + } ); + + it( 'should return line for not allowed type', () => { + const query = { + chartType: 'burrito', + }; + expect( getChartTypeForQuery( query ) ).toBe( 'line' ); + } ); +} ); + +describe( 'getStoreTimeZoneMoment', () => { + it( 'should return the default moment when no timezone exists', () => { + const mockTz = ( moment.prototype.tz = jest.fn() ); + const utcOffset = ( moment.prototype.utcOffset = jest.fn() ); + + expect( getStoreTimeZoneMoment() ).toHaveProperty( '_isAMomentObject' ); + + expect( mockTz ).not.toHaveBeenCalled(); + expect( utcOffset ).not.toHaveBeenCalled(); + } ); + + it( 'should use the timezone string when one is set', () => { + global.window.wcSettings = { + timeZone: 'Asia/Taipei', + }; + + const mockTz = ( moment.prototype.tz = jest.fn() ); + const utcOffset = ( moment.prototype.utcOffset = jest.fn() ); + + getStoreTimeZoneMoment(); + + expect( mockTz ).toHaveBeenCalledWith( 'Asia/Taipei' ); + expect( utcOffset ).not.toHaveBeenCalled(); + } ); + + it( 'should use the utc offest when it is set', () => { + global.window.wcSettings = { + timeZone: '+06:00', + }; + + const mockTz = ( moment.prototype.tz = jest.fn() ); + const utcOffset = ( moment.prototype.utcOffset = jest.fn() ); + + getStoreTimeZoneMoment(); + + expect( mockTz ).not.toHaveBeenCalled(); + expect( utcOffset ).toHaveBeenCalledWith( '+06:00' ); + + global.window.wcSettings = { + timeZone: '-04:00', + }; + + getStoreTimeZoneMoment(); + + expect( mockTz ).not.toHaveBeenCalled(); + expect( utcOffset ).toHaveBeenCalledWith( '-04:00' ); + } ); +} ); + +describe( 'getDateFormatsForIntervalPhp', () => { + test.each( [ + { interval: 'hour', ticks: 0 }, + { interval: 'day', ticks: dayTicksThreshold - 1 }, + { interval: 'day', ticks: dayTicksThreshold + 1 }, + { interval: 'week', ticks: dayTicksThreshold - 1 }, + { interval: 'week', ticks: dayTicksThreshold + 1 }, + { interval: 'quarter', ticks: 0 }, + { interval: 'month', ticks: 0 }, + { interval: 'year', ticks: 0 }, + { interval: 'default', ticks: 0 }, + ] )( + 'should return formatted date same as getDateFormatsForIntervalD3 when interval is $interval and ticks is $ticks', + ( { interval, ticks } ) => { + const date = new Date(); + + const dateFormatsPhp = getDateFormatsForIntervalPhp( + interval, + ticks + ); + const dateFormatsD3 = getDateFormatsForIntervalD3( + interval, + ticks + ); + + expect( + formatDate( dateFormatsPhp.screenReaderFormat, date ) + ).toBe( + d3TimeFormat( dateFormatsD3.screenReaderFormat )( + date + ).trimStart() // trim the leading space since d3.timeFormat adds it but it does not affect the UI + ); + + expect( + formatDate( dateFormatsPhp.tooltipLabelFormat, date ) + ).toBe( + d3TimeFormat( dateFormatsD3.tooltipLabelFormat )( + date + ).trimStart() + ); + + expect( formatDate( dateFormatsPhp.xFormat, date ) ).toBe( + d3TimeFormat( dateFormatsD3.xFormat )( date ).trimStart() + ); + + expect( formatDate( dateFormatsPhp.x2Format, date ) ).toBe( + d3TimeFormat( dateFormatsD3.x2Format )( date ).trimStart() + ); + } + ); +} ); diff --git a/packages/js/date/tsconfig-cjs.json b/packages/js/date/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/date/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/date/tsconfig.json b/packages/js/date/tsconfig.json new file mode 100644 index 00000000000..e8f14a25fa4 --- /dev/null +++ b/packages/js/date/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module" + } +} \ No newline at end of file diff --git a/packages/js/dependency-extraction-webpack-plugin/.eslintrc.js b/packages/js/dependency-extraction-webpack-plugin/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/dependency-extraction-webpack-plugin/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/dependency-extraction-webpack-plugin/.npmrc b/packages/js/dependency-extraction-webpack-plugin/.npmrc new file mode 100644 index 00000000000..9cf9495031e --- /dev/null +++ b/packages/js/dependency-extraction-webpack-plugin/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/packages/js/dependency-extraction-webpack-plugin/CHANGELOG.md b/packages/js/dependency-extraction-webpack-plugin/CHANGELOG.md new file mode 100644 index 00000000000..71dbcd5675f --- /dev/null +++ b/packages/js/dependency-extraction-webpack-plugin/CHANGELOG.md @@ -0,0 +1,43 @@ +# Unreleased + +# 2.0.0 + +- Update all js packages with minor/patch version changes. #8392 + +## Breaking changes + - Updated to webpack 5 compatible #8476 + - Will need to change webpack config output.libraryTarget from 'this' to 'window' #8476 + +# 1.6.0 + +- Add new `bundledPackages` option to bundle in specific packages. + +# 1.5.0 + +- Add `@woocommerce/explat` to list of packages. +- Add `@woocommerce/experimental` to list of packages. + +# 1.4.0 + +- Add `@woocommerce/settings` to list of packages. + +# 1.3.0 + +- Remove `@woocommerce/block-settings` from internal maps and use `@woocommerce/settings` instead. This external exposes the `getSetting` API interface which encourages read only use of the global. +- Remove explicitly scoping externals to `this`. The plugin compiler will take care of scoping the external to the correct context and `this` is not correct in some contexts consuming this package. + +# 1.2.0 + +- Add WooCommerce Blocks Dependencies. #6228 + +# 1.1.0 + +- Fix: Handle irregular package names that don't conform to a pattern. + +# 1.0.1 + +- Fix: Avoid transpiling packaged code. + +# 1.0.0 + +- Released package diff --git a/packages/js/dependency-extraction-webpack-plugin/README.md b/packages/js/dependency-extraction-webpack-plugin/README.md new file mode 100644 index 00000000000..c7fa75ac89a --- /dev/null +++ b/packages/js/dependency-extraction-webpack-plugin/README.md @@ -0,0 +1,59 @@ +# Dependency Extraction Webpack Plugin + +Extends Wordpress [Dependency Extraction Webpack Plugin](https://github.com/WordPress/gutenberg/tree/master/packages/dependency-extraction-webpack-plugin) to automatically include WooCommerce dependencies in addition to WordPress dependencies. + +## Installation + +Install the module + +``` +pnpm install @woocommerce/dependency-extraction-webpack-plugin --save-dev +``` + +## Usage + +Use this as you would [Dependency Extraction Webpack Plugin](https://github.com/WordPress/gutenberg/tree/master/packages/dependency-extraction-webpack-plugin). The API is exactly the same, except that WooCommerce packages are also handled automatically. + +```js +// webpack.config.js +const WooCommerceDependencyExtractionWebpackPlugin = require( '@woocommerce/dependency-extraction-webpack-plugin' ); + +module.exports = { + // …snip + plugins: [ new WooCommerceDependencyExtractionWebpackPlugin() ], +}; +``` + +Additional module requests on top of Wordpress [Dependency Extraction Webpack Plugin](https://github.com/WordPress/gutenberg/tree/master/packages/dependency-extraction-webpack-plugin) are: + +| Request | Global | Script handle | Notes | +| ------------------------------ | ------------------------ | ---------------------- | --------------------------------------------------------| +| `@woocommerce/data` | `wc['data']` | `wc-store-data` | | +| `@woocommerce/csv-export` | `wc['csvExport']` | `wc-csv` | | +| `@woocommerce/blocks-registry` | `wc['wcBlocksRegistry']` | `wc-blocks-registry` | | +| `@woocommerce/block-data` | `wc['wcBlocksData']` | `wc-blocks-data-store` | This dependency does not have an associated npm package | +| `@woocommerce/settings` | `wc['wcSettings']` | `wc-settings` | | +| `@woocommerce/*` | `wc['*']` | `wc-*` | | + +#### Options + +An object can be passed to the constructor to customize the behavior, for example: + +```js +module.exports = { + plugins: [ + new WooCommerceDependencyExtractionWebpackPlugin( { + bundledPackages: [ '@woocommerce/components' ], + } ), + ], +}; +``` + +##### `bundledPackages` + +- Type: array +- Default: [] + +A list of potential WooCommerce excluded packages, this will include the excluded package within the bundle (example above). + +For more supported options see the original [dependency extraction plugin](https://github.com/WordPress/gutenberg/blob/trunk/packages/dependency-extraction-webpack-plugin/README.md#options). diff --git a/packages/js/dependency-extraction-webpack-plugin/assets/packages.js b/packages/js/dependency-extraction-webpack-plugin/assets/packages.js new file mode 100644 index 00000000000..bb7f725721b --- /dev/null +++ b/packages/js/dependency-extraction-webpack-plugin/assets/packages.js @@ -0,0 +1,22 @@ +module.exports = [ + // wc-admin packages + '@woocommerce/components', + '@woocommerce/csv-export', + '@woocommerce/currency', + '@woocommerce/customer-effort-score', + '@woocommerce/data', + '@woocommerce/date', + '@woocommerce/dependency-extraction-webpack-plugin', + '@woocommerce/eslint-plugin', + '@woocommerce/experimental', + '@woocommerce/explat', + '@woocommerce/navigation', + '@woocommerce/notices', + '@woocommerce/number', + '@woocommerce/tracks', + // wc-blocks packages + '@woocommerce/blocks-checkout', + '@woocommerce/block-data', + '@woocommerce/blocks-registry', + '@woocommerce/settings', +]; diff --git a/packages/js/dependency-extraction-webpack-plugin/package.json b/packages/js/dependency-extraction-webpack-plugin/package.json new file mode 100644 index 00000000000..fe6cdd0cacf --- /dev/null +++ b/packages/js/dependency-extraction-webpack-plugin/package.json @@ -0,0 +1,43 @@ +{ + "name": "@woocommerce/dependency-extraction-webpack-plugin", + "version": "2.0.0", + "description": "WooCommerce Dependency Extraction Webpack Plugin", + "author": "Automattic", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "woocommerce" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/dependency-extraction-webpack-plugin/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "src/index.js", + "dependencies": { + "@wordpress/dependency-extraction-webpack-plugin": "^3.3.0" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2", + "webpack": "^5.70.0", + "webpack-cli": "^3.3.12" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] + } +} diff --git a/packages/js/dependency-extraction-webpack-plugin/project.json b/packages/js/dependency-extraction-webpack-plugin/project.json new file mode 100644 index 00000000000..5009a1dac96 --- /dev/null +++ b/packages/js/dependency-extraction-webpack-plugin/project.json @@ -0,0 +1,14 @@ +{ + "root": "packages/js/dependency-extraction-webpack-plugin", + "sourceRoot": "packages/js/dependency-extraction-webpack-plugin/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/dependency-extraction-webpack-plugin" + } + } + } + } \ No newline at end of file diff --git a/packages/js/dependency-extraction-webpack-plugin/src/index.js b/packages/js/dependency-extraction-webpack-plugin/src/index.js new file mode 100644 index 00000000000..dadc478d9ab --- /dev/null +++ b/packages/js/dependency-extraction-webpack-plugin/src/index.js @@ -0,0 +1,110 @@ +const WPDependencyExtractionWebpackPlugin = require( '@wordpress/dependency-extraction-webpack-plugin' ); +const packages = require( '../assets/packages' ); + +const WOOCOMMERCE_NAMESPACE = '@woocommerce/'; + +/** + * Given a string, returns a new string with dash separators converted to + * camelCase equivalent. This is not as aggressive as `_.camelCase` in + * converting to uppercase, where Lodash will also capitalize letters + * following numbers. + * + * @param {string} string Input dash-delimited string. + * + * @return {string} Camel-cased string. + */ +function camelCaseDash( string ) { + return string.replace( /-([a-z])/g, ( _, letter ) => letter.toUpperCase() ); +} + +const wooRequestToExternal = ( request, excludedExternals ) => { + if ( packages.includes( request ) ) { + const handle = request.substring( WOOCOMMERCE_NAMESPACE.length ); + const irregularExternalMap = { + 'block-data': [ 'wc', 'wcBlocksData' ], + 'blocks-registry': [ 'wc', 'wcBlocksRegistry' ], + settings: [ 'wc', 'wcSettings' ], + }; + + if ( ( excludedExternals || [] ).includes( request ) ) { + return; + } + + if ( irregularExternalMap[ handle ] ) { + return irregularExternalMap[ handle ]; + } + + return [ 'wc', camelCaseDash( handle ) ]; + } +}; + +const wooRequestToHandle = ( request ) => { + if ( packages.includes( request ) ) { + const handle = request.substring( WOOCOMMERCE_NAMESPACE.length ); + const irregularHandleMap = { + data: 'wc-store-data', + 'block-data': 'wc-blocks-data-store', + 'csv-export': 'wc-csv', + }; + + if ( irregularHandleMap[ handle ] ) { + return irregularHandleMap[ handle ]; + } + + return 'wc-' + handle; + } +}; + +class DependencyExtractionWebpackPlugin extends WPDependencyExtractionWebpackPlugin { + externalizeWpDeps( _context, request, callback ) { + let externalRequest; + + // Handle via options.requestToExternal first + if ( typeof this.options.requestToExternal === 'function' ) { + externalRequest = this.options.requestToExternal( request ); + } + + // Cascade to default if unhandled and enabled + if ( + typeof externalRequest === 'undefined' && + this.options.useDefaults + ) { + externalRequest = wooRequestToExternal( + request, + this.options.bundledPackages || [] + ); + } + + if ( externalRequest ) { + this.externalizedDeps.add( request ); + + return callback( null, externalRequest ); + } + + // Fall back to the WP method + return super.externalizeWpDeps( _context, request, callback ); + } + + mapRequestToDependency( request ) { + // Handle via options.requestToHandle first + if ( typeof this.options.requestToHandle === 'function' ) { + const scriptDependency = this.options.requestToHandle( request ); + if ( scriptDependency ) { + return scriptDependency; + } + } + + // Cascade to default if enabled + if ( this.options.useDefaults ) { + const scriptDependency = wooRequestToHandle( request ); + if ( scriptDependency ) { + return scriptDependency; + } + } + + // Fall back to the WP method + return super.mapRequestToDependency( request ); + } +} + +module.exports = DependencyExtractionWebpackPlugin; diff --git a/packages/js/e2e-builds/.eslintrc.js b/packages/js/e2e-builds/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/e2e-builds/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/e2e-builds/.npmrc b/packages/js/e2e-builds/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/e2e-builds/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/e2e-builds/CHANGELOG.md b/packages/js/e2e-builds/CHANGELOG.md new file mode 100644 index 00000000000..8e8631b9da0 --- /dev/null +++ b/packages/js/e2e-builds/CHANGELOG.md @@ -0,0 +1,5 @@ +# Unreleased + +# 0.1.0 + +- Released package diff --git a/packages/js/bin/build.js b/packages/js/e2e-builds/build.js similarity index 96% rename from packages/js/bin/build.js rename to packages/js/e2e-builds/build.js index 7b5ba705009..e0928272e45 100755 --- a/packages/js/bin/build.js +++ b/packages/js/e2e-builds/build.js @@ -38,8 +38,8 @@ const isJsFile = ( filepath ) => { /** * Get Build Path for a specified file * - * @param {string} file File to build - * @param {string} buildFolder Output folder + * @param {string} file File to build + * @param {string} buildFolder Output folder * @return {string} Build path */ function getBuildPath( file, buildFolder ) { @@ -72,7 +72,7 @@ function buildFiles( files ) { /** * Build a javaScript file for the required environments (node and ES5) * - * @param {string} file File path to build + * @param {string} file File path to build * @param {boolean} silent Show logs */ function buildJsFile( file, silent ) { diff --git a/packages/js/e2e-builds/get-babel-config.js b/packages/js/e2e-builds/get-babel-config.js new file mode 100644 index 00000000000..b474321e9ae --- /dev/null +++ b/packages/js/e2e-builds/get-babel-config.js @@ -0,0 +1,59 @@ +/** + * External dependencies + */ +const { get, map } = require( 'lodash' ); +const babel = require( '@babel/core' ); + +/** + * WordPress dependencies + */ +const { options: babelDefaultConfig } = babel.loadPartialConfig( { + configFile: '@wordpress/babel-preset-default', +} ); +const plugins = babelDefaultConfig.plugins; +if ( ! process.env.SKIP_JSX_PRAGMA_TRANSFORM ) { + plugins.push( [ + '@wordpress/babel-plugin-import-jsx-pragma', + { + scopeVariable: 'createElement', + source: '@wordpress/element', + isDefault: false, + }, + ] ); +} + +const overrideOptions = ( target, targetName, options ) => { + if ( get( target, [ 'file', 'request' ] ) === targetName ) { + return [ targetName, Object.assign( {}, target.options, options ) ]; + } + return target; +}; + +const babelConfigs = { + main: Object.assign( {}, babelDefaultConfig, { + plugins, + presets: map( babelDefaultConfig.presets, ( preset ) => + overrideOptions( preset, '@babel/preset-env', { + modules: 'commonjs', + } ) + ), + } ), + module: Object.assign( {}, babelDefaultConfig, { + plugins: map( plugins, ( plugin ) => + overrideOptions( plugin, '@babel/plugin-transform-runtime', { + useESModules: true, + } ) + ), + presets: map( babelDefaultConfig.presets, ( preset ) => + overrideOptions( preset, '@babel/preset-env', { + modules: false, + } ) + ), + } ), +}; + +function getBabelConfig( environment ) { + return babelConfigs[ environment ]; +} + +module.exports = getBabelConfig; diff --git a/packages/js/e2e-builds/package.json b/packages/js/e2e-builds/package.json new file mode 100644 index 00000000000..bc9ea6362e4 --- /dev/null +++ b/packages/js/e2e-builds/package.json @@ -0,0 +1,26 @@ +{ + "name": "@woocommerce/e2e-builds", + "version": "0.1.0", + "description": "Utility build files for e2e packages", + "private": "true", + "main": "build.js", + "bin": { + "e2e-builds": "./build.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/woocommerce/woocommerce.git" + }, + "license": "GPL-3.0+", + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "homepage": "https://github.com/woocommerce/woocommerce#readme", + "devDependencies": { + "@babel/core": "7.12.9", + "chalk": "^4.1.2", + "glob": "^7.2.0", + "mkdirp": "^1.0.4", + "lodash": "^4.17.21" + } +} diff --git a/packages/js/e2e-builds/project.json b/packages/js/e2e-builds/project.json new file mode 100644 index 00000000000..02b9146de79 --- /dev/null +++ b/packages/js/e2e-builds/project.json @@ -0,0 +1,15 @@ +{ + "root": "packages/js/e2e-builds", + "sourceRoot": "packages/js/e2e-builds", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/e2e-builds" + } + } + } + } + \ No newline at end of file diff --git a/packages/js/e2e-core-tests/CHANGELOG.md b/packages/js/e2e-core-tests/CHANGELOG.md index 65f185480e2..f66d296b2af 100644 --- a/packages/js/e2e-core-tests/CHANGELOG.md +++ b/packages/js/e2e-core-tests/CHANGELOG.md @@ -2,18 +2,23 @@ ## Fixed +- Updated assertion in the block `can update order details` from the e2e test `order-edit.test.js` that wasn't checking properly the date value when editing an order, allowing the test to return a false positive. - Moved `merchant.login()` out of `beforeAll()` block and into test body for retried runs. ## Added +- Additional Merchant Order Edit tests to increase the downloadable products coverage. - A `specs/data` folder to store page element data. - Tests to verify that different top-level menus and their associated sub-menus load successfully. - Test scaffolding via `npx wc-e2e install @woocommerce/e2e-core-tests` ## Changed +- The e2e test `update-product-settings.test.js` now covers setting and unsetting the `X-Accel-Redirect/X-Sendfile` download method and `Append a unique string to filename for security` flag. +- The e2e test `order-edit.test.js` now uses the API to create orders. - New coupon test deletes the coupon instead of trashing it. - A copy of sample_data.csv is included in the package. +- Removed `faker` dependency # 0.1.6 @@ -27,7 +32,7 @@ - Support for re-running setup and shopper tests - Shopper Order Email Receiving -- New tests - See [README.md](https://github.com/woocommerce/woocommerce/blob/trunk/tests/e2e/core-tests/README.md) for list of available tests +- New tests - See [README.md](https://github.com/woocommerce/woocommerce/blob/trunk/packages/js/e2e-core-tests/README.md) for list of available tests ## Fixed diff --git a/packages/js/e2e-core-tests/changelog/fix-e2e-builds-private-package b/packages/js/e2e-core-tests/changelog/fix-e2e-builds-private-package new file mode 100644 index 00000000000..ea5be2b5fdb --- /dev/null +++ b/packages/js/e2e-core-tests/changelog/fix-e2e-builds-private-package @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Create dependency for new private e2e-builds package diff --git a/packages/js/e2e-core-tests/package.json b/packages/js/e2e-core-tests/package.json index 72e6da077d4..ee3708d3cae 100644 --- a/packages/js/e2e-core-tests/package.json +++ b/packages/js/e2e-core-tests/package.json @@ -1,6 +1,6 @@ { "name": "@woocommerce/e2e-core-tests", - "version": "0.1.6", + "version": "0.2.0", "description": "End-To-End (E2E) tests for WooCommerce", "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/e2e-core-tests/README.md", "repository": { @@ -21,8 +21,7 @@ "dependencies": { "@jest/globals": "^26.4.2", "@wordpress/deprecated": "^3.2.3", - "config": "3.3.3", - "faker": "^5.1.0" + "config": "3.3.3" }, "devDependencies": { "@babel/cli": "7.12.8", @@ -33,6 +32,7 @@ "@babel/plugin-transform-runtime": "^7.16.4", "@babel/polyfill": "7.12.1", "@babel/preset-env": "7.12.7", + "@woocommerce/e2e-builds": "workspace:*", "@wordpress/babel-plugin-import-jsx-pragma": "1.1.3", "@wordpress/babel-preset-default": "3.0.2", "@wordpress/browserslist-config": "^4.1.0" @@ -47,7 +47,12 @@ "scripts": { "prepare": "pnpm run build", "clean": "rm -rf ./build ./build-module", - "compile": "node ./../bin/build.js", + "compile": "e2e-builds", "build": "./bin/build.sh && pnpm run clean && pnpm run compile" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] } } diff --git a/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-order-edit.test.js b/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-order-edit.test.js index 774b1b8c1dc..cb1c2db5af8 100644 --- a/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-order-edit.test.js +++ b/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-order-edit.test.js @@ -3,22 +3,30 @@ */ const { merchant, - createSimpleOrder, withRestApi, utils, + createSimpleDownloadableProduct, + createOrder, + verifyValueOfInputField, + orderPageSaveChanges, } = require( '@woocommerce/e2e-utils' ); let orderId; +const orderStatus = { + processing: 'processing', + completed: 'completed' +}; + const runEditOrderTest = () => { describe('WooCommerce Orders > Edit order', () => { beforeAll(async () => { + orderId = await createOrder( { status: orderStatus.processing } ); await merchant.login(); - orderId = await createSimpleOrder('Processing'); }); afterAll( async () => { - await withRestApi.deleteAllOrders(); + await withRestApi.deleteOrder( orderId ); }); it('can view single order', async () => { @@ -69,14 +77,158 @@ const runEditOrderTest = () => { await utils.waitForTimeout( 2000 ); // Save the order changes - await expect( page ).toClick( 'button.save_order' ); - await page.waitForSelector( '#message' ); + await orderPageSaveChanges(); // Verify await expect( page ).toMatchElement( '#message', { text: 'Order updated.' } ); - await expect( page ).toMatchElement( 'input[name=order_date]', { value: '2018-12-14' } ); + await verifyValueOfInputField( 'input[name=order_date]' , '2018-12-14' ); }); }); + + describe( 'WooCommerce Orders > Edit order > Downloadable product permissions', () => { + const productName = 'TDP 001'; + const customerBilling = { + email: 'john.doe@example.com', + }; + + let productId; + + beforeAll( async () => { + await merchant.login(); + } ); + + beforeEach(async () => { + productId = await createSimpleDownloadableProduct( productName ); + orderId = await createOrder( { + productId, + customerBilling , + status: orderStatus.processing + } ); + } ); + + afterEach( async () => { + await withRestApi.deleteOrder( orderId ); + await withRestApi.deleteProduct( productId ); + } ); + + it( 'can add downloadable product permissions to order without product', async () => { + // Create order without product + const newOrderId = await createOrder( { + customerBilling, + status: orderStatus.processing + } ); + + // Open order we created + await merchant.goToOrder( newOrderId ); + + // Add permission + await merchant.addDownloadableProductPermission( productName ); + + // Verify new downloadable product permission details + await merchant.verifyDownloadableProductPermission( productName ) + + // Remove order + await withRestApi.deleteOrder( newOrderId ); + } ); + + it( 'can add downloadable product permissions to order with product', async () => { + // Create new downloadable product + const newProductName = 'TDP 002'; + const newProductId = await createSimpleDownloadableProduct( newProductName ); + + // Open order we created + await merchant.goToOrder( orderId ); + + // Add permission + await merchant.addDownloadableProductPermission( newProductName ); + + // Verify new downloadable product permission details + await merchant.verifyDownloadableProductPermission( newProductName ) + + // Remove product + await withRestApi.deleteProduct( newProductId ); + } ); + + it( 'can edit downloadable product permissions', async () => { + // Define expected downloadable product attributes + const expectedDownloadsRemaining = '10'; + const expectedDownloadsExpirationDate = '2050-01-01'; + + // Open order we created + await merchant.goToOrder( orderId ); + + // Update permission + await merchant.updateDownloadableProductPermission( + productName, + expectedDownloadsExpirationDate, + expectedDownloadsRemaining + ); + + // Verify new downloadable product permission details + await merchant.verifyDownloadableProductPermission( + productName, + expectedDownloadsExpirationDate, + expectedDownloadsRemaining + ); + } ); + + it( 'can revoke downloadable product permissions', async () => { + // Open order we created + await merchant.goToOrder( orderId ); + + // Revoke permission + await merchant.revokeDownloadableProductPermission( productName ); + + // Verify + await expect( page ).not.toMatchElement( 'div.order_download_permissions', { + text: productName + } ); + } ); + + it( 'should not allow downloading a product if download attempts are exceeded', async () => { + // Define expected download error reason + const expectedReason = 'Sorry, you have reached your download limit for this file'; + + // Create order with product without any available download attempt + const newProductId = await createSimpleDownloadableProduct( productName, 0 ); + const newOrderId = await createOrder( { + productId: newProductId, + customerBilling, + status: orderStatus.processing + } ); + + // Open order we created + await merchant.goToOrder( newOrderId ); + + // Open download page + const downloadPage = await merchant.openDownloadLink(); + + // Verify file download cannot start + await merchant.verifyCannotDownloadFromBecause( downloadPage, expectedReason ); + + // Remove data + await withRestApi.deleteOrder( newOrderId ); + await withRestApi.deleteProduct( newProductId ); + } ); + + it( 'should not allow downloading a product if expiration date is exceeded', async () => { + // Define expected download error reason + const expectedReason = 'Sorry, this download has expired'; + + // Open order we created + await merchant.goToOrder( orderId ); + + // Update permission so that the expiration date has already passed + // Note: Seems this operation can't be performed through the API + await merchant.updateDownloadableProductPermission( productName, '2018-12-14' ); + + // Open download page + const downloadPage = await merchant.openDownloadLink(); + + // Verify file download cannot start + await merchant.verifyCannotDownloadFromBecause( downloadPage, expectedReason ); + } ); + } ); } module.exports = runEditOrderTest; diff --git a/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-settings-product.test.js b/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-settings-product.test.js index 433196ad442..a88e29db467 100644 --- a/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-settings-product.test.js +++ b/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-settings-product.test.js @@ -29,6 +29,7 @@ const runProductSettingsTest = () => { await setCheckbox('#woocommerce_downloads_require_login'); await setCheckbox('#woocommerce_downloads_grant_access_after_payment'); await setCheckbox('#woocommerce_downloads_redirect_fallback_allowed'); + await unsetCheckbox('#woocommerce_downloads_add_hash_to_filename'); await settingsPageSaveChanges(); // Verify that settings have been saved @@ -38,22 +39,35 @@ const runProductSettingsTest = () => { verifyCheckboxIsSet('#woocommerce_downloads_require_login'), verifyCheckboxIsSet('#woocommerce_downloads_grant_access_after_payment'), verifyCheckboxIsSet('#woocommerce_downloads_redirect_fallback_allowed'), + verifyCheckboxIsUnset('#woocommerce_downloads_add_hash_to_filename') ]); await page.reload(); - await expect(page).toSelect('#woocommerce_file_download_method', 'Force downloads'); + await expect(page).toSelect('#woocommerce_file_download_method', 'X-Accel-Redirect/X-Sendfile'); await unsetCheckbox('#woocommerce_downloads_require_login'); await unsetCheckbox('#woocommerce_downloads_grant_access_after_payment'); await unsetCheckbox('#woocommerce_downloads_redirect_fallback_allowed'); + await setCheckbox('#woocommerce_downloads_add_hash_to_filename'); await settingsPageSaveChanges(); // Verify that settings have been saved await Promise.all([ expect(page).toMatchElement('#message', {text: 'Your settings have been saved.'}), - expect(page).toMatchElement('#woocommerce_file_download_method', {text: 'Force downloads'}), + expect(page).toMatchElement('#woocommerce_file_download_method', {text: 'X-Accel-Redirect/X-Sendfile'}), verifyCheckboxIsUnset('#woocommerce_downloads_require_login'), verifyCheckboxIsUnset('#woocommerce_downloads_grant_access_after_payment'), verifyCheckboxIsUnset('#woocommerce_downloads_redirect_fallback_allowed'), + verifyCheckboxIsSet('#woocommerce_downloads_add_hash_to_filename') + ]); + + await page.reload(); + await expect(page).toSelect('#woocommerce_file_download_method', 'Force downloads'); + await settingsPageSaveChanges(); + + // Verify that settings have been saved + await Promise.all([ + expect(page).toMatchElement('#message', {text: 'Your settings have been saved.'}), + expect(page).toMatchElement('#woocommerce_file_download_method', {text: 'Force downloads'}) ]); }); }); diff --git a/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-settings-shipping-classes.test.js b/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-settings-shipping-classes.test.js index 1eeea39852e..323b33b14ef 100644 --- a/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-settings-shipping-classes.test.js +++ b/packages/js/e2e-core-tests/src/specs/merchant/wp-admin-settings-shipping-classes.test.js @@ -3,11 +3,6 @@ */ const { merchant, withRestApi } = require('@woocommerce/e2e-utils'); -/** - * External dependencies - */ -const { lorem, helpers } = require('faker'); - const runAddShippingClassesTest = () => { describe('Merchant can add shipping classes', () => { beforeAll(async () => { @@ -23,14 +18,14 @@ const runAddShippingClassesTest = () => { it('can add shipping classes', async () => { const shippingClassSlug = { - name: lorem.words(), - slug: lorem.slug(), - description: lorem.sentence() + name: 'Small Items', + slug: 'small-items', + description: 'Small items that don\'t cost much to ship.' }; const shippingClassNoSlug = { - name: lorem.words(3), + name: 'Poster Pack', slug: '', - description: lorem.sentence() + description: '' }; const shippingClasses = [shippingClassSlug, shippingClassNoSlug]; @@ -53,9 +48,7 @@ const runAddShippingClassesTest = () => { await expect(page).toClick('.wc-shipping-class-save'); // Set the expected auto-generated slug - shippingClassNoSlug.slug = helpers.slugify( - shippingClassNoSlug.name - ); + shippingClassNoSlug.slug = 'poster-pack'; // Verify that the specified shipping classes were saved for (const { name, slug, description } of shippingClasses) { diff --git a/packages/js/e2e-environment/.gitignore b/packages/js/e2e-environment/.gitignore index f6c640e8915..af8ddb30d10 100644 --- a/packages/js/e2e-environment/.gitignore +++ b/packages/js/e2e-environment/.gitignore @@ -1,2 +1,3 @@ config/default.json docker/wp-cli/initialize.sh +test-results.json diff --git a/packages/js/e2e-environment/CHANGELOG.md b/packages/js/e2e-environment/CHANGELOG.md index 03070024935..38e04d5ca50 100644 --- a/packages/js/e2e-environment/CHANGELOG.md +++ b/packages/js/e2e-environment/CHANGELOG.md @@ -1,5 +1,17 @@ # Unreleased +## Fixed +- Removed the restart policy from e2e containers +- Makes sure that the php containers are only spun up when the db containers is healthy and ready to accept connections +- Wait for WordPress itself to be "healthy and ready" when running `pnpm docker:up` + +## Changed +- Updated `resolveSingleE2EPath` + - it resolves the full path if the filePath is valid + - otherwise, it removes `tests/e2e` from the given filePath before resolving a full path. +- Updated `getLatestReleaseZipUrl` to make use of the assets download url over the archive zip. + + ## Added - Added `post-results-to-github-pr.js` to post test results to a GitHub PR. @@ -15,6 +27,7 @@ - `WC_E2E_FOLDER` for mapping plugin root to path within repo - Added the `resolveSingleE2EPath()` method which builds a path to a specific E2E test - Added the ability to take screenshots from multiple test failures (when retried) in `utils/take-screenshot.js`. +- `docker:wait` to allow for waiting for env to be built without running tests ## Changed diff --git a/packages/js/e2e-environment/README.md b/packages/js/e2e-environment/README.md index ae8a3df1506..7093a98bd53 100644 --- a/packages/js/e2e-environment/README.md +++ b/packages/js/e2e-environment/README.md @@ -95,7 +95,7 @@ The E2E environment has the following methods to let us control Jest's overall b **NOTE:** The amount of times failed tests are retried can also be set using the `E2E_RETRY_TIMES` environment variable when executing tests. This can be done using the command below: ``` -E2E_RETRY_TIMES=2 pnpx wc-e2e test:e2e +E2E_RETRY_TIMES=2 pnpm exec wc-e2e test:e2e ``` #### Test Screenshots diff --git a/packages/js/e2e-environment/bin/get-latest-docker-tag.js b/packages/js/e2e-environment/bin/get-latest-docker-tag.js index 5a376b6d4a5..fce237353e9 100755 --- a/packages/js/e2e-environment/bin/get-latest-docker-tag.js +++ b/packages/js/e2e-environment/bin/get-latest-docker-tag.js @@ -68,7 +68,7 @@ async function fetchLatestTagFromPage( image, nameSearch, page ) { }); req.end(); } - ) + ); } /** diff --git a/packages/js/e2e-environment/bin/wc-e2e.sh b/packages/js/e2e-environment/bin/wc-e2e.sh index b0a95270e76..ef65fbf7c49 100755 --- a/packages/js/e2e-environment/bin/wc-e2e.sh +++ b/packages/js/e2e-environment/bin/wc-e2e.sh @@ -9,6 +9,7 @@ usage() { echo 'scripts:' echo ' docker:up [initialization-script] - boot docker container' echo ' docker:down - shut down docker container' + echo ' docker:wait - wait for env to be built' echo ' docker:ssh - open SSH shell into docker container' echo ' docker:clear-all - remove all docker containers' echo ' test:e2e [test-script] - run e2e test suite or specific test-script' @@ -48,7 +49,10 @@ fi # Run scripts case $1 in 'docker:up') - ./bin/docker-compose.sh up $2 + ./bin/docker-compose.sh up $2 && ./bin/wait-for-build.sh + ;; + 'docker:wait') + ./bin/wait-for-build.sh ;; 'docker:down') ./bin/docker-compose.sh down diff --git a/packages/js/e2e-environment/builtin.md b/packages/js/e2e-environment/builtin.md index 2b91b80bd98..3f76a8a8332 100644 --- a/packages/js/e2e-environment/builtin.md +++ b/packages/js/e2e-environment/builtin.md @@ -16,7 +16,7 @@ wp post create --post_type=page --post_status=publish --post_title='Ready' --pos ### Project Initialization -Each project will have its own begin test state and initialization script. For example, a project might start testing expecting that the [sample products](https://github.com/woocommerce/woocommerce/tree/trunk/sample-data) have already been imported. Below is the WP CLI equivalent of the built in initialization script for WooCommerce Core E2E testing: +Each project will have its own begin test state and initialization script. For example, a project might start testing expecting that the [sample products](https://github.com/woocommerce/woocommerce/tree/trunk/plugins/woocommerce/sample-data) have already been imported. Below is the WP CLI equivalent of the built in initialization script for WooCommerce Core E2E testing: ``` @@ -52,7 +52,7 @@ wp plugin install wp-mail-logging --activate The container build script supports an initialization script parameter ```shell script -pnpx wc-e2e docker:up plugins/woocommerce/tests/e2e/docker/init-wp-beta.sh +pnpm exec wc-e2e docker:up plugins/woocommerce/tests/e2e/docker/init-wp-beta.sh ``` This script updates WordPress to the latest nightly point release diff --git a/packages/js/e2e-environment/changelog/fix-e2e-builds-private-package b/packages/js/e2e-environment/changelog/fix-e2e-builds-private-package new file mode 100644 index 00000000000..ea5be2b5fdb --- /dev/null +++ b/packages/js/e2e-environment/changelog/fix-e2e-builds-private-package @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Create dependency for new private e2e-builds package diff --git a/packages/js/e2e-environment/config/default.json b/packages/js/e2e-environment/config/default.json index e70b3c6c5d8..cf613320940 100644 --- a/packages/js/e2e-environment/config/default.json +++ b/packages/js/e2e-environment/config/default.json @@ -1,207 +1,208 @@ { "url": "http://localhost:8084/", + "appName": "woocommerce_e2e", "users": { - "admin": { - "username": "admin", - "password": "password" - }, - "customer": { - "username": "customer", - "password": "password" - } + "admin": { + "username": "admin", + "password": "password" + }, + "customer": { + "username": "customer", + "password": "password" + } }, "products": { - "simple": { - "name": "Simple product" - }, - "variable": { - "name": "Variable Product with Three Attributes", - "defaultAttributes": [ - { - "id": 0, - "name": "Size", - "option": "Medium" - }, - { - "id": 0, - "name": "Colour", - "option": "Blue" - } - ], - "attributes": [ - { - "id": 0, - "name": "Colour", - "isVisibleOnProductPage": true, - "isForVariations": true, - "options": [ - "Red", - "Green", - "Blue" - ], - "sortOrder": 0 - }, - { - "id": 0, - "name": "Size", - "isVisibleOnProductPage": true, - "isForVariations": true, - "options": [ - "Small", - "Medium", - "Large" - ], - "sortOrder": 0 - }, - { - "id": 0, - "name": "Logo", - "isVisibleOnProductPage": true, - "isForVariations": true, - "options": [ - "Woo", - "WordPress" - ], - "sortOrder": 0 - } - ] - }, - "variations": [ - { - "regularPrice": "19.99", - "attributes": [ - { - "name": "Size", - "option": "Large" - }, - { - "name": "Colour", - "option": "Red" - } - ] - }, - { - "regularPrice": "18.99", - "attributes": [ - { - "name": "Size", - "option": "Medium" - }, - { - "name": "Colour", - "option": "Green" - } - ] - }, - { - "regularPrice": "17.99", - "attributes": [ - { - "name": "Size", - "option": "Small" - }, - { - "name": "Colour", - "option": "Blue" - } - ] - } - ], - "grouped": { - "name": "Grouped Product with Three Children", - "groupedProducts": [ - { - "name": "Base Unit", - "regularPrice": "29.99" - }, - { - "name": "Add-on A", - "regularPrice": "11.95" - }, - { - "name": "Add-on B", - "regularPrice": "18.97" - } - ] - }, - "external": { - "name": "External product", - "regularPrice": "24.99", - "buttonText": "Buy now", - "externalUrl": "https://wordpress.org/plugins/woocommerce" - } + "simple": { + "name": "Simple product" + }, + "variable": { + "name": "Variable Product with Three Attributes", + "defaultAttributes": [ + { + "id": 0, + "name": "Size", + "option": "Medium" + }, + { + "id": 0, + "name": "Colour", + "option": "Blue" + } + ], + "attributes": [ + { + "id": 0, + "name": "Colour", + "isVisibleOnProductPage": true, + "isForVariations": true, + "options": [ + "Red", + "Green", + "Blue" + ], + "sortOrder": 0 + }, + { + "id": 0, + "name": "Size", + "isVisibleOnProductPage": true, + "isForVariations": true, + "options": [ + "Small", + "Medium", + "Large" + ], + "sortOrder": 0 + }, + { + "id": 0, + "name": "Logo", + "isVisibleOnProductPage": true, + "isForVariations": true, + "options": [ + "Woo", + "WordPress" + ], + "sortOrder": 0 + } + ] + }, + "variations": [ + { + "regularPrice": "19.99", + "attributes": [ + { + "name": "Size", + "option": "Large" + }, + { + "name": "Colour", + "option": "Red" + } + ] + }, + { + "regularPrice": "18.99", + "attributes": [ + { + "name": "Size", + "option": "Medium" + }, + { + "name": "Colour", + "option": "Green" + } + ] + }, + { + "regularPrice": "17.99", + "attributes": [ + { + "name": "Size", + "option": "Small" + }, + { + "name": "Colour", + "option": "Blue" + } + ] + } + ], + "grouped": { + "name": "Grouped Product with Three Children", + "groupedProducts": [ + { + "name": "Base Unit", + "regularPrice": "29.99" + }, + { + "name": "Add-on A", + "regularPrice": "11.95" + }, + { + "name": "Add-on B", + "regularPrice": "18.97" + } + ] + }, + "external": { + "name": "External product", + "regularPrice": "24.99", + "buttonText": "Buy now", + "externalUrl": "https://wordpress.org/plugins/woocommerce" + } }, "coupons": { - "percentage": { - "code": "20percent", - "discountType": "percent", - "amount": "20.00" - } + "percentage": { + "code": "20percent", + "discountType": "percent", + "amount": "20.00" + } }, "addresses": { - "admin": { - "store": { - "email": "admin@woocommercecoree2etestsuite.com", - "firstname": "John", - "lastname": "Doe", - "company": "Automattic", - "country": "United States (US)", - "addressfirstline": "addr 1", - "addresssecondline": "addr 2", - "countryandstate": "United States (US) — California", - "city": "San Francisco", - "state": "CA", - "postcode": "94107" - } - }, - "customer": { - "billing": { - "firstname": "John", - "lastname": "Doe", - "company": "Automattic", - "country": "United States (US)", - "addressfirstline": "addr 1", - "addresssecondline": "addr 2", - "city": "San Francisco", - "state": "CA", - "postcode": "94107", - "phone": "123456789", - "email": "john.doe@example.com" - }, - "shipping": { - "firstname": "John", - "lastname": "Doe", - "company": "Automattic", - "country": "United States (US)", - "addressfirstline": "addr 1", - "addresssecondline": "addr 2", - "city": "San Francisco", - "state": "CA", - "postcode": "94107" - } - } + "admin": { + "store": { + "email": "admin@woocommercecoree2etestsuite.com", + "firstname": "John", + "lastname": "Doe", + "company": "Automattic", + "country": "United States (US)", + "addressfirstline": "addr 1", + "addresssecondline": "addr 2", + "countryandstate": "United States (US) — California", + "city": "San Francisco", + "state": "CA", + "postcode": "94107" + } + }, + "customer": { + "billing": { + "firstname": "John", + "lastname": "Doe", + "company": "Automattic", + "country": "United States (US)", + "addressfirstline": "addr 1", + "addresssecondline": "addr 2", + "city": "San Francisco", + "state": "CA", + "postcode": "94107", + "phone": "123456789", + "email": "john.doe@example.com" + }, + "shipping": { + "firstname": "John", + "lastname": "Doe", + "company": "Automattic", + "country": "United States (US)", + "addressfirstline": "addr 1", + "addresssecondline": "addr 2", + "city": "San Francisco", + "state": "CA", + "postcode": "94107" + } + } }, "orders": { - "basicPaidOrder": { - "paymentMethod": "cod", - "status": "processing", - "billing": { - "firstName": "John", - "lastName": "Doe", - "email": "john.doe@example.com" - } - } + "basicPaidOrder": { + "paymentMethod": "cod", + "status": "processing", + "billing": { + "firstName": "John", + "lastName": "Doe", + "email": "john.doe@example.com" + } + } }, "onboardingwizard": { - "industry": "Test industry", - "numberofproducts": "1 - 10", - "sellingelsewhere": "No" + "industry": "Test industry", + "numberofproducts": "1 - 10", + "sellingelsewhere": "No" }, "settings": { - "shipping": { - "zonename": "United States", - "zoneregions": "United States (US)", - "shippingmethod": "Free shipping" - } + "shipping": { + "zonename": "United States", + "zoneregions": "United States (US)", + "shippingmethod": "Free shipping" + } } } diff --git a/packages/js/e2e-environment/docker-compose.yaml b/packages/js/e2e-environment/docker-compose.yaml index d9327d9adad..1598cd52abc 100644 --- a/packages/js/e2e-environment/docker-compose.yaml +++ b/packages/js/e2e-environment/docker-compose.yaml @@ -1,11 +1,10 @@ -version: '3.3' +version: '3.8' services: db: container_name: "${APP_NAME}_db" image: mariadb:${DC_MARIADB_VERSION} - restart: on-failure environment: MYSQL_DATABASE: ${WORDPRESS_DB_NAME} MYSQL_USER: ${WORDPRESS_DB_USER} @@ -13,19 +12,26 @@ services: MYSQL_RANDOM_ROOT_PASSWORD: 'yes' volumes: - db:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -P 3306 --user=${WORDPRESS_DB_USER} --password=${WORDPRESS_DB_PASSWORD} | grep 'mysqld is alive' || exit 1"] + interval: 2s + retries: 30 php: container_name: "${APP_NAME}_php" image: php:${DC_PHP_VERSION} + depends_on: + db: + condition: service_healthy wordpress-www: container_name: "${APP_NAME}_wordpress-www" depends_on: - - db + db: + condition: service_healthy image: wordpress:${WORDPRESS_VERSION} ports: - ${WORDPRESS_PORT}:80 - restart: on-failure environment: WORDPRESS_DB_HOST: ${WORDPRESS_DB_HOST} WORDPRESS_DB_NAME: ${WORDPRESS_DB_NAME} @@ -41,11 +47,9 @@ services: wordpress-cli: container_name: "${APP_NAME}_wordpress-cli" depends_on: - - db - - wordpress-www + - wordpress-www build: context: ./docker/wp-cli - restart: on-failure environment: WORDPRESS_PORT: ${WORDPRESS_PORT} WORDPRESS_HOST: wordpress-www:80 diff --git a/packages/js/e2e-environment/docker/wp-cli/Dockerfile b/packages/js/e2e-environment/docker/wp-cli/Dockerfile index 6fc3261f01b..19f332ea43c 100644 --- a/packages/js/e2e-environment/docker/wp-cli/Dockerfile +++ b/packages/js/e2e-environment/docker/wp-cli/Dockerfile @@ -2,10 +2,6 @@ FROM wordpress:cli-2.5.0 USER root -COPY wait-for-it.sh /usr/local/bin/wait-for-it -RUN chown xfs:xfs /usr/local/bin/wait-for-it && \ - chmod +x /usr/local/bin/wait-for-it - COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chown xfs:xfs /usr/local/bin/entrypoint.sh && \ chmod +x /usr/local/bin/entrypoint.sh diff --git a/packages/js/e2e-environment/docker/wp-cli/entrypoint.sh b/packages/js/e2e-environment/docker/wp-cli/entrypoint.sh index ad7aaa18fd1..1fe4ec56dff 100644 --- a/packages/js/e2e-environment/docker/wp-cli/entrypoint.sh +++ b/packages/js/e2e-environment/docker/wp-cli/entrypoint.sh @@ -1,14 +1,13 @@ #!/usr/bin/env bash set -eu -declare -p WORDPRESS_HOST -wait-for-it ${WORDPRESS_HOST} -t 120 - -## if file exists then exit early because initialization already happened. -if [ -f /var/www/html/.initialized ]; +# If WordPress is installed and the page "ready" exists, we bail the initialization. +if [ $(wp --allow-root core is-installed) ] && [ $(wp --allow-root post exists $(wp --allow-root post list --format=ids --post_name=ready)) ]; then echo "The environment has already been initialized." exit 0 +else + echo "Initializing the environment..." fi chown xfs:xfs /var/www/html/wp-content @@ -29,23 +28,18 @@ declare -p WORDPRESS_PORT URL="http://localhost" || \ URL="http://localhost:${WORDPRESS_PORT}" -if $(wp core is-installed); -then - echo "WordPress is already installed..." -else - declare -p WORDPRESS_TITLE >/dev/null - declare -p WORDPRESS_LOGIN >/dev/null - declare -p WORDPRESS_PASSWORD >/dev/null - declare -p WORDPRESS_EMAIL >/dev/null - echo "Installing WordPress..." - wp core install \ - --url=${URL} \ - --title="$WORDPRESS_TITLE" \ - --admin_user=${WORDPRESS_LOGIN} \ - --admin_password=${WORDPRESS_PASSWORD} \ - --admin_email=${WORDPRESS_EMAIL} \ - --skip-email -fi +declare -p WORDPRESS_TITLE >/dev/null +declare -p WORDPRESS_LOGIN >/dev/null +declare -p WORDPRESS_PASSWORD >/dev/null +declare -p WORDPRESS_EMAIL >/dev/null +echo "Installing WordPress..." +wp core install \ + --url=${URL} \ + --title="$WORDPRESS_TITLE" \ + --admin_user=${WORDPRESS_LOGIN} \ + --admin_password=${WORDPRESS_PASSWORD} \ + --admin_email=${WORDPRESS_EMAIL} \ + --skip-email ## Check for an initialization script. declare -r INIT_SCRIPT=$(command -v initialize.sh) @@ -61,17 +55,11 @@ if ! [[ ${CURRENT_DOMAIN} == ${URL} ]]; then wp search-replace ${CURRENT_DOMAIN} ${URL} fi -if $(wp post list --post_type=page --name=ready); -then - echo "Ready page already exists..." -else - wp post create \ - --url=${URL} \ - --post_type=page \ - --post_status=publish \ - --post_title='Ready' \ - --post_content='E2E-tests.' -fi +wp post create \ + --url=${URL} \ + --post_type=page \ + --post_status=publish \ + --post_title='Ready' \ + --post_content='E2E-tests.' echo "Visit $(wp option get siteurl)" -touch /var/www/html/.initialized diff --git a/packages/js/e2e-environment/docker/wp-cli/initialize.sh b/packages/js/e2e-environment/docker/wp-cli/initialize.sh index 7c0ab8f9991..f86c50f218e 100755 --- a/packages/js/e2e-environment/docker/wp-cli/initialize.sh +++ b/packages/js/e2e-environment/docker/wp-cli/initialize.sh @@ -18,6 +18,9 @@ wp user create customer customer@woocommercecoree2etestsuite.com \ # we cannot create API keys for the API, so we using basic auth, this plugin allows that. wp plugin install https://github.com/WP-API/Basic-Auth/archive/master.zip --activate +# Reset plugin that allows us to reset WooCommerce state between tests. +wp plugin install https://github.com/woocommerce/woocommerce-reset/zipball/trunk/ --activate + # install the WP Mail Logging plugin to test emails wp plugin install wp-mail-logging --activate diff --git a/packages/js/e2e-environment/docker/wp-cli/wait-for-it.sh b/packages/js/e2e-environment/docker/wp-cli/wait-for-it.sh deleted file mode 100644 index 61e9858419c..00000000000 --- a/packages/js/e2e-environment/docker/wp-cli/wait-for-it.sh +++ /dev/null @@ -1,208 +0,0 @@ -#!/usr/bin/env bash -#source https://github.com/vishnubob/wait-for-it/pull/81 -#The MIT License (MIT) -# -#Original work Copyright (c) 2016 Giles Hall: wait-for-it.sh -#Modified work Copyright (c) 2019 iturgeon: wait-for-it.sh -# -#Permission is hereby granted, free of charge, to any person obtaining a copy of -#this software and associated documentation files (the "Software"), to deal in -#the Software without restriction, including without limitation the rights to -#use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies -#of the Software, and to permit persons to whom the Software is furnished to do -#so, subject to the following conditions: -# -#The above copyright notice and this permission notice shall be included in all -#copies or substantial portions of the Software. -# -#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -#IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -#FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -#AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -#LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -#OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -#SOFTWARE. - -# Use this script to test if a given TCP host/port are available - -WAITFORIT_cmdname=${0##*/} - -echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } - -usage() -{ - cat << USAGE >&2 -Usage: - $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] - -h HOST | --host=HOST Host or IP under test - -p PORT | --port=PORT TCP port under test - Alternatively, you specify the host and port as host:port - -s | --strict Only execute subcommand if the test succeeds - -q | --quiet Don't output any status messages - -t TIMEOUT | --timeout=TIMEOUT - Timeout in seconds, zero for no timeout - -- COMMAND ARGS Execute command with args after the test finishes -USAGE - exit 1 -} - -wait_for() -{ - if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then - echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" - else - echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" - fi - WAITFORIT_start_ts=$(date +%s) - while : - do - if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then - nc -z $WAITFORIT_HOST $WAITFORIT_PORT - WAITFORIT_result=$? - else - (echo > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 - WAITFORIT_result=$? - fi - if [[ $WAITFORIT_result -eq 0 ]]; then - WAITFORIT_end_ts=$(date +%s) - echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" - break - fi - sleep 1 - done - return $WAITFORIT_result -} - -wait_for_wrapper() -{ - # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 - if [[ $WAITFORIT_QUIET -eq 1 ]]; then - timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & - else - timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & - fi - WAITFORIT_PID=$! - trap "kill -INT -$WAITFORIT_PID" INT - wait $WAITFORIT_PID - WAITFORIT_RESULT=$? - if [[ $WAITFORIT_RESULT -ne 0 ]]; then - echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" - fi - return $WAITFORIT_RESULT -} - -# process arguments -while [[ $# -gt 0 ]] -do - case "$1" in - *:* ) - WAITFORIT_hostport=(${1//:/ }) - WAITFORIT_HOST=${WAITFORIT_hostport[0]} - WAITFORIT_PORT=${WAITFORIT_hostport[1]} - shift 1 - ;; - --child) - WAITFORIT_CHILD=1 - shift 1 - ;; - -q | --quiet) - WAITFORIT_QUIET=1 - shift 1 - ;; - -s | --strict) - WAITFORIT_STRICT=1 - shift 1 - ;; - -h) - WAITFORIT_HOST="$2" - if [[ $WAITFORIT_HOST == "" ]]; then break; fi - shift 2 - ;; - --host=*) - WAITFORIT_HOST="${1#*=}" - shift 1 - ;; - -p) - WAITFORIT_PORT="$2" - if [[ $WAITFORIT_PORT == "" ]]; then break; fi - shift 2 - ;; - --port=*) - WAITFORIT_PORT="${1#*=}" - shift 1 - ;; - -t) - WAITFORIT_TIMEOUT="$2" - if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi - shift 2 - ;; - --timeout=*) - WAITFORIT_TIMEOUT="${1#*=}" - shift 1 - ;; - --) - shift - WAITFORIT_CLI=("$@") - break - ;; - --help) - usage - ;; - *) - echoerr "Unknown argument: $1" - usage - ;; - esac -done - -if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then - echoerr "Error: you need to provide a host and port to test." - usage -fi - -WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} -WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} -WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} -WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} -WAITFORIT_ISBUSY=0 -WAITFORIT_BUSYTIMEFLAG="" -WAITFORIT_TIMEOUT_PATH=$(type -p timeout) -WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) - -# check to see if we're using busybox? -if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then - WAITFORIT_ISBUSY=1 -fi - -# see if timeout.c args have been updated in busybox v1.30.0 or newer -# note: this requires the use of bash on Alpine -if [[ $WAITFORIT_ISBUSY && $(busybox | head -1) =~ ^.*v([[:digit:]]+)\.([[:digit:]]+)\..+$ ]]; then - if [[ ${BASH_REMATCH[1]} -le 1 && ${BASH_REMATCH[2]} -lt 30 ]]; then - # using pre 1.30.0 version with `-t SEC` arg - WAITFORIT_BUSYTIMEFLAG="-t" - fi -fi - -if [[ $WAITFORIT_CHILD -gt 0 ]]; then - wait_for - WAITFORIT_RESULT=$? - exit $WAITFORIT_RESULT -else - if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then - wait_for_wrapper - WAITFORIT_RESULT=$? - else - wait_for - WAITFORIT_RESULT=$? - fi -fi - -if [[ $WAITFORIT_CLI != "" ]]; then - if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then - echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" - exit $WAITFORIT_RESULT - fi - exec "${WAITFORIT_CLI[@]}" -else - exit $WAITFORIT_RESULT -fi diff --git a/packages/js/e2e-environment/index.js b/packages/js/e2e-environment/index.js index d7289c77e66..3d8fc201cc4 100644 --- a/packages/js/e2e-environment/index.js +++ b/packages/js/e2e-environment/index.js @@ -5,7 +5,7 @@ const babelConfig = require( './babel.config' ); const esLintConfig = require( './.eslintrc.js' ); const allE2EConfig = require( './config' ); const allE2EUtils = require( './utils' ); -const slackUtils = require( './src/slack' ); +const slackUtils = require( './build/slack' ); /** * External dependencies */ diff --git a/packages/js/e2e-environment/package.json b/packages/js/e2e-environment/package.json index 94d87eefc44..1ecb668744b 100644 --- a/packages/js/e2e-environment/package.json +++ b/packages/js/e2e-environment/package.json @@ -1,6 +1,6 @@ { "name": "@woocommerce/e2e-environment", - "version": "0.2.3", + "version": "0.3.0", "description": "WooCommerce End to End Testing Environment Configuration.", "author": "Automattic", "license": "GPL-3.0-or-later", @@ -49,6 +49,7 @@ "@babel/plugin-transform-runtime": "^7.16.4", "@babel/polyfill": "7.12.1", "@babel/preset-env": "7.12.7", + "@woocommerce/e2e-builds": "workspace:*", "@wordpress/babel-plugin-import-jsx-pragma": "1.1.3", "@wordpress/babel-preset-default": "3.0.2", "@wordpress/browserslist-config": "^4.1.0", @@ -62,10 +63,11 @@ }, "scripts": { "clean": "rm -rf ./build ./build-module", - "compile": "node ./../bin/build.js", + "compile": "e2e-builds", "build": "pnpm run clean && pnpm run compile", "prepare": "pnpm run build", "docker:up": "./bin/docker-compose.sh up", + "docker:wait": "bash ./bin/wait-for-build.sh", "docker:down": "./bin/docker-compose.sh down", "docker:clear-all": "docker rmi --force $(docker images -q)", "docker:ssh": "docker exec -it $(node utils/get-app-name.js)_wordpress-www /bin/bash", @@ -76,5 +78,10 @@ }, "bin": { "wc-e2e": "bin/wc-e2e.sh" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] } } diff --git a/packages/js/e2e-environment/src/setup/jest.failure.js b/packages/js/e2e-environment/src/setup/jest.failure.js index 7eb0947a784..2f74adebc87 100644 --- a/packages/js/e2e-environment/src/setup/jest.failure.js +++ b/packages/js/e2e-environment/src/setup/jest.failure.js @@ -21,17 +21,16 @@ const originalIt = global.it; /** * A custom describe function that stores the name of the describe block. + * * @type {describe} */ -global.describe = (() => { +global.describe = ( () => { const describe = ( blockName, callback ) => { - try { originalDescribe( blockName, callback ); } catch ( e ) { throw e; } - }; const only = ( blockName, callback ) => { originalDescribe.only( blockName, callback ); @@ -47,7 +46,7 @@ global.describe = (() => { describe.skip = skip; return describe; -})(); +} )(); /** * A custom it function that wraps the test function in a callback @@ -55,7 +54,7 @@ global.describe = (() => { * * @type {function(*=, *=): *} */ -global.it = (() => { +global.it = ( () => { const test = async ( testName, callback ) => { const testCallback = async () => screenshotTest( testName, callback ); return originalIt( testName, testCallback ); @@ -74,13 +73,14 @@ global.it = (() => { test.skip = skip; return test; -})(); +} )(); /** * Save a screenshot during a test if the test fails. + * * @param testName * @param callback - * @returns {Promise<void>} + * @return {Promise<void>} */ const screenshotTest = async ( testName, callback ) => { try { @@ -92,6 +92,6 @@ const screenshotTest = async ( testName, callback ) => { await sendFailedTestScreenshotToSlack( filePath ); } - throw ( e ); + throw e; } }; diff --git a/packages/js/e2e-environment/src/setup/jest.setup.js b/packages/js/e2e-environment/src/setup/jest.setup.js index f7ed5466984..faa488d8287 100644 --- a/packages/js/e2e-environment/src/setup/jest.setup.js +++ b/packages/js/e2e-environment/src/setup/jest.setup.js @@ -21,8 +21,10 @@ const pageEvents = []; /** * Set of logged messages that will only be logged once. */ -addConsoleSuppression('Failed to load resource: net::ERR_PROXY_CONNECTION_FAILED'); -addConsoleSuppression('the server responded with a status of 404'); +addConsoleSuppression( + 'Failed to load resource: net::ERR_PROXY_CONNECTION_FAILED' +); +addConsoleSuppression( 'the server responded with a status of 404' ); /** * Set of console logging types observed to protect against unexpected yet @@ -42,7 +44,7 @@ async function setupBrowser() { await setBrowserViewport( { width: 1280, height: 800, - }); + } ); } /** @@ -66,20 +68,21 @@ function removePageEvents() { /** * Add an expect range matcher. + * * @see https://jestjs.io/docs/expect#expectextendmatchers */ -expect.extend({ - toBeInRange: function (received, floor, ceiling) { +expect.extend( { + toBeInRange( received, floor, ceiling ) { const pass = received >= floor && received <= ceiling; const condition = pass ? 'not to be' : 'to be'; return { message: () => - `expected ${received} ${condition} within range ${floor} - ${ceiling}`, + `expected ${ received } ${ condition } within range ${ floor } - ${ ceiling }`, pass, }; }, -}); +} ); /** * Adds a page event handler to emit uncaught exception to process if one of diff --git a/packages/js/e2e-environment/src/slack/reporter.js b/packages/js/e2e-environment/src/slack/reporter.js index 79655089a14..67a7b9ba403 100644 --- a/packages/js/e2e-environment/src/slack/reporter.js +++ b/packages/js/e2e-environment/src/slack/reporter.js @@ -20,7 +20,7 @@ let web; /** * Initialize the Slack web client. * - * @returns {WebClient} + * @return {WebClient} */ const initializeWeb = () => { if ( ! web ) { @@ -31,7 +31,8 @@ const initializeWeb = () => { /** * Initialize Slack parameters if tests are running in CI. - * @returns {Object|boolean} + * + * @return {Object|boolean} */ const initializeSlack = () => { if ( ! WC_E2E_SCREENSHOTS || ! E2E_SLACK_TOKEN ) { @@ -67,7 +68,7 @@ const initializeSlack = () => { * Post a message to a Slack channel for a failed test. * * @param testName - * @returns {Promise<void>} + * @return {Promise<void>} */ async function sendFailedTestMessageToSlack( testName ) { const { branch, commit, webUrl } = initializeSlack(); @@ -78,39 +79,44 @@ async function sendFailedTestMessageToSlack( testName ) { try { // Adding the app does not add the app user to the channel - await web.conversations.join({ + await web.conversations.join( { channel: E2E_SLACK_CHANNEL, token: E2E_SLACK_TOKEN, - }); + } ); } catch ( error ) { // Check the code property and log the response - if ( error.code === ErrorCode.PlatformError || error.code === ErrorCode.RequestError || - error.code === ErrorCode.RateLimitedError || error.code === ErrorCode.HTTPError ) { + if ( + error.code === ErrorCode.PlatformError || + error.code === ErrorCode.RequestError || + error.code === ErrorCode.RateLimitedError || + error.code === ErrorCode.HTTPError + ) { if ( error.data.error != 'channel_not_found' ) { - console.log(error.data); + console.log( error.data ); } } else { // Some other error, oh no! - console.log( - 'Error joining channel', - error - ); + console.log( 'Error joining channel', error ); } } try { // For details, see: https://api.slack.com/methods/chat.postMessage - await web.chat.postMessage({ + await web.chat.postMessage( { channel: E2E_SLACK_CHANNEL, token: E2E_SLACK_TOKEN, text: `Test failed on *${ branch }* branch. \n The commit this build is testing is *${ commit }*. \n The name of the test that failed: *${ testName }*. \n See screenshot of the failed test below. *Build log* could be found here: ${ webUrl }`, - }); + } ); } catch ( error ) { // Check the code property and log the response - if ( error.code === ErrorCode.PlatformError || error.code === ErrorCode.RequestError || - error.code === ErrorCode.RateLimitedError || error.code === ErrorCode.HTTPError ) { + if ( + error.code === ErrorCode.PlatformError || + error.code === ErrorCode.RequestError || + error.code === ErrorCode.RateLimitedError || + error.code === ErrorCode.HTTPError + ) { console.log( error.data ); } else { // Some other error, oh no! @@ -124,8 +130,9 @@ async function sendFailedTestMessageToSlack( testName ) { /** * Post a screenshot to a Slack channel for a failed test. + * * @param screenshotOfFailedTest - * @returns {Promise<void>} + * @return {Promise<void>} */ async function sendFailedTestScreenshotToSlack( screenshotOfFailedTest ) { const pr = initializeSlack(); @@ -137,20 +144,26 @@ async function sendFailedTestScreenshotToSlack( screenshotOfFailedTest ) { try { // For details, see: https://api.slack.com/methods/files.upload - await web.files.upload({ + await web.files.upload( { channels: E2E_SLACK_CHANNEL, token: E2E_SLACK_TOKEN, filename, file: createReadStream( screenshotOfFailedTest ), - }); + } ); } catch ( error ) { // Check the code property and log the response - if ( error.code === ErrorCode.PlatformError || error.code === ErrorCode.RequestError || - error.code === ErrorCode.RateLimitedError || error.code === ErrorCode.HTTPError ) { + if ( + error.code === ErrorCode.PlatformError || + error.code === ErrorCode.RequestError || + error.code === ErrorCode.RateLimitedError || + error.code === ErrorCode.HTTPError + ) { console.log( error.data ); } else { // Some other error, oh no! - console.log( 'The error occurred does not match an error we are checking for in this block.' ); + console.log( + 'The error occurred does not match an error we are checking for in this block.' + ); } } } diff --git a/packages/js/e2e-environment/utils/get-plugin-zip.js b/packages/js/e2e-environment/utils/get-plugin-zip.js index 56350b24f4f..5309500afeb 100644 --- a/packages/js/e2e-environment/utils/get-plugin-zip.js +++ b/packages/js/e2e-environment/utils/get-plugin-zip.js @@ -77,11 +77,20 @@ const getLatestReleaseZipUrl = async ( } } ); } else if ( authorizationToken ) { - // If it's a private repo, we need to download the archive this way - const tagName = body.tag_name; - resolve( - `https://github.com/${ repository }/archive/${ tagName }.zip` - ); + // If it's a private repo, we need to download the archive this way. + // Use uploaded assets over downloading the zip archive. + if ( + body.assets && + body.assets.length > 0 && + body.assets[ 0 ].browser_download_url + ) { + resolve( body.assets[ 0 ].browser_download_url ); + } else { + const tagName = body.tag_name; + resolve( + `https://github.com/${ repository }/archive/${ tagName }.zip` + ); + } } else { resolve( body.assets[ 0 ].browser_download_url ); } diff --git a/packages/js/e2e-environment/utils/test-config.js b/packages/js/e2e-environment/utils/test-config.js index 3cd94888299..4dfa51bc569 100644 --- a/packages/js/e2e-environment/utils/test-config.js +++ b/packages/js/e2e-environment/utils/test-config.js @@ -100,28 +100,16 @@ const resolvePackagePath = ( filename, packageName = '' ) => { * @param {Array} exclude An array of directories that won't be removed in the event that duplicates exist. * @return {string} */ -const resolveSingleE2EPath = ( filePath, exclude = [ 'woocommerce' ] ) => { - const { SMOKE_TEST_URL } = process.env; - let prunedPath; +const resolveSingleE2EPath = ( filePath ) => { + const { SMOKE_TEST_URL, GITHUB_ACTIONS } = process.env; + const localPath = resolveLocalE2ePath( filePath ); - // Removes 'plugins/woocommerce/' from path only for tests against a smoke test site. - if ( SMOKE_TEST_URL ) { - prunedPath = filePath.replace( 'plugins/woocommerce/', '' ); + if ( fs.existsSync( localPath ) ) { + return localPath; } else { - prunedPath = filePath; + const prunedPath = filePath.replace( 'tests/e2e', '' ); + return resolveLocalE2ePath( prunedPath ); } - - const pathArray = resolveLocalE2ePath( prunedPath ).split( '/' ); - - // removes duplicate directories from the path - return pathArray - .filter( ( element, index, arr ) => { - return ( - arr.indexOf( element ) === index || - exclude.indexOf( element ) !== -1 - ); - } ) - .join( '/' ); }; // Copy local test configuration file if it exists. diff --git a/packages/js/e2e-utils/.eslintrc.js b/packages/js/e2e-utils/.eslintrc.js index 75d467c88d1..fcf93fdd047 100644 --- a/packages/js/e2e-utils/.eslintrc.js +++ b/packages/js/e2e-utils/.eslintrc.js @@ -1,4 +1,5 @@ module.exports = { + root: true, parser: '@typescript-eslint/parser', env: { 'jest/globals': true, diff --git a/packages/js/e2e-utils/CHANGELOG.md b/packages/js/e2e-utils/CHANGELOG.md index bae80153843..fe86f80c6c1 100644 --- a/packages/js/e2e-utils/CHANGELOG.md +++ b/packages/js/e2e-utils/CHANGELOG.md @@ -2,6 +2,30 @@ ## Fixed +- Added the `root: true` flag to `e2e-utils` ESLint config file so that ESLint ignores other ancestor config files when checking that package. This solves a version conflict when running ESLint. + +## Added + +- `createSimpleDownloadableProduct` component which creates a simple downloadable product, containing four parameters for title, price, download name and download limit. +- `orderPageSaveChanges()` to save changes in the order page. +- `getSelectorAttribute( selector, attribute )` to retrieve the desired HTML attribute from an element. +- `verifyValueOfElementAttribute( selector, attribute, expectedValue )` to check that a specific HTML attribute from an element matches the expected value. +- `withRestApi.deleteProduct()` that deletes a single product. +- `withRestApi.deleteOrder()` that deletes a single order. +- `merchant.addDownloadableProductPermission()` to add a downloadable product permission to an order. +- `merchant.updateDownloadableProductPermission()` to update the attributes of an existing downloadable product permission. +- `merchant.revokeDownloadableProductPermission()` to remove the existing downloadable product permission from an order. +- `merchant.verifyDownloadableProductPermission()` to check that the attributes of an existing downloadable product permission are correct. +- `merchant.openDownloadLink()` to open the url of a download in a new tab. +- `merchant.verifyCannotDownloadFromBecause()` to check that a download cannot happen for a specific reason. + +## Changed +- Removed `faker` dependency + +# 0.1.7 + +## Fixed + - Identified the default product category using `slug == 'uncategorized'` in `deleteAllProductCategories` ## Added diff --git a/packages/js/e2e-utils/README.md b/packages/js/e2e-utils/README.md index 3ef636a55f5..b8d34e6721d 100644 --- a/packages/js/e2e-utils/README.md +++ b/packages/js/e2e-utils/README.md @@ -84,6 +84,7 @@ This package provides support for enabling retries in tests: | Function | Parameters | Description | |----------|-------------|------------| +| `addDownloadableProductPermission` | `productName` | Add a downloadable permission for product in order | | `collapseAdminMenu` | `collapse` | Collapse or expand the WP admin menu | | `dismissOnboardingWizard` | | Dismiss the onboarding wizard if present | | `goToOrder` | `orderId` | Go to view a single order | @@ -107,11 +108,16 @@ This package provides support for enabling retries in tests: | `openImportProducts` | | Open the Import Products page | | `openExtensions` | | Go to WooCommerce -> Extensions | | `openWordPressUpdatesPage` | | Go to Dashboard -> Updates | +| `revokeDownloadableProductPermission` | `productName` | Remove a downloadable product permission from order | | `installAllUpdates` | | Install all pending updates on Dashboard -> Updates| +| `updateDownloadableProductPermission` | `productName, expirationDate, downloadsRemaining` | Update the attributes of a downloadable product permission in order | | `updateWordPress` | | Install pending WordPress updates on Dashboard -> Updates| | `updatePlugins` | | Install all pending plugin updates on Dashboard -> Updates| | `updateThemes` | | Install all pending theme updates on Dashboard -> Updates| +| `verifyCannotDownloadFromBecause` | `page, reason` | Verify that cannot download a product from `page` because of `reason` | +| `verifyDownloadableProductPermission` | `productName, expirationDate, downloadsRemaining` | Verify the attributes of a downloadable product permission in order | | `runDatabaseUpdate` || Runs the database update if needed | +| `openDownloadLink` | | Open the download link of a product | ### Shopper `shopper` @@ -160,6 +166,8 @@ Please note: if you're using a non-SSL environment (such as a Docker container f | `deleteAllShippingZones` | `testResponse` | Permanently delete all shipping zones except the default | | `deleteCoupon` | `couponId` | Permanently delete a coupon | | `deleteCustomerByEmail` | `emailAddress` | Delete customer user account. Posts are reassigned to user ID 1 | +| `deleteOrder` | `orderId` | Permanently delete an order | +| `deleteProduct` | `productId` | Permanently delete a simple product | | `getSystemEnvironment` | | Get the current environment from the WooCommerce system status API. | | `resetOnboarding` | | Reset onboarding settings | | `resetSettingsGroupToDefault` | `settingsGroup`, `testResponse` | Reset settings in settings group to default except `select` fields | @@ -203,13 +211,16 @@ There is a general utilities object `utils` with the following functions: | `completeOnboardingWizard` | | completes the onboarding wizard with some default settings | | `createCoupon` | `couponAmount`, `couponType` | creates a basic coupon. Default amount is 5. Default coupon type is fixed discount. Returns the generated coupon code. | | `createGroupedProduct` | | creates a grouped product for the grouped product tests. Returns the product id. | +| `createSimpleDownloadableProduct` | `name, downloadLimit, downloadName, price` | Create a simple downloadable product | | `createSimpleOrder` | `status` | creates a basic order with the provided status string | | `createSimpleProduct` | | creates the simple product configured in default.json. Returns the product id. | | `createSimpleProductWithCategory` | `name`, `price`,`categoryName` | creates a simple product used passed values. Returns the product id. | | `createVariableProduct` | | creates a variable product for the variable product tests. Returns the product id. | | `deleteAllEmailLogs` | | deletes the emails generated by WP Mail Logging plugin | | `evalAndClick` | `selector` | helper method that clicks an element inserted in the DOM by a script | +| `getSelectorAttribute` | `selector, attribute` | Retrieve the desired HTML attribute from a selector | | `moveAllItemsToTrash` | | helper method that checks every item in a list page and moves them to the trash | +| `orderPageSaveChanges` | | Save the current order page | | `permalinkSettingsPageSaveChanges` | | Save the current Permalink settings | | `removeCoupon` | | helper method that removes a single coupon within cart or checkout | | `selectOptionInSelect2` | `selector, value` | helper method that searchs for select2 type fields and select plus insert value inside | @@ -222,6 +233,7 @@ There is a general utilities object `utils` with the following functions: | `verifyCheckboxIsSet` | `selector` | Verify that a checkbox is checked | | `verifyCheckboxIsUnset` | `selector` | Verify that a checkbox is unchecked | | `verifyPublishAndTrash` | `button, publishNotice, publishVerification, trashVerification` | Verify that an item can be published and trashed | +| `verifyValueOfElementAttribute` | `selector, attribute, expectedValue` | Assert the value of the desired HTML attribute of a selector | | `verifyValueOfInputField` | `selector, value` | Verify an input contains the passed value | | `clickFilter` | `selector` | Click on a list page filter | | `moveAllItemsToTrash` | | Moves all items in a list view to the Trash | diff --git a/packages/js/e2e-utils/changelog/fix-e2e-builds-private-package b/packages/js/e2e-utils/changelog/fix-e2e-builds-private-package new file mode 100644 index 00000000000..ea5be2b5fdb --- /dev/null +++ b/packages/js/e2e-utils/changelog/fix-e2e-builds-private-package @@ -0,0 +1,4 @@ +Significance: minor +Type: dev + +Create dependency for new private e2e-builds package diff --git a/packages/js/e2e-utils/package.json b/packages/js/e2e-utils/package.json index e277882b16f..2e0d9763070 100644 --- a/packages/js/e2e-utils/package.json +++ b/packages/js/e2e-utils/package.json @@ -1,6 +1,6 @@ { "name": "@woocommerce/e2e-utils", - "version": "0.1.6", + "version": "0.2.0", "description": "End-To-End (E2E) test utils for WooCommerce", "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/e2e-utils/README.md", "repository": { @@ -15,7 +15,6 @@ "@wordpress/deprecated": "^3.2.3", "@wordpress/e2e-test-utils": "^4.16.1", "config": "3.3.3", - "faker": "^5.1.0", "fishery": "^1.2.0" }, "devDependencies": { @@ -29,6 +28,7 @@ "@babel/preset-env": "7.12.7", "@typescript-eslint/eslint-plugin": "^5.3.0", "@typescript-eslint/parser": "^5.3.0", + "@woocommerce/e2e-builds": "workspace:*", "@wordpress/babel-plugin-import-jsx-pragma": "1.1.3", "@wordpress/babel-preset-default": "3.0.2", "@wordpress/browserslist-config": "^4.1.0", @@ -42,7 +42,7 @@ }, "scripts": { "clean": "rm -rf ./build ./build-module", - "compile": "node ./../bin/build.js", + "compile": "e2e-builds", "build": "pnpm run clean && pnpm run compile", "prepare": "pnpm run build", "lint": "eslint src" diff --git a/packages/js/e2e-utils/src/components.js b/packages/js/e2e-utils/src/components.js index 31738064a1a..f117738a7ec 100644 --- a/packages/js/e2e-utils/src/components.js +++ b/packages/js/e2e-utils/src/components.js @@ -23,9 +23,13 @@ import { Coupon, Order } from '@woocommerce/api'; const client = factories.api.withDefaultPermalinks; const config = require( 'config' ); const simpleProductName = config.get( 'products.simple.name' ); -const simpleProductPrice = config.has('products.simple.price') ? config.get('products.simple.price') : '9.99'; -const defaultVariableProduct = config.get('products.variable'); -const defaultGroupedProduct = config.get('products.grouped'); +const simpleProductPrice = config.has( 'products.simple.price' ) + ? config.get( 'products.simple.price' ) + : '9.99'; +const defaultVariableProduct = config.get( 'products.variable' ); +const defaultGroupedProduct = config.get( 'products.grouped' ); + +const uuid = require( 'uuid' ); /** * Verify and publish @@ -41,14 +45,16 @@ const verifyAndPublish = async ( noticeText ) => { await page.waitForSelector( '.updated.notice' ); // Verify - await expect( page ).toMatchElement( '.updated.notice', { text: noticeText } ); + await expect( page ).toMatchElement( '.updated.notice', { + text: noticeText, + } ); }; /** * Wait for primary button to be enabled and click. * * @param waitForNetworkIdle - Wait for network idle after click - * @returns {Promise<void>} + * @return {Promise<void>} */ const waitAndClickPrimary = async ( waitForNetworkIdle = true ) => { // Wait for "Continue" button to become active @@ -66,19 +72,34 @@ const completeOnboardingWizard = async () => { await merchant.runSetupWizard(); // Fill store's address - first line - await expect( page ).toFill( '#inspector-text-control-0', config.get( 'addresses.admin.store.addressfirstline' ) ); + await expect( page ).toFill( + '#inspector-text-control-0', + config.get( 'addresses.admin.store.addressfirstline' ) + ); // Fill store's address - second line - await expect( page ).toFill( '#inspector-text-control-1', config.get( 'addresses.admin.store.addresssecondline' ) ); + await expect( page ).toFill( + '#inspector-text-control-1', + config.get( 'addresses.admin.store.addresssecondline' ) + ); // Fill country and state where the store is located - await expect( page ).toFill( '.woocommerce-select-control__control-input', config.get( 'addresses.admin.store.countryandstate' ) ); + await expect( page ).toFill( + '.woocommerce-select-control__control-input', + config.get( 'addresses.admin.store.countryandstate' ) + ); // Fill the city where the store is located - await expect( page ).toFill( '#inspector-text-control-2', config.get( 'addresses.admin.store.city' ) ); + await expect( page ).toFill( + '#inspector-text-control-2', + config.get( 'addresses.admin.store.city' ) + ); // Fill postcode of the store - await expect( page ).toFill( '#inspector-text-control-3', config.get( 'addresses.admin.store.postcode' ) ); + await expect( page ).toFill( + '#inspector-text-control-3', + config.get( 'addresses.admin.store.postcode' ) + ); // Verify that checkbox next to "I'm setting up a store for a client" is not selected await verifyCheckboxIsUnset( '.components-checkbox-control__input' ); @@ -90,33 +111,45 @@ const completeOnboardingWizard = async () => { await page.click( 'button.is-primary', { text: 'Continue' } ); // Wait for usage tracking pop-up window to appear on a new site - const usageTrackingHeader = await page.$('.components-modal__header-heading'); + const usageTrackingHeader = await page.$( + '.components-modal__header-heading' + ); if ( usageTrackingHeader ) { - await expect(page).toMatchElement( - '.components-modal__header-heading', {text: 'Build a better WooCommerce'} + await expect( page ).toMatchElement( + '.components-modal__header-heading', + { + text: 'Build a better WooCommerce', + } ); // Query for "No Thanks" buttons - const continueButtons = await page.$$( '.woocommerce-usage-modal__actions button.is-secondary' ); + const continueButtons = await page.$$( + '.woocommerce-usage-modal__actions button.is-secondary' + ); expect( continueButtons ).toHaveLength( 1 ); - await continueButtons[0].click(); + await continueButtons[ 0 ].click(); } await page.waitForNavigation( { waitUntil: 'networkidle0' } ); // Industry section // Query for the industries checkboxes - const industryCheckboxes = await page.$$( '.components-checkbox-control__input' ); + const industryCheckboxes = await page.$$( + '.components-checkbox-control__input' + ); expect( industryCheckboxes ).toHaveLength( 8 ); // Select all industries including "Other" for ( let i = 0; i < 8; i++ ) { - await industryCheckboxes[i].click(); + await industryCheckboxes[ i ].click(); } // Fill "Other" industry - await expect( page ).toFill( '.components-text-control__input', config.get( 'onboardingwizard.industry' ) ); + await expect( page ).toFill( + '.components-text-control__input', + config.get( 'onboardingwizard.industry' ) + ); // Wait for "Continue" button to become active await waitAndClickPrimary(); @@ -124,12 +157,14 @@ const completeOnboardingWizard = async () => { // Product types section // Query for the product types checkboxes - const productTypesCheckboxes = await page.$$( '.components-checkbox-control__input' ); + const productTypesCheckboxes = await page.$$( + '.components-checkbox-control__input' + ); expect( productTypesCheckboxes ).toHaveLength( 7 ); // Select Physical and Downloadable products for ( let i = 1; i < 2; i++ ) { - await productTypesCheckboxes[i].click(); + await productTypesCheckboxes[ i ].click(); } // Wait for "Continue" button to become active @@ -145,14 +180,18 @@ const completeOnboardingWizard = async () => { expect( selectControls ).toHaveLength( 2 ); // Fill the number of products you plan to sell - await selectControls[0].click(); + await selectControls[ 0 ].click(); await page.waitForSelector( '.woocommerce-select-control__control' ); - await expect( page ).toClick( '.woocommerce-select-control__option', { text: config.get( 'onboardingwizard.numberofproducts' ) } ); + await expect( page ).toClick( '.woocommerce-select-control__option', { + text: config.get( 'onboardingwizard.numberofproducts' ), + } ); // Fill currently selling elsewhere - await selectControls[1].click(); + await selectControls[ 1 ].click(); await page.waitForSelector( '.woocommerce-select-control__control' ); - await expect( page ).toClick( '.woocommerce-select-control__option', { text: config.get( 'onboardingwizard.sellingelsewhere' ) } ); + await expect( page ).toClick( '.woocommerce-select-control__option', { + text: config.get( 'onboardingwizard.sellingelsewhere' ), + } ); // Wait for "Continue" button to become active await waitAndClickPrimary( false ); @@ -172,15 +211,17 @@ const completeOnboardingWizard = async () => { } // Wait for homescreen welcome modal to appear - let welcomeHeader = await waitForSelectorWithoutThrow( '.woocommerce__welcome-modal__page-content' ); + const welcomeHeader = await waitForSelectorWithoutThrow( + '.woocommerce__welcome-modal__page-content' + ); if ( ! welcomeHeader ) { return; } // Click two Next buttons for ( let b = 0; b < 2; b++ ) { - await page.waitForSelector('button.components-guide__forward-button'); - await page.click('button.components-guide__forward-button'); + await page.waitForSelector( 'button.components-guide__forward-button' ); + await page.click( 'button.components-guide__forward-button' ); } // Wait for "Let's go" button to become active await page.waitForSelector( 'button.components-guide__finish-button' ); @@ -215,7 +256,11 @@ const createSimpleProduct = async ( * @param productPrice Product's price which can be changed when writing a test * @param categoryName Product's category which can be changed when writing a test */ -const createSimpleProductWithCategory = async ( productName, productPrice, categoryName ) => { +const createSimpleProductWithCategory = async ( + productName, + productPrice, + categoryName +) => { // Get the category ID so we can add it to the product below const categoryId = await withRestApi.createProductCategory( categoryName ); @@ -225,7 +270,7 @@ const createSimpleProductWithCategory = async ( productName, productPrice, categ categories: [ { id: categoryId, - } + }, ], isVirtual: true, } ); @@ -233,73 +278,103 @@ const createSimpleProductWithCategory = async ( productName, productPrice, categ return product.id; }; +/** + * Create simple downloadable product + * + * @param name Product's name. Defaults to 'Simple Product' (see createSimpleProduct definition). + * @param downloadLimit Product's download limit. Defaults to '-1' (unlimited). + * @param downloadName Product's download name. Defaults to 'Single'. + * @param price Product's price. Defaults to '$9.99' (see createSimpleProduct definition). + */ +const createSimpleDownloadableProduct = async ( + name, + downloadLimit = -1, + downloadName = 'Single', + price +) => { + const productDownloadDetails = { + downloadable: true, + downloads: [ + { + id: uuid.v4(), + name: downloadName, + file: + 'https://demo.woothemes.com/woocommerce/wp-content/uploads/sites/56/2017/08/single.jpg', + }, + ], + download_limit: downloadLimit, + }; + + return await createSimpleProduct( name, price, productDownloadDetails ); +}; + /** * Create variable product. * Also, create variations for all attributes. * * @param varProduct Defaults to the variable product object in `default.json` - * @returns the ID of the created variable product + * @return the ID of the created variable product */ -const createVariableProduct = async (varProduct = defaultVariableProduct) => { +const createVariableProduct = async ( varProduct = defaultVariableProduct ) => { const { attributes } = varProduct; - const { id } = await factories.products.variable.create(varProduct); // create the variable product + const { id } = await factories.products.variable.create( varProduct ); // create the variable product const variations = []; const buffer = []; // accumulated attributes while looping const aIdx = 0; // attributes[] index // Create variation for all attributes - const createVariation = (aIdx) => { - const { name, options } = attributes[aIdx]; + const createVariation = ( aIdx ) => { + const { name, options } = attributes[ aIdx ]; const isLastAttribute = aIdx === attributes.length - 1; // Add each attribute value to the buffer. - options.forEach((opt) => { - buffer.push({ - name: name, - option: opt - }); + options.forEach( ( opt ) => { + buffer.push( { + name, + option: opt, + } ); - if (isLastAttribute) { + if ( isLastAttribute ) { // If this is the last attribute, it means the variation is now complete. // Save whatever's been accumulated in the buffer to the `variations[]` array. - variations.push({ - attributes: [...buffer] - }); + variations.push( { + attributes: [ ...buffer ], + } ); } else { // Otherwise, move to the next attribute first // before proceeding to the next value in this attribute. - createVariation(aIdx + 1); + createVariation( aIdx + 1 ); } buffer.pop(); - }); + } ); }; - createVariation(aIdx); + createVariation( aIdx ); // Set some properties of 1st variation - variations[0].regularPrice = '9.99'; - variations[0].virtual = true; + variations[ 0 ].regularPrice = '9.99'; + variations[ 0 ].virtual = true; // Set some properties of 2nd variation - variations[1].regularPrice = '11.99'; - variations[1].virtual = true; + variations[ 1 ].regularPrice = '11.99'; + variations[ 1 ].virtual = true; // Set some properties of 3rd variation - variations[2].regularPrice = '20'; - variations[2].weight = '200'; - variations[2].dimensions = { + variations[ 2 ].regularPrice = '20'; + variations[ 2 ].weight = '200'; + variations[ 2 ].dimensions = { length: '10', width: '20', - height: '15' + height: '15', }; - variations[2].manage_stock = true; + variations[ 2 ].manage_stock = true; // Use API to create each variation - for (const v of variations) { - await factories.products.variation.create({ + for ( const v of variations ) { + await factories.products.variation.create( { productId: id, - variation: v - }); + variation: v, + } ); } return id; @@ -309,23 +384,25 @@ const createVariableProduct = async (varProduct = defaultVariableProduct) => { * Create grouped product. * * @param groupedProduct Defaults to the grouped product object in `default.json` - * @returns ID of the grouped product + * @return ID of the grouped product */ -const createGroupedProduct = async (groupedProduct = defaultGroupedProduct) => { +const createGroupedProduct = async ( + groupedProduct = defaultGroupedProduct +) => { const { name, groupedProducts } = groupedProduct; const simpleProductIds = []; let groupedProductRequest; // Using the api, create simple products to be grouped - for (const simpleProduct of groupedProducts) { - const { id } = await factories.products.simple.create(simpleProduct); - simpleProductIds.push(id); + for ( const simpleProduct of groupedProducts ) { + const { id } = await factories.products.simple.create( simpleProduct ); + simpleProductIds.push( id ); } // Using the api, create the grouped product groupedProductRequest = { - name: name, - groupedProducts: simpleProductIds + name, + groupedProducts: simpleProductIds, }; const { id } = await factories.products.grouped.create( groupedProductRequest @@ -337,8 +414,8 @@ const createGroupedProduct = async (groupedProduct = defaultGroupedProduct) => { /** * Use the API to create an order with the provided details. * - * @param {object} orderOptions - * @returns {Promise<number>} ID of the created order. + * @param {Object} orderOptions + * @return {Promise<number>} ID of the created order. */ const createOrder = async ( orderOptions = {} ) => { const newOrder = { @@ -389,10 +466,14 @@ const createSimpleOrder = async ( orderStatus = 'Pending payment' ) => { await page.waitForSelector( '#message' ); // Verify - await expect( page ).toMatchElement( '#message', { text: 'Order updated.' } ); + await expect( page ).toMatchElement( '#message', { + text: 'Order updated.', + } ); const variablePostId = await page.$( '#post_ID' ); - let variablePostIdValue = ( await ( await variablePostId.getProperty( 'value' ) ).jsonValue() ); + const variablePostIdValue = await ( + await variablePostId.getProperty( 'value' ) + ).jsonValue(); return variablePostIdValue; }; @@ -402,23 +483,23 @@ const createSimpleOrder = async ( orderStatus = 'Pending payment' ) => { * * @param statuses Array of order statuses */ -const batchCreateOrders = async (statuses) => { - const defaultOrder = config.get('orders.basicPaidOrder'); +const batchCreateOrders = async ( statuses ) => { + const defaultOrder = config.get( 'orders.basicPaidOrder' ); const path = '/wc/v3/orders/batch'; // Create an order per status - const orders = statuses.map((s) => { + const orders = statuses.map( ( s ) => { return { ...defaultOrder, - status: s + status: s, }; - }); + } ); // Set the request payload from the created orders. // Then send the API request. const payload = { create: orders }; - const response = await client.post(path, payload); - expect( response.status ).toEqual(200); + const response = await client.post( path, payload ); + expect( response.status ).toEqual( 200 ); }; /** @@ -434,17 +515,26 @@ const addProductToOrder = async ( orderId, productName ) => { await expect( page ).toClick( 'button.add-line-item' ); await expect( page ).toClick( 'button.add-order-item' ); await page.waitForSelector( '.wc-backbone-modal-header' ); - await expect( page ).toClick( '.wc-backbone-modal-content .wc-product-search' ); - await expect( page ).toFill('#wc-backbone-modal-dialog + .select2-container .select2-search__field', productName); - await page.waitForSelector( 'li[aria-selected="true"]', { timeout: 10000 } ); + await expect( page ).toClick( + '.wc-backbone-modal-content .wc-product-search' + ); + await expect( page ).toFill( + '#wc-backbone-modal-dialog + .select2-container .select2-search__field', + productName + ); + await page.waitForSelector( 'li[aria-selected="true"]', { + timeout: 10000, + } ); await expect( page ).toClick( 'li[aria-selected="true"]' ); await page.click( '.wc-backbone-modal-content #btn-ok' ); await backboneUnblocked(); // Verify the product we added shows as a line item now - await expect( page ).toMatchElement( '.wc-order-item-name', { text: productName } ); -} + await expect( page ).toMatchElement( '.wc-order-item-name', { + text: productName, + } ); +}; /** * Creates a basic coupon with the provided coupon amount. Returns the coupon code. @@ -452,16 +542,19 @@ const addProductToOrder = async ( orderId, productName ) => { * @param couponAmount Amount to be applied. Defaults to 5. * @param discountType Type of a coupon. Defaults to Fixed cart discount. */ -const createCoupon = async ( couponAmount = '5', discountType = 'Fixed cart discount' ) => { +const createCoupon = async ( + couponAmount = '5', + discountType = 'Fixed cart discount' +) => { let couponType; switch ( discountType ) { - case "Fixed cart discount": + case 'Fixed cart discount': couponType = 'fixed_cart'; break; - case "Fixed product discount": + case 'Fixed product discount': couponType = 'fixed_product'; break; - case "Percentage discount": + case 'Percentage discount': couponType = 'percent'; break; default: @@ -469,13 +562,13 @@ const createCoupon = async ( couponAmount = '5', discountType = 'Fixed cart disc } // Fill in coupon code - let couponCode = 'code-' + couponType + new Date().getTime().toString(); + const couponCode = 'code-' + couponType + new Date().getTime().toString(); const repository = Coupon.restRepository( client ); await repository.create( { code: couponCode, discountType: couponType, amount: couponAmount, - }); + } ); return couponCode; }; @@ -488,34 +581,44 @@ const createCoupon = async ( couponAmount = '5', discountType = 'Fixed cart disc * @param zipCode Shipping zone zip code. Defaults to empty one space. * @param zoneMethod Shipping method type. Defaults to flat_rate (use also: free_shipping or local_pickup) */ -const addShippingZoneAndMethod = async ( zoneName, zoneLocation = 'country:US', zipCode = ' ', zoneMethod = 'flat_rate' ) => { +const addShippingZoneAndMethod = async ( + zoneName, + zoneLocation = 'country:US', + zipCode = ' ', + zoneMethod = 'flat_rate' +) => { await merchant.openNewShipping(); // Fill shipping zone name - await page.waitForSelector('input#zone_name'); - await expect(page).toFill('input#zone_name', zoneName); + await page.waitForSelector( 'input#zone_name' ); + await expect( page ).toFill( 'input#zone_name', zoneName ); // Select shipping zone location - await expect(page).toSelect('select[name="zone_locations"]', zoneLocation); + await expect( page ).toSelect( + 'select[name="zone_locations"]', + zoneLocation + ); await uiUnblocked(); // Fill shipping zone postcode if needed otherwise just put empty space - await page.waitForSelector('a.wc-shipping-zone-postcodes-toggle'); - await expect(page).toClick('a.wc-shipping-zone-postcodes-toggle'); - await expect(page).toFill('#zone_postcodes', zipCode); - await expect(page).toMatchElement('#zone_postcodes', zipCode); - await expect(page).toClick('button#submit'); + await page.waitForSelector( 'a.wc-shipping-zone-postcodes-toggle' ); + await expect( page ).toClick( 'a.wc-shipping-zone-postcodes-toggle' ); + await expect( page ).toFill( '#zone_postcodes', zipCode ); + await expect( page ).toMatchElement( '#zone_postcodes', zipCode ); + await expect( page ).toClick( 'button#submit' ); await uiUnblocked(); // Add shipping zone method - await page.waitFor(1000); - await expect(page).toClick('button.wc-shipping-zone-add-method', {text:'Add shipping method'}); - await page.waitForSelector('.wc-shipping-zone-method-selector'); - await expect(page).toSelect('select[name="add_method_id"]', zoneMethod); - await expect(page).toClick('button#btn-ok'); - await page.waitForSelector('#zone_locations'); + await page.waitFor( 1000 ); + await expect( page ).toClick( 'button.wc-shipping-zone-add-method', { + text: 'Add shipping method', + } ); + await page.waitForSelector( '.wc-shipping-zone-method-selector' ); + await expect( page ).toSelect( 'select[name="add_method_id"]', zoneMethod ); + await expect( page ).toClick( 'button#btn-ok' ); + await page.waitForSelector( '#zone_locations' ); await uiUnblocked(); }; @@ -536,7 +639,9 @@ const clickUpdateOrder = async ( noticeText, waitForSave = false ) => { await page.waitForSelector( '.updated.notice' ); // Verify - await expect( page ).toMatchElement( '.updated.notice', { text: noticeText } ); + await expect( page ).toMatchElement( '.updated.notice', { + text: noticeText, + } ); }; /** @@ -546,7 +651,7 @@ const deleteAllEmailLogs = async () => { await merchant.openEmailLog(); // Make sure we have emails to delete. If we don't, this selector will return null. - if ( await page.$( '#bulk-action-selector-top' ) !== null ) { + if ( ( await page.$( '#bulk-action-selector-top' ) ) !== null ) { await setCheckbox( '#cb-select-all-1' ); await expect( page ).toSelect( '#bulk-action-selector-top', 'Delete' ); await Promise.all( [ @@ -560,27 +665,27 @@ const deleteAllEmailLogs = async () => { * Delete all the existing shipping zones. */ const deleteAllShippingZones = async () => { - await merchant.openSettings('shipping'); + await merchant.openSettings( 'shipping' ); // Delete existing shipping zones. try { let zone = await page.$( '.wc-shipping-zone-delete' ); if ( zone ) { // WP action links aren't clickable because they are hidden with a left=-9999 style. - await page.evaluate(() => { - document.querySelector('.wc-shipping-zone-name .row-actions') - .style - .left = '0'; - }); + await page.evaluate( () => { + document.querySelector( + '.wc-shipping-zone-name .row-actions' + ).style.left = '0'; + } ); while ( zone ) { await evalAndClick( '.wc-shipping-zone-delete' ); await uiUnblocked(); zone = await page.$( '.wc-shipping-zone-delete' ); - }; - }; - } catch (error) { + } + } + } catch ( error ) { // Prevent an error here causing the test to fail. - }; + } }; export { @@ -594,6 +699,7 @@ export { createCoupon, addShippingZoneAndMethod, createSimpleProductWithCategory, + createSimpleDownloadableProduct, clickUpdateOrder, deleteAllEmailLogs, deleteAllShippingZones, diff --git a/packages/js/e2e-utils/src/factories.js b/packages/js/e2e-utils/src/factories.js index 4ebc5e38041..d28e7c327d7 100644 --- a/packages/js/e2e-utils/src/factories.js +++ b/packages/js/e2e-utils/src/factories.js @@ -25,7 +25,7 @@ const factories = { simple: simpleProductFactory( withDefaultPermalinks ), variable: variableProductFactory( withDefaultPermalinks ), variation: variationFactory( withDefaultPermalinks ), - grouped: groupedProductFactory( withDefaultPermalinks ) + grouped: groupedProductFactory( withDefaultPermalinks ), }, }; diff --git a/packages/js/e2e-utils/src/factories/grouped-product.js b/packages/js/e2e-utils/src/factories/grouped-product.js index 53f1962b9ef..264a2192476 100644 --- a/packages/js/e2e-utils/src/factories/grouped-product.js +++ b/packages/js/e2e-utils/src/factories/grouped-product.js @@ -7,18 +7,18 @@ import { Factory } from 'fishery'; * @param {HTTPClient} httpClient The HTTP client we will give the repository. * @return {AsyncFactory} The factory for creating models. */ -export function groupedProductFactory(httpClient) { - const repository = GroupedProduct.restRepository(httpClient); +export function groupedProductFactory( httpClient ) { + const repository = GroupedProduct.restRepository( httpClient ); - return Factory.define(({ params, onCreate }) => { - onCreate((model) => { - return repository.create(model); - }); + return Factory.define( ( { params, onCreate } ) => { + onCreate( ( model ) => { + return repository.create( model ); + } ); return { name: params.name, type: 'grouped', - groupedProducts: params.groupedProducts + groupedProducts: params.groupedProducts, }; - }); + } ); } diff --git a/packages/js/e2e-utils/src/factories/simple-product.js b/packages/js/e2e-utils/src/factories/simple-product.js index e6a8a545991..b8ba34f37f0 100644 --- a/packages/js/e2e-utils/src/factories/simple-product.js +++ b/packages/js/e2e-utils/src/factories/simple-product.js @@ -1,6 +1,6 @@ import { SimpleProduct } from '@woocommerce/api'; -const faker = require( 'faker/locale/en' ); import { Factory } from 'fishery'; +import crypto from 'crypto'; /** * Creates a new factory for creating models. @@ -10,6 +10,8 @@ import { Factory } from 'fishery'; */ export function simpleProductFactory( httpClient ) { const repository = SimpleProduct.restRepository( httpClient ); + const defaultProductName = `Simple product ${ crypto.randomUUID() }`; + const defaultRegularPrice = '10.99'; return Factory.define( ( { params, onCreate } ) => { onCreate( ( model ) => { @@ -17,8 +19,10 @@ export function simpleProductFactory( httpClient ) { } ); return { - name: params.name ? params.name : faker.commerce.productName(), - regularPrice: params.regularPrice ? params.regularPrice : faker.commerce.price(), + name: params.name ? params.name : defaultProductName, + regularPrice: params.regularPrice + ? params.regularPrice + : defaultRegularPrice, }; } ); } diff --git a/packages/js/e2e-utils/src/factories/variable-product.js b/packages/js/e2e-utils/src/factories/variable-product.js index 5080dbc0422..30f123ae227 100644 --- a/packages/js/e2e-utils/src/factories/variable-product.js +++ b/packages/js/e2e-utils/src/factories/variable-product.js @@ -5,23 +5,23 @@ import { Factory } from 'fishery'; * Creates a new factory for creating variable products. * This does not include creating product variations. * Instead, use `variationFactory()` for that. - * + * * @param {HTTPClient} httpClient The HTTP client we will give the repository. * @return {AsyncFactory} The factory for creating models. */ -export function variableProductFactory(httpClient) { - const repository = VariableProduct.restRepository(httpClient); +export function variableProductFactory( httpClient ) { + const repository = VariableProduct.restRepository( httpClient ); - return Factory.define(({ params, onCreate }) => { - onCreate((model) => { - return repository.create(model); - }); + return Factory.define( ( { params, onCreate } ) => { + onCreate( ( model ) => { + return repository.create( model ); + } ); - return { - name: params.name, - type: 'variable', - defaultAttributes: params.defaultAttributes, - attributes: params.attributes - }; - }); + return { + name: params.name, + type: 'variable', + defaultAttributes: params.defaultAttributes, + attributes: params.attributes, + }; + } ); } diff --git a/packages/js/e2e-utils/src/factories/variation.js b/packages/js/e2e-utils/src/factories/variation.js index 4aed65ad353..fa820894dfd 100644 --- a/packages/js/e2e-utils/src/factories/variation.js +++ b/packages/js/e2e-utils/src/factories/variation.js @@ -7,16 +7,16 @@ import { Factory } from 'fishery'; * @param {HTTPClient} httpClient The HTTP client we will give the repository. * @return {AsyncFactory} The factory for creating models. */ -export function variationFactory(httpClient) { - const repository = ProductVariation.restRepository(httpClient); +export function variationFactory( httpClient ) { + const repository = ProductVariation.restRepository( httpClient ); - return Factory.define(({ params, onCreate }) => { - const { productId, variation } = params; + return Factory.define( ( { params, onCreate } ) => { + const { productId, variation } = params; - onCreate((model) => { - return repository.create(productId, model); - }); + onCreate( ( model ) => { + return repository.create( productId, model ); + } ); - return variation; - }); + return variation; + } ); } diff --git a/packages/js/e2e-utils/src/flows/constants.js b/packages/js/e2e-utils/src/flows/constants.js index 118a207bdd6..33444302cff 100644 --- a/packages/js/e2e-utils/src/flows/constants.js +++ b/packages/js/e2e-utils/src/flows/constants.js @@ -6,45 +6,56 @@ const baseUrl = config.get( 'url' ); /** * WordPress core dashboard pages. + * * @type {string} */ export const WP_ADMIN_LOGIN = baseUrl + 'wp-login.php'; export const WP_ADMIN_DASHBOARD = baseUrl + 'wp-admin/'; export const WP_ADMIN_WP_UPDATES = WP_ADMIN_DASHBOARD + 'update-core.php'; export const WP_ADMIN_PLUGINS = WP_ADMIN_DASHBOARD + 'plugins.php'; -export const WP_ADMIN_PLUGIN_INSTALL = WP_ADMIN_DASHBOARD + 'plugin-install.php'; -export const WP_ADMIN_PERMALINK_SETTINGS = WP_ADMIN_DASHBOARD + 'options-permalink.php'; +export const WP_ADMIN_PLUGIN_INSTALL = + WP_ADMIN_DASHBOARD + 'plugin-install.php'; +export const WP_ADMIN_PERMALINK_SETTINGS = + WP_ADMIN_DASHBOARD + 'options-permalink.php'; export const WP_ADMIN_ALL_USERS_VIEW = WP_ADMIN_DASHBOARD + 'users.php'; /** * WooCommerce core post type pages. + * * @type {string} */ export const WP_ADMIN_POST_TYPE = WP_ADMIN_DASHBOARD + 'edit.php?post_type='; -export const WP_ADMIN_NEW_POST_TYPE = WP_ADMIN_DASHBOARD + 'post-new.php?post_type='; +export const WP_ADMIN_NEW_POST_TYPE = + WP_ADMIN_DASHBOARD + 'post-new.php?post_type='; export const WP_ADMIN_ALL_COUPONS_VIEW = WP_ADMIN_POST_TYPE + 'shop_coupon'; export const WP_ADMIN_NEW_COUPON = WP_ADMIN_NEW_POST_TYPE + 'shop_coupon'; export const WP_ADMIN_ALL_ORDERS_VIEW = WP_ADMIN_POST_TYPE + 'shop_order'; export const WP_ADMIN_NEW_ORDER = WP_ADMIN_NEW_POST_TYPE + 'shop_order'; export const WP_ADMIN_ALL_PRODUCTS_VIEW = WP_ADMIN_POST_TYPE + 'product'; export const WP_ADMIN_NEW_PRODUCT = WP_ADMIN_NEW_POST_TYPE + 'product'; -export const WP_ADMIN_IMPORT_PRODUCTS = WP_ADMIN_ALL_PRODUCTS_VIEW + '&page=product_importer'; +export const WP_ADMIN_IMPORT_PRODUCTS = + WP_ADMIN_ALL_PRODUCTS_VIEW + '&page=product_importer'; /** * WooCommerce settings pages. + * * @type {string} */ export const WP_ADMIN_PLUGIN_PAGE = WP_ADMIN_DASHBOARD + 'admin.php?page='; export const WP_ADMIN_WC_HOME = WP_ADMIN_PLUGIN_PAGE + 'wc-admin'; export const WP_ADMIN_SETUP_WIZARD = WP_ADMIN_WC_HOME + '&path=%2Fsetup-wizard'; -export const WP_ADMIN_ANALYTICS_PAGES = WP_ADMIN_WC_HOME + '&path=%2Fanalytics%2F'; +export const WP_ADMIN_ANALYTICS_PAGES = + WP_ADMIN_WC_HOME + '&path=%2Fanalytics%2F'; export const WP_ADMIN_WC_SETTINGS = WP_ADMIN_PLUGIN_PAGE + 'wc-settings&tab='; -export const WP_ADMIN_NEW_SHIPPING_ZONE = WP_ADMIN_WC_SETTINGS + 'shipping&zone_id=new'; +export const WP_ADMIN_NEW_SHIPPING_ZONE = + WP_ADMIN_WC_SETTINGS + 'shipping&zone_id=new'; export const WP_ADMIN_WC_EXTENSIONS = WP_ADMIN_PLUGIN_PAGE + 'wc-addons'; -export const WP_ADMIN_WC_HELPER = WP_ADMIN_PLUGIN_PAGE + 'wc-addons§ion=helper'; +export const WP_ADMIN_WC_HELPER = + WP_ADMIN_PLUGIN_PAGE + 'wc-addons§ion=helper'; /** * Shop pages. + * * @type {string} */ export const SHOP_PAGE = baseUrl + 'shop'; @@ -55,6 +66,7 @@ export const SHOP_MY_ACCOUNT_PAGE = baseUrl + 'my-account/'; /** * Customer account pages. + * * @type {string} */ export const MY_ACCOUNT_ORDERS = SHOP_MY_ACCOUNT_PAGE + 'orders'; @@ -64,6 +76,7 @@ export const MY_ACCOUNT_ACCOUNT_DETAILS = SHOP_MY_ACCOUNT_PAGE + 'edit-account'; /** * Test control flags. + * * @type {boolean} */ export const IS_RETEST_MODE = process.env.E2E_RETEST == '1'; diff --git a/packages/js/e2e-utils/src/flows/expressions.js b/packages/js/e2e-utils/src/flows/expressions.js index 2ce09a3f61e..63c3974144f 100644 --- a/packages/js/e2e-utils/src/flows/expressions.js +++ b/packages/js/e2e-utils/src/flows/expressions.js @@ -1,14 +1,13 @@ -export const getProductColumnExpression = ( productTitle ) => ( +export const getProductColumnExpression = ( productTitle ) => 'td[@class="product-name" and ' + `a[contains(text(), "${ productTitle }")]` + - ']' -); + ']'; -export const getQtyColumnExpression = ( args ) => ( +export const getQtyColumnExpression = ( args ) => 'td[@class="product-quantity" and ' + - './/' + getQtyInputExpression( args ) + - ']' -); + './/' + + getQtyInputExpression( args ) + + ']'; export const getQtyInputExpression = ( args = {} ) => { let qtyValue = ''; @@ -20,14 +19,12 @@ export const getQtyInputExpression = ( args = {} ) => { return 'input[contains(@class, "input-text")' + qtyValue + ']'; }; -export const getCartItemExpression = ( productTitle, args ) => ( +export const getCartItemExpression = ( productTitle, args ) => '//tr[contains(@class, "cart_item") and ' + getProductColumnExpression( productTitle ) + ' and ' + getQtyColumnExpression( args ) + - ']' -); + ']'; -export const getRemoveExpression = () => ( - 'td[@class="product-remove"]//a[@class="remove"]' -); +export const getRemoveExpression = () => + 'td[@class="product-remove"]//a[@class="remove"]'; diff --git a/packages/js/e2e-utils/src/flows/merchant.js b/packages/js/e2e-utils/src/flows/merchant.js index ab880f255de..086b47db362 100644 --- a/packages/js/e2e-utils/src/flows/merchant.js +++ b/packages/js/e2e-utils/src/flows/merchant.js @@ -6,7 +6,16 @@ const config = require( 'config' ); /** * Internal dependencies */ -const { clearAndFillInput, setCheckbox } = require( '../page-utils' ); +const { + clearAndFillInput, + selectOptionInSelect2, + setCheckbox, + verifyValueOfInputField, + getSelectorAttribute, + orderPageSaveChanges, + verifyValueOfElementAttribute, +} = require( '../page-utils' ); + const { WP_ADMIN_ALL_ORDERS_VIEW, WP_ADMIN_ALL_PRODUCTS_VIEW, @@ -31,10 +40,17 @@ const { IS_RETEST_MODE, } = require( './constants' ); -const { getSlug, waitForTimeout } = require('./utils'); +const { getSlug, waitForTimeout } = require( './utils' ); const baseUrl = config.get( 'url' ); -const WP_ADMIN_SINGLE_CPT_VIEW = ( postId ) => baseUrl + `wp-admin/post.php?post=${ postId }&action=edit`; +const WP_ADMIN_SINGLE_CPT_VIEW = ( postId ) => + baseUrl + `wp-admin/post.php?post=${ postId }&action=edit`; + +// Reusable selectors +const INPUT_DOWNLOADS_REMAINING = 'input[name="downloads_remaining[0]"]'; +const INPUT_EXPIRATION_DATE = 'input[name="access_expires[0]"]'; +const ORDER_DOWNLOADS = '#woocommerce-order-downloads'; +const BTN_COPY_DOWNLOAD_LINK = '#copy-download-link'; const merchant = { login: async () => { @@ -57,17 +73,17 @@ const merchant = { }, logout: async () => { - // Log out link in admin bar is not visible so can't be clicked directly. - const logoutLinks = await page.$$eval( - '#wp-admin-bar-logout a', - ( am ) => am.filter( ( e ) => e.href ).map( ( e ) => e.href ) - ); + await page.goto( WP_ADMIN_LOGIN + '?action=logout', { + waitUntil: 'networkidle0', + } ); - if ( logoutLinks && logoutLinks[0] ) { - await page.goto(logoutLinks[0], { - waitUntil: 'networkidle0', - }); - } + // Confirm logout using XPath, which works on all languages. + const elements = await page.$x( + "//a[contains(@href,'action=logout')]" + ); + await elements[ 0 ].click(); + + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); }, openAllOrdersView: async () => { @@ -143,13 +159,14 @@ const merchant = { }, runSetupWizard: async () => { - const setupWizard = IS_RETEST_MODE ? WP_ADMIN_SETUP_WIZARD : WP_ADMIN_WC_HOME; - await page.goto( setupWizard, { + const setupWizard = IS_RETEST_MODE + ? WP_ADMIN_SETUP_WIZARD + : WP_ADMIN_WC_HOME; + await page.goto( setupWizard, { waitUntil: 'networkidle0', } ); }, - goToOrder: async ( orderId ) => { await page.goto( WP_ADMIN_SINGLE_CPT_VIEW( orderId ), { waitUntil: 'networkidle0', @@ -170,34 +187,178 @@ const merchant = { await waitForTimeout( 2000 ); await expect( page ).toClick( 'button.save_order' ); await page.waitForSelector( '#message' ); - await expect( page ).toMatchElement( '#message', { text: 'Order updated.' } ); + await expect( page ).toMatchElement( '#message', { + text: 'Order updated.', + } ); }, - verifyOrder: async (orderId, productName, productPrice, quantity, orderTotal, ensureCustomerRegistered = false) => { - await merchant.goToOrder(orderId); + verifyOrder: async ( + orderId, + productName, + productPrice, + quantity, + orderTotal, + ensureCustomerRegistered = false + ) => { + await merchant.goToOrder( orderId ); // Verify that the order page is indeed of the order that was placed // Verify order number - await expect(page).toMatchElement('.woocommerce-order-data__heading', {text: 'Order #' + orderId + ' details'}); + await expect( page ).toMatchElement( + '.woocommerce-order-data__heading', + { text: 'Order #' + orderId + ' details' } + ); // Verify product name - await expect(page).toMatchElement('.wc-order-item-name', {text: productName}); + await expect( page ).toMatchElement( '.wc-order-item-name', { + text: productName, + } ); // Verify product cost - await expect(page).toMatchElement('.woocommerce-Price-amount.amount', {text: productPrice}); + await expect( page ).toMatchElement( + '.woocommerce-Price-amount.amount', + { text: productPrice } + ); // Verify product quantity - await expect(page).toMatchElement('.quantity', {text: quantity.toString()}); + await expect( page ).toMatchElement( '.quantity', { + text: quantity.toString(), + } ); // Verify total order amount without shipping - await expect(page).toMatchElement('.line_cost', {text: orderTotal}); + await expect( page ).toMatchElement( '.line_cost', { + text: orderTotal, + } ); if ( ensureCustomerRegistered ) { // Verify customer profile link is present to verify order was placed by a registered customer, not a guest - await expect( page ).toMatchElement( 'label[for="customer_user"] a[href*=user-edit]', { text: 'Profile' } ); + await expect( page ).toMatchElement( + 'label[for="customer_user"] a[href*=user-edit]', + { + text: 'Profile', + } + ); } }, + addDownloadableProductPermission: async ( productName ) => { + // Add downloadable product permission + await selectOptionInSelect2( productName ); + await expect( page ).toClick( 'button.grant_access' ); + + // Save the order changes + await orderPageSaveChanges(); + }, + + updateDownloadableProductPermission: async ( + productName, + expirationDate, + downloadsRemaining + ) => { + // Update downloadable product permission + await expect( page ).toClick( ORDER_DOWNLOADS, { text: productName } ); + + if ( downloadsRemaining ) { + await clearAndFillInput( + INPUT_DOWNLOADS_REMAINING, + downloadsRemaining + ); + } + + if ( expirationDate ) { + await clearAndFillInput( INPUT_EXPIRATION_DATE, expirationDate ); + } + + // Save the order changes + await orderPageSaveChanges(); + }, + + revokeDownloadableProductPermission: async ( productName ) => { + // Revoke downloadable product permission + const permission = await expect( + page + ).toMatchElement( 'div.wc-metabox > h3', { text: productName } ); + await expect( permission ).toClick( 'button.revoke_access' ); + + // Wait for auto save + await waitForTimeout( 2000 ); + + // Save the order changes + await orderPageSaveChanges(); + }, + + verifyDownloadableProductPermission: async ( + productName, + expirationDate = '', + downloadsRemaining = '' + ) => { + // Open downloadable product permission details + await expect( page ).toClick( ORDER_DOWNLOADS, { text: productName } ); + + // Verify downloads remaining + await verifyValueOfElementAttribute( + INPUT_DOWNLOADS_REMAINING, + 'placeholder', + 'Unlimited' + ); + await verifyValueOfInputField( + INPUT_DOWNLOADS_REMAINING, + downloadsRemaining + ); + + // Verify downloads expiration date + await verifyValueOfElementAttribute( + INPUT_EXPIRATION_DATE, + 'placeholder', + 'Never' + ); + await verifyValueOfInputField( INPUT_EXPIRATION_DATE, expirationDate ); + + // Verify 'Copy link' and 'View report' buttons are available + await expect( page ).toMatchElement( BTN_COPY_DOWNLOAD_LINK, { + text: 'Copy link', + } ); + await expect( page ).toMatchElement( '.button', { + text: 'View report', + } ); + }, + + openDownloadLink: async () => { + // Open downloadable product permission details + await expect( page ).toClick( + '#woocommerce-order-downloads > div.inside > div > div.wc-metaboxes > div' + ); + + // Get download link + const downloadLink = await getSelectorAttribute( + BTN_COPY_DOWNLOAD_LINK, + 'href' + ); + + const newPage = await browser.newPage(); + + // Open download link in new tab + await newPage.goto( downloadLink, { + waitUntil: 'networkidle0', + } ); + + return newPage; + }, + + verifyCannotDownloadFromBecause: async ( page, reason ) => { + // Select download page tab + await page.bringToFront(); + + // Verify error in download page + await expect( page.title() ).resolves.toMatch( 'WordPress › Error' ); + await expect( page ).toMatchElement( 'div.wp-die-message', { + text: reason, + } ); + + // Close tab + await page.close(); + }, + openNewShipping: async () => { await page.goto( WP_ADMIN_NEW_SHIPPING_ZONE, { waitUntil: 'networkidle0', @@ -205,9 +366,12 @@ const merchant = { }, openEmailLog: async () => { - await page.goto( `${baseUrl}wp-admin/tools.php?page=wpml_plugin_log`, { - waitUntil: 'networkidle0', - } ); + await page.goto( + `${ baseUrl }wp-admin/tools.php?page=wpml_plugin_log`, + { + waitUntil: 'networkidle0', + } + ); }, openAnalyticsPage: async ( pageName ) => { @@ -222,8 +386,8 @@ const merchant = { } ); }, - openImportProducts: async () => { - await page.goto( WP_ADMIN_IMPORT_PRODUCTS , { + openImportProducts: async () => { + await page.goto( WP_ADMIN_IMPORT_PRODUCTS, { waitUntil: 'networkidle0', } ); }, @@ -251,13 +415,20 @@ const merchant = { */ updateWordPress: async () => { await merchant.openWordPressUpdatesPage(); - if ( null !== await page.$( 'form[action="update-core.php?action=do-core-upgrade"][name="upgrade"]' ) ) { - await Promise.all([ + if ( + ( await page.$( + 'form[action="update-core.php?action=do-core-upgrade"][name="upgrade"]' + ) ) !== null + ) { + await Promise.all( [ expect( page ).toClick( 'input.button-primary' ), // The WordPress update can take some time, so setting a longer timeout here - page.waitForNavigation( { waitUntil: 'networkidle0', timeout: 1000000 } ), - ]); + page.waitForNavigation( { + waitUntil: 'networkidle0', + timeout: 1000000, + } ), + ] ); } }, @@ -266,12 +437,16 @@ const merchant = { */ updatePlugins: async () => { await merchant.openWordPressUpdatesPage(); - if ( null !== await page.$( 'form[action="update-core.php?action=do-plugin-upgrade"][name="upgrade-plugins"]' ) ) { + if ( + ( await page.$( + 'form[action="update-core.php?action=do-plugin-upgrade"][name="upgrade-plugins"]' + ) ) !== null + ) { await setCheckbox( '#plugins-select-all' ); - await Promise.all([ + await Promise.all( [ expect( page ).toClick( '#upgrade-plugins' ), page.waitForNavigation( { waitUntil: 'networkidle0' } ), - ]); + ] ); } }, @@ -280,150 +455,165 @@ const merchant = { */ updateThemes: async () => { await merchant.openWordPressUpdatesPage(); - if ( null !== await page.$( 'form[action="update-core.php?action=do-theme-upgrade"][name="upgrade-themes"]' )) { + if ( + ( await page.$( + 'form[action="update-core.php?action=do-theme-upgrade"][name="upgrade-themes"]' + ) ) !== null + ) { await setCheckbox( '#themes-select-all' ); - await Promise.all([ + await Promise.all( [ expect( page ).toClick( '#upgrade-themes' ), page.waitForNavigation( { waitUntil: 'networkidle0' } ), - ]); + ] ); } }, /* Uploads and activates a plugin located at the provided file path. This will also deactivate and delete the plugin if it exists. - * - * @param {string} pluginFilePath The location of the plugin zip file to upload. - * @param {string} pluginName The name of the plugin. For example, `WooCommerce`. - */ - uploadAndActivatePlugin: async ( pluginFilePath, pluginName ) => { - await merchant.openPlugins(); + * + * @param {string} pluginFilePath The location of the plugin zip file to upload. + * @param {string} pluginName The name of the plugin. For example, `WooCommerce`. + */ + uploadAndActivatePlugin: async ( pluginFilePath, pluginName ) => { + await merchant.openPlugins(); - // Deactivate and delete the plugin if it exists - let pluginSlug = getSlug( pluginName ); - if ( await page.$( `a#deactivate-${pluginSlug}` ) !== null ) { - await merchant.deactivatePlugin( pluginName, true ); - } + // Deactivate and delete the plugin if it exists + const pluginSlug = getSlug( pluginName ); + if ( ( await page.$( `a#deactivate-${ pluginSlug }` ) ) !== null ) { + await merchant.deactivatePlugin( pluginName, true ); + } - // Open the plugin install page - await page.goto( WP_ADMIN_PLUGIN_INSTALL, { - waitUntil: 'networkidle0', - } ); + // Open the plugin install page + await page.goto( WP_ADMIN_PLUGIN_INSTALL, { + waitUntil: 'networkidle0', + } ); - // Upload the plugin zip - await page.click( 'a.upload-view-toggle' ); + // Upload the plugin zip + await page.click( 'a.upload-view-toggle' ); - await expect( page ).toMatchElement( - 'p.install-help', - { - text: 'If you have a plugin in a .zip format, you may install or update it by uploading it here.' - } - ); + await expect( page ).toMatchElement( 'p.install-help', { + text: + 'If you have a plugin in a .zip format, you may install or update it by uploading it here.', + } ); - const uploader = await page.$( 'input[type=file]' ); + const uploader = await page.$( 'input[type=file]' ); - await uploader.uploadFile( pluginFilePath ); + await uploader.uploadFile( pluginFilePath ); - // Manually update the button to `enabled` so we can submit the file - await page.evaluate(() => { - document.getElementById( 'install-plugin-submit' ).disabled = false; - }); + // Manually update the button to `enabled` so we can submit the file + await page.evaluate( () => { + document.getElementById( 'install-plugin-submit' ).disabled = false; + } ); - // Click to upload the file - await page.click( '#install-plugin-submit' ); + // Click to upload the file + await page.click( '#install-plugin-submit' ); - await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); - // Click to activate the plugin - await page.click( '.button-primary' ); + // Click to activate the plugin + await page.click( '.button-primary' ); - await page.waitForNavigation( { waitUntil: 'networkidle0' } ); - }, + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + }, - /** - * Activate a given plugin by the plugin's name. - * - * @param {string} pluginName The name of the plugin to activate. For example, `WooCommerce`. - */ - activatePlugin: async ( pluginName ) => { - let pluginSlug = getSlug( pluginName ); + /** + * Activate a given plugin by the plugin's name. + * + * @param {string} pluginName The name of the plugin to activate. For example, `WooCommerce`. + */ + activatePlugin: async ( pluginName ) => { + const pluginSlug = getSlug( pluginName ); - await expect( page ).toClick( `a#activate-${pluginSlug}` ); + await expect( page ).toClick( `a#activate-${ pluginSlug }` ); - await page.waitForNavigation( { waitUntil: 'networkidle0' } ); - }, + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + }, - /** - * Deactivate a plugin by the plugin's name with the option to delete the plugin as well. - * - * @param {string} pluginName The name of the plugin to deactivate. For example, `WooCommerce`. - * @param {Boolean} deletePlugin Pass in `true` to delete the plugin. Defaults to `false`. - */ - deactivatePlugin: async ( pluginName, deletePlugin = false ) => { - let pluginSlug = getSlug( pluginName ); + /** + * Deactivate a plugin by the plugin's name with the option to delete the plugin as well. + * + * @param {string} pluginName The name of the plugin to deactivate. For example, `WooCommerce`. + * @param {boolean} deletePlugin Pass in `true` to delete the plugin. Defaults to `false`. + */ + deactivatePlugin: async ( pluginName, deletePlugin = false ) => { + const pluginSlug = getSlug( pluginName ); - await expect( page ).toClick( `a#deactivate-${pluginSlug}` ); + await expect( page ).toClick( `a#deactivate-${ pluginSlug }` ); - await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); - if ( deletePlugin ) { - await merchant.deletePlugin( pluginName ); - } - }, + if ( deletePlugin ) { + await merchant.deletePlugin( pluginName ); + } + }, - /** - * Delete a plugin by the plugin's name. - * - * @param {string} pluginName The name of the plugin to delete. For example, `WooCommerce`. - */ - deletePlugin: async ( pluginName ) => { - let pluginSlug = getSlug( pluginName ); + /** + * Delete a plugin by the plugin's name. + * + * @param {string} pluginName The name of the plugin to delete. For example, `WooCommerce`. + */ + deletePlugin: async ( pluginName ) => { + const pluginSlug = getSlug( pluginName ); - await expect( page ).toClick( `a#delete-${pluginSlug}` ); + await expect( page ).toClick( `a#delete-${ pluginSlug }` ); - // Wait for Ajax calls to finish - await page.waitForResponse( response => response.status() === 200 ); - }, + // Wait for Ajax calls to finish + await page.waitForResponse( ( response ) => response.status() === 200 ); + }, /** * Runs the database update if needed. For example, after uploading the WooCommerce plugin or updating WooCommerce. */ - runDatabaseUpdate: async () => { - if ( await page.$( '.updated.woocommerce-message.wc-connect' ) !== null ) { - await expect( page ).toMatchElement( 'a.wc-update-now', { text: 'Update WooCommerce Database' } ); - await expect( page ).toClick( 'a.wc-update-now' ); - await page.waitForNavigation( { waitUntil: 'networkidle0' } ); - await merchant.checkDatabaseUpdateComplete(); + runDatabaseUpdate: async () => { + if ( + ( await page.$( '.updated.woocommerce-message.wc-connect' ) ) !== + null + ) { + await expect( page ).toMatchElement( 'a.wc-update-now', { + text: 'Update WooCommerce Database', + } ); + await expect( page ).toClick( 'a.wc-update-now' ); + await page.waitForNavigation( { waitUntil: 'networkidle0' } ); + await merchant.checkDatabaseUpdateComplete(); } - }, + }, /** * Checks if the database update is complete, if not, refresh the page until it is. */ - checkDatabaseUpdateComplete: async () => { - await page.reload( { waitUntil: [ 'networkidle0', 'domcontentloaded'] } ); + checkDatabaseUpdateComplete: async () => { + await page.reload( { + waitUntil: [ 'networkidle0', 'domcontentloaded' ], + } ); const thanksButtonSelector = 'a.components-button.is-primary'; - if ( await page.$( thanksButtonSelector ) !== null ) { - await expect( page ).toMatchElement( thanksButtonSelector, { text: 'Thanks!' } ); + if ( ( await page.$( thanksButtonSelector ) ) !== null ) { + await expect( page ).toMatchElement( thanksButtonSelector, { + text: 'Thanks!', + } ); await expect( page ).toClick( thanksButtonSelector ); } else { await merchant.checkDatabaseUpdateComplete(); } - }, + }, /** * Dismiss the onboarding wizard if it is open. */ dismissOnboardingWizard: async () => { let waitForNav = false; - const skipButton = await page.$( '.woocommerce-profile-wizard__footer-link' ); + const skipButton = await page.$( + '.woocommerce-profile-wizard__footer-link' + ); if ( skipButton ) { await skipButton.click(); waitForNav = true; } // Dismiss usage tracking pop-up window if it appears on a new site - const usageTrackingHeader = await page.$( '.woocommerce-usage-modal button.is-secondary' ); + const usageTrackingHeader = await page.$( + '.woocommerce-usage-modal button.is-secondary' + ); if ( usageTrackingHeader ) { await usageTrackingHeader.click(); waitForNav = true; @@ -436,14 +626,14 @@ const merchant = { /** * Expand or collapse the WP admin menu. + * * @param {boolean} collapse Flag to collapse or expand the menu. Default collapse. */ collapseAdminMenu: async ( collapse = true ) => { const collapseButton = await page.$( '.folded #collapse-button' ); - if ( ( ! collapseButton ) == collapse ) { + if ( ! collapseButton == collapse ) { await collapseButton.click(); } - }, }; diff --git a/packages/js/e2e-utils/src/flows/shopper.js b/packages/js/e2e-utils/src/flows/shopper.js index 3f9e2cb43ff..8daefa8f21e 100644 --- a/packages/js/e2e-utils/src/flows/shopper.js +++ b/packages/js/e2e-utils/src/flows/shopper.js @@ -10,7 +10,7 @@ const config = require( 'config' ); const { getQtyInputExpression, getCartItemExpression, - getRemoveExpression + getRemoveExpression, } = require( './expressions' ); const { MY_ACCOUNT_ADDRESSES, @@ -21,7 +21,7 @@ const { SHOP_CART_PAGE, SHOP_CHECKOUT_PAGE, SHOP_PAGE, - SHOP_PRODUCT_PAGE + SHOP_PRODUCT_PAGE, } = require( './constants' ); const { uiUnblocked, clickAndWaitForSelector } = require( '../page-utils' ); @@ -46,16 +46,18 @@ const shopper = { await page.click( addToCart ); await expect( page ).toMatchElement( addToCart + '.added' ); } else { - const addToCartXPath = `//li[contains(@class, "type-product") and a/h2[contains(text(), "${ productIdOrTitle }")]]` + + const addToCartXPath = + `//li[contains(@class, "type-product") and a/h2[contains(text(), "${ productIdOrTitle }")]]` + '//a[contains(@class, "add_to_cart_button") and contains(@class, "ajax_add_to_cart")'; const [ addToCartButton ] = await page.$x( addToCartXPath + ']' ); await addToCartButton.click(); // @todo: Update to waitForXPath when available in Puppeteer api. - await page.waitFor( addToCartXPath + ' and contains(@class, "added")]' ); + await page.waitFor( + addToCartXPath + ' and contains(@class, "added")]' + ); } - }, goToCheckout: async () => { @@ -71,9 +73,9 @@ const shopper = { }, goToShop: async () => { - await page.goto(SHOP_PAGE, { + await page.goto( SHOP_PAGE, { waitUntil: 'networkidle0', - }); + } ); }, placeOrder: async () => { @@ -83,11 +85,24 @@ const shopper = { ] ); }, - productIsInCheckout: async ( productTitle, quantity, total, cartSubtotal ) => { - await expect( page ).toMatchElement( '.product-name', { text: productTitle } ); - await expect( page ).toMatchElement( '.product-quantity', { text: quantity } ); - await expect( page ).toMatchElement( '.product-total .amount', { text: total } ); - await expect( page ).toMatchElement( '.cart-subtotal .amount', { text: cartSubtotal } ); + productIsInCheckout: async ( + productTitle, + quantity, + total, + cartSubtotal + ) => { + await expect( page ).toMatchElement( '.product-name', { + text: productTitle, + } ); + await expect( page ).toMatchElement( '.product-quantity', { + text: quantity, + } ); + await expect( page ).toMatchElement( '.product-total .amount', { + text: total, + } ); + await expect( page ).toMatchElement( '.cart-subtotal .amount', { + text: cartSubtotal, + } ); }, goToCart: async () => { @@ -98,35 +113,98 @@ const shopper = { productIsInCart: async ( productTitle, quantity = null ) => { const cartItemArgs = quantity ? { qty: quantity } : {}; - const cartItemXPath = getCartItemExpression( productTitle, cartItemArgs ); + const cartItemXPath = getCartItemExpression( + productTitle, + cartItemArgs + ); await expect( page.$x( cartItemXPath ) ).resolves.toHaveLength( 1 ); }, - fillBillingDetails: async ( customerBillingDetails ) => { - await expect( page ).toFill( '#billing_first_name', customerBillingDetails.firstname ); - await expect( page ).toFill( '#billing_last_name', customerBillingDetails.lastname ); - await expect( page ).toFill( '#billing_company', customerBillingDetails.company ); - await expect( page ).toSelect( '#billing_country', customerBillingDetails.country ); - await expect( page ).toFill( '#billing_address_1', customerBillingDetails.addressfirstline ); - await expect( page ).toFill( '#billing_address_2', customerBillingDetails.addresssecondline ); - await expect( page ).toFill( '#billing_city', customerBillingDetails.city ); - await expect( page ).toSelect( '#billing_state', customerBillingDetails.state ); - await expect( page ).toFill( '#billing_postcode', customerBillingDetails.postcode ); - await expect( page ).toFill( '#billing_phone', customerBillingDetails.phone ); - await expect( page ).toFill( '#billing_email', customerBillingDetails.email ); + fillBillingDetails: async ( customerBillingDetails ) => { + await expect( page ).toFill( + '#billing_first_name', + customerBillingDetails.firstname + ); + await expect( page ).toFill( + '#billing_last_name', + customerBillingDetails.lastname + ); + await expect( page ).toFill( + '#billing_company', + customerBillingDetails.company + ); + await expect( page ).toSelect( + '#billing_country', + customerBillingDetails.country + ); + await expect( page ).toFill( + '#billing_address_1', + customerBillingDetails.addressfirstline + ); + await expect( page ).toFill( + '#billing_address_2', + customerBillingDetails.addresssecondline + ); + await expect( page ).toFill( + '#billing_city', + customerBillingDetails.city + ); + await expect( page ).toSelect( + '#billing_state', + customerBillingDetails.state + ); + await expect( page ).toFill( + '#billing_postcode', + customerBillingDetails.postcode + ); + await expect( page ).toFill( + '#billing_phone', + customerBillingDetails.phone + ); + await expect( page ).toFill( + '#billing_email', + customerBillingDetails.email + ); }, fillShippingDetails: async ( customerShippingDetails ) => { - await expect( page ).toFill( '#shipping_first_name', customerShippingDetails.firstname ); - await expect( page ).toFill( '#shipping_last_name', customerShippingDetails.lastname ); - await expect( page ).toFill( '#shipping_company', customerShippingDetails.company ); - await expect( page ).toSelect( '#shipping_country', customerShippingDetails.country ); - await expect( page ).toFill( '#shipping_address_1', customerShippingDetails.addressfirstline ); - await expect( page ).toFill( '#shipping_address_2', customerShippingDetails.addresssecondline ); - await expect( page ).toFill( '#shipping_city', customerShippingDetails.city ); - await expect( page ).toSelect( '#shipping_state', customerShippingDetails.state ); - await expect( page ).toFill( '#shipping_postcode', customerShippingDetails.postcode ); + await expect( page ).toFill( + '#shipping_first_name', + customerShippingDetails.firstname + ); + await expect( page ).toFill( + '#shipping_last_name', + customerShippingDetails.lastname + ); + await expect( page ).toFill( + '#shipping_company', + customerShippingDetails.company + ); + await expect( page ).toSelect( + '#shipping_country', + customerShippingDetails.country + ); + await expect( page ).toFill( + '#shipping_address_1', + customerShippingDetails.addressfirstline + ); + await expect( page ).toFill( + '#shipping_address_2', + customerShippingDetails.addresssecondline + ); + await expect( page ).toFill( + '#shipping_city', + customerShippingDetails.city + ); + await expect( page ).toSelect( + '#shipping_state', + customerShippingDetails.state + ); + await expect( page ).toFill( + '#shipping_postcode', + customerShippingDetails.postcode + ); }, removeFromCart: async ( productIdOrTitle ) => { @@ -134,7 +212,8 @@ const shopper = { await page.click( `a[data-product_id="${ productIdOrTitle }"]` ); } else { const cartItemXPath = getCartItemExpression( productIdOrTitle ); - const removeItemXPath = cartItemXPath + '//' + getRemoveExpression(); + const removeItemXPath = + cartItemXPath + '//' + getRemoveExpression(); const [ removeButton ] = await page.$x( removeItemXPath ); await removeButton.click(); @@ -147,30 +226,32 @@ const shopper = { } ); // Remove products if they exist - if ( await page.$( '.remove' ) !== null ) { - products = await page.$( '.remove' ); + if ( ( await page.$( '.remove' ) ) !== null ) { + let products = await page.$$( '.remove' ); while ( products && products.length > 0 ) { - for (let p = 0; p < products.length; p++ ) { - await page.click( p ); - await uiUnblocked(); - } - products = await page.$( '.remove' ); + await page.click( '.remove' ); + await uiUnblocked(); + products = await page.$$( '.remove' ); } } // Remove coupons if they exist - if ( await page.$( '.woocommerce-remove-coupon' ) !== null ) { + if ( ( await page.$( '.woocommerce-remove-coupon' ) ) !== null ) { await page.click( '.woocommerce-remove-coupon' ); await uiUnblocked(); } - await page.waitForSelector('.woocommerce-info'); - await expect( page ).toMatchElement( '.woocommerce-info', { text: 'Your cart is currently empty.' } ); + await page.waitForSelector( '.woocommerce-info' ); + // eslint-disable-next-line jest/no-standalone-expect + await expect( page ).toMatchElement( '.woocommerce-info', { + text: 'Your cart is currently empty.', + } ); }, setCartQuantity: async ( productTitle, quantityValue ) => { const cartItemXPath = getCartItemExpression( productTitle ); - const quantityInputXPath = cartItemXPath + '//' + getQtyInputExpression(); + const quantityInputXPath = + cartItemXPath + '//' + getQtyInputExpression(); const [ quantityInput ] = await page.$x( quantityInputXPath ); await quantityInput.focus(); @@ -180,17 +261,21 @@ const shopper = { searchForProduct: async ( prouductName ) => { const searchFieldSelector = 'input.wp-block-search__input'; - await page.waitForSelector(searchFieldSelector, { timeout: 100000 }); - await expect(page).toFill(searchFieldSelector, prouductName); - await expect(page).toClick('.wp-block-search__button'); + await page.waitForSelector( searchFieldSelector, { timeout: 100000 } ); + await expect( page ).toFill( searchFieldSelector, prouductName ); + await expect( page ).toClick( '.wp-block-search__button' ); // Single search results may go directly to product page - if ( await page.waitForSelector('h2.entry-title') ) { - await expect(page).toMatchElement('h2.entry-title', {text: prouductName}); - await expect(page).toClick('h2.entry-title', {text: prouductName}); + if ( await page.waitForSelector( 'h2.entry-title' ) ) { + await expect( page ).toMatchElement( 'h2.entry-title', { + text: prouductName, + } ); + await expect( page ).toClick( 'h2.entry-title', { + text: prouductName, + } ); } - await page.waitForSelector('h1.entry-title'); - await expect(page.title()).resolves.toMatch(prouductName); - await expect(page).toMatchElement('h1.entry-title', prouductName); + await page.waitForSelector( 'h1.entry-title' ); + await expect( page.title() ).resolves.toMatch( prouductName ); + await expect( page ).toMatchElement( 'h1.entry-title', prouductName ); }, /* @@ -220,15 +305,15 @@ const shopper = { } ); }, - gotoMyAccount: gotoMyAccount, + gotoMyAccount, login: async () => { await gotoMyAccount(); await expect( page.title() ).resolves.toMatch( 'My account' ); - await page.type( '#username', config.get('users.customer.username') ); - await page.type( '#password', config.get('users.customer.password') ); + await page.type( '#username', config.get( 'users.customer.username' ) ); + await page.type( '#password', config.get( 'users.customer.password' ) ); await Promise.all( [ page.waitForNavigation( { waitUntil: 'networkidle0' } ), @@ -239,7 +324,9 @@ const shopper = { await gotoMyAccount(); await expect( page.title() ).resolves.toMatch( 'My account' ); - await page.click( '.woocommerce-MyAccount-navigation-link--customer-logout a' ); + await page.click( + '.woocommerce-MyAccount-navigation-link--customer-logout a' + ); }, }; diff --git a/packages/js/e2e-utils/src/flows/utils.js b/packages/js/e2e-utils/src/flows/utils.js index 0670c0504bc..4dd531a57bf 100644 --- a/packages/js/e2e-utils/src/flows/utils.js +++ b/packages/js/e2e-utils/src/flows/utils.js @@ -1,28 +1,32 @@ /** * Take a string name and generate the slug for it. * Example: 'My plugin' => 'my-plugin' + * * @param text string to convert to a slug * * Sourced from: https://gist.github.com/spyesx/561b1d65d4afb595f295 - **/ - export const getSlug = ( text ) => { + */ +export const getSlug = ( text ) => { text = text.trim().toLowerCase(); // remove accents, swap ñ for n, etc const from = 'åàáãäâèéëêìíïîòóöôùúüûñç·/_,:;'; const to = 'aaaaaaeeeeiiiioooouuuunc------'; - for (let i = 0, l = from.length; i < l; i++) { - text = text.replace(new RegExp(from.charAt(i), "g"), to.charAt(i)); + for ( let i = 0, l = from.length; i < l; i++ ) { + text = text.replace( + new RegExp( from.charAt( i ), 'g' ), + to.charAt( i ) + ); } return text - .replace(/[^a-z0-9 -]/g, '') // remove invalid chars - .replace(/\s+/g, '-') // collapse whitespace and replace by - - .replace(/-+/g, '-') // collapse dashes - .replace(/^-+/, '') // trim - from start of text - .replace(/-+$/, '') // trim - from end of text - .replace(/-/g, '-'); + .replace( /[^a-z0-9 -]/g, '' ) // remove invalid chars + .replace( /\s+/g, '-' ) // collapse whitespace and replace by - + .replace( /-+/g, '-' ) // collapse dashes + .replace( /^-+/, '' ) // trim - from start of text + .replace( /-+$/, '' ) // trim - from end of text + .replace( /-/g, '-' ); }; // Conditionally determine whether or not to skip a test suite @@ -30,13 +34,13 @@ export const describeIf = ( condition ) => condition ? describe : describe.skip; // Conditionally determine whether or not to skip a test case -export const itIf = ( condition ) => - condition ? it : it.skip; +export const itIf = ( condition ) => ( condition ? it : it.skip ); /** * Wait for a timeout in milliseconds + * * @param timeout delay time in milliseconds - * @returns {Promise<void>} + * @return {Promise<void>} */ export const waitForTimeout = async ( timeout ) => { await new Promise( ( resolve ) => setTimeout( resolve, timeout ) ); diff --git a/packages/js/e2e-utils/src/flows/with-rest-api.js b/packages/js/e2e-utils/src/flows/with-rest-api.js index 6e52d58d07f..455c1299616 100644 --- a/packages/js/e2e-utils/src/flows/with-rest-api.js +++ b/packages/js/e2e-utils/src/flows/with-rest-api.js @@ -1,6 +1,6 @@ import factories from '../factories'; import { getSlug } from './utils'; -import {Coupon, Setting, SimpleProduct, Order} from '@woocommerce/api'; +import { Coupon, Setting, SimpleProduct, Order } from '@woocommerce/api'; const client = factories.api.withDefaultPermalinks; const onboardingProfileEndpoint = '/wc-admin/onboarding/profile'; @@ -19,25 +19,29 @@ const userEndpoint = '/wp/v2/users'; * @param repository * @param defaultObjectId * @param statuses Status of the object to check - * @returns {Promise<void>} + * @return {Promise<void>} */ -const deleteAllRepositoryObjects = async ( repository, defaultObjectId = null, statuses = [ 'draft', 'publish', 'trash' ] ) => { +const deleteAllRepositoryObjects = async ( + repository, + defaultObjectId = null, + statuses = [ 'draft', 'publish', 'trash' ] +) => { let objects; const minimum = defaultObjectId == null ? 0 : 1; for ( let s = 0; s < statuses.length; s++ ) { const status = statuses[ s ]; objects = await repository.list( { status } ); - while (objects.length > minimum) { - for (let o = 0; o < objects.length; o++) { + while ( objects.length > minimum ) { + for ( let o = 0; o < objects.length; o++ ) { // Skip default data store object - if (objects[o].id == defaultObjectId) { + if ( objects[ o ].id == defaultObjectId ) { continue; } // We may be getting a cached copy of the dataset and the object has already been deleted. try { - await repository.delete(objects[o].id); - } catch (e) {} + await repository.delete( objects[ o ].id ); + } catch ( e ) {} } objects = await repository.list( { status } ); } @@ -47,7 +51,7 @@ const deleteAllRepositoryObjects = async ( repository, defaultObjectId = null, s /** * Utility to flatten a tax rate. * - * @param {object} taxRate Tax rate to be flattened. + * @param {Object} taxRate Tax rate to be flattened. * @return {string} */ const flattenTaxRate = ( taxRate ) => { @@ -60,7 +64,8 @@ const flattenTaxRate = ( taxRate ) => { export const withRestApi = { /** * Reset onboarding to equivalent of new site. - * @returns {Promise<void>} + * + * @return {Promise<void>} */ resetOnboarding: async () => { const onboardingReset = { @@ -77,7 +82,10 @@ export const withRestApi = { wccom_connected: false, }; - const response = await client.put( onboardingProfileEndpoint, onboardingReset ); + const response = await client.put( + onboardingProfileEndpoint, + onboardingReset + ); expect( response.statusCode ).toEqual( 200 ); }, /** @@ -108,6 +116,16 @@ export const withRestApi = { const repository = SimpleProduct.restRepository( client ); await deleteAllRepositoryObjects( repository ); }, + /** + * Use api package to delete a product. + * + * @param {number} productId Product ID. + * @return {Promise} Promise resolving once the product has been deleted. + */ + deleteProduct: async ( productId ) => { + const repository = SimpleProduct.restRepository( client ); + await repository.delete( productId ); + }, /** * Use the API to delete all product attributes. * @@ -119,7 +137,10 @@ export const withRestApi = { const productAttributes = await client.get( productAttributesPath ); if ( productAttributes.data && productAttributes.data.length ) { for ( let a = 0; a < productAttributes.data.length; a++ ) { - const response = await client.delete( productAttributesPath + `/${productAttributes.data[a].id}?force=true` ); + const response = await client.delete( + productAttributesPath + + `/${ productAttributes.data[ a ].id }?force=true` + ); if ( testResponse ) { expect( response.status ).toBe( 200 ); } @@ -138,10 +159,13 @@ export const withRestApi = { if ( productCategories.data && productCategories.data.length ) { for ( let c = 0; c < productCategories.data.length; c++ ) { // The default `uncategorized` category can't be deleted - if ( productCategories.data[c].slug == 'uncategorized' ) { + if ( productCategories.data[ c ].slug == 'uncategorized' ) { continue; } - const response = await client.delete( productCategoriesPath + `/${productCategories.data[c].id}?force=true` ); + const response = await client.delete( + productCategoriesPath + + `/${ productCategories.data[ c ].id }?force=true` + ); if ( testResponse ) { expect( response.status ).toBe( 200 ); } @@ -159,7 +183,10 @@ export const withRestApi = { const productTags = await client.get( productTagsPath ); if ( productTags.data && productTags.data.length ) { for ( let t = 0; t < productTags.data.length; t++ ) { - const response = await client.delete( productTagsPath + `/${productTags.data[t].id}?force=true` ); + const response = await client.delete( + productTagsPath + + `/${ productTags.data[ t ].id }?force=true` + ); if ( testResponse ) { expect( response.status ).toBe( 200 ); } @@ -173,10 +200,29 @@ export const withRestApi = { */ deleteAllOrders: async () => { // We need to specfically filter on order status here to make sure we catch all orders to delete. - const orderStatuses = ['pending', 'processing', 'on-hold', 'completed', 'cancelled', 'refunded', 'failed', 'trash']; + const orderStatuses = [ + 'pending', + 'processing', + 'on-hold', + 'completed', + 'cancelled', + 'refunded', + 'failed', + 'trash', + ]; const repository = Order.restRepository( client ); await deleteAllRepositoryObjects( repository, null, orderStatuses ); }, + /** + * Use api package to delete an order. + * + * @param {number} orderId Order ID. + * @return {Promise} Promise resolving once the order has been deleted. + */ + deleteOrder: async ( orderId ) => { + const repository = Order.restRepository( client ); + await repository.delete( orderId ); + }, /** * Adds a shipping zone along with a shipping method using the API. * @@ -195,73 +241,85 @@ export const withRestApi = { zoneMethod = 'flat_rate', cost = '', additionalZoneMethods = [], - testResponse = true ) => { - + testResponse = true + ) => { const path = 'wc/v3/shipping/zones'; const response = await client.post( path, { name: zoneName } ); if ( testResponse ) { expect( response.status ).toEqual( 201 ); } - let zoneId = response.data.id; + const zoneId = response.data.id; // Select shipping zone location - let [ zoneType, zoneCode ] = zoneLocation.split(/:(.+)/); - let zoneLocationPayload = [ - { - code: zoneCode, - type: zoneType, - } + const [ zoneType, zoneCode ] = zoneLocation.split( /:(.+)/ ); + const zoneLocationPayload = [ + { + code: zoneCode, + type: zoneType, + }, ]; // Fill shipping zone postcode if provided if ( zipCode ) { - zoneLocationPayload.push( { - code: zipCode, - type: "postcode", - } ); + zoneLocationPayload.push( { + code: zipCode, + type: 'postcode', + } ); } - const locationResponse = await client.put( path + `/${zoneId}/locations`, zoneLocationPayload ); + const locationResponse = await client.put( + path + `/${ zoneId }/locations`, + zoneLocationPayload + ); if ( testResponse ) { expect( locationResponse.status ).toEqual( 200 ); } - // Add shipping zone method - let methodPayload = { - method_id: zoneMethod - } + // Add shipping zone method + const methodPayload = { + method_id: zoneMethod, + }; - const methodsResponse = await client.post( path + `/${zoneId}/methods`, methodPayload ); + const methodsResponse = await client.post( + path + `/${ zoneId }/methods`, + methodPayload + ); if ( testResponse ) { expect( methodsResponse.status ).toEqual( 200 ); } - let methodId = methodsResponse.data.id; + const methodId = methodsResponse.data.id; - // Add in cost, if provided - if ( cost ) { - let costPayload = { - settings: { - cost: cost - } - } + // Add in cost, if provided + if ( cost ) { + const costPayload = { + settings: { + cost, + }, + }; - const costResponse = await client.put( path + `/${zoneId}/methods/${methodId}`, costPayload ); - if ( testResponse ) { - expect( costResponse.status ).toEqual( 200 ); - } - } + const costResponse = await client.put( + path + `/${ zoneId }/methods/${ methodId }`, + costPayload + ); + if ( testResponse ) { + expect( costResponse.status ).toEqual( 200 ); + } + } - // Add any additional zones, if provided - if (additionalZoneMethods.length > 0) { - for ( let z = 0; z < additionalZoneMethods.length; z++ ) { - let response = await client.post( path + `/${zoneId}/methods`, { method_id: additionalZoneMethods[z] } ); - if ( testResponse ) { - expect( response.status ).toBe( 200 ); - } - } - } - }, + // Add any additional zones, if provided + if ( additionalZoneMethods.length > 0 ) { + for ( let z = 0; z < additionalZoneMethods.length; z++ ) { + const response = await client.post( + path + `/${ zoneId }/methods`, + { method_id: additionalZoneMethods[ z ] } + ); + if ( testResponse ) { + expect( response.status ).toBe( 200 ); + } + } + } + }, /** * Use api package to delete shipping zones. * @@ -273,10 +331,13 @@ export const withRestApi = { if ( shippingZones.data && shippingZones.data.length ) { for ( let z = 0; z < shippingZones.data.length; z++ ) { // The data store doesn't support deleting the default zone. - if ( shippingZones.data[z].id == 0 ) { + if ( shippingZones.data[ z ].id == 0 ) { continue; } - const response = await client.delete( shippingZoneEndpoint + `/${shippingZones.data[z].id}?force=true` ); + const response = await client.delete( + shippingZoneEndpoint + + `/${ shippingZones.data[ z ].id }?force=true` + ); if ( testResponse ) { expect( response.status ).toBe( 200 ); } @@ -293,7 +354,10 @@ export const withRestApi = { const shippingClasses = await client.get( shippingClassesEndpoint ); if ( shippingClasses.data && shippingClasses.data.length ) { for ( let c = 0; c < shippingClasses.data.length; c++ ) { - const response = await client.delete( shippingClassesEndpoint + `/${shippingClasses.data[c].id}?force=true` ); + const response = await client.delete( + shippingClassesEndpoint + + `/${ shippingClasses.data[ c ].id }?force=true` + ); if ( testResponse ) { expect( response.status ).toBe( 200 ); } @@ -304,7 +368,7 @@ export const withRestApi = { * Delete a customer account by their email address if the user exists. * * @param emailAddress Customer user account email address. - * @returns {Promise<void>} + * @return {Promise<void>} */ deleteCustomerByEmail: async ( emailAddress ) => { const query = { @@ -316,41 +380,55 @@ export const withRestApi = { if ( customers.data && customers.data.length ) { for ( let c = 0; c < customers.data.length; c++ ) { const deleteUser = { - id: customers.data[c].id, + id: customers.data[ c ].id, force: true, reassign: 1, - } - await client.delete( userEndpoint + `/${ deleteUser.id }`, deleteUser ); + }; + await client.delete( + userEndpoint + `/${ deleteUser.id }`, + deleteUser + ); } } }, /** * Reset a settings group to default values except selects. + * * @param settingsGroup * @param {boolean} testResponse Test the response status code. - * @returns {Promise<void>} + * @return {Promise<void>} */ - resetSettingsGroupToDefault: async ( settingsGroup, testResponse = true ) => { + resetSettingsGroupToDefault: async ( + settingsGroup, + testResponse = true + ) => { const settingsClient = Setting.restRepository( client ); const settings = await settingsClient.list( settingsGroup ); - if ( ! settings.length ) { + if ( ! settings.length ) { return; } for ( let s = 0; s < settings.length; s++ ) { // The rest api doesn't allow selects to be set to ''. - if ( settings[s].type == 'select' && settings[s].default == '' ) { + if ( + settings[ s ].type == 'select' && + settings[ s ].default == '' + ) { continue; } const defaultSetting = { group_id: settingsGroup, - id: settings[s].id, - value: settings[s].default, + id: settings[ s ].id, + value: settings[ s ].default, }; - const response = await settingsClient.update( settingsGroup, defaultSetting.id, defaultSetting ); + const response = await settingsClient.update( + settingsGroup, + defaultSetting.id, + defaultSetting + ); // Multi-selects have a default '' but return an empty []. - if ( testResponse && settings[s].type != 'multiselect' ) { + if ( testResponse && settings[ s ].type != 'multiselect' ) { expect( response.value ).toBe( defaultSetting.value ); } } @@ -360,7 +438,7 @@ export const withRestApi = { * * @param {string} settingsGroup The settings group to update. * @param {string} settingId The setting ID to update - * @param {object} payload An object with a key/value pair to update. + * @param {Object} payload An object with a key/value pair to update. */ updateSettingOption: async ( settingsGroup, settingId, payload = {} ) => { const settingsClient = Setting.restRepository( client ); @@ -370,11 +448,18 @@ export const withRestApi = { * Update a payment gateway. * * @param {string} paymentGatewayId The ID of the payment gateway to update. - * @param {object} payload An object with the key/value pair to update. + * @param {Object} payload An object with the key/value pair to update. * @param {boolean} testResponse Test the response status code. */ - updatePaymentGateway: async ( paymentGatewayId, payload = {}, testResponse = true ) => { - const response = await client.put( `/wc/v3/payment_gateways/${paymentGatewayId}`, payload ); + updatePaymentGateway: async ( + paymentGatewayId, + payload = {}, + testResponse = true + ) => { + const response = await client.put( + `/wc/v3/payment_gateways/${ paymentGatewayId }`, + payload + ); if ( testResponse ) { expect( response.status ).toEqual( 200 ); } @@ -389,7 +474,7 @@ export const withRestApi = { const path = '/wc/v3/orders/batch'; const payload = { create: orders }; - const response = await client.post(path, payload); + const response = await client.post( path, payload ); if ( testResponse ) { expect( response.status ).toBe( 200 ); } @@ -398,13 +483,17 @@ export const withRestApi = { * Add tax classes. * * @param {<Array<Object>>} taxClasses Array of tax class objects. - * @returns {Promise<void>} + * @return {Promise<void>} */ addTaxClasses: async ( taxClasses ) => { // Only add tax classes which don't already exist. const existingTaxClasses = await client.get( taxClassesEndpoint ); - const existingTaxNames = existingTaxClasses.data.map( taxClass => taxClass.name ); - const newTaxClasses = taxClasses.filter( taxClass => ! existingTaxNames.includes( taxClass.name ) ); + const existingTaxNames = existingTaxClasses.data.map( + ( taxClass ) => taxClass.name + ); + const newTaxClasses = taxClasses.filter( + ( taxClass ) => ! existingTaxNames.includes( taxClass.name ) + ); for ( const taxClass of newTaxClasses ) { await client.post( taxClassesEndpoint, taxClass ); @@ -414,12 +503,14 @@ export const withRestApi = { * Add tax rates. * * @param {<Array<Object>>} taxRates Array of tax rate objects. - * @returns {Promise<void>} + * @return {Promise<void>} */ addTaxRates: async ( taxRates ) => { // Only add rates which don't already exist const existingTaxRates = await client.get( taxRatesEndpoint ); - const existingRates = existingTaxRates.data.map( taxRate => flattenTaxRate( taxRate ) ); + const existingRates = existingTaxRates.data.map( ( taxRate ) => + flattenTaxRate( taxRate ) + ); for ( const taxRate of taxRates ) { if ( ! existingRates.includes( flattenTaxRate( taxRate ) ) ) { @@ -432,14 +523,12 @@ export const withRestApi = { * * For more details, see: https://woocommerce.github.io/woocommerce-rest-api-docs/#system-status-environment-properties * - * @returns {Promise<object>} The environment object from the API response. + * @return {Promise<object>} The environment object from the API response. */ getSystemEnvironment: async () => { const response = await client.get( systemStatusEndpoint ); if ( response.data.environment ) { return response.data.environment; - } else { - return; } }, /** diff --git a/packages/js/e2e-utils/src/old-flows.js b/packages/js/e2e-utils/src/old-flows.js index bb182bcc796..4afde090920 100644 --- a/packages/js/e2e-utils/src/old-flows.js +++ b/packages/js/e2e-utils/src/old-flows.js @@ -6,22 +6,18 @@ import deprecated from '@wordpress/deprecated'; /** * Internal dependencies */ -const { - merchant, - shopper -} = require( './flows' ); - +const { merchant, shopper } = require( './flows' ); const CustomerFlowDeprecated = () => { deprecated( 'CustomerFlow', { alternative: 'shopper', - }); + } ); }; const StoreOwnerFlowDeprecated = () => { deprecated( 'StoreOwnerFlow', { alternative: 'merchant', - }); + } ); }; const CustomerFlow = { @@ -55,9 +51,19 @@ const CustomerFlow = { await shopper.placeOrder(); }, - productIsInCheckout: async ( productTitle, quantity, total, cartSubtotal ) => { + productIsInCheckout: async ( + productTitle, + quantity, + total, + cartSubtotal + ) => { CustomerFlowDeprecated(); - await shopper.productIsInCheckout( productTitle, quantity, total, cartSubtotal ); + await shopper.productIsInCheckout( + productTitle, + quantity, + total, + cartSubtotal + ); }, goToCart: async () => { @@ -70,7 +76,7 @@ const CustomerFlow = { await shopper.productIsInCart( productTitle, quantity ); }, - fillBillingDetails: async ( customerBillingDetails ) => { + fillBillingDetails: async ( customerBillingDetails ) => { CustomerFlowDeprecated(); await shopper.fillBillingDetails( customerBillingDetails ); }, @@ -173,7 +179,4 @@ const StoreOwnerFlow = { }, }; -export { - CustomerFlow, - StoreOwnerFlow, -}; +export { CustomerFlow, StoreOwnerFlow }; diff --git a/packages/js/e2e-utils/src/page-utils.js b/packages/js/e2e-utils/src/page-utils.js index a1b101778ef..e052817be99 100644 --- a/packages/js/e2e-utils/src/page-utils.js +++ b/packages/js/e2e-utils/src/page-utils.js @@ -53,15 +53,25 @@ export const permalinkSettingsPageSaveChanges = async () => { ] ); }; +/** + * Save changes on Order page. + */ +export const orderPageSaveChanges = async () => { + await expect( page ).toClick( 'button.save_order' ); + await page.waitForSelector( '#message' ); +}; + /** * Set checkbox. * * @param {string} selector */ -export const setCheckbox = async( selector ) => { +export const setCheckbox = async ( selector ) => { await page.focus( selector ); const checkbox = await page.$( selector ); - const checkboxStatus = ( await ( await checkbox.getProperty( 'checked' ) ).jsonValue() ); + const checkboxStatus = await ( + await checkbox.getProperty( 'checked' ) + ).jsonValue(); if ( checkboxStatus !== true ) { await checkbox.click(); } @@ -72,10 +82,12 @@ export const setCheckbox = async( selector ) => { * * @param {string} selector */ -export const unsetCheckbox = async( selector ) => { +export const unsetCheckbox = async ( selector ) => { await page.focus( selector ); const checkbox = await page.$( selector ); - const checkboxStatus = ( await ( await checkbox.getProperty( 'checked' ) ).jsonValue() ); + const checkboxStatus = await ( + await checkbox.getProperty( 'checked' ) + ).jsonValue(); if ( checkboxStatus === true ) { await checkbox.click(); } @@ -85,14 +97,18 @@ export const unsetCheckbox = async( selector ) => { * Wait for UI blocking to end. */ export const uiUnblocked = async () => { - await page.waitForFunction( () => ! Boolean( document.querySelector( '.blockUI' ) ) ); + await page.waitForFunction( + () => ! Boolean( document.querySelector( '.blockUI' ) ) + ); }; /** * Wait for backbone blocking to end. */ export const backboneUnblocked = async () => { - await page.waitForFunction( () => ! Boolean( document.querySelector( '.wc-backbone-modal' ) ) ); + await page.waitForFunction( + () => ! Boolean( document.querySelector( '.wc-backbone-modal' ) ) + ); }; /** @@ -100,9 +116,12 @@ export const backboneUnblocked = async () => { * * @param selector * @param timeoutInSeconds - * @returns {Promise<boolean>} + * @return {Promise<boolean>} */ -export const waitForSelectorWithoutThrow = async ( selector, timeoutInSeconds = 5 ) => { +export const waitForSelectorWithoutThrow = async ( + selector, + timeoutInSeconds = 5 +) => { let selected = await page.$( selector ); for ( let s = 0; s < timeoutInSeconds; s++ ) { if ( selected ) { @@ -122,7 +141,12 @@ export const waitForSelectorWithoutThrow = async ( selector, timeoutInSeconds = * @param {string} publishVerification * @param {string} trashVerification */ -export const verifyPublishAndTrash = async ( button, publishNotice, publishVerification, trashVerification ) => { +export const verifyPublishAndTrash = async ( + button, + publishNotice, + publishVerification, + trashVerification +) => { const adminEdit = new AdminEdit(); await adminEdit.verifyPublish( button, publishNotice, publishVerification ); @@ -132,7 +156,9 @@ export const verifyPublishAndTrash = async ( button, publishNotice, publishVerif await page.waitForSelector( '#message' ); // Verify - await expect( page ).toMatchElement( publishNotice, { text: trashVerification } ); + await expect( page ).toMatchElement( publishNotice, { + text: trashVerification, + } ); }; /** @@ -140,10 +166,12 @@ export const verifyPublishAndTrash = async ( button, publishNotice, publishVerif * * @param {string} selector Selector of the checkbox that needs to be verified. */ -export const verifyCheckboxIsSet = async( selector ) => { +export const verifyCheckboxIsSet = async ( selector ) => { await page.focus( selector ); const checkbox = await page.$( selector ); - const checkboxStatus = ( await ( await checkbox.getProperty( 'checked' ) ).jsonValue() ); + const checkboxStatus = await ( + await checkbox.getProperty( 'checked' ) + ).jsonValue(); await expect( checkboxStatus ).toBe( true ); }; @@ -152,10 +180,12 @@ export const verifyCheckboxIsSet = async( selector ) => { * * @param {string} selector Selector of the checkbox that needs to be verified. */ -export const verifyCheckboxIsUnset = async( selector ) => { +export const verifyCheckboxIsUnset = async ( selector ) => { await page.focus( selector ); const checkbox = await page.$( selector ); - const checkboxStatus = ( await ( await checkbox.getProperty( 'checked' ) ).jsonValue() ); + const checkboxStatus = await ( + await checkbox.getProperty( 'checked' ) + ).jsonValue(); await expect( checkboxStatus ).not.toBe( true ); }; @@ -165,10 +195,10 @@ export const verifyCheckboxIsUnset = async( selector ) => { * @param {string} selector Selector of the input field that needs to be verified. * @param {string} value Value of the input field that needs to be verified. */ -export const verifyValueOfInputField = async( selector, value ) => { +export const verifyValueOfInputField = async ( selector, value ) => { await page.focus( selector ); const field = await page.$( selector ); - const fieldValue = ( await ( await field.getProperty( 'value' ) ).jsonValue() ); + const fieldValue = await ( await field.getProperty( 'value' ) ).jsonValue(); await expect( fieldValue ).toBe( value ); }; @@ -181,7 +211,7 @@ export const clickFilter = async ( selector ) => { await page.waitForSelector( selector ); await page.focus( selector ); await Promise.all( [ - page.click( `.subsubsub > ${selector} > a` ), + page.click( `.subsubsub > ${ selector } > a` ), page.waitForNavigation( { waitUntil: 'networkidle0' } ), ] ); }; @@ -193,7 +223,10 @@ export const clickFilter = async ( selector ) => { */ export const moveAllItemsToTrash = async () => { await setCheckbox( '#cb-select-all-1' ); - await expect( page ).toSelect( '#bulk-action-selector-top', 'Move to Trash' ); + await expect( page ).toSelect( + '#bulk-action-selector-top', + 'Move to Trash' + ); await Promise.all( [ page.click( '#doaction' ), page.waitForNavigation( { waitUntil: 'networkidle0' } ), @@ -210,7 +243,7 @@ export const moveAllItemsToTrash = async () => { export const evalAndClick = async ( selector ) => { // We use this when `expect(page).toClick()` is unable to find the element // See: https://github.com/puppeteer/puppeteer/issues/1769#issuecomment-637645219 - page.$eval( selector, elem => elem.click() ); + page.$eval( selector, ( elem ) => elem.click() ); }; /** @@ -219,7 +252,10 @@ export const evalAndClick = async ( selector ) => { * @param {string} value Value of what to be selected * @param {string} selector Selector of the select2 search field */ -export const selectOptionInSelect2 = async ( value, selector = 'input.select2-search__field' ) => { +export const selectOptionInSelect2 = async ( + value, + selector = 'input.select2-search__field' +) => { await page.waitForSelector( selector ); await page.click( selector ); await page.type( selector, value ); @@ -234,12 +270,14 @@ export const selectOptionInSelect2 = async ( value, selector = 'input.select2-se * @param {string} orderId Order ID * @param {string} customerName Customer's full name attached to order ID. */ -export const searchForOrder = async ( value, orderId, customerName) => { +export const searchForOrder = async ( value, orderId, customerName ) => { await clearAndFillInput( '#post-search-input', value ); await expect( page ).toMatchElement( '#post-search-input', value ); await expect( page ).toClick( '#search-submit' ); await page.waitForSelector( '#the-list', { timeout: 10000 } ); - await expect( page ).toMatchElement( '.order_number > a.order-view', { text: `#${orderId} ${customerName}` } ); + await expect( page ).toMatchElement( '.order_number > a.order-view', { + text: `#${ orderId } ${ customerName }`, + } ); }; /** @@ -247,18 +285,20 @@ export const searchForOrder = async ( value, orderId, customerName) => { * Method will try to apply a coupon in the checkout, otherwise will try to apply in the cart. * * @param couponCode string - * @returns {Promise<void>} + * @return {Promise<void>} */ export const applyCoupon = async ( couponCode ) => { try { - await Promise.all([ + await Promise.all( [ page.reload(), page.waitForNavigation( { waitUntil: 'networkidle0' } ), - ]); - await expect( page ).toClick( 'a', { text: 'Click here to enter your code' } ); + ] ); + await expect( page ).toClick( 'a', { + text: 'Click here to enter your code', + } ); await uiUnblocked(); await clearAndFillInput( '#coupon_code', couponCode ); - await expect( page ).toClick( 'button', {text: 'Apply coupon' } ); + await expect( page ).toClick( 'button', { text: 'Apply coupon' } ); await uiUnblocked(); } catch ( error ) { await clearAndFillInput( '#coupon_code', couponCode ); @@ -271,16 +311,21 @@ export const applyCoupon = async ( couponCode ) => { * Remove one coupon within cart or checkout. * * @param couponCode Coupon name. - * @returns {Promise<void>} + * @return {Promise<void>} */ export const removeCoupon = async ( couponCode ) => { - await Promise.all([ + await Promise.all( [ page.reload(), page.waitForNavigation( { waitUntil: 'networkidle0' } ), - ]); - await expect( page ).toClick( '[data-coupon="'+couponCode.toLowerCase()+'"]', {text: '[Remove]' } ); + ] ); + await expect( page ).toClick( + '[data-coupon="' + couponCode.toLowerCase() + '"]', + { text: '[Remove]' } + ); await uiUnblocked(); - await expect( page ).toMatchElement( '.woocommerce-message', {text: 'Coupon has been removed.' } ); + await expect( page ).toMatchElement( '.woocommerce-message', { + text: 'Coupon has been removed.', + } ); }; /** @@ -295,8 +340,7 @@ export const selectOrderAction = async ( action ) => { page.click( '.wc-reload' ), page.waitForNavigation( { waitUntil: 'networkidle0' } ), ] ); -} - +}; /** * Evaluate and click a button selector then wait for a result selector. @@ -305,17 +349,17 @@ export const selectOrderAction = async ( action ) => { * @param {string} buttonSelector Selector of button to click * @param {string} resultSelector Selector to wait for after click * @param {number} timeout Timeout length in milliseconds. Default 5000. - * @returns {Promise<void>} + * @return {Promise<void>} */ -export const clickAndWaitForSelector = async ( buttonSelector, resultSelector, timeout = 5000 ) => { +export const clickAndWaitForSelector = async ( + buttonSelector, + resultSelector, + timeout = 5000 +) => { await evalAndClick( buttonSelector ); - await waitForSelector( - page, - resultSelector, - { - timeout - } - ); + await waitForSelector( page, resultSelector, { + timeout, + } ); }; /** * Waits for selector to be present in DOM. @@ -335,3 +379,35 @@ export async function waitForSelector( page, selector, options = {} ) { const element = await page.waitForSelector( selector, options ); return element; } + +/** + * Retrieves the desired HTML attribute from a selector. + * For example, the 'value' attribute of an input element. + * + * @param {string} selector Selector of the element you want to get the attribute from. + * @param {string} attribute The desired HTML attribute. + * @return {Promise<string>} + */ +export async function getSelectorAttribute( selector, attribute ) { + return await page.$eval( + selector, + ( element, attribute ) => element.getAttribute( attribute ), + attribute + ); +} + +/** + * Asserts the value of the desired HTML attribute of a selector. + * + * @param {string} selector Selector of the element you want to verify. + * @param {string} attribute The desired HTML attribute. + * @param {string} expectedValue The expected value. + */ +export async function verifyValueOfElementAttribute( + selector, + attribute, + expectedValue +) { + const actualValue = await getSelectorAttribute( selector, attribute ); + expect( actualValue ).toBe( expectedValue ); +} diff --git a/packages/js/e2e-utils/src/pages/admin-edit.js b/packages/js/e2e-utils/src/pages/admin-edit.js index 226bd37271c..19a82fb0566 100644 --- a/packages/js/e2e-utils/src/pages/admin-edit.js +++ b/packages/js/e2e-utils/src/pages/admin-edit.js @@ -1,6 +1,5 @@ import { waitForTimeout } from '../flows/utils'; - export class AdminEdit { /** * Publish the object being edited and verify published status @@ -8,7 +7,7 @@ export class AdminEdit { * @param button Publish button selector * @param publishNotice Publish notice selector * @param publishVerification Expected notice on successful publish - * @returns {Promise<void>} + * @return {Promise<void>} */ async verifyPublish( button, publishNotice, publishVerification ) { // Wait for auto save @@ -17,17 +16,22 @@ export class AdminEdit { // Publish and verify await expect( page ).toClick( button ); await page.waitForSelector( publishNotice ); - await expect( page ).toMatchElement( publishNotice, { text: publishVerification } ); + await expect( page ).toMatchElement( publishNotice, { + text: publishVerification, + } ); } /** * Get the ID of the object being edited * - * @returns {Promise<*>} + * @return {Promise<*>} */ async getId() { - let postId = await page.$( '#post_ID' ); - let objectID = await page.evaluate( element => element.value, postId ); + const postId = await page.$( '#post_ID' ); + const objectID = await page.evaluate( + ( element ) => element.value, + postId + ); return objectID; } } diff --git a/packages/js/e2e-utils/src/system-environment.js b/packages/js/e2e-utils/src/system-environment.js index 37a6c85937b..3412474b4cb 100644 --- a/packages/js/e2e-utils/src/system-environment.js +++ b/packages/js/e2e-utils/src/system-environment.js @@ -9,8 +9,8 @@ export const getEnvironmentContext = async () => { return { wpVersion: environment.wp_version, wcVersion: environment.version, - } + }; } catch ( error ) { // Prevent an error here causing tests to fail. } -} +}; diff --git a/packages/js/eslint-plugin/.eslintrc.js b/packages/js/eslint-plugin/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/eslint-plugin/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/eslint-plugin/.npmrc b/packages/js/eslint-plugin/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/eslint-plugin/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/eslint-plugin/CHANGELOG.md b/packages/js/eslint-plugin/CHANGELOG.md new file mode 100644 index 00000000000..c76f06133a2 --- /dev/null +++ b/packages/js/eslint-plugin/CHANGELOG.md @@ -0,0 +1,29 @@ +# Unreleased + +# 2.0.0 + +- Update all js packages with minor/patch version changes. #8392 + +## Breaking changes + +- Update ESLint from v7 to ^8. #8475 +- Update `eslint-plugin-testing-library` from v3 to v5. #8475 + - `no-unnecessary-act` is now enabled by default. + - `no-wait-for-multiple-assertions` is now enabled by default. +- Update `@wordpress/eslint-plugin` from v8 to v11. #8475 +- Update `@typescript-eslint/parser` from v4 to v5. #8475 +- Drop support for Node v10. Required node version is now ^12.22.0 || ^14.17.0 || >=16.0.0. #8475 +- Update recommended eslint rules for @woocommerce/* packages, please see `recommended.js` for details. + +# 1.2.0 + +- Updated `dependency-group` rule to have imports starting with `~/` labeled as local. #6517 + +# 1.1.0 + +- Updated `@wordpress/eslint-plugin` dependency to latest version. + - `jsdoc` linting is configured to allow typescript style jsdocs. + +# 1.0.0 + +- Released package diff --git a/packages/js/eslint-plugin/README.md b/packages/js/eslint-plugin/README.md new file mode 100644 index 00000000000..26f0f643ef4 --- /dev/null +++ b/packages/js/eslint-plugin/README.md @@ -0,0 +1,47 @@ +# ESLint Plugin + +This is an [ESLint](https://eslint.org/) plugin including configurations and custom rules for WooCommerce development. + +**Note:** This primarily extends the [`@wordpress/eslint-plugin/recommended`](https://github.com/WordPress/gutenberg/tree/master/packages/eslint-plugin) ruleset and does not change any of the rules exposed on that plugin. As a base, all WooCommerce projects are expected to follow WordPress JavaScript Code Styles. + +However, this ruleset does implement the following (which do not conflict with WordPress standards): + +- Using typescript eslint parser to allow for eslint Import ([see issue](https://github.com/gajus/eslint-plugin-jsdoc/issues/604#issuecomment-653962767)) +- prettier formatting (using `wp-prettier`) +- Dependency grouping (External and Internal) for dependencies in JavaScript files +- No yoda conditionals +- Radix argument required for `parseInt`. + +## Installation + +Install the module + +``` +pnpm install @woocommerce/eslint-plugin --save-dev +``` + +## Usage + +To opt-in to the default configuration, extend your own project's `.eslintrc.js` file: + +```js +module.exports = { + "extends": [ "plugin:@woocommerce/eslint-plugin/recommended" ] +} +``` + +Refer to the [ESLint documentation on Shareable Configs](http://eslint.org/docs/developer-guide/shareable-configs) for more information. + +The `recommended` preset will include rules governing an ES2015+ environment, and includes rules from the [`@wordpress/eslint-plugin/recommended`](https://github.com/WordPress/gutenberg/tree/master/packages/eslint-plugin) project. + +If you want to use prettier in your code editor, you'll need ot create a `.prettierrc.js` file at the root of your project with the following: + +```js +module.exports = require("@wordpress/prettier-config"); +``` + +### Rules + +| Rule | Description | Recommended | +| -------------------------------------------------------------------------- | ----------------------------------------- | ----------- | +| [dependency-group](/packages/eslint-plugin/docs/rules/dependency-group.md) | Enforce dependencies docblocks formatting | ✓ | diff --git a/packages/js/eslint-plugin/configs/custom.js b/packages/js/eslint-plugin/configs/custom.js new file mode 100644 index 00000000000..968f33b69de --- /dev/null +++ b/packages/js/eslint-plugin/configs/custom.js @@ -0,0 +1,24 @@ +module.exports = { + plugins: [ '@wordpress', '@woocommerce' ], + rules: { + '@woocommerce/dependency-group': 'error', + }, + settings: { + jsdoc: { + mode: 'typescript', + }, + }, + overrides: [ + { + files: [ + '**/@(test|__tests__)/**/*.js', + '**/?(*.)test.js', + '**/tests/**/*.js', + ], + extends: [ + 'plugin:@wordpress/eslint-plugin/test-unit', + require.resolve( './react-testing-library' ), + ], + }, + ], +}; diff --git a/packages/js/eslint-plugin/configs/index.js b/packages/js/eslint-plugin/configs/index.js new file mode 100644 index 00000000000..035c09a8fa7 --- /dev/null +++ b/packages/js/eslint-plugin/configs/index.js @@ -0,0 +1 @@ +module.exports = require( 'requireindex' )( __dirname ); diff --git a/packages/js/eslint-plugin/configs/react-testing-library.js b/packages/js/eslint-plugin/configs/react-testing-library.js new file mode 100644 index 00000000000..ea33b0aab16 --- /dev/null +++ b/packages/js/eslint-plugin/configs/react-testing-library.js @@ -0,0 +1,21 @@ +module.exports = { + extends: [ 'plugin:testing-library/react' ], + rules: { + /* + * TODO: remove these rules once we fix all lint errors. + * These rules enabled in eslint-plugin-testing-library lead to new reported * errors after updating to v5. + */ + 'testing-library/prefer-query-by-disappearance': 'off', + 'testing-library/render-result-naming-convention': 'off', + 'testing-library/prefer-screen-queries': 'off', + 'testing-library/prefer-presence-queries': 'off', + 'testing-library/no-container': 'off', + 'testing-library/no-node-access': 'off', + 'testing-library/prefer-find-by': 'off', + // allow the use of render in beforeEach + 'testing-library/no-render-in-setup': [ + 'error', + { allowTestingFrameworkSetupHook: 'beforeEach' }, + ], + }, +}; diff --git a/packages/js/eslint-plugin/configs/recommended.js b/packages/js/eslint-plugin/configs/recommended.js new file mode 100644 index 00000000000..acadbb1a92f --- /dev/null +++ b/packages/js/eslint-plugin/configs/recommended.js @@ -0,0 +1,81 @@ +module.exports = { + extends: [ + 'plugin:react-hooks/recommended', + require.resolve( './custom.js' ), + 'plugin:@wordpress/eslint-plugin/recommended', + ], + parser: '@typescript-eslint/parser', + globals: { + wcSettings: 'readonly', + 'jest/globals': true, + jest: true, + }, + plugins: [ '@wordpress' ], + rules: { + radix: 'error', + yoda: [ 'error', 'never' ], + // temporary conversion to warnings until the below are all handled. + '@wordpress/i18n-translator-comments': 'warn', + '@wordpress/valid-sprintf': 'warn', + '@wordpress/no-unsafe-wp-apis': 'warn', + '@wordpress/no-global-active-element': 'warn', + 'import/no-extraneous-dependencies': 'warn', + 'import/no-unresolved': 'warn', + 'jest/no-deprecated-functions': 'warn', + 'jest/valid-title': 'warn', + 'jsdoc/check-tag-names': [ + 'error', + { + definedTags: [ + 'jest-environment', + 'filter', + 'action', + 'slotFill', + 'scope', + ], + }, + ], + 'no-unused-vars': [ + 'error', + { + varsIgnorePattern: 'createElement', + }, + ], + 'react/react-in-jsx-scope': 'error', + }, + settings: { + 'import/resolver': 'typescript', + // List of modules that are externals in our webpack config. + 'import/core-modules': [ '@woocommerce/settings', 'lodash', 'react' ], + react: { + pragma: 'createElement', + }, + }, + overrides: [ + { + files: [ '*.ts', '*.tsx' ], + extends: [ 'plugin:@typescript-eslint/recommended' ], + rules: { + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-use-before-define': [ 'error' ], + '@typescript-eslint/no-shadow': [ 'error' ], + '@typescript-eslint/no-empty-function': 'off', + camelcase: 'off', + 'no-use-before-define': 'off', + 'jsdoc/require-param': 'off', + // Making use of typescript no-shadow instead, fixes issues with enum. + 'no-shadow': 'off', + }, + }, + { + files: [ + '**/stories/*.js', + '**/stories/*.jsx', + '**/docs/example.js', + ], + rules: { + 'react/react-in-jsx-scope': 'off', + }, + }, + ], +}; diff --git a/packages/js/eslint-plugin/docs/rules/dependency-group.md b/packages/js/eslint-plugin/docs/rules/dependency-group.md new file mode 100644 index 00000000000..2a3dcc4fa47 --- /dev/null +++ b/packages/js/eslint-plugin/docs/rules/dependency-group.md @@ -0,0 +1,32 @@ +# Enforce dependencies docblocks formatting (dependency-group) + +Ensures that all top-level package imports adhere to dependencies grouping conventions. + +Specifically, this ensures that: + +- An import is preceded by "External dependencies" or "Internal dependencies" as appropriate by the import source. + +## Rule details + +Examples of **incorrect** code for this rule: + +```js +import { get } from 'lodash'; +import { Component } from '@wordpress/element'; +import edit from './edit'; +``` + +Examples of **correct** code for this rule: + +```js +/* + * External dependencies + */ +import { get } from 'lodash'; +import { Component } from '@wordpress/element'; + +/* + * Internal dependencies + */ +import edit from './edit'; +``` diff --git a/packages/js/eslint-plugin/index.js b/packages/js/eslint-plugin/index.js new file mode 100644 index 00000000000..fcba80b4820 --- /dev/null +++ b/packages/js/eslint-plugin/index.js @@ -0,0 +1,4 @@ +module.exports = { + configs: require( './configs' ), + rules: require( './rules' ), +}; diff --git a/packages/js/eslint-plugin/package.json b/packages/js/eslint-plugin/package.json new file mode 100644 index 00000000000..df23e6f4649 --- /dev/null +++ b/packages/js/eslint-plugin/package.json @@ -0,0 +1,50 @@ +{ + "name": "@woocommerce/eslint-plugin", + "version": "2.0.0", + "description": "ESLint plugin for WooCommerce development.", + "author": "Automattic", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "eslint", + "plugin" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/eslint-plugin/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git", + "directory": "packages/eslint-plugin" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "files": [ + "configs", + "rules", + "index.js" + ], + "main": "index.js", + "dependencies": { + "@typescript-eslint/parser": "^5.14.0", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint-plugin-react-hooks": "^4.3.0", + "eslint-plugin-testing-library": "^5.1.0", + "requireindex": "^1.2.0" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "lint": "eslint ./rules ./configs" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "eslint": "^8.11.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + } +} diff --git a/packages/js/eslint-plugin/project.json b/packages/js/eslint-plugin/project.json new file mode 100644 index 00000000000..2f719585b8f --- /dev/null +++ b/packages/js/eslint-plugin/project.json @@ -0,0 +1,14 @@ +{ + "root": "packages/js/eslint-plugin", + "sourceRoot": "packages/js/eslint-plugin/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/eslint-plugin" + } + } + } + } \ No newline at end of file diff --git a/packages/js/eslint-plugin/rules/__tests__/dependency-group.js b/packages/js/eslint-plugin/rules/__tests__/dependency-group.js new file mode 100644 index 00000000000..56e88245154 --- /dev/null +++ b/packages/js/eslint-plugin/rules/__tests__/dependency-group.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import { RuleTester } from 'eslint'; + +/** + * Internal dependencies + */ +import rule from '../dependency-group'; + +const ruleTester = new RuleTester( { + parserOptions: { + sourceType: 'module', + ecmaVersion: 6, + }, +} ); + +ruleTester.run( 'dependency-group', rule, { + valid: [ + { + code: ` +/** + * External dependencies + */ +import { get } from 'lodash'; +import classnames from 'classnames'; +import { Component } from '@wordpress/element'; +import { SearchListControl } from '@woocommerce/components'; +import { withProductVariations } from '@woocommerce/block-hocs'; +/** + * Internal dependencies + */ +import edit from './edit'; +import './style.scss';`, + }, + ], + invalid: [ + { + code: ` +/** + * External dependencies + */ +import { get } from 'lodash'; +import './style.scss'; +import { withProductVariations } from '@woocommerce/block-hocs'; +/** + * Internal dependencies + */ +import edit from './edit'; +import classnames from 'classnames'; +import { Component } from '@wordpress/element'; +import { SearchListControl } from '@woocommerce/components';`, + errors: [ + { + message: + 'Expected preceding "Internal dependencies" comment block', + }, + { + message: + 'Expected "External dependencies" to be defined before Internal', + }, + { + message: + 'Expected "External dependencies" to be defined before Internal', + }, + { + message: + 'Expected preceding "External dependencies" comment block', + }, + { + message: + 'Expected preceding "External dependencies" comment block', + }, + { + message: + 'Expected preceding "External dependencies" comment block', + }, + ], + }, + ], +} ); diff --git a/packages/js/eslint-plugin/rules/dependency-group.js b/packages/js/eslint-plugin/rules/dependency-group.js new file mode 100644 index 00000000000..8777cf54dad --- /dev/null +++ b/packages/js/eslint-plugin/rules/dependency-group.js @@ -0,0 +1,207 @@ +module.exports = { + meta: { + type: 'layout', + schema: [], + }, + create( context ) { + const comments = context.getSourceCode().getAllComments(); + + /** + * Locality classification of an import, "External" or "Internal". + * + * @typedef {string} WCPackageLocality + */ + + /** + * Given a desired locality, generates the expected comment node value + * property. + * + * @param {WCPackageLocality} locality Desired package locality. + * + * @return {string} Expected comment node value. + */ + function getCommentValue( locality ) { + return `*\n * ${ locality } dependencies\n `; + } + + /** + * Given an import source string, returns the locality classification + * of the import sort. + * + * @param {string} source Import source string. + * + * @return {WCPackageLocality} Package locality. + */ + function getPackageLocality( source ) { + if ( source.startsWith( '.' ) || source.startsWith( '~/' ) ) { + return 'Internal'; + } + return 'External'; + } + + /** + * Returns true if the given comment node satisfies a desired locality, + * or false otherwise. + * + * @param {espree.Node} node Comment node to check. + * @param {WCPackageLocality} locality Desired package locality. + * + * @return {boolean} Whether comment node satisfies locality. + */ + function isLocalityDependencyBlock( node, locality ) { + const { type, value } = node; + if ( type !== 'Block' ) { + return false; + } + + // Tolerances: + // - Normalize `/**` and `/*` + // - Case insensitive "Dependencies" vs. "dependencies" + // - Ending period + // - "Node" dependencies as an alias for External + + if ( locality === 'External' ) { + locality = '(External|Node)'; + } + + const pattern = new RegExp( + `^\\*?\\n \\* ${ locality } dependencies\\.?\\n $`, + 'i' + ); + return pattern.test( value ); + } + + /** + * Returns true if the given node occurs prior in code to a reference, + * or false otherwise. + * + * @param {espree.Node} node Node to test being before reference. + * @param {espree.Node} reference Node against which to compare. + * + * @return {boolean} Whether node occurs before reference. + */ + function isBefore( node, reference ) { + if ( ! node.range || ! reference.range ) { + return false; + } + return node.range[ 0 ] < reference.range[ 0 ]; + } + + /** + * Tests source comments to determine whether a comment exists which + * satisfies the desired locality. If a match is found and requires no + * updates, the function returns false. Otherwise, it will return true. + * + * @param {espree.Node} node Node to test. + * @param {WCPackageLocality} locality Desired package locality. + * + * @return {boolean} Whether the node is in the correct locality. + */ + function isNodeInLocality( node, locality ) { + const value = getCommentValue( locality ); + + let comment; + let nextComment; + for ( let i = 0; i < comments.length; i++ ) { + comment = comments[ i ]; + nextComment = + i < comments.length - 1 ? comments[ i + 1 ] : null; + + if ( nextComment && isBefore( nextComment, node ) ) { + // If it's not the immediately previous comment, continue. + continue; + } + + if ( ! isBefore( comment, node ) ) { + // Exhausted options. + break; + } + + if ( ! isLocalityDependencyBlock( comment, locality ) ) { + // Not usable (either not an block comment, or not one + // matching a tolerable pattern). + continue; + } + + if ( comment.value === value ) { + // No change needed. (OK) + return true; + } + + // Found a comment needing correction. + return false; + } + + return false; + } + + /** + * Tests if the locality is defined in the correct order (External, WooCommerce, Internal). + * + * @param {espree.Node} child Node to test. + * @param {WCPackageLocality} locality Package locality. + * @param {WCPackageLocality} previousLocality Previous package locality. + */ + function checkLocalityOrder( child, locality, previousLocality ) { + switch ( locality ) { + case 'External': + if ( previousLocality === 'Internal' ) { + context.report( { + node: child, + message: `Expected "External dependencies" to be defined before ${ previousLocality }`, + } ); + } + break; + } + } + + return { + Program( node ) { + let previousLocality = null; + + // Since we only care to enforce imports which occur at the + // top-level scope, match on Program and test its children, + // rather than matching the import nodes directly. + node.body.forEach( ( child ) => { + let source; + switch ( child.type ) { + case 'ImportDeclaration': + source = child.source.value; + break; + + case 'CallExpression': + const { callee, arguments: args } = child; + if ( + callee.name === 'require' && + args.length === 1 && + args[ 0 ].type === 'Literal' && + typeof args[ 0 ].value === 'string' + ) { + source = args[ 0 ].value; + } + break; + } + + if ( ! source ) { + return; + } + + const locality = getPackageLocality( source ); + + checkLocalityOrder( child, locality, previousLocality ); + + previousLocality = locality; + + if ( isNodeInLocality( child, locality ) ) { + return; + } + + context.report( { + node: child, + message: `Expected preceding "${ locality } dependencies" comment block`, + } ); + } ); + }, + }; + }, +}; diff --git a/packages/js/eslint-plugin/rules/index.js b/packages/js/eslint-plugin/rules/index.js new file mode 100644 index 00000000000..035c09a8fa7 --- /dev/null +++ b/packages/js/eslint-plugin/rules/index.js @@ -0,0 +1 @@ +module.exports = require( 'requireindex' )( __dirname ); diff --git a/packages/js/experimental/.eslintrc.js b/packages/js/experimental/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/experimental/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/experimental/.npmrc b/packages/js/experimental/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/experimental/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/experimental/CHANGELOG.md b/packages/js/experimental/CHANGELOG.md new file mode 100644 index 00000000000..824278ada63 --- /dev/null +++ b/packages/js/experimental/CHANGELOG.md @@ -0,0 +1,73 @@ +# Unreleased + +- Fix setup task list style conflict #32704 +- Update dependency `@wordpress/icons` to ^8.1.0 +- Added Typescript type declarations. #32615 + +# 3.0.1 + +- Update all js packages with minor/patch version changes. #8392 + +# 3.0.0 + +## Breaking changes + +- Update dependencies to support react 17. #8305 +- Drop support for IE11. #8305 + +# 2.2.0 + +- Make the Inbox note title clickable. #7975 +- Fix incorrectly displayed note created date. #8179 +- Fix inbox note css #7983 +- Implement inbox note read state #7896 + +# 2.1.0 + +- Renaming remindMeLater() to onSnooze() for consistency. #7616 +- Applied new Inbox 2.0 design to the inbox-note component. #7864 + +# 2.0.3 + +- Adjust task-item css class to prevent css conflicts. #7593 +- Update task-item logic to only display content when expanded is true. #7611 +- Add expandable tasklist item. #7632 + +# 1.5.1 + +- Fix animations for collapsible list. #7429 + +# 1.5.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 + +# 1.4.0 + +- Add new VerticalCSSTransition component for handling height transitions. #7203 +- Add a delete option to completed tasks #7300 +- Make the action button optionable in TaskItem. #7263 +- Fix WordPress 5.8 compatibility UI fixes #7255 +- Fix inbox note dismiss dropdown not closing on Safari #7278 +- Update TaskItem to make use of the VerticalCSSTransition. #7203 +- Update CollapsibleList to support nested transitional items. #7263 + +# 1.3.0 + +- Remove the use of Dashicons and replace with @wordpress/icons or gridicons #7020 +- Add expanded item text and CTA button. #6956 +- Add inbox note components (InboxNoteCard, InboxNotePlaceholder, and InboxDismissConfirmationModal). #7006 +- Add transition animation to expanding TaskItems. +- Add tree shaking support to this package. + +# 1.2.0 + +- Add task item component. #6978 + +# 1.1.0 + +- Add collapsible list item component. #6869 +- Add new list component. #6787 + +# 1.0.0 + +- Initial package diff --git a/packages/js/experimental/README.md b/packages/js/experimental/README.md new file mode 100644 index 00000000000..2093a803369 --- /dev/null +++ b/packages/js/experimental/README.md @@ -0,0 +1,31 @@ +# Experimental + +This is a private package not meant for use by third parties. + +A collection of component imports and exports that are aliases for components transitioning from experimental to non-experimental. This package prevents the component from being undefined when the `@wordpress/components` library version is unclear. + +It also contains several in-development components that are slated for inclusion in later releases of `@woocommerce/components`. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/experimental --save +``` + +## Usage + +Simply import the component name with the `__experimental` prefix. If found, the non-experimental version will be imported and if not, this will fallback to the experimental version. + +```jsx +import { Text } from '@woocommerce/experimental'; + +render() { + return ( + <Text> + … + </Text> + ); +} +``` diff --git a/packages/js/experimental/jest.config.json b/packages/js/experimental/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/experimental/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/experimental/package.json b/packages/js/experimental/package.json new file mode 100644 index 00000000000..66bd374e07f --- /dev/null +++ b/packages/js/experimental/package.json @@ -0,0 +1,94 @@ +{ + "name": "@woocommerce/experimental", + "version": "3.0.1", + "description": "WooCommerce experimental components.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "experimental" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/experimental/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "types": "build-types", + "react-native": "src/index", + "sideEffects": [ + "build-style/**", + "src/**/*.scss" + ], + "dependencies": { + "@woocommerce/components": "workspace:*", + "@wordpress/components": "^19.5.0", + "@wordpress/element": "^4.1.1", + "@wordpress/i18n": "^4.3.1", + "@wordpress/icons": "^8.1.0", + "@wordpress/keycodes": "^3.3.1", + "classnames": "^2.3.1", + "dompurify": "^2.3.6", + "gridicons": "^3.4.0", + "moment": "^2.29.1", + "react-transition-group": "^4.4.2", + "react-visibility-sensor": "^5.1.1" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@babel/runtime": "^7.17.2", + "@storybook/addon-actions": "^6.4.0", + "@storybook/addon-console": "^1.2.3", + "@storybook/react": "^6.4.19", + "@testing-library/dom": "^8.11.3", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^13.5.0", + "@types/dompurify": "^2.3.3", + "@types/react-transition-group": "^4.4.4", + "@woocommerce/style-build": "workspace:*", + "@wordpress/browserslist-config": "^4.1.1", + "@wordpress/eslint-plugin": "^11.0.0", + "concurrently": "^7.0.0", + "css-loader": "^3.6.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "postcss-loader": "^3.0.0", + "rimraf": "^3.0.2", + "sass-loader": "^10.2.1", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2", + "webpack": "^5.70.0", + "webpack-cli": "^3.3.12" + }, + "peerDependencies": { + "react": "^17.0.0", + "react-dom": "^17.0.0" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "pnpm run build:js && pnpm run build:css", + "build:js": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "build:css": "webpack", + "start": "concurrently \"tsc --build --watch\" \"webpack --watch\"", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/experimental/project.json b/packages/js/experimental/project.json new file mode 100644 index 00000000000..541917cc9f6 --- /dev/null +++ b/packages/js/experimental/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/experimental", + "sourceRoot": "packages/js/experimental/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/experimental" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/experimental/src/experimental-list/collapsible-list/index.tsx b/packages/js/experimental/src/experimental-list/collapsible-list/index.tsx new file mode 100644 index 00000000000..6a0ad1cb127 --- /dev/null +++ b/packages/js/experimental/src/experimental-list/collapsible-list/index.tsx @@ -0,0 +1,316 @@ +/** + * External dependencies + */ +import { Icon, chevronUp, chevronDown } from '@wordpress/icons'; +import { + createElement, + useState, + useCallback, + useEffect, + Children, + useRef, + isValidElement, + cloneElement, +} from '@wordpress/element'; +import { + Transition, + CSSTransition, + TransitionGroup, +} from 'react-transition-group'; +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import { ExperimentalListItem } from '../experimental-list-item'; +import { ListProps, ExperimentalList } from '../experimental-list'; + +type CollapsibleListProps = { + collapseLabel: string; + expandLabel: string; + collapsed?: boolean; + show?: number; + onCollapse?: () => void; + onExpand?: () => void; +} & ListProps; + +const defaultStyle = { + transitionProperty: 'max-height', + transitionDuration: '500ms', + maxHeight: 0, + overflow: 'hidden', +}; + +function getContainerHeight( collapseContainer: HTMLDivElement | null ) { + let containerHeight = 0; + if ( collapseContainer ) { + for ( const child of collapseContainer.children ) { + containerHeight += child.clientHeight; + const style = window.getComputedStyle( child ); + + containerHeight += parseInt( style.marginTop, 10 ) || 0; + containerHeight += parseInt( style.marginBottom, 10 ) || 0; + } + } + return containerHeight; +} + +/** + * This functions returns a new list of shown children depending on the new children updates. + * If one is removed, it will remove it from the show array. + * If one is added, it will add it back to the shown list, making use of the new children list to keep order. + * + * @param {Array.<import('react').ReactElement>} currentChildren a list of the current children. + * @param {Array.<import('react').ReactElement>} currentShownChildren a list of the current shown children. + * @param {Array.<import('react').ReactElement>} newChildren a list of the new children. + * @return {Array.<import('react').ReactElement>} new list of children that should be shown. + */ +function getUpdatedShownChildren( + currentChildren: React.ReactElement[], + currentShownChildren: React.ReactElement[], + newChildren: React.ReactElement[] +): React.ReactElement[] { + if ( newChildren.length < currentChildren.length ) { + const newChildrenKeys = newChildren.map( ( child ) => child.key ); + // Filter out removed child + return currentShownChildren.filter( + ( item ) => item.key && newChildrenKeys.includes( item.key ) + ); + } + const currentShownChildrenKeys = currentShownChildren.map( + ( child ) => child.key + ); + const currentChildrenKeys = currentChildren.map( ( child ) => child.key ); + // Add new child back in. + return newChildren.filter( + ( child ) => + child.key && + ( currentShownChildrenKeys.includes( child.key ) || + ! currentChildrenKeys.includes( child.key ) ) + ); +} + +const getTransitionStyle = ( + state: 'entering' | 'entered' | 'exiting' | 'exited', + isCollapsed: boolean, + elementRef: HTMLDivElement | null +) => { + let maxHeight = 0; + if ( ( state === 'entered' || state === 'entering' ) && elementRef ) { + maxHeight = getContainerHeight( elementRef ); + } + const styles: React.CSSProperties = { + ...defaultStyle, + maxHeight, + }; + + // only include transition styles when entering or exiting. + if ( state !== 'entering' && state !== 'exiting' ) { + delete styles.transitionDuration; + delete styles.transition; + delete styles.transitionProperty; + } + // Remove maxHeight when entered, so we do not need to worry about nested items changing height while expanded. + if ( state === 'entered' && ! isCollapsed ) { + delete styles.maxHeight; + } + + return styles; +}; + +export const ExperimentalCollapsibleList: React.FC< CollapsibleListProps > = ( { + children, + collapsed = true, + collapseLabel, + expandLabel, + show = 0, + onCollapse, + onExpand, + ...listProps +} ): JSX.Element => { + const [ isCollapsed, setCollapsed ] = useState( collapsed ); + const [ + isTransitionComponentCollapsed, + setTransitionComponentCollapsed, + ] = useState( collapsed ); + const [ footerLabels, setFooterLabels ] = useState( { + collapse: collapseLabel, + expand: expandLabel, + } ); + const [ displayedChildren, setDisplayedChildren ] = useState< { + all: React.ReactElement[]; + shown: React.ReactElement[]; + hidden: React.ReactElement[]; + } >( { + all: [], + shown: [], + hidden: [], + } ); + const collapseContainerRef = useRef< HTMLDivElement >( null ); + + const updateChildren = () => { + let shownChildren: React.ReactElement[] = []; + const allChildren = + Children.map( children, ( child ) => + isValidElement( child ) && 'key' in child ? child : null + ) || []; + let hiddenChildren = allChildren; + if ( show > 0 ) { + shownChildren = allChildren.slice( 0, show ); + hiddenChildren = allChildren.slice( show ); + } + if ( hiddenChildren.length > 0 ) { + // Only update when footer will be shown, this way it won't update mid transition if the outer component + // updates the label as well. + setFooterLabels( { expand: expandLabel, collapse: collapseLabel } ); + } + setDisplayedChildren( { + all: allChildren, + shown: shownChildren, + hidden: hiddenChildren, + } ); + }; + + // This allows for an extra render cycle that adds the maxHeight back in before the exiting transition. + // This way the exiting transition still works correctly. + useEffect( () => { + setTransitionComponentCollapsed( isCollapsed ); + }, [ isCollapsed ] ); + + useEffect( () => { + const allChildren = + Children.map( children, ( child ) => + isValidElement( child ) && 'key' in child ? child : null + ) || []; + if ( + displayedChildren.all.length > 0 && + isCollapsed && + listProps.animation !== 'none' + ) { + setDisplayedChildren( { + ...displayedChildren, + shown: getUpdatedShownChildren( + displayedChildren.all, + displayedChildren.shown, + allChildren + ), + } ); + // Update the hidden children after the remove/add transition is done, making the transition less busy. + setTimeout( () => { + updateChildren(); + }, 500 ); + } else { + updateChildren(); + } + }, [ children ] ); + + const triggerCallbacks = ( newCollapseValue: boolean ) => { + if ( onCollapse && newCollapseValue ) { + onCollapse(); + } + if ( onExpand && ! newCollapseValue ) { + onExpand(); + } + }; + + const clickHandler = useCallback( () => { + setCollapsed( ! isCollapsed ); + triggerCallbacks( ! isCollapsed ); + }, [ isCollapsed ] ); + + const listClasses = classnames( + listProps.className || '', + 'woocommerce-experimental-list' + ); + + const wrapperClasses = classnames( { + 'woocommerce-experimental-list-wrapper': ! isCollapsed, + } ); + + return ( + <ExperimentalList { ...listProps } className={ listClasses }> + { [ + ...displayedChildren.shown, + <Transition + key="remaining-children" + timeout={ 500 } + in={ ! isTransitionComponentCollapsed } + mountOnEnter={ true } + unmountOnExit={ false } + > + { ( + state: 'entering' | 'entered' | 'exiting' | 'exited' + ) => { + const transitionStyles = getTransitionStyle( + state, + isCollapsed, + collapseContainerRef.current + ); + return ( + <div + className={ wrapperClasses } + ref={ collapseContainerRef } + style={ transitionStyles } + > + <TransitionGroup className="woocommerce-experimental-list"> + { Children.map( + displayedChildren.hidden, + ( child ) => { + const { + onExited, + in: inTransition, + enter, + exit, + ...remainingProps + } = child.props; + const animationProp = + remainingProps.animation || + listProps.animation; + return ( + <CSSTransition + key={ child.key } + timeout={ 500 } + onExited={ onExited } + in={ inTransition } + enter={ enter } + exit={ exit } + classNames="woocommerce-list__item" + > + { cloneElement( child, { + animation: animationProp, + ...remainingProps, + } ) } + </CSSTransition> + ); + } + ) } + </TransitionGroup> + </div> + ); + } } + </Transition>, + displayedChildren.hidden.length > 0 ? ( + <ExperimentalListItem + key="collapse-item" + className="list-item-collapse" + onClick={ clickHandler } + animation="none" + disableGutters + > + <p> + { isCollapsed + ? footerLabels.expand + : footerLabels.collapse } + </p> + + <Icon + className="list-item-collapse__icon" + size={ 30 } + icon={ isCollapsed ? chevronDown : chevronUp } + /> + </ExperimentalListItem> + ) : null, + ] } + </ExperimentalList> + ); +}; diff --git a/packages/js/experimental/src/experimental-list/collapsible-list/style.scss b/packages/js/experimental/src/experimental-list/collapsible-list/style.scss new file mode 100644 index 00000000000..3c84a28a643 --- /dev/null +++ b/packages/js/experimental/src/experimental-list/collapsible-list/style.scss @@ -0,0 +1,43 @@ +.woocommerce-experimental-list__item.list-item-collapse { + overflow: hidden; + display: grid; + grid-template-columns: auto 48px; + + // IE doesn't support `align-items` on grid container + & > * { + align-self: center; + } + + p { + margin: 0; + padding: $gap $gap-large; + } + + .list-item-collapse__icon-container { + justify-content: flex-end; + } + + &.woocommerce-list__item-enter { + max-height: 0; + opacity: 1; + } + + &.woocommerce-list__item-enter-active { + max-height: 55px; + transition: max-height 500ms; + } + + &.woocommerce-list__item-exit { + max-height: 55px; + } + + &.woocommerce-list__item-exit-active { + max-height: 0; + opacity: 1; + transition: max-height 500ms; + } +} + +.woocommerce-experimental-list-wrapper { + border-top: 1px solid $gray-100; +} diff --git a/packages/js/experimental/src/experimental-list/experimental-list-item.tsx b/packages/js/experimental/src/experimental-list/experimental-list-item.tsx new file mode 100644 index 00000000000..d5e06e37f82 --- /dev/null +++ b/packages/js/experimental/src/experimental-list/experimental-list-item.tsx @@ -0,0 +1,90 @@ +/** + * External dependencies + */ +import { CSSTransition } from 'react-transition-group'; +import { createElement } from '@wordpress/element'; +import { ENTER } from '@wordpress/keycodes'; +import classnames from 'classnames'; + +function handleKeyDown( + event: React.KeyboardEvent< HTMLElement >, + onClick?: + | React.MouseEventHandler< HTMLElement > + | React.KeyboardEventHandler< HTMLElement > +) { + if ( typeof onClick === 'function' && event.keyCode === ENTER ) { + ( onClick as React.KeyboardEventHandler< HTMLElement > )( event ); + } +} + +type CSSTransitionProps = { + in: boolean; + exit: boolean; + enter: boolean; + onExited: () => void; +}; + +type ListItemProps = { + // control whether to display padding on list item or not. + disableGutters?: boolean; + animation?: ListAnimation; + className?: string; +} & Partial< CSSTransitionProps > & + React.AllHTMLAttributes< HTMLElement >; + +export type ListAnimation = 'slide-right' | 'none' | 'custom'; + +export const ExperimentalListItem: React.FC< ListItemProps > = ( { + children, + disableGutters = false, + animation = 'none', + className = '', + // extract out the props that must be passed down from TransitionGroup + exit, + enter, + onExited, + // in is a TS reserved keyword so can't be a variable name + in: transitionIn, + + // Everything else you might pass into an HTML element + ...otherProps +} ): JSX.Element => { + // for styling purposes only + const hasAction = !! otherProps?.onClick; + + const roleProps = hasAction + ? { + role: 'button', + onKeyDown: ( e: React.KeyboardEvent< HTMLElement > ) => + handleKeyDown( e, otherProps.onClick ), + tabIndex: 0, + } + : {}; + + const tagClasses = classnames( { + 'has-action': hasAction, + 'has-gutters': ! disableGutters, + // since there is only one valid animation right now, any other value disables them. + 'transitions-disabled': animation !== 'slide-right', + } ); + + return ( + <CSSTransition + timeout={ 500 } + classNames={ className || 'woocommerce-list__item' } + in={ transitionIn } + exit={ exit } + enter={ enter } + onExited={ onExited } + > + <li + // spread role props first, in case it is desired to override them + { ...roleProps } + { ...otherProps } + className={ `woocommerce-experimental-list__item ${ tagClasses } ${ className }` } + > + { children } + </li> + </CSSTransition> + ); +}; diff --git a/packages/js/experimental/src/experimental-list/experimental-list.tsx b/packages/js/experimental/src/experimental-list/experimental-list.tsx new file mode 100644 index 00000000000..edbc2dff1a8 --- /dev/null +++ b/packages/js/experimental/src/experimental-list/experimental-list.tsx @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import { + createElement, + Children, + cloneElement, + isValidElement, +} from '@wordpress/element'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; + +/** + * Internal dependencies + */ +import type { ListAnimation } from './experimental-list-item'; + +type ListType = 'ol' | 'ul'; + +export type ListProps = { + listType?: ListType; + animation?: ListAnimation; +} & React.HTMLAttributes< HTMLElement >; + +export const ExperimentalList: React.FC< ListProps > = ( { + children, + listType, + animation = 'none', + // Allow passing any other property overrides that are legal on an HTML element + ...otherProps +} ) => { + return ( + <TransitionGroup + component={ listType || 'ul' } + className="woocommerce-experimental-list" + { ...otherProps } + > + { /* Wrapping all children in a CSS Transition means no invalid props are passed to children and that anything can be animated. */ } + { + Children.map( children, ( child ) => { + if ( isValidElement( child ) ) { + const { + onExited, + in: inTransition, + enter, + exit, + ...remainingProps + } = child.props; + const animationProp = + remainingProps.animation || animation; + return ( + <CSSTransition + timeout={ 500 } + onExited={ onExited } + in={ inTransition } + enter={ enter } + exit={ exit } + classNames="woocommerce-list__item" + > + { cloneElement( child, { + animation: animationProp, + ...remainingProps, + } ) } + </CSSTransition> + ); + } + + return child; + // TODO - create a less restrictive type definition for children of react-transition-group. React.Children.map seems incompatible with the type expected by `children`. + } ) as React.ReactElement[] + } + </TransitionGroup> + ); +}; diff --git a/packages/js/experimental/src/experimental-list/stories/index.tsx b/packages/js/experimental/src/experimental-list/stories/index.tsx new file mode 100644 index 00000000000..665e4f7e535 --- /dev/null +++ b/packages/js/experimental/src/experimental-list/stories/index.tsx @@ -0,0 +1,197 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { withConsole } from '@storybook/addon-console'; +import { Meta, Story } from '@storybook/react'; +/** + * Internal dependencies + */ +import { List, ListItem, CollapsibleList } from '../..'; +import { TaskItem } from '../task-item'; +import { ListProps } from '../experimental-list'; +import './style.scss'; + +export default { + title: 'WooCommerce Admin/experimental/List', + component: List, + decorators: [ ( storyFn, context ) => withConsole()( storyFn )( context ) ], +} as Meta; + +const Template: Story< ListProps > = ( args ) => ( + <List { ...args }> + <ListItem disableGutters onClick={ () => {} }> + <div>Without gutters no padding is added to the list item.</div> + </ListItem> + <ListItem onClick={ () => {} }> + <div>Any markup can go here.</div> + </ListItem> + <ListItem onClick={ () => {} }> + <div>Any markup can go here.</div> + </ListItem> + <ListItem onClick={ () => {} }> + <div>Any markup can go here.</div> + </ListItem> + </List> +); + +export const Primary = Template.bind( { onClick: () => {} } ); + +Primary.args = { + listType: 'ul', + animation: 'slide-right', +}; + +export const CollapsibleListExample: Story = () => { + return ( + <CollapsibleList + collapseLabel="Show less" + expandLabel="Show more items" + show={ 2 } + onCollapse={ () => { + // eslint-disable-next-line no-console + console.log( 'collapsed' ); + } } + onExpand={ () => { + // eslint-disable-next-line no-console + console.log( 'expanded' ); + } } + > + <ListItem onClick={ () => {} }> + <div>Any markup can go here.</div> + </ListItem> + <ListItem onClick={ () => {} }> + <div>Any markup can go here.</div> + </ListItem> + <ListItem onClick={ () => {} }> + <div> + Any markup can go here. + <br /> + Bigger task item + <br /> + Another line + </div> + </ListItem> + <ListItem onClick={ () => {} }> + <div>Any markup can go here.</div> + </ListItem> + <ListItem onClick={ () => {} }> + <div>Any markup can go here.</div> + </ListItem> + </CollapsibleList> + ); +}; + +CollapsibleListExample.storyName = 'List with CollapsibleListItem.'; + +export const TaskItemExample: Story = ( args ) => ( + <List { ...args }> + <TaskItem + action={ () => + // eslint-disable-next-line no-console + console.log( 'Primary action clicked' ) + } + actionLabel="Primary action" + completed={ false } + content="Task content" + expandable={ true } + expanded={ true } + level={ 1 } + onClick={ () => + // eslint-disable-next-line no-console + console.log( 'Task clicked' ) + } + onCollapse={ () => + // eslint-disable-next-line no-console + console.log( 'Task will be expanded' ) + } + onExpand={ () => + // eslint-disable-next-line no-console + console.log( 'Task will be collapsed' ) + } + showActionButton={ true } + title="A high-priority task" + /> + <TaskItem + action={ () => + // eslint-disable-next-line no-console + console.log( 'Primary action clicked' ) + } + actionLabel="Primary action" + completed={ false } + content="Task content" + expandable={ false } + expanded={ true } + level={ 1 } + onClick={ () => + // eslint-disable-next-line no-console + console.log( 'Task clicked' ) + } + showActionButton={ false } + title="A high-priority task without `Primary action`" + /> + <TaskItem + action={ () => {} } + completed={ false } + content="Task content" + expandable={ false } + expanded={ true } + level={ 2 } + onClick={ () => + // eslint-disable-next-line no-console + console.log( 'Task clicked' ) + } + title="Setup task" + onDismiss={ () => + // eslint-disable-next-line no-console + console.log( 'Task dismissed' ) + } + onSnooze={ () => + // eslint-disable-next-line no-console + console.log( 'Task snoozed' ) + } + time="5 minutes" + /> + <TaskItem + action={ () => {} } + completed={ false } + content="Task content" + expandable={ false } + expanded={ true } + level={ 3 } + onClick={ () => + // eslint-disable-next-line no-console + console.log( 'Task clicked' ) + } + title="A low-priority task" + onDismiss={ () => + // eslint-disable-next-line no-console + console.log( 'Task dismissed' ) + } + onSnooze={ () => + // eslint-disable-next-line no-console + console.log( 'Task snoozed' ) + } + time="3 minutes" + /> + <TaskItem + action={ () => {} } + completed={ true } + content="Task content" + expandable={ false } + expanded={ true } + level={ 3 } + onClick={ () => + // eslint-disable-next-line no-console + console.log( 'Task clicked' ) + } + title="Another low-priority task" + onDelete={ () => + // eslint-disable-next-line no-console + console.log( 'Task deleted' ) + } + /> + </List> +); + +TaskItemExample.storyName = 'TaskItems.'; diff --git a/packages/js/experimental/src/experimental-list/stories/style.scss b/packages/js/experimental/src/experimental-list/stories/style.scss new file mode 100644 index 00000000000..7250d522c5a --- /dev/null +++ b/packages/js/experimental/src/experimental-list/stories/style.scss @@ -0,0 +1,23 @@ +.storybook-custom-list { + border: 1px solid $gray-400; + border-radius: 2px; + padding: 0; + + .woocommerce-list__item { + &:not(:first-child) { + border-top: 1px solid $gray-200; + } + } +} + +.woocommerce-experimental-list { + li { + padding-left: 25px; + .components-dropdown { + div { + position: absolute; + right: 0; + } + } + } +} diff --git a/packages/js/experimental/src/experimental-list/style.scss b/packages/js/experimental/src/experimental-list/style.scss new file mode 100644 index 00000000000..638a22174ef --- /dev/null +++ b/packages/js/experimental/src/experimental-list/style.scss @@ -0,0 +1,158 @@ +.woocommerce-experimental-list { + margin: 0; + padding: 0; +} + +a.woocommerce-experimental-list__item { + color: inherit; +} + +.woocommerce-experimental-list__item { + display: flex; + align-items: center; + margin-bottom: 0; + text-decoration: none; + + &.has-gutters { + padding: $gap $gap-large; + } + + &.has-action:not(.expanded) { + cursor: pointer; + } + + &:focus:not(.expanded) { + box-shadow: inset 0 0 0 1px $studio-wordpress-blue, + inset 0 0 0 2px $studio-white; + } + + &:focus-visible { + box-shadow: none; + } + + // transitions + &:not(.transitions-disabled) { + &.woocommerce-list__item-enter { + opacity: 0; + max-height: 0; + transform: translateX(50%); + } + + &.woocommerce-list__item-enter-active { + opacity: 1; + max-height: 100vh; + transform: translateX(0%); + transition: opacity 500ms, transform 500ms, max-height 500ms; + } + + &.woocommerce-list__item-exit { + opacity: 1; + max-height: 100vh; + transform: translateX(0%); + } + + &.woocommerce-list__item-exit-active { + opacity: 0; + max-height: 0; + transform: translateX(50%); + transition: opacity 500ms, transform 500ms, max-height 500ms; + } + } + + > .woocommerce-list__item-inner { + text-decoration: none; + width: 100%; + display: flex; + align-items: center; + padding: $gap $gap-large; + + &:focus { + box-shadow: inset 0 0 0 1px $studio-wordpress-blue, + inset 0 0 0 2px $studio-white; + } + } + + .woocommerce-list__item-title { + color: $studio-gray-90; + } + + .woocommerce-list__item-expandable-content { + margin-top: $gap-smallest; + display: block; + font-size: 14px; + line-height: 20px; + color: #50575d; + } + + .woocommerce-list__item-before { + margin-right: 20px; + display: flex; + align-items: center; + } + + .woocommerce-list__item-after { + margin-left: $gap; + display: flex; + align-items: center; + margin-left: auto; + } + + $chevron-color: $gray-900; + $background-color: $white; + $background-color-hover: $gray-100; + $border-color: $gray-100; + $foreground-color: var(--wp-admin-theme-color); + $foreground-color-hover: var(--wp-admin-theme-color); + + background-color: $background-color; + + &:not(:first-child) { + border-top: 1px solid $border-color; + } + + &:hover { + background-color: $background-color-hover; + + .woocommerce-list__item-title { + color: $foreground-color-hover; + } + + .woocommerce-list__item-before > svg { + fill: $foreground-color-hover; + } + } + + .woocommerce-list__item-title { + color: $foreground-color; + } + + .woocommerce-list__item-before > svg { + fill: $foreground-color; + } + + .woocommerce-list__item-after > svg { + fill: $chevron-color; + } + + &.complete { + .woocommerce-task__icon { + background-color: var(--wp-admin-theme-color); + } + + .woocommerce-list__item-title { + color: $gray-700; + } + + .woocommerce-list__item-expandable-content { + display: none; + } + } +} + +.woocommerce-experimental-list__item-title { + color: $studio-gray-80; +} + +.woocommerce-experimental-list__item-expandable-content { + color: $studio-gray-50; +} diff --git a/packages/js/experimental/src/experimental-list/task-item/README.md b/packages/js/experimental/src/experimental-list/task-item/README.md new file mode 100644 index 00000000000..e8a068ff82d --- /dev/null +++ b/packages/js/experimental/src/experimental-list/task-item/README.md @@ -0,0 +1,49 @@ +# TaskItem + +Use `TaskItem` to display a task item. + +## Usage + +```jsx +<TaskItem + action={ () => alert( '"My action" button has been clicked' ) } + actionLabel="My action" + additionalInfo="Additional task information" + completed={ true } + content="Task content" + expandable={ false } + expanded={ false } + level="Task title" + onClick={ () => alert( 'The task has been clicked' ) } + onCollapse={ () => alert( 'The task was collapsed' ) } + onDelete={ () => alert( 'The task has been deleted' ) } + onDismiss={ () => alert( 'The task was dismissed' ) } + onExpand={ () => alert( 'The task was expanded' ) } + onSnooze={ () => alert( 'The task was snoozed' ) } + showActionButton={ false } + time="10 minutes" + title="Task title" +/> +``` + +### Props + +| Name | Type | Default | Description | +| ------------------ | -------- | ------- | ------------------------------------------------------------ | +| `action` | Function | `null` | A function to be called when the primary action is triggered | +| `actionLabel` | String | `null` | Primary action label | +| `additionalInfo` | String | `null` | Additional task information | +| `completed` | Boolean | `null` | Whether the task is completed or not | +| `content` | String | `null` | Task content | +| `expandable` | Boolean | `false` | Whether it's an expandable task | +| `expanded` | Boolean | `false` | Whether the task is expanded by default | +| `level` | Number | `3` | Task hierarchy level (between 1 and 3) | +| `onClick` | Function | `null` | A function to be called after clicking on the task item | +| `onCollapse` | Function | `null` | A function to be called after the task is collapsed | +| `onDelete` | Function | `null` | A function to be called after the task is deleted | +| `onDismiss` | Function | `null` | A function to be called after the task is dismissed | +| `onExpand` | Function | `null` | A function to be called after the task is expanded | +| `onSnooze` | Function | `null` | A function to be called after the task is snoozed | +| `showActionButton` | Boolean | `null` | Whether the primary action (button) will be shown | +| `time` | String | `null` | Time to finish the task | +| `title` | String | `null` | (required) Task title | diff --git a/packages/js/experimental/src/experimental-list/task-item/index.tsx b/packages/js/experimental/src/experimental-list/task-item/index.tsx new file mode 100644 index 00000000000..8aa2864d296 --- /dev/null +++ b/packages/js/experimental/src/experimental-list/task-item/index.tsx @@ -0,0 +1,290 @@ +/** + * External dependencies + */ +import { + createElement, + Fragment, + useEffect, + useState, +} from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Icon, check } from '@wordpress/icons'; +import { Button, Tooltip } from '@wordpress/components'; +import NoticeOutline from 'gridicons/dist/notice-outline'; +import { EllipsisMenu } from '@woocommerce/components'; +import classnames from 'classnames'; +import { sanitize } from 'dompurify'; + +/** + * Internal dependencies + */ +import { Text, ListItem } from '../../'; +import { VerticalCSSTransition } from '../../vertical-css-transition'; + +const ALLOWED_TAGS = [ 'a', 'b', 'em', 'i', 'strong', 'p', 'br' ]; +const ALLOWED_ATTR = [ 'target', 'href', 'rel', 'name', 'download' ]; + +const sanitizeHTML = ( html: string ) => { + return { + __html: sanitize( html, { ALLOWED_TAGS, ALLOWED_ATTR } ), + }; +}; + +type TaskLevel = 1 | 2 | 3; + +type ActionArgs = { + isExpanded?: boolean; +}; + +type TaskItemProps = { + title: string; + completed: boolean; + onClick: () => void; + onCollapse?: () => void; + onDelete?: () => void; + onDismiss?: () => void; + onSnooze?: () => void; + onExpand?: () => void; + additionalInfo?: string; + time?: string; + content: string; + expandable?: boolean; + expanded?: boolean; + showActionButton?: boolean; + level?: TaskLevel; + action: ( + event?: React.MouseEvent | React.KeyboardEvent, + args?: ActionArgs + ) => void; + actionLabel?: string; + className?: string; +}; + +const OptionalTaskTooltip: React.FC< { + level: TaskLevel; + completed: boolean; + children: JSX.Element; +} > = ( { level, completed, children } ) => { + let tooltip = ''; + if ( level === 1 && ! completed ) { + tooltip = __( + 'This task is required to keep your store running', + 'woocommerce' + ); + } else if ( level === 2 && ! completed ) { + tooltip = __( + 'This task is required to set up your extension', + 'woocommerce' + ); + } + if ( tooltip === '' ) { + return children; + } + return <Tooltip text={ tooltip }>{ children }</Tooltip>; +}; + +const OptionalExpansionWrapper: React.FC< { + expandable: boolean; + expanded: boolean; +} > = ( { children, expandable, expanded } ) => { + if ( ! expandable ) { + return expanded ? <>{ children }</> : null; + } + return ( + <VerticalCSSTransition + timeout={ 500 } + in={ expanded } + classNames="woocommerce-task-list__item-expandable-content" + defaultStyle={ { + transitionProperty: 'max-height, opacity', + } } + > + { children } + </VerticalCSSTransition> + ); +}; + +export const TaskItem: React.FC< TaskItemProps > = ( { + completed, + title, + onDelete, + onCollapse, + onDismiss, + onSnooze, + onExpand, + onClick, + additionalInfo, + time, + content, + expandable = false, + expanded = false, + showActionButton, + level = 3, + action, + actionLabel, + ...listItemProps +} ) => { + const [ isTaskExpanded, setTaskExpanded ] = useState( expanded ); + useEffect( () => { + setTaskExpanded( expanded ); + }, [ expanded ] ); + + const className = classnames( 'woocommerce-task-list__item', { + complete: completed, + expanded: isTaskExpanded, + 'level-2': level === 2 && ! completed, + 'level-1': level === 1 && ! completed, + } ); + if ( showActionButton === undefined ) { + showActionButton = expandable; + } + + const showEllipsisMenu = + ( ( onDismiss || onSnooze ) && ! completed ) || + ( onDelete && completed ); + + const toggleActionVisibility = () => { + setTaskExpanded( ! isTaskExpanded ); + if ( isTaskExpanded && onExpand ) { + onExpand(); + } + if ( ! isTaskExpanded && onCollapse ) { + onCollapse(); + } + }; + + return ( + <ListItem + disableGutters + className={ className } + onClick={ + expandable && showActionButton + ? toggleActionVisibility + : onClick + } + { ...listItemProps } + > + <OptionalTaskTooltip level={ level } completed={ completed }> + <div className="woocommerce-task-list__item-before"> + { level === 1 && ! completed ? ( + <NoticeOutline size={ 36 } /> + ) : ( + <div className="woocommerce-task__icon"> + { completed && <Icon icon={ check } /> } + </div> + ) } + </div> + </OptionalTaskTooltip> + <div className="woocommerce-task-list__item-text"> + <Text + as="div" + size="14" + lineHeight={ completed ? '18px' : '20px' } + weight={ completed ? 'normal' : '600' } + variant={ completed ? 'body.small' : 'button' } + > + <span className="woocommerce-task-list__item-title"> + { title } + </span> + <OptionalExpansionWrapper + expandable={ expandable } + expanded={ isTaskExpanded } + > + <div className="woocommerce-task-list__item-expandable-content"> + { content } + { expandable && ! completed && additionalInfo && ( + <div + className="woocommerce-task__additional-info" + dangerouslySetInnerHTML={ sanitizeHTML( + additionalInfo + ) } + ></div> + ) } + { ! completed && showActionButton && ( + <Button + className="woocommerce-task-list__item-action" + isPrimary + onClick={ ( + event: + | React.MouseEvent + | React.KeyboardEvent + ) => { + event.stopPropagation(); + action( event, { isExpanded: true } ); + } } + > + { actionLabel || title } + </Button> + ) } + </div> + </OptionalExpansionWrapper> + + { ! expandable && ! completed && additionalInfo && ( + <div + className="woocommerce-task__additional-info" + dangerouslySetInnerHTML={ sanitizeHTML( + additionalInfo + ) } + ></div> + ) } + { time && ( + <div className="woocommerce-task__estimated-time"> + { time } + </div> + ) } + </Text> + </div> + { showEllipsisMenu && ( + <EllipsisMenu + label={ __( 'Task Options', 'woocommerce' ) } + className="woocommerce-task-list__item-after" + onToggle={ ( e: React.MouseEvent | React.KeyboardEvent ) => + e.stopPropagation() + } + renderContent={ () => ( + <div className="woocommerce-task-card__section-controls"> + { onDismiss && ! completed && ( + <Button + onClick={ ( + e: + | React.MouseEvent + | React.KeyboardEvent + ) => { + e.stopPropagation(); + onDismiss(); + } } + > + { __( 'Dismiss', 'woocommerce' ) } + </Button> + ) } + { onSnooze && ! completed && ( + <Button + onClick={ ( e: React.MouseEvent ) => { + e.stopPropagation(); + onSnooze(); + } } + > + { __( 'Remind me later', 'woocommerce' ) } + </Button> + ) } + { onDelete && completed && ( + <Button + onClick={ ( + e: + | React.MouseEvent + | React.KeyboardEvent + ) => { + e.stopPropagation(); + onDelete(); + } } + > + { __( 'Delete', 'woocommerce' ) } + </Button> + ) } + </div> + ) } + /> + ) } + </ListItem> + ); +}; diff --git a/packages/js/experimental/src/experimental-list/task-item/style.scss b/packages/js/experimental/src/experimental-list/task-item/style.scss new file mode 100644 index 00000000000..f7fb5eb81df --- /dev/null +++ b/packages/js/experimental/src/experimental-list/task-item/style.scss @@ -0,0 +1,154 @@ +$foreground-color: var(--wp-admin-theme-color); +$task-alert-yellow: #f0b849; + +.woocommerce-task-list__item { + position: relative; + display: grid; + grid-template-columns: 72px auto 48px; + + // IE doesn't support `align-items` on grid container + + & > * { + align-self: center; + } + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 4px; + height: 100%; + background: transparent; + } + + &.level-1 { + &::before { + background-color: $alert-red; + } + .gridicons-notice-outline { + fill: $alert-red; + } + } + + &.level-2 { + &::before { + background-color: $task-alert-yellow; + } + } + + .woocommerce-task-list__item-title { + color: $foreground-color; + } + + .woocommerce-task__additional-info, + .woocommerce-task-list__item-expandable-content, + .woocommerce-task__estimated-time { + color: $gray-700; + font-weight: 400; + font-size: 12px; + } + + .woocommerce-task__estimated-time { + margin-top: $gap-smallest; + } + + .woocommerce-task-list__item-before { + display: flex; + align-items: center; + padding: $gap 0 $gap $gap-large; + } + + .woocommerce-task-list__item-text { + padding: $gap 0; + + .woocommerce-pill { + padding: 1px $gap-smaller; + margin-left: $gap-smaller; + } + } + + .woocommerce-task-list__item-expandable-content { + margin-top: $gap-smallest; + overflow: hidden; + + &.woocommerce-task-list__item-expandable-content-enter { + opacity: 0; + } + + &.woocommerce-task-list__item-expandable-content-enter-active { + opacity: 1; + } + + &.woocommerce-task-list__item-expandable-content-enter-done { + opacity: 1; + } + + &.woocommerce-task-list__item-expandable-content-exit { + opacity: 1; + } + + &.woocommerce-task-list__item-expandable-content-exit-active { + opacity: 0; + } + + .woocommerce-task__additional-info { + margin-top: $gap-smaller; + } + } + + .woocommerce-task-list__item-action { + margin-top: $gap-smaller; + margin-bottom: $gap-smallest; + display: block; + } + + .woocommerce-task-list__item-after { + display: flex; + align-items: center; + } + + .woocommerce-task-list__item-before .woocommerce-task__icon { + border-radius: 50%; + width: 32px; + height: 32px; + } + + .woocommerce-task-list__item-before .woocommerce-task__icon svg { + fill: $white; + position: relative; + top: 4px; + left: 5px; + } + + &.complete { + .woocommerce-task__icon { + background-color: var(--wp-admin-theme-color); + } + + .woocommerce-task-list__item-title { + color: $gray-700; + } + + .woocommerce-task-list__item-expandable-content, + .woocommerce-task__estimated-time { + display: none; + } + } + + &:not(.complete) { + .woocommerce-task__icon { + border: 1px solid $gray-100; + background: $white; + } + } + + .components-tooltip .components-popover__content { + width: 160px; + white-space: normal; + } + + .woocommerce-task-card__section-controls { + text-align: left; + } +} diff --git a/packages/js/experimental/src/experimental-list/test/index.tsx b/packages/js/experimental/src/experimental-list/test/index.tsx new file mode 100644 index 00000000000..c37e714bc76 --- /dev/null +++ b/packages/js/experimental/src/experimental-list/test/index.tsx @@ -0,0 +1,355 @@ +/** + * External dependencies + */ +import { act, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ExperimentalList } from '../experimental-list'; +import { ExperimentalListItem } from '../experimental-list-item'; +import { ExperimentalCollapsibleList } from '../collapsible-list'; + +jest.mock( 'react-transition-group', () => { + const EmptyTransition: React.FC< { component?: string } > = ( { + children, + component, + } ) => { + if ( component === 'ul' ) { + return <ul>{ children }</ul>; + } + if ( component === 'ol' ) { + return <ol>{ children }</ol>; + } + return <div>{ children }</div>; + }; + return { + ...jest.requireActual( 'react-transition-group' ), + TransitionGroup: EmptyTransition, + CSSTransition: EmptyTransition, + }; +} ); + +describe( 'Experimental List', () => { + it( 'should render the new List which defaults to a ul component if items are not passed in', () => { + const { container } = render( + <ExperimentalList> + <div>Test</div> + </ExperimentalList> + ); + + expect( container.querySelector( 'ul' ) ).toBeInTheDocument(); + } ); + + it( 'should render children passed in', () => { + const { container } = render( + <ExperimentalList> + <div>Test</div> + </ExperimentalList> + ); + + expect( container ).toHaveTextContent( 'Test' ); + } ); + + it( 'should allow overriding the list type, and passing in arbitrary element props', () => { + const { container } = render( + <ExperimentalList listType="ol" role="menu"> + <div>Test</div> + </ExperimentalList> + ); + + expect( container.querySelector( 'ol' ) ).toBeInTheDocument(); + } ); + + describe( 'ExperimentalListItem', () => { + it( 'should render children passed in', () => { + const { container } = render( + <ExperimentalListItem> + <div>Test</div> + </ExperimentalListItem> + ); + + expect( container ).toHaveTextContent( 'Test' ); + } ); + + it( 'allows disabling the gutter styling', () => { + const { container } = render( + <ExperimentalListItem disableGutters> + <div>Test</div> + </ExperimentalListItem> + ); + + expect( + container.querySelector( '.has-gutters' ) + ).not.toBeInTheDocument(); + } ); + + it( 'should disable animations by default and for unsupported values', () => { + // disabled by default + const { container, rerender } = render( + <ExperimentalListItem> + <div>Test</div> + </ExperimentalListItem> + ); + + expect( + container.querySelector( '.transitions-disabled' ) + ).toBeInTheDocument(); + + // invalid value + rerender( + <ExperimentalListItem animation="none"> + <div>Test</div> + </ExperimentalListItem> + ); + + expect( + container.querySelector( '.transitions-disabled' ) + ).toBeInTheDocument(); + } ); + + it( 'should not disable animations if you provide a valid animation value', () => { + const { container } = render( + <ExperimentalListItem animation="slide-right"> + <div>Test</div> + </ExperimentalListItem> + ); + + expect( + container.querySelector( '.transitions-disabled' ) + ).not.toBeInTheDocument(); + } ); + + it( 'supports onClick on the list item, and handles keyboard events', () => { + const dummyOnClick = jest.fn(); + + const { container, queryByRole } = render( + <ExperimentalListItem onClick={ dummyOnClick }> + <div>Test</div> + </ExperimentalListItem> + ); + + const listItem = container.querySelector( + '.woocommerce-experimental-list__item' + ); + + if ( listItem ) { + userEvent.click( listItem ); + + // it doesn't actually matter what key you hit here while handleKeyDown is mocked. + userEvent.type( listItem, '{enter}' ); + } + + // TODO check that the button role was added. + expect( queryByRole( 'button' ) ).toBeInTheDocument(); + expect( dummyOnClick ).toHaveBeenCalled(); + } ); + + it( 'includes correct ARIA roles and a11y attributes when the item has an action', () => { + const clickHandler = jest.fn(); + render( + <ExperimentalListItem onClick={ clickHandler }> + <div>Test</div> + </ExperimentalListItem> + ); + + const item = screen.getByRole( 'button' ); + expect( item ).toBeInTheDocument(); + expect( item ).toHaveAttribute( 'role', 'button' ); + expect( item ).toHaveAttribute( 'tabindex', '0' ); + } ); + } ); + + describe( 'ExperimentalListItemCollapse', () => { + it( 'should not render its children intially, but an extra list footer with show text', () => { + const { container } = render( + <ExperimentalCollapsibleList + collapseLabel="Show less" + expandLabel="Show more items" + > + <div>Test</div> + </ExperimentalCollapsibleList> + ); + + expect( container ).not.toHaveTextContent( 'Test' ); + expect( container ).toHaveTextContent( 'Show more items' ); + } ); + + it( 'should render list items when footer is clicked and trigger onExpand', () => { + const onExpand = jest.fn(); + const onCollapse = jest.fn(); + const { container } = render( + <ExperimentalCollapsibleList + collapseLabel="Show less" + expandLabel="Show more items" + onExpand={ onExpand } + onCollapse={ onCollapse } + > + <div>Test</div> + <div>Test 2</div> + </ExperimentalCollapsibleList> + ); + + const listItem = container.querySelector( '.list-item-collapse' ); + + if ( listItem ) { + userEvent.click( listItem ); + } + expect( container ).toHaveTextContent( 'Test' ); + expect( container ).toHaveTextContent( 'Test 2' ); + expect( container ).not.toHaveTextContent( 'Show more items' ); + expect( container ).toHaveTextContent( 'Show less' ); + expect( onExpand ).toHaveBeenCalled(); + expect( onCollapse ).not.toHaveBeenCalled(); + } ); + + it( 'should render minimum children if minChildrenToShow is set and show the rest on expand', () => { + const onExpand = jest.fn(); + const onCollapse = jest.fn(); + const { container } = render( + <ExperimentalCollapsibleList + collapseLabel="Show less" + expandLabel="Show more items" + onExpand={ onExpand } + onCollapse={ onCollapse } + show={ 2 } + > + <div>Test</div> + <div>Test 2</div> + <div>Test 3</div> + <div>Test 4</div> + </ExperimentalCollapsibleList> + ); + + expect( container ).toHaveTextContent( 'Test' ); + expect( container ).toHaveTextContent( 'Test 2' ); + expect( container ).not.toHaveTextContent( 'Test 3' ); + expect( container ).not.toHaveTextContent( 'Test 4' ); + const listItem = container.querySelector( '.list-item-collapse' ); + + if ( listItem ) { + userEvent.click( listItem ); + } + expect( container ).toHaveTextContent( 'Test' ); + expect( container ).toHaveTextContent( 'Test 2' ); + expect( container ).toHaveTextContent( 'Test 3' ); + expect( container ).toHaveTextContent( 'Test 4' ); + expect( container ).not.toHaveTextContent( 'Show more items' ); + expect( container ).toHaveTextContent( 'Show less' ); + expect( onExpand ).toHaveBeenCalled(); + expect( onCollapse ).not.toHaveBeenCalled(); + } ); + + it( 'should correctly toggle the list', async () => { + const onExpand = jest.fn(); + const onCollapse = jest.fn(); + const { container } = render( + <ExperimentalCollapsibleList + collapseLabel="Show less" + expandLabel="Show more items" + onExpand={ onExpand } + onCollapse={ onCollapse } + > + <div id="test">Test</div> + <div>Test 2</div> + </ExperimentalCollapsibleList> + ); + + let listItem = container.querySelector( '.list-item-collapse' ); + + if ( listItem ) { + userEvent.click( listItem ); + } + expect( container ).toHaveTextContent( 'Test' ); + expect( container ).toHaveTextContent( 'Test 2' ); + expect( container ).not.toHaveTextContent( 'Show more items' ); + expect( container ).toHaveTextContent( 'Show less' ); + + listItem = container.querySelector( '.list-item-collapse' ); + + if ( listItem ) { + userEvent.click( listItem ); + } + expect( container ).toHaveTextContent( 'Show more items' ); + expect( container ).not.toHaveTextContent( 'Show less' ); + expect( onExpand ).toHaveBeenCalledTimes( 1 ); + expect( onCollapse ).toHaveBeenCalledTimes( 1 ); + } ); + + describe( 'staggering transition', () => { + const StaggerTestComponent = ( { list }: { list: string[] } ) => { + return ( + <ExperimentalCollapsibleList + collapseLabel="Show less" + expandLabel="Show more items" + show={ 2 } + > + { list.map( ( item ) => ( + <div key={ item }>{ item }</div> + ) ) } + </ExperimentalCollapsibleList> + ); + }; + + beforeEach( () => { + jest.useFakeTimers(); + } ); + + afterEach( () => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } ); + + it( 'should only update the shown items at first', () => { + const { queryByText, rerender } = render( + <StaggerTestComponent + list={ [ 'item-1', 'item-2', 'item-3' ] } + /> + ); + + expect( queryByText( 'Show more items' ) ).toBeInTheDocument(); + + act( () => + rerender( + <StaggerTestComponent list={ [ 'item-1', 'item-3' ] } /> + ) + ); + + expect( queryByText( 'Show more items' ) ).toBeInTheDocument(); + expect( queryByText( 'item-2' ) ).not.toBeInTheDocument(); + + act( () => { + jest.runAllTimers(); + } ); + } ); + + it( 'should update the hidden items as well after a 500ms timeout', () => { + const { queryByText, rerender } = render( + <StaggerTestComponent + list={ [ 'item-1', 'item-2', 'item-3' ] } + /> + ); + + expect( queryByText( 'Show more items' ) ).toBeInTheDocument(); + + act( () => + rerender( + <StaggerTestComponent list={ [ 'item-1', 'item-3' ] } /> + ) + ); + + expect( queryByText( 'item-3' ) ).not.toBeInTheDocument(); + + act( () => { + jest.advanceTimersByTime( 500 ); + } ); + expect( + queryByText( 'Show more items' ) + ).not.toBeInTheDocument(); + expect( queryByText( 'item-3' ) ).toBeInTheDocument(); + } ); + } ); + } ); +} ); diff --git a/packages/js/experimental/src/inbox-note/action.tsx b/packages/js/experimental/src/inbox-note/action.tsx new file mode 100644 index 00000000000..55aafd21453 --- /dev/null +++ b/packages/js/experimental/src/inbox-note/action.tsx @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import { Button } from '@wordpress/components'; +import { createElement, useState } from '@wordpress/element'; + +type InboxNoteActionProps = { + onClick: () => void; + label: string; + href?: string; + preventBusyState?: boolean; + variant: 'link' | 'secondary'; +}; + +/** + * Renders a secondary button that can also be a link. If href is provided it will + * automatically open it in a new tab/window. + */ +export const InboxNoteActionButton: React.FC< InboxNoteActionProps > = ( { + label, + onClick, + href, + preventBusyState, + variant = 'link', +} ) => { + const [ inAction, setInAction ] = useState( false ); + + const handleActionClick: React.MouseEventHandler< HTMLAnchorElement > = ( + event + ) => { + const targetHref = event.currentTarget.href || ''; + let isActionable = true; + + let adminUrl = ''; + if ( window.wcSettings ) { + adminUrl = window.wcSettings.adminUrl; + } + + if ( + targetHref.length && + ( ! adminUrl || ! targetHref.startsWith( adminUrl ) ) + ) { + event.preventDefault(); + isActionable = false; // link buttons shouldn't be "busy". + window.open( targetHref, '_blank' ); + } + + if ( preventBusyState ) { + isActionable = false; + } + + setInAction( isActionable ); + onClick(); + }; + + return ( + <Button + isSecondary={ variant === 'secondary' } + isLink={ variant === 'link' } + isBusy={ inAction } + disabled={ inAction } + href={ href } + onClick={ handleActionClick } + > + { label } + </Button> + ); +}; diff --git a/packages/js/experimental/src/inbox-note/inbox-dismiss-confirmation-modal.tsx b/packages/js/experimental/src/inbox-note/inbox-dismiss-confirmation-modal.tsx new file mode 100644 index 00000000000..e3933aac987 --- /dev/null +++ b/packages/js/experimental/src/inbox-note/inbox-dismiss-confirmation-modal.tsx @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, Modal } from '@wordpress/components'; +import { createElement, useState } from '@wordpress/element'; + +type ConfirmationModalProps = { + onClose: () => void; + onDismiss: () => void; + buttonLabel?: string; +}; + +export const InboxDismissConfirmationModal: React.FC< ConfirmationModalProps > = ( { + onClose, + onDismiss, + buttonLabel = __( "Yes, I'm sure", 'woocommerce' ), +} ) => { + const [ inAction, setInAction ] = useState( false ); + + return ( + <Modal + title={ __( 'Are you sure?', 'woocommerce' ) } + onRequestClose={ () => onClose() } + className="woocommerce-inbox-dismiss-confirmation_modal" + > + <div className="woocommerce-inbox-dismiss-confirmation_wrapper"> + <p> + { __( + 'Dismissed messages cannot be viewed again', + 'woocommerce' + ) } + </p> + <div className="woocommerce-inbox-dismiss-confirmation_buttons"> + <Button isSecondary onClick={ () => onClose() }> + { __( 'Cancel', 'woocommerce' ) } + </Button> + <Button + isSecondary + isBusy={ inAction } + disabled={ inAction } + onClick={ () => { + setInAction( true ); + onDismiss(); + } } + > + { buttonLabel } + </Button> + </div> + </div> + </Modal> + ); +}; diff --git a/packages/js/experimental/src/inbox-note/inbox-note.tsx b/packages/js/experimental/src/inbox-note/inbox-note.tsx new file mode 100644 index 00000000000..1df953e5fa0 --- /dev/null +++ b/packages/js/experimental/src/inbox-note/inbox-note.tsx @@ -0,0 +1,236 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createElement, Fragment, useState, useRef } from '@wordpress/element'; +import { Button } from '@wordpress/components'; +import VisibilitySensor from 'react-visibility-sensor'; +import moment from 'moment'; +import classnames from 'classnames'; +import { H, Section } from '@woocommerce/components'; +import { sanitize } from 'dompurify'; + +/** + * Internal dependencies + */ +import { InboxNoteActionButton } from './action'; +import { useCallbackOnLinkClick } from './use-callback-on-link-click'; + +const ALLOWED_TAGS = [ 'a', 'b', 'em', 'i', 'strong', 'p', 'br' ]; +const ALLOWED_ATTR = [ 'target', 'href', 'rel', 'name', 'download' ]; + +const sanitizeHTML = ( html: string ) => { + return { + __html: sanitize( html, { ALLOWED_TAGS, ALLOWED_ATTR } ), + }; +}; + +type InboxNoteAction = { + id: number; + url: string; + label: string; + primary: boolean; + actioned_text?: boolean; +}; + +type InboxNote = { + id: number; + status: string; + title: string; + name: string; + content: string; + date_created: string; + date_created_gmt: string; + actions: InboxNoteAction[]; + layout: string; + image: string; + is_deleted: boolean; + type: string; + is_read: boolean; +}; + +type InboxNoteProps = { + note: InboxNote; + onDismiss?: ( note: InboxNote ) => void; + onNoteActionClick?: ( note: InboxNote, action: InboxNoteAction ) => void; + onBodyLinkClick?: ( note: InboxNote, link: string ) => void; + onNoteVisible?: ( note: InboxNote ) => void; + className?: string; +}; + +const InboxNoteCard: React.FC< InboxNoteProps > = ( { + note, + onDismiss, + onNoteActionClick, + onBodyLinkClick, + onNoteVisible, + className, +} ) => { + const [ clickedActionText, setClickedActionText ] = useState( false ); + const hasBeenSeen = useRef( false ); + const linkCallbackRef = useCallbackOnLinkClick( ( innerLink ) => { + if ( onBodyLinkClick ) { + onBodyLinkClick( note, innerLink ); + } + } ); + + // Trigger a view Tracks event when the note is seen. + const onVisible = ( isVisible: boolean ) => { + if ( isVisible && ! hasBeenSeen.current ) { + if ( onNoteVisible ) { + onNoteVisible( note ); + } + + hasBeenSeen.current = true; + } + }; + + const renderDismissButton = () => { + if ( clickedActionText ) { + return null; + } + + return ( + <Button + className="woocommerce-admin-dismiss-notification" + onClick={ () => onDismiss && onDismiss( note ) } + > + { __( 'Dismiss', 'woocommerce' ) } + </Button> + ); + }; + + const onActionClicked = ( action: InboxNoteAction ) => { + if ( onNoteActionClick ) { + onNoteActionClick( note, action ); + } + if ( ! action.actioned_text ) { + return; + } + + setClickedActionText( action.actioned_text ); + }; + + const renderActions = () => { + const { actions: noteActions } = note; + + if ( !! clickedActionText ) { + return clickedActionText; + } + + if ( ! noteActions ) { + return; + } + + return ( + <> + { noteActions.map( ( action ) => ( + <InboxNoteActionButton + key={ action.id } + label={ action.label } + variant="secondary" + href={ + action && action.url && action.url.length + ? action.url + : undefined + } + onClick={ () => onActionClicked( action ) } + /> + ) ) } + </> + ); + }; + + const { + content, + date_created: dateCreated, + date_created_gmt: dateCreatedGmt, + image, + is_deleted: isDeleted, + layout, + status, + title, + is_read, + } = note; + + if ( isDeleted ) { + return null; + } + + const unread = is_read === false; + const hasImage = layout === 'thumbnail'; + const cardClassName = classnames( + 'woocommerce-inbox-message', + className, + layout, + { + 'message-is-unread': unread && status === 'unactioned', + } + ); + + const actionWrapperClassName = classnames( + 'woocommerce-inbox-message__actions', + { + 'has-multiple-actions': note.actions.length > 1, + } + ); + + return ( + <VisibilitySensor onChange={ onVisible }> + <section className={ cardClassName }> + { hasImage && ( + <div className="woocommerce-inbox-message__image"> + <img src={ image } alt="" /> + </div> + ) } + <div className="woocommerce-inbox-message__wrapper"> + <div className="woocommerce-inbox-message__content"> + { unread && ( + <div className="woocommerce-inbox-message__unread-indicator" /> + ) } + { dateCreatedGmt && ( + <span className="woocommerce-inbox-message__date"> + { moment.utc( dateCreatedGmt ).fromNow() } + </span> + ) } + <H className="woocommerce-inbox-message__title"> + { note.actions && note.actions.length === 1 && ( + <InboxNoteActionButton + key={ note.actions[ 0 ].id } + label={ title } + preventBusyState={ true } + variant="link" + href={ + note.actions[ 0 ].url && + note.actions[ 0 ].url.length + ? note.actions[ 0 ].url + : undefined + } + onClick={ () => + onActionClicked( note.actions[ 0 ] ) + } + /> + ) } + + { note.actions && note.actions.length > 1 && title } + </H> + <Section className="woocommerce-inbox-message__text"> + <span + dangerouslySetInnerHTML={ sanitizeHTML( + content + ) } + ref={ linkCallbackRef } + /> + </Section> + </div> + <div className={ actionWrapperClassName }> + { renderActions() } + { renderDismissButton() } + </div> + </div> + </section> + </VisibilitySensor> + ); +}; + +export { InboxNoteCard, InboxNote, InboxNoteAction }; diff --git a/packages/js/experimental/src/inbox-note/index.ts b/packages/js/experimental/src/inbox-note/index.ts new file mode 100644 index 00000000000..e24e80ad92f --- /dev/null +++ b/packages/js/experimental/src/inbox-note/index.ts @@ -0,0 +1,3 @@ +export * from './inbox-note'; +export * from './inbox-dismiss-confirmation-modal'; +export * from './placeholder'; diff --git a/packages/js/experimental/src/inbox-note/placeholder.tsx b/packages/js/experimental/src/inbox-note/placeholder.tsx new file mode 100644 index 00000000000..226e249072d --- /dev/null +++ b/packages/js/experimental/src/inbox-note/placeholder.tsx @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +type PlaceholderProps = { + className: string; +}; + +const InboxNotePlaceholder: React.FC< PlaceholderProps > = ( { + className, +} ) => { + return ( + <div + className={ `woocommerce-inbox-message is-placeholder ${ className }` } + aria-hidden + > + <div className="woocommerce-inbox-message__wrapper"> + <div className="woocommerce-inbox-message__content"> + <div className="woocommerce-inbox-message__date"> + <div className="sixth-line" /> + </div> + <div className="woocommerce-inbox-message__title"> + <div className="line" /> + <div className="line" /> + </div> + <div className="woocommerce-inbox-message__text"> + <div className="line" /> + <div className="third-line" /> + </div> + </div> + <div className="woocommerce-inbox-message__actions"> + <div className="fifth-line" /> + <div className="fifth-line" /> + </div> + </div> + </div> + ); +}; + +export { InboxNotePlaceholder }; diff --git a/packages/js/experimental/src/inbox-note/style.scss b/packages/js/experimental/src/inbox-note/style.scss new file mode 100644 index 00000000000..63835f1d335 --- /dev/null +++ b/packages/js/experimental/src/inbox-note/style.scss @@ -0,0 +1,231 @@ +.woocommerce-inbox-message { + position: relative; + color: $gray-text; + background: $studio-white; + border-radius: 2px; + @include font-size( 13 ); + margin: 0 0; + -ms-box-orient: horizontal; + &.banner { + -webkit-flex-direction: column; + flex-direction: column; + img { + width: 100%; + } + } + &.thumbnail { + display: flex; + -webkit-flex-direction: row-reverse; + flex-direction: row-reverse; + img { + width: 128px; + height: 100%; + } + } + &:hover { + background: $gray-100; + .woocommerce-inbox-message__actions button.woocommerce-admin-dismiss-notification { + visibility: visible; + } + } + + &:not(.message-is-unread) { + .woocommerce-inbox-message__title { + font-weight: normal; + a { + font-weight: normal; + } + } + } + + .woocommerce-homepage-column & { + margin: 20px 0; + } + + &:not(.is-placeholder) { + border: 0; + border-bottom: 1px solid $gray-200; + } + + .line { + width: 100%; + } + + .third-line { + width: 33%; + } + + .fifth-line { + width: 20%; + } + + .sixth-line { + width: 16%; + } +} + +.woocommerce-inbox-message__content { + .woocommerce-inbox-message__title { + color: $gray-900; + @include font-size( 16 ); + font-style: normal; + line-height: 1.5; + font-weight: bold; + margin: $gap-smaller 0; + + .is-placeholder & { + & > div { + @include placeholder(); + margin: 5px 0; + } + margin-bottom: 10px; + } + + a { + @extend .woocommerce-inbox-message__title; + color: $gray-900 !important; + text-decoration: none !important; + } + } + + .woocommerce-inbox-message__date { + color: $gray-700; + @include font-size( 12 ); + margin-bottom: $gap; + font-style: normal; + font-weight: normal; + line-height: 16px; + .is-placeholder & { + & > div { + @include placeholder(); + } + margin-bottom: 10px; + } + } +} + +.woocommerce-inbox-message__wrapper .woocommerce-inbox-message__content { + padding-bottom: 0; +} + +.woocommerce-inbox-message__text { + color: $gray-700; + font-style: normal; + font-weight: normal; + @include font-size( 14 ); + line-height: 20px; + & > p:first-child { + margin-top: 0; + } + + & > p:last-child { + margin-bottom: 0; + } + + .is-placeholder & { + & > div { + @include placeholder(); + margin: 5px 0; + } + } +} + +.woocommerce-inbox-message__actions { + // Ensures any immediate child with a sibling has space between the items + & > * + * { + margin-left: 0.5em; + } + + a, + button { + cursor: pointer; + &.is-link { + text-decoration: none; + } + } + + &.has-multiple-actions { + button.is-link { + padding: 6px 12px; + border: 1px solid var(--wp-admin-theme-color); + margin-right: 0.5em; + } + a { + margin-right: 0.5em; + } + } + + border-top: 0; + + button.woocommerce-admin-dismiss-notification { + color: $gray-700; + &:hover { + box-shadow: none !important; + } + visibility: hidden; + } + + .components-dropdown { + display: inline; + + .components-popover__content { + min-width: 195px; + ul { + text-align: center; + } + li { + margin: 0; + cursor: pointer; + } + } + } + + .is-placeholder & { + & > div { + @include placeholder(); + float: left; + height: 28px; + margin-right: 8px; + } + } +} +.woocommerce-inbox-message__wrapper { + padding-top: 0; +} + +.woocommerce-inbox-dismiss-confirmation_modal { + text-align: left; +} +.woocommerce-inbox-dismiss-confirmation_wrapper { + p { + font-size: 16px; + color: $gray-700; + } + .woocommerce-inbox-dismiss-confirmation_buttons { + text-align: right; + button { + margin-left: 10px; + } + } +} + +.woocommerce-inbox-message__wrapper > div { + padding: $gap $gap-large; + .is-placeholder & { + padding: 10px 24px; + display: flow-root; + } +} + +// Tweak to fix dropdown and placeholder in IE 11 +@media all and ( -ms-high-contrast: none ), ( -ms-high-contrast: active ) { + .woocommerce-admin-dismiss-dropdown { + margin-top: 0; + } + + .woocommerce-inbox-message__wrapper { + .is-placeholder & { + padding-bottom: 10px; + } + } +} diff --git a/packages/js/experimental/src/inbox-note/test/inbox-dismiss-confirmation-modal.tsx b/packages/js/experimental/src/inbox-note/test/inbox-dismiss-confirmation-modal.tsx new file mode 100644 index 00000000000..01ef4d1cdda --- /dev/null +++ b/packages/js/experimental/src/inbox-note/test/inbox-dismiss-confirmation-modal.tsx @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { InboxDismissConfirmationModal } from '../inbox-dismiss-confirmation-modal'; + +describe( 'InboxDismissConfirmationModal', () => { + it( "should render with default button label - Yes, I'am sure", () => { + const { queryByText } = render( + <InboxDismissConfirmationModal + onClose={ jest.fn() } + onDismiss={ jest.fn() } + /> + ); + expect( queryByText( "Yes, I'm sure" ) ).toBeInTheDocument(); + } ); + + it( 'should render passed in button label if provided', () => { + const { queryByText } = render( + <InboxDismissConfirmationModal + onClose={ jest.fn() } + onDismiss={ jest.fn() } + buttonLabel="Custom button" + /> + ); + expect( queryByText( 'Custom button' ) ).toBeInTheDocument(); + } ); + + it( 'should call onClose if Cancel is clicked', () => { + const onClose = jest.fn(); + const { getByText } = render( + <InboxDismissConfirmationModal + onClose={ onClose } + onDismiss={ jest.fn() } + /> + ); + userEvent.click( getByText( 'Cancel' ) ); + expect( onClose ).toHaveBeenCalled(); + } ); + + it( 'should call onDismiss if dismiss button is clicked', () => { + const onDismiss = jest.fn(); + const { getByText } = render( + <InboxDismissConfirmationModal + onClose={ jest.fn() } + onDismiss={ onDismiss } + /> + ); + userEvent.click( getByText( "Yes, I'm sure" ) ); + expect( onDismiss ).toHaveBeenCalled(); + } ); +} ); diff --git a/packages/js/experimental/src/inbox-note/test/inbox-note.tsx b/packages/js/experimental/src/inbox-note/test/inbox-note.tsx new file mode 100644 index 00000000000..f1078577e04 --- /dev/null +++ b/packages/js/experimental/src/inbox-note/test/inbox-note.tsx @@ -0,0 +1,215 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement, Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { InboxNoteCard } from '../inbox-note'; + +jest.mock( 'react-visibility-sensor', () => + jest.fn().mockImplementation( ( { children, onChange } ) => { + return ( + <> + <button onClick={ () => onChange( true ) }> + Trigger change + </button> + { children } + </> + ); + } ) +); + +describe( 'InboxNoteCard', () => { + const note = { + id: 1, + name: 'wc-admin-wc-helper-connection', + type: 'info', + title: 'Connect to WooCommerce.com', + content: 'Connect to get important product notifications and updates.', + status: 'unactioned', + date_created: '2020-05-10T16:57:31', + actions: [ + { + id: 1, + name: 'connect', + label: 'Connect', + query: '', + status: 'unactioned', + primary: false, + url: 'http://test.com', + }, + { + id: 2, + name: 'learnmore', + label: 'Learn More', + query: '', + status: 'unactioned', + primary: false, + url: 'http://test.com', + }, + ], + layout: 'plain', + image: '', + date_created_gmt: '2020-05-10T16:57:31', + is_deleted: false, + is_read: false, + }; + + it( 'should render the defined action buttons', () => { + const { queryByText } = render( + <InboxNoteCard key={ note.id } note={ note } /> + ); + expect( queryByText( 'Connect' ) ).toBeInTheDocument(); + expect( queryByText( 'Learn More' ) ).toBeInTheDocument(); + } ); + + it( 'should render a dismiss button', () => { + const { queryByText } = render( + <InboxNoteCard key={ note.id } note={ note } /> + ); + expect( queryByText( 'Dismiss' ) ).toBeInTheDocument(); + } ); + + it( 'should render a notification type banner', () => { + const bannerNote = { ...note, layout: 'banner' }; + const { container } = render( + <InboxNoteCard key={ bannerNote.id } note={ bannerNote } /> + ); + const listNoteWithBanner = container.querySelector( '.banner' ); + expect( listNoteWithBanner ).not.toBeNull(); + } ); + + it( 'should render a notification type thumbnail', () => { + const thumbnailNote = { ...note, layout: 'thumbnail' }; + const { container } = render( + <InboxNoteCard key={ thumbnailNote.id } note={ thumbnailNote } /> + ); + const listNoteWithThumbnail = container.querySelector( '.thumbnail' ); + expect( listNoteWithThumbnail ).not.toBeNull(); + } ); + + it( 'should render a read notification', () => { + const noteWithoutActions = { + ...{ ...note, is_read: true }, + actions: [], + }; + const { container } = render( + <InboxNoteCard key={ note.id } note={ noteWithoutActions } /> + ); + const unreadNote = container.querySelector( '.message-is-unread' ); + const readNote = container.querySelector( + '.woocommerce-inbox-message' + ); + expect( unreadNote ).toBeNull(); + expect( readNote ).not.toBeNull(); + } ); + + it( 'should render an unread notification', () => { + const noteWithoutActions = { + ...note, + actions: [], + }; + const { container } = render( + <InboxNoteCard key={ note.id } note={ noteWithoutActions } /> + ); + const unreadNote = container.querySelector( '.message-is-unread' ); + expect( unreadNote ).not.toBeNull(); + } ); + + it( 'should not render any notification', () => { + const deletedNote = { ...note, is_deleted: true }; + const { container } = render( + <InboxNoteCard key={ note.id } note={ deletedNote } /> + ); + const unreadNote = container.querySelector( + '.woocommerce-inbox-message' + ); + expect( unreadNote ).toBeNull(); + } ); + + describe( 'callbacks', () => { + it( 'should call onDismiss with note when "Dismiss this message" is clicked', () => { + const onDismiss = jest.fn(); + const { getByText } = render( + <InboxNoteCard + key={ note.id } + note={ note } + onDismiss={ onDismiss } + /> + ); + userEvent.click( getByText( 'Dismiss' ) ); + expect( onDismiss ).toHaveBeenCalledWith( note ); + } ); + + it( 'should call onNoteActionClick with specific action when action is clicked', () => { + const onNoteActionClick = jest.fn(); + const { getByText } = render( + <InboxNoteCard + key={ note.id } + note={ note } + onNoteActionClick={ onNoteActionClick } + /> + ); + userEvent.click( getByText( 'Learn More' ) ); + expect( onNoteActionClick ).toHaveBeenCalledWith( + note, + note.actions[ 1 ] + ); + } ); + + it( 'should call onBodyLinkClick with innerLink if link within content is clicked', () => { + const onBodyLinkClick = jest.fn(); + const noteWithInnerLink = { + ...note, + content: + note.content + + ' <a href="http://somewhere.com">Somewhere</a>', + }; + const { getByText } = render( + <InboxNoteCard + key={ noteWithInnerLink.id } + note={ noteWithInnerLink } + onBodyLinkClick={ onBodyLinkClick } + /> + ); + userEvent.click( getByText( 'Somewhere' ) ); + expect( onBodyLinkClick ).toHaveBeenCalledWith( + noteWithInnerLink, + 'http://somewhere.com/' + ); + } ); + + it( 'should call onVisible when visiblity sensor calls it', () => { + const onVisible = jest.fn(); + const { getByText } = render( + <InboxNoteCard + key={ note.id } + note={ note } + onNoteVisible={ onVisible } + /> + ); + expect( onVisible ).not.toHaveBeenCalled(); + userEvent.click( getByText( 'Trigger change' ) ); + expect( onVisible ).toHaveBeenCalledWith( note ); + } ); + + it( 'should call onVisible when visiblity sensor calls it, but only once', () => { + const onVisible = jest.fn(); + const { getByText } = render( + <InboxNoteCard + key={ note.id } + note={ note } + onNoteVisible={ onVisible } + /> + ); + userEvent.click( getByText( 'Trigger change' ) ); + userEvent.click( getByText( 'Trigger change' ) ); + userEvent.click( getByText( 'Trigger change' ) ); + expect( onVisible ).toHaveBeenCalledTimes( 1 ); + } ); + } ); +} ); diff --git a/packages/js/experimental/src/inbox-note/test/use-callback-on-link-click.tsx b/packages/js/experimental/src/inbox-note/test/use-callback-on-link-click.tsx new file mode 100644 index 00000000000..a7c4d334565 --- /dev/null +++ b/packages/js/experimental/src/inbox-note/test/use-callback-on-link-click.tsx @@ -0,0 +1,45 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useCallbackOnLinkClick } from '../use-callback-on-link-click'; + +const TestComp = ( { callback }: { callback: ( link: string ) => void } ) => { + const containerRef = useCallbackOnLinkClick( ( link ) => { + callback( link ); + } ); + + return ( + <span ref={ containerRef }> + Some Text + <br /> + <span> + Inner paragraph + <a href="http://tosomewhere.com">Link</a> + </span> + <button>Button</button> + </span> + ); +}; + +describe( 'useCallbackOnLinkClick hook', () => { + it( 'should call callback with link when inner anchor element is clicked', () => { + const callback = jest.fn(); + const { getByText } = render( <TestComp callback={ callback } /> ); + userEvent.click( getByText( 'Link' ) ); + expect( callback ).toHaveBeenCalledWith( 'http://tosomewhere.com/' ); + } ); + + it( 'should not call callback if click event target does not have an href', () => { + const callback = jest.fn(); + const { getByText } = render( <TestComp callback={ callback } /> ); + userEvent.click( getByText( 'Button' ) ); + expect( callback ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/packages/js/experimental/src/inbox-note/use-callback-on-link-click.ts b/packages/js/experimental/src/inbox-note/use-callback-on-link-click.ts new file mode 100644 index 00000000000..e7243aff958 --- /dev/null +++ b/packages/js/experimental/src/inbox-note/use-callback-on-link-click.ts @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { useCallback } from '@wordpress/element'; + +export function useCallbackOnLinkClick( onClick: ( link: string ) => void ) { + const onNodeClick = useCallback( + ( event: MouseEvent ): void => { + const target = event.target as + | EventTarget + | HTMLAnchorElement + | null; + if ( target && 'href' in target ) { + const innerLink = target.href; + if ( innerLink && onClick ) { + onClick( innerLink ); + } + } + }, + [ onClick ] + ); + + return useCallback( + ( node: HTMLElement ) => { + if ( node ) { + node.addEventListener( 'click', onNodeClick ); + } + return () => { + if ( node ) { + node.removeEventListener( 'click', onNodeClick ); + } + }; + }, + [ onNodeClick ] + ); +} diff --git a/packages/js/experimental/src/index.js b/packages/js/experimental/src/index.js new file mode 100644 index 00000000000..2e1801e1696 --- /dev/null +++ b/packages/js/experimental/src/index.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { + __experimentalNavigation, + __experimentalNavigationBackButton, + __experimentalNavigationGroup, + __experimentalNavigationMenu, + __experimentalNavigationItem, + __experimentalText, + __experimentalUseSlot, + Navigation as NavigationComponent, + NavigationBackButton as NavigationBackButtonComponent, + NavigationGroup as NavigationGroupComponent, + NavigationMenu as NavigationMenuComponent, + NavigationItem as NavigationItemComponent, + Text as TextComponent, + useSlot as useSlotHook, +} from '@wordpress/components'; + +/** + * Prioritize exports of non-experimental components over experimental. + */ +export const Navigation = NavigationComponent || __experimentalNavigation; +export const NavigationBackButton = + NavigationBackButtonComponent || __experimentalNavigationBackButton; +export const NavigationGroup = + NavigationGroupComponent || __experimentalNavigationGroup; +export const NavigationMenu = + NavigationMenuComponent || __experimentalNavigationMenu; +export const NavigationItem = + NavigationItemComponent || __experimentalNavigationItem; +export const Text = TextComponent || __experimentalText; +export const useSlot = useSlotHook || __experimentalUseSlot; + +export { ExperimentalListItem as ListItem } from './experimental-list/experimental-list-item'; +export { ExperimentalList as List } from './experimental-list/experimental-list'; +export { ExperimentalCollapsibleList as CollapsibleList } from './experimental-list/collapsible-list'; +export { TaskItem } from './experimental-list/task-item'; +export * from './inbox-note'; + +export * from './vertical-css-transition'; diff --git a/packages/js/experimental/src/style.scss b/packages/js/experimental/src/style.scss new file mode 100644 index 00000000000..61ad27d6ea6 --- /dev/null +++ b/packages/js/experimental/src/style.scss @@ -0,0 +1,7 @@ +/** + * Internal Dependencies + */ +@import 'experimental-list/style.scss'; +@import 'experimental-list/collapsible-list/style.scss'; +@import 'experimental-list/task-item/style.scss'; +@import 'inbox-note/style.scss'; diff --git a/packages/js/experimental/src/vertical-css-transition/README.md b/packages/js/experimental/src/vertical-css-transition/README.md new file mode 100644 index 00000000000..f4063eeab09 --- /dev/null +++ b/packages/js/experimental/src/vertical-css-transition/README.md @@ -0,0 +1,36 @@ +# VerticalCSSTransition + +This is a wrapper to the [React CSSTransition](https://reactcommunity.org/react-transition-group/css-transition) component, allowing for each vertical height transitions (collapsing/expanding). CSS does not support height: auto transitions, this uses JavaScript instead. + +## Usage + +```jsx +<VerticalCSSTransition timeout={ 500 } in={ true } classNames="my-node" defaultStyle={ transitionProperty: 'max-height, opacity' }> + <div>some content</div> + <div> + some more content <br /> line 2 <br /> line 3 + </div> +</VerticalCSSTransition> +``` + +```css +.my-node-enter { + opacity: 0; +} + +.my-node-enter-active { + opacity: 1; +} +``` + +### Props + +Props extends the [CSSTransition props](https://reactcommunity.org/react-transition-group/css-transition#CSSTransition-props). +Name | Type | Default | Description +--- | --- | --- | --- +`defaultStyle` | CSSProperties | `null` | Custom CSS properties for the transition component. + +### defaultStyle + +`defaultStyle` is used to extend the current list of CSS properties added by this component. It also allows you to add extra transition +properties by overwriting the `transitionProperty` property (see above for example). diff --git a/packages/js/experimental/src/vertical-css-transition/index.ts b/packages/js/experimental/src/vertical-css-transition/index.ts new file mode 100644 index 00000000000..978ac9bc86b --- /dev/null +++ b/packages/js/experimental/src/vertical-css-transition/index.ts @@ -0,0 +1 @@ +export * from './vertical-css-transition'; diff --git a/packages/js/experimental/src/vertical-css-transition/stories/index.tsx b/packages/js/experimental/src/vertical-css-transition/stories/index.tsx new file mode 100644 index 00000000000..976b4b51146 --- /dev/null +++ b/packages/js/experimental/src/vertical-css-transition/stories/index.tsx @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { createElement, Fragment, useState } from '@wordpress/element'; +import { withConsole } from '@storybook/addon-console'; +import { Meta, Story } from '@storybook/react'; + +/** + * Internal dependencies + */ +import { + VerticalCSSTransition, + VerticalCSSTransitionProps, +} from '../vertical-css-transition'; +import './style.scss'; + +export default { + title: 'WooCommerce Admin/experimental/VerticalCSSTransition', + component: VerticalCSSTransition, + decorators: [ ( storyFn, context ) => withConsole()( storyFn )( context ) ], +} as Meta; + +const Parent: React.FC< VerticalCSSTransitionProps > = ( args ) => { + const [ expanded, setExpanded ] = useState( true ); + return ( + <> + <button onClick={ () => setExpanded( ! expanded ) }> + { expanded ? 'collapse' : 'expand' } + </button> + <VerticalCSSTransition { ...args } in={ expanded }> + <div>some content</div> + <div> + some more content <br /> line 2 <br /> line 3 + </div> + </VerticalCSSTransition> + </> + ); +}; + +const Template: Story< VerticalCSSTransitionProps > = ( args ) => ( + <Parent { ...args } /> +); + +export const Primary = Template.bind( { onClick: () => {} } ); + +Primary.args = { + appear: true, + timeout: 500, + classNames: 'collapsible-content', + defaultStyle: { + transitionProperty: 'max-height, opacity', + }, +}; diff --git a/packages/js/experimental/src/vertical-css-transition/stories/style.scss b/packages/js/experimental/src/vertical-css-transition/stories/style.scss new file mode 100644 index 00000000000..84677803cd1 --- /dev/null +++ b/packages/js/experimental/src/vertical-css-transition/stories/style.scss @@ -0,0 +1,23 @@ +.collapsible-content-enter, +.collapsible-content-exit-done { + opacity: 0; +} + +.collapsible-content-enter-active { + opacity: 1; +} + +.collapsible-content-appear, +.collapsible-content-appear-active, +.collapsible-content-appear-done, +.collapsible-content-enter-done { + opacity: 1; +} + +.collapsible-content-exit { + opacity: 1; +} + +.collapsible-content-exit-active { + opacity: 0; +} diff --git a/packages/js/experimental/src/vertical-css-transition/test/vertical-css-transition.tsx b/packages/js/experimental/src/vertical-css-transition/test/vertical-css-transition.tsx new file mode 100644 index 00000000000..b59e749dc3e --- /dev/null +++ b/packages/js/experimental/src/vertical-css-transition/test/vertical-css-transition.tsx @@ -0,0 +1,303 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createElement, createRef } from '@wordpress/element'; +/** + * Internal dependencies + */ +import { + VerticalCSSTransition, + VerticalCSSTransitionProps, +} from '../vertical-css-transition'; + +describe( 'VerticalCSSTransition', () => { + const originalClientHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'clientHeight' + ); + + beforeEach( () => { + Object.defineProperty( HTMLElement.prototype, 'clientHeight', { + configurable: true, + value: 100, + } ); + } ); + + afterEach( () => { + if ( originalClientHeight ) { + Object.defineProperty( + HTMLElement.prototype, + 'offsetHeight', + originalClientHeight + ); + } + } ); + + it( 'should set maxHeight of children to container on entering and remove it when entered', ( done ) => { + const nodeRef = createRef< undefined | HTMLDivElement >(); + let onEnteringCalledCount = 0; + const props: VerticalCSSTransitionProps = { + in: false, + timeout: 0, + nodeRef: nodeRef as React.RefObject< undefined >, + classNames: 'test', + onEntering: () => { + onEnteringCalledCount++; + expect( + nodeRef.current && + nodeRef.current.parentElement?.style.maxHeight + ).toBe( '100px' ); + }, + onEntered: () => { + expect( + nodeRef.current && + nodeRef.current.parentElement?.style.maxHeight + ).toBe( '' ); + expect( onEnteringCalledCount ).toEqual( 1 ); + done(); + }, + }; + const { rerender } = render( + <VerticalCSSTransition { ...props }> + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + jest.runOnlyPendingTimers(); + + rerender( + <VerticalCSSTransition { ...props } in={ true }> + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + jest.runOnlyPendingTimers(); + } ); + + it( 'should update maxHeight when children are updated', ( done ) => { + const nodeRef = createRef< undefined | HTMLDivElement >(); + let onEnteringCalledCount = 0; + const props: VerticalCSSTransitionProps = { + in: false, + timeout: 0, + nodeRef: nodeRef as React.RefObject< undefined >, + classNames: 'test', + onEntering: () => { + onEnteringCalledCount++; + expect( + nodeRef.current && + nodeRef.current.parentElement?.style.maxHeight + ).toBe( '200px' ); + }, + onEntered: () => { + expect( + nodeRef.current && + nodeRef.current.parentElement?.style.maxHeight + ).toBe( '' ); + expect( onEnteringCalledCount ).toEqual( 1 ); + done(); + }, + }; + const { rerender } = render( + <VerticalCSSTransition { ...props }> + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + jest.runOnlyPendingTimers(); + + rerender( + <VerticalCSSTransition { ...props } in={ true }> + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + <div>New child</div> + </VerticalCSSTransition> + ); + expect( + nodeRef.current && nodeRef.current.parentElement?.style.maxHeight + ).toBe( '200px' ); + jest.runOnlyPendingTimers(); + } ); + + it( 'should set maxHeight to zero if in is set to false', () => { + const nodeRef = createRef< undefined | HTMLDivElement >(); + render( + <VerticalCSSTransition + in={ false } + timeout={ 0 } + nodeRef={ nodeRef as React.RefObject< undefined > } + classNames="test" + > + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + + expect( + nodeRef.current && nodeRef.current.parentElement?.style.maxHeight + ).toBe( '0' ); + } ); + + it( 'should not set transition variables when not in transition', () => { + const nodeRef = createRef< undefined | HTMLDivElement >(); + render( + <VerticalCSSTransition + in={ true } + timeout={ 0 } + nodeRef={ nodeRef as React.RefObject< undefined > } + classNames="test" + > + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + + expect( + nodeRef.current && + nodeRef.current.parentElement?.style.transitionDuration + ).toBe( '' ); + expect( + nodeRef.current && + nodeRef.current.parentElement?.style.transitionProperty + ).toBe( '' ); + } ); + + it( 'should add transition style properties when in transition', ( done ) => { + const nodeRef = createRef< undefined | HTMLDivElement >(); + render( + <VerticalCSSTransition + in={ true } + appear + timeout={ 0 } + nodeRef={ nodeRef as React.RefObject< undefined > } + classNames="test" + onEntering={ () => { + expect( + nodeRef.current && + nodeRef.current.parentElement?.style + .transitionDuration + ).toBe( '0ms' ); + expect( + nodeRef.current && + nodeRef.current.parentElement?.style + .transitionProperty + ).toBe( 'max-height' ); + done(); + } } + > + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + } ); + + it( 'should still set css classes on enter transition', ( done ) => { + const nodeRef = createRef< undefined | HTMLDivElement >(); + const props: VerticalCSSTransitionProps = { + in: false, + timeout: 0, + nodeRef: nodeRef as React.RefObject< undefined >, + classNames: 'test', + onEntering: () => { + expect( + nodeRef.current && + nodeRef.current.classList.contains( 'test-enter' ) + ).toEqual( true ); + expect( + nodeRef.current && + nodeRef.current.classList.contains( + 'test-enter-active' + ) + ).toEqual( true ); + done(); + }, + }; + const { rerender } = render( + <VerticalCSSTransition { ...props }> + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + rerender( + <VerticalCSSTransition { ...props } in={ true }> + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + } ); + + it( 'should still set css classes on exit transition', ( done ) => { + const nodeRef = createRef< undefined | HTMLDivElement >(); + const props: VerticalCSSTransitionProps = { + in: true, + timeout: 0, + nodeRef: nodeRef as React.RefObject< undefined >, + classNames: 'test', + onExiting: () => { + expect( + nodeRef.current && + nodeRef.current.classList.contains( 'test-exit' ) + ).toEqual( true ); + expect( + nodeRef.current && + nodeRef.current.classList.contains( 'test-exit-active' ) + ).toEqual( true ); + done(); + }, + }; + const { rerender } = render( + <VerticalCSSTransition { ...props }> + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + rerender( + <VerticalCSSTransition { ...props } in={ false }> + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + } ); + + describe( 'defaultStyle', () => { + it( 'should overwrite default style when passed in', ( done ) => { + const nodeRef = createRef< undefined | HTMLDivElement >(); + render( + <VerticalCSSTransition + in={ true } + appear + timeout={ 0 } + nodeRef={ nodeRef as React.RefObject< undefined > } + classNames="test" + defaultStyle={ { + transitionProperty: 'max-height, opacity', + } } + onEntering={ () => { + expect( + nodeRef.current && + nodeRef.current.parentElement?.style + .transitionProperty + ).toBe( 'max-height, opacity' ); + done(); + } } + > + <div ref={ nodeRef as React.RefObject< HTMLDivElement > }> + Test + </div> + </VerticalCSSTransition> + ); + } ); + } ); +} ); diff --git a/packages/js/experimental/src/vertical-css-transition/vertical-css-transition.tsx b/packages/js/experimental/src/vertical-css-transition/vertical-css-transition.tsx new file mode 100644 index 00000000000..d228e3ca029 --- /dev/null +++ b/packages/js/experimental/src/vertical-css-transition/vertical-css-transition.tsx @@ -0,0 +1,135 @@ +/** + * External dependencies + */ +import { + createElement, + useState, + useCallback, + useEffect, + useRef, +} from '@wordpress/element'; +import { CSSTransitionProps } from 'react-transition-group/CSSTransition'; +import { CSSTransition } from 'react-transition-group'; + +export type VerticalCSSTransitionProps< + Ref extends HTMLElement | undefined = undefined +> = CSSTransitionProps< Ref > & { + defaultStyle?: React.CSSProperties; +}; + +function getContainerHeight( container: HTMLDivElement ) { + let containerHeight = 0; + for ( const child of container.children ) { + containerHeight += child.clientHeight; + const style = window.getComputedStyle( child ); + + containerHeight += parseInt( style.marginTop, 10 ) || 0; + containerHeight += parseInt( style.marginBottom, 10 ) || 0; + } + return containerHeight; +} + +/** + * VerticalCSSTransition is a wrapper for CSSTransition, automatically adding a vertical height transition. + * The maxHeight is calculated through JS, something CSS does not support. + */ +export const VerticalCSSTransition: React.FC< VerticalCSSTransitionProps > = ( { + children, + defaultStyle, + ...props +} ) => { + const [ containerHeight, setContainerHeight ] = useState( 0 ); + const [ transitionIn, setTransitionIn ] = useState( props.in || false ); + const cssTransitionRef = useRef< CSSTransition< HTMLElement > | null >( + null + ); + const collapseContainerRef = useCallback( + ( containerElement: HTMLDivElement ) => { + if ( containerElement ) { + setContainerHeight( getContainerHeight( containerElement ) ); + } + }, + [ children ] + ); + + useEffect( () => { + setTransitionIn( props.in || false ); + }, [ props.in ] ); + + const getTimeouts = () => { + const { timeout } = props; + let exit, enter, appear; + + if ( typeof timeout === 'number' ) { + exit = enter = appear = timeout; + } + + if ( timeout !== undefined && typeof timeout !== 'number' ) { + exit = timeout.exit; + enter = timeout.enter; + appear = timeout.appear !== undefined ? timeout.appear : enter; + } + return { exit, enter, appear }; + }; + + const transitionStyles = { + entered: { maxHeight: containerHeight }, + entering: { maxHeight: containerHeight }, + exiting: { maxHeight: 0 }, + exited: { maxHeight: 0 }, + }; + + const getTransitionStyle = ( + state: 'entering' | 'entered' | 'exiting' | 'exited' + ) => { + const timeouts = getTimeouts(); + const appearing = + cssTransitionRef.current && + cssTransitionRef.current.context && + cssTransitionRef.current.context.isMounting; + let duration; + if ( state.startsWith( 'enter' ) ) { + duration = timeouts[ appearing ? 'enter' : 'appear' ]; + } else { + duration = timeouts.exit; + } + + const styles: React.CSSProperties = { + transitionProperty: 'max-height', + transitionDuration: + duration === undefined ? '500ms' : duration + 'ms', + overflow: 'hidden', + ...( defaultStyle || {} ), + ...transitionStyles[ state ], + }; + // only include transition styles when entering or exiting. + if ( state !== 'entering' && state !== 'exiting' ) { + delete styles.transitionDuration; + delete styles.transition; + delete styles.transitionProperty; + } + // Remove maxHeight when entered, so we do not need to worry about nested items changing height while expanded. + if ( state === 'entered' && props.in ) { + delete styles.maxHeight; + } + return styles; + }; + + return ( + <CSSTransition + { ...props } + in={ transitionIn } + ref={ cssTransitionRef } + > + { ( state: 'entering' | 'entered' | 'exiting' | 'exited' ) => ( + <div + className="vertical-css-transition-container" + style={ getTransitionStyle( state ) } + ref={ collapseContainerRef } + > + { children } + </div> + ) } + </CSSTransition> + ); +}; diff --git a/packages/js/experimental/tsconfig-cjs.json b/packages/js/experimental/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/experimental/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/experimental/tsconfig.json b/packages/js/experimental/tsconfig.json new file mode 100644 index 00000000000..b2ffc578087 --- /dev/null +++ b/packages/js/experimental/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module", + "target": "es2019", + "declaration": true, + "declarationMap": true, + "declarationDir": "./build-types" + } +} diff --git a/packages/js/experimental/typings/global.d.ts b/packages/js/experimental/typings/global.d.ts new file mode 100644 index 00000000000..981b7be303c --- /dev/null +++ b/packages/js/experimental/typings/global.d.ts @@ -0,0 +1,10 @@ +declare global { + interface Window { + wcSettings: { + adminUrl: string; + }; + } +} + +/*~ If your module exports nothing, you'll need this line. Otherwise, delete it */ +export {}; diff --git a/packages/js/experimental/typings/index.d.ts b/packages/js/experimental/typings/index.d.ts new file mode 100644 index 00000000000..d2e9b92c988 --- /dev/null +++ b/packages/js/experimental/typings/index.d.ts @@ -0,0 +1,8 @@ +declare module '@woocommerce/components'; +declare module 'gridicons/dist/*' { + const value: React.ElementType< { + size?: 12 | 18 | 24 | 36 | 48 | 54 | 72; + onClick?: ( event: MouseEvent | KeyboardEvent ) => void; + } >; + export default value; +} diff --git a/packages/js/experimental/webpack.config.js b/packages/js/experimental/webpack.config.js new file mode 100644 index 00000000000..5c56294e0f0 --- /dev/null +++ b/packages/js/experimental/webpack.config.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +const { webpackConfig } = require( '@woocommerce/style-build' ); + +module.exports = { + mode: process.env.NODE_ENV || 'development', + entry: { + 'build-style': __dirname + '/src/style.scss', + }, + output: { + path: __dirname, + }, + module: { + rules: webpackConfig.rules, + }, + plugins: webpackConfig.plugins, +}; diff --git a/packages/js/explat/.eslintrc.js b/packages/js/explat/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/explat/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/explat/.npmrc b/packages/js/explat/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/explat/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/explat/CHANGELOG.md b/packages/js/explat/CHANGELOG.md new file mode 100644 index 00000000000..e0f138a9e04 --- /dev/null +++ b/packages/js/explat/CHANGELOG.md @@ -0,0 +1,46 @@ +# Unreleased + +- Update dependency `@wordpress/hooks` to ^3.5.0 +- Added Typescript type declarations. #32615 +# 2.1.0 + +- Add missing dependencies. #8349 +- Update all js packages with minor/patch version changes. #8392 +- Add `@wordpress/api-fetch` as dependencies. #8428 +- Export `*WithAuth` methods to authenticate WPCOM users. #8428 +# 2.0.0 + +- Make ExPlat request URL args filterable. Added woocommerce_explat_request_args filter #8231 +## Breaking changes + +- Update dependencies to support react 17. #8305 +- Drop support for IE11. #8305 + +# 1.1.4 + +- Fix an error when getting woocommerce_default_country value. #7600 +- Attempts to get the woocommerce_default_country value in wcSettings.preloadSettings.general first for the backward compatibility #7600 + +# 1.1.3 + +- Retry fix for missing build-module folder + +# 1.1.2 + +- Fix missing build-module folder +# 1.1.1 + +- Update add woo_country_code param when fetching an assignment #7533 + +# 1.1.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 +- Fix and refactor explat polling to use setTimeout #7274 + +# 1.0.1 + +- Update ExPlat client dependencies + +# 1.0.0 + +- Initial package diff --git a/packages/js/explat/README.md b/packages/js/explat/README.md new file mode 100644 index 00000000000..e979d611391 --- /dev/null +++ b/packages/js/explat/README.md @@ -0,0 +1,42 @@ +# ExPlat + +This packages includes a component and utility functions that can be used to run A/B Tests in WooCommerce dashboard and reports pages. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/explat --save +``` + +This package assumes that your code will run in an ES2015+ environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using core-js or @babel/polyfill will add support for these methods. Learn more about it in Babel docs. + +## Usage + +```js +import { Experiment } from '@woocommerce/explat'; + +const DefaultExperience = <div>Hello World</div>; + +const TreatmentExperience = <div>Hello WooCommerce!</div>; + +const LoadingExperience = <div>⏰</div>; + +<Experiment + name="woocommerce_example_experiment" + defaultExperience={ DefaultExperience } + treatmentExperience={ TreatmentExperience } + loadingExperience={ LoadingExperience } +/>; + +// Get the experiment assignment with authentication as a WPCOM user. +import { ExperimentWithAuth } from '@woocommerce/explat'; + +<ExperimentWithAuth + name="woocommerce_example_experiment" + defaultExperience={ DefaultExperience } + treatmentExperience={ TreatmentExperience } + loadingExperience={ LoadingExperience } +/>; +``` diff --git a/packages/js/explat/jest.config.json b/packages/js/explat/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/explat/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/explat/package.json b/packages/js/explat/package.json new file mode 100644 index 00000000000..2bbd9aea625 --- /dev/null +++ b/packages/js/explat/package.json @@ -0,0 +1,65 @@ +{ + "name": "@woocommerce/explat", + "version": "2.1.0", + "description": "WooCommerce component and utils for A/B testing.", + "author": "Automattic", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "abtest", + "explat" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/explat/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "types": "build-types", + "react-native": "src/index", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@automattic/explat-client": "^0.0.3", + "@automattic/explat-client-react-helpers": "^0.0.4", + "@wordpress/api-fetch": "^6.0.1", + "@wordpress/hooks": "^3.5.0", + "cookie": "^0.4.2", + "qs": "^6.10.3" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@types/cookie": "^0.4.1", + "@types/node": "^17.0.21", + "@types/qs": "^6.9.7", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "start": "tsc --build --watch", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/explat/project.json b/packages/js/explat/project.json new file mode 100644 index 00000000000..d9bf51f1f41 --- /dev/null +++ b/packages/js/explat/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/explat", + "sourceRoot": "packages/js/explat/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/explat" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/explat/src/anon.ts b/packages/js/explat/src/anon.ts new file mode 100644 index 00000000000..afa6da00fcf --- /dev/null +++ b/packages/js/explat/src/anon.ts @@ -0,0 +1,61 @@ +/** + * External dependencies + */ +import cookie from 'cookie'; + +let initializeAnonIdPromise: null | Promise< string | null > = null; +const anonIdPollingIntervalMilliseconds = 50; +const anonIdPollingIntervalMaxAttempts = 100; // 50 * 100 = 5000 = 5 seconds + +/** + * Gather w.js anonymous cookie, tk_ai + */ +export const readAnonCookie = (): string | null => { + return cookie.parse( document.cookie ).tk_ai || null; +}; + +/** + * Initializes the anonId: + * - Polls for AnonId receival + * - Should only be called once at startup + * - Happens to be safe to call multiple times if it is necessary to reset the anonId - something like this was necessary for testing. + * + * This purely for boot-time initialization, in usual circumstances it will be retrieved within 100-300ms, it happens in parallel booting + * so should only delay experiment loading that much for boot-time experiments. In some circumstances such as a very slow connection this + * can take a lot longer. + * + * The state of initializeAnonIdPromise should be used rather than the return of this function. + * The return is only avaliable to make this easier to test. + * + * Throws on error. + */ +export const initializeAnonId = async (): Promise< string | null > => { + let attempt = 0; + initializeAnonIdPromise = new Promise( ( res ) => { + const poll = () => { + const anonId = readAnonCookie(); + if ( typeof anonId === 'string' && anonId !== '' ) { + res( anonId ); + return; + } + + if ( anonIdPollingIntervalMaxAttempts - 1 <= attempt ) { + res( null ); + return; + } + attempt = attempt + 1; + setTimeout( poll, anonIdPollingIntervalMilliseconds ); + }; + poll(); + } ); + + return initializeAnonIdPromise; +}; + +export const getAnonId = async (): Promise< string | null > => { + if ( ! window.wcTracks?.isEnabled ) { + return null; + } + + return await initializeAnonIdPromise; +}; diff --git a/packages/js/explat/src/assignment.ts b/packages/js/explat/src/assignment.ts new file mode 100644 index 00000000000..2edef45ea1e --- /dev/null +++ b/packages/js/explat/src/assignment.ts @@ -0,0 +1,84 @@ +/** + * External dependencies + */ +import { stringify } from 'qs'; +import { applyFilters } from '@wordpress/hooks'; +import apiFetch from '@wordpress/api-fetch'; + +const EXPLAT_VERSION = '0.1.0'; + +const getRequestQueryString = ( { + experimentName, + anonId, +}: { + experimentName: string; + anonId: string | null; +} ): string => { + /** + * List of URL query parameters to be sent to the server. + * + * @filter woocommerce_explat_request_args + * @example + * addFilter( + * 'woocommerce_explat_request_args', + * 'woocommerce_explat_request_args', + * ( args ) => { + * args.experimentName = 'my-experiment'; + * return args; + * }); + */ + return stringify( + applyFilters( 'woocommerce_explat_request_args', { + experiment_name: experimentName, + anon_id: anonId ?? undefined, + woo_country_code: + window.wcSettings?.preloadSettings?.general + ?.woocommerce_default_country || + window.wcSettings?.admin?.preloadSettings?.general + ?.woocommerce_default_country, + } ) + ); +}; + +export const fetchExperimentAssignment = async ( { + experimentName, + anonId, +}: { + experimentName: string; + anonId: string | null; +} ): Promise< unknown > => { + if ( ! window.wcTracks?.isEnabled ) { + throw new Error( + `Tracking is disabled, can't fetch experimentAssignment` + ); + } + return await window.fetch( + `https://public-api.wordpress.com/wpcom/v2/experiments/${ EXPLAT_VERSION }/assignments/woocommerce?${ getRequestQueryString( + { + experimentName, + anonId, + } + ) }` + ); +}; + +export const fetchExperimentAssignmentWithAuth = async ( { + experimentName, + anonId, +}: { + experimentName: string; + anonId: string | null; +} ): Promise< unknown > => { + if ( ! window.wcTracks?.isEnabled ) { + throw new Error( + `Tracking is disabled, can't fetch experimentAssignment` + ); + } + // Use apiFetch to send request with credentials and nonce to our backend api to get the assignment with a user token via Jetpack. + return await apiFetch( { + path: `/wc-admin/experiments/assignment?${ getRequestQueryString( { + experimentName, + anonId, + } ) }`, + } ); +}; diff --git a/packages/js/explat/src/error.ts b/packages/js/explat/src/error.ts new file mode 100644 index 00000000000..919e1fb7b0d --- /dev/null +++ b/packages/js/explat/src/error.ts @@ -0,0 +1,47 @@ +/** + * Internal dependencies + */ +import { isDevelopmentMode } from './utils'; + +export const logError = ( + error: Record< string, string > & { message: string } +): void => { + const onLoggingError = ( e: unknown ) => { + if ( isDevelopmentMode ) { + console.error( '[ExPlat] Unable to send error to server:', e ); // eslint-disable-line no-console + } + }; + + try { + const { message, ...properties } = error; + const logStashError = { + message, + properties: { + ...properties, + context: 'explat', + explat_client: 'woocommerce', + }, + }; + + if ( isDevelopmentMode ) { + console.error( '[ExPlat] ', error.message, error ); // eslint-disable-line no-console + } else { + if ( ! window.wcTracks?.isEnabled ) { + throw new Error( + `Tracking is disabled, can't send error to the server` + ); + } + + const body = new window.FormData(); + body.append( 'error', JSON.stringify( logStashError ) ); + window + .fetch( 'https://public-api.wordpress.com/rest/v1.1/js-error', { + method: 'POST', + body, + } ) + .catch( onLoggingError ); + } + } catch ( e ) { + onLoggingError( e ); + } +}; diff --git a/packages/js/explat/src/index.ts b/packages/js/explat/src/index.ts new file mode 100644 index 00000000000..22a9a1b1db4 --- /dev/null +++ b/packages/js/explat/src/index.ts @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import { createExPlatClient } from '@automattic/explat-client'; +import createExPlatClientReactHelpers from '@automattic/explat-client-react-helpers'; + +/** + * Internal dependencies + */ +import { isDevelopmentMode } from './utils'; +import { logError } from './error'; +import { + fetchExperimentAssignment, + fetchExperimentAssignmentWithAuth, +} from './assignment'; +import { getAnonId, initializeAnonId } from './anon'; +declare global { + interface Window { + wcTracks: { + isEnabled: boolean; + }; + } +} + +export const initializeExPlat = (): void => { + if ( window.wcTracks?.isEnabled ) { + initializeAnonId().catch( ( e ) => logError( { message: e.message } ) ); + } +}; + +initializeExPlat(); + +const exPlatClient = createExPlatClient( { + fetchExperimentAssignment, + getAnonId, + logError, + isDevelopmentMode, +} ); + +export const { + loadExperimentAssignment, + dangerouslyGetExperimentAssignment, +} = exPlatClient; + +export const { + useExperiment, + Experiment, + ProvideExperimentData, +} = createExPlatClientReactHelpers( exPlatClient ); + +// Create another auth client that send request to wpcom as auth user. +const exPlatClientWithAuth = createExPlatClient( { + fetchExperimentAssignment: fetchExperimentAssignmentWithAuth, + getAnonId, + logError, + isDevelopmentMode, +} ); + +export const { + loadExperimentAssignment: loadExperimentAssignmentWithAuth, + dangerouslyGetExperimentAssignment: dangerouslyGetExperimentAssignmentWithAuth, +} = exPlatClientWithAuth; + +export const { + useExperiment: useExperimentWithAuth, + Experiment: ExperimentWithAuth, + ProvideExperimentData: ProvideExperimentDataWithAuth, +} = createExPlatClientReactHelpers( exPlatClientWithAuth ); diff --git a/packages/js/explat/src/test/assignment-test.js b/packages/js/explat/src/test/assignment-test.js new file mode 100644 index 00000000000..156622a51d0 --- /dev/null +++ b/packages/js/explat/src/test/assignment-test.js @@ -0,0 +1,73 @@ +/** + * External dependencies + */ +import { addFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { + fetchExperimentAssignment, + fetchExperimentAssignmentWithAuth, +} from '../assignment'; +global.fetch = jest.fn().mockImplementation( () => + Promise.resolve( { + json: () => Promise.resolve( {} ), + status: 200, + } ) +); +global.wcTracks.isEnabled = true; + +const fetchMock = jest.spyOn( global, 'fetch' ); + +describe( 'fetchExperimentAssignment', () => { + it( 'applies woocommerce_explat_request_args before constructing the full URL', () => { + addFilter( + 'woocommerce_explat_request_args', + 'test', + function ( args ) { + args.test = 'test'; + return args; + } + ); + + const fetchPromise = fetchExperimentAssignment( { + experimentId: '123', + anonId: 'abc', + } ); + Promise.resolve( fetchPromise ); + + expect( fetchMock ).toHaveBeenCalledWith( + 'https://public-api.wordpress.com/wpcom/v2/experiments/0.1.0/assignments/woocommerce?anon_id=abc&test=test' + ); + } ); +} ); + +describe( 'fetchExperimentAssignmentWithAuth', () => { + it( 'applies woocommerce_explat_request_args before constructing the full URL', () => { + fetchMock.mockClear(); + addFilter( + 'woocommerce_explat_request_args', + 'test', + function ( args ) { + args.test = 'test'; + return args; + } + ); + + const fetchPromise = fetchExperimentAssignmentWithAuth( { + experimentId: '123', + anonId: 'abc', + } ); + Promise.resolve( fetchPromise ); + + expect( fetchMock ).toHaveBeenCalledWith( + '/wc-admin/experiments/assignment?anon_id=abc&test=test&_locale=user', + { + body: undefined, + credentials: 'include', + headers: { Accept: 'application/json, */*;q=0.1' }, + } + ); + } ); +} ); diff --git a/packages/js/explat/src/utils.ts b/packages/js/explat/src/utils.ts new file mode 100644 index 00000000000..c67735d788d --- /dev/null +++ b/packages/js/explat/src/utils.ts @@ -0,0 +1,26 @@ +/** + * Boolean determining if environment is development. + */ +export const isDevelopmentMode = process.env.NODE_ENV === 'development'; + +interface generalSettings { + woocommerce_default_country: string; +} + +interface preloadSettings { + general: generalSettings; +} +interface admin { + preloadSettings: preloadSettings; +} + +interface wcSettings { + admin: admin; + preloadSettings: preloadSettings; +} + +declare global { + interface Window { + wcSettings: wcSettings; + } +} diff --git a/packages/js/explat/tsconfig-cjs.json b/packages/js/explat/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/explat/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/explat/tsconfig.json b/packages/js/explat/tsconfig.json new file mode 100644 index 00000000000..a63e196f6fd --- /dev/null +++ b/packages/js/explat/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module", + "declaration": true, + "declarationMap": true, + "declarationDir": "./build-types" + }, +} diff --git a/packages/js/js-tests/.eslintrc.js b/packages/js/js-tests/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/js-tests/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/js-tests/.npmrc b/packages/js/js-tests/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/js-tests/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/js-tests/jest.config.js b/packages/js/js-tests/jest.config.js new file mode 100644 index 00000000000..8f745a8753d --- /dev/null +++ b/packages/js/js-tests/jest.config.js @@ -0,0 +1,52 @@ +/** + * External packages + */ +const path = require( 'path' ); + +module.exports = { + moduleNameMapper: { + tinymce: path.resolve( __dirname, 'build/mocks/tinymce' ), + '@woocommerce/settings': path.resolve( + __dirname, + 'build/mocks/woocommerce-settings' + ), + '~/(.*)': path.resolve( + __dirname, + '../../../plugins/woocommerce-admin/client/$1' + ), + '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': path.resolve( + __dirname, + 'build/mocks/static' + ), + '\\.(scss|css)$': path.resolve( + __dirname, + 'build/mocks/style-mock.js' + ), + }, + restoreMocks: true, + setupFiles: [ + path.resolve( __dirname, 'build/setup-window-globals.js' ), + path.resolve( __dirname, 'build/setup-globals.js' ), + ], + setupFilesAfterEnv: [ + path.resolve( __dirname, 'build/setup-react-testing-library.js' ), + ], + testMatch: [ + '**/__tests__/**/*.[jt]s?(x)', + '**/test/*.[jt]s?(x)', + '**/?(*.)test.[jt]s?(x)', + ], + testPathIgnorePatterns: [ + '/node_modules/', + '<rootDir>/.*/build/', + '<rootDir>/.*/build-module/', + '<rootDir>/tests/e2e/', + ], + transformIgnorePatterns: [ '/node_modules', '/build/' ], + transform: { + '^.+\\.[jt]sx?$': 'ts-jest', + }, + testEnvironment: 'jest-environment-jsdom', + timers: 'modern', + verbose: true, +}; diff --git a/packages/js/js-tests/package.json b/packages/js/js-tests/package.json new file mode 100644 index 00000000000..ad6cfa050a5 --- /dev/null +++ b/packages/js/js-tests/package.json @@ -0,0 +1,49 @@ +{ + "name": "@woocommerce/js-tests", + "version": "1.1.0", + "description": "JavaScript test tooling.", + "author": "Automattic", + "license": "GPL-2.0-or-later", + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/js-tests/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git", + "directory": "packages/js-tests" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "private": true, + "main": "build/util/index.js", + "module": "build-module/util/index.js", + "scripts": { + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "ts:check": "tsc --noEmit --project ./tsconfig.json", + "clean": "pnpm exec rimraf *.tsbuildinfo build build-*", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src" + }, + "dependencies": { + "@testing-library/jest-dom": "^5.16.2", + "@testing-library/react": "^12.1.3", + "@wordpress/data": "^6.3.0", + "@wordpress/i18n": "^4.3.1", + "@wordpress/jest-console": "^5.0.1", + "regenerator-runtime": "^0.13.9" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] + } +} diff --git a/packages/js/js-tests/project.json b/packages/js/js-tests/project.json new file mode 100644 index 00000000000..ca1caf65fbd --- /dev/null +++ b/packages/js/js-tests/project.json @@ -0,0 +1,26 @@ +{ + "root": "packages/js/js-tests", + "sourceRoot": "packages/js/js-tests/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/js-tests" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/js-tests/src/mocks/api-request.js b/packages/js/js-tests/src/mocks/api-request.js new file mode 100644 index 00000000000..6ee585673ed --- /dev/null +++ b/packages/js/js-tests/src/mocks/api-request.js @@ -0,0 +1 @@ +module.exports = jest.fn(); diff --git a/packages/js/js-tests/src/mocks/static.js b/packages/js/js-tests/src/mocks/static.js new file mode 100644 index 00000000000..f053ebf7976 --- /dev/null +++ b/packages/js/js-tests/src/mocks/static.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/packages/js/js-tests/src/mocks/style-mock.js b/packages/js/js-tests/src/mocks/style-mock.js new file mode 100644 index 00000000000..f053ebf7976 --- /dev/null +++ b/packages/js/js-tests/src/mocks/style-mock.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/packages/js/js-tests/src/mocks/tinymce.js b/packages/js/js-tests/src/mocks/tinymce.js new file mode 100644 index 00000000000..6ee585673ed --- /dev/null +++ b/packages/js/js-tests/src/mocks/tinymce.js @@ -0,0 +1 @@ +module.exports = jest.fn(); diff --git a/packages/js/js-tests/src/mocks/woocommerce-settings.js b/packages/js/js-tests/src/mocks/woocommerce-settings.js new file mode 100644 index 00000000000..896cd7ae351 --- /dev/null +++ b/packages/js/js-tests/src/mocks/woocommerce-settings.js @@ -0,0 +1,11 @@ +module.exports = { + getSetting: ( key, backup ) => { + return global.wcSettings[ key ] || backup; + }, + getAdminLink: ( path ) => { + if ( global.wcSettings && global.wcSettings.adminUrl ) { + return global.wcSettings.adminUrl + path; + } + return path; + }, +}; diff --git a/packages/js/js-tests/src/setup-globals.js b/packages/js/js-tests/src/setup-globals.js new file mode 100644 index 00000000000..b95d9e74d2c --- /dev/null +++ b/packages/js/js-tests/src/setup-globals.js @@ -0,0 +1,131 @@ +/** + * External dependencies + */ +import { setLocaleData } from '@wordpress/i18n'; +import { registerStore } from '@wordpress/data'; +import 'regenerator-runtime/runtime'; + +// Set up `wp.*` aliases. Doing this because any tests importing wp stuff will +// likely run into this. +global.wp = { + shortcode: { + next() {}, + regexp: jest.fn().mockReturnValue( new RegExp() ), + }, +}; + +global.wc = {}; + +const wordPressPackages = [ 'element', 'date', 'data' ]; + +const wooCommercePackages = [ + 'components', + 'csv', + 'currency', + 'date', + 'navigation', + 'number', + 'data', +]; + +global.wcTracks = { + isEnabled: false, +}; + +// aliases +global.wcSettings = { + adminUrl: 'https://vagrant.local/wp/wp-admin/', + countries: [], + currency: { + code: 'USD', + precision: 2, + symbol: '$', + symbolPosition: 'left', + decimalSeparator: '.', + priceFormat: '%1$s%2$s', + thousandSeparator: ',', + }, + defaultDateRange: 'period=month&compare=previous_year', + date: { + dow: 0, + }, + locale: { + siteLocale: 'en_US', + userLocale: 'en_US', + weekdaysShort: [ 'Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat' ], + }, + admin: { + orderStatuses: { + pending: 'Pending payment', + processing: 'Processing', + 'on-hold': 'On hold', + completed: 'Completed', + cancelled: 'Cancelled', + refunded: 'Refunded', + failed: 'Failed', + }, + wcAdminSettings: { + woocommerce_actionable_order_statuses: [], + woocommerce_excluded_report_order_statuses: [], + }, + dataEndpoints: { + performanceIndicators: [ + { + chart: 'total_sales', + label: 'Total sales', + stat: 'revenue/total_sales', + }, + { + chart: 'net_revenue', + label: 'Net sales', + stat: 'revenue/net_revenue', + }, + { + chart: 'orders_count', + label: 'Orders', + stat: 'orders/orders_count', + }, + { + chart: 'items_sold', + label: 'Items sold', + stat: 'products/items_sold', + }, + ], + }, + }, +}; + +wordPressPackages.forEach( ( lib ) => { + Object.defineProperty( global.wp, lib, { + get: () => require( `@wordpress/${ lib }` ), + } ); +} ); + +wooCommercePackages.forEach( ( lib ) => { + Object.defineProperty( global.wc, lib, { + get: () => require( `@woocommerce/${ lib }` ), + } ); +} ); + +const config = require( '../../../../plugins/woocommerce/client/admin/config/development.json' ); + +// Check if test is jsdom or node +if ( global.window ) { + window.wcAdminFeatures = config && config.features ? config.features : {}; +} + +setLocaleData( + { '': { domain: 'woocommerce', lang: 'en_US' } }, + 'woocommerce' +); + +// Mock core/notices store for components dispatching core notices +registerStore( 'core/notices', { + reducer: () => { + return {}; + }, + actions: { + createNotice: () => {}, + }, + selectors: {}, +} ); diff --git a/packages/js/js-tests/src/setup-react-testing-library.js b/packages/js/js-tests/src/setup-react-testing-library.js new file mode 100644 index 00000000000..4c6aaa154cc --- /dev/null +++ b/packages/js/js-tests/src/setup-react-testing-library.js @@ -0,0 +1,5 @@ +/** + * External dependencies + */ +import '@wordpress/jest-console'; +import '@testing-library/jest-dom'; diff --git a/packages/js/js-tests/src/setup-window-globals.js b/packages/js/js-tests/src/setup-window-globals.js new file mode 100644 index 00000000000..773f08becc1 --- /dev/null +++ b/packages/js/js-tests/src/setup-window-globals.js @@ -0,0 +1,27 @@ +// Check if we're in a JSDOM test or not +if ( global.window ) { + // These are necessary to load TinyMCE successfully + global.URL = window.URL; + global.window.tinyMCEPreInit = { + // Without this, TinyMCE tries to determine its URL by looking at the + // <script> tag where it was loaded from, which of course fails here. + baseURL: 'about:blank', + }; + global.window.requestAnimationFrame = setTimeout; + global.window.cancelAnimationFrame = clearTimeout; + global.window.matchMedia = () => ( { + matches: false, + addListener: () => {}, + removeListener: () => {}, + } ); + + // Setup fake localStorage + const storage = {}; + global.window.localStorage = { + getItem: ( key ) => ( key in storage ? storage[ key ] : null ), + setItem: ( key, value ) => ( storage[ key ] = value ), + }; + + // UserSettings global + global.window.userSettings = { uid: 1 }; +} diff --git a/packages/js/js-tests/src/util/index.js b/packages/js/js-tests/src/util/index.js new file mode 100644 index 00000000000..1972dab47e5 --- /dev/null +++ b/packages/js/js-tests/src/util/index.js @@ -0,0 +1,23 @@ +/** + * External dependencies + */ +import { screen } from '@testing-library/react'; + +/** + * For use with react-testing-library, like getText but allows the text to reside in multiple elements + * + * @param {Object} query - Original query. + * + * @return {Array} - Array of two arrays, first including truthy values, and second including falsy. + */ +const withMarkup = ( query ) => ( text ) => + query( ( content, node ) => { + const hasText = ( domNode ) => domNode.textContent === text; + const childrenDontHaveText = Array.from( node.children ).every( + ( child ) => ! hasText( child ) + ); + + return hasText( node ) && childrenDontHaveText; + } ); + +export const getByTextWithMarkup = withMarkup( screen.getByText ); diff --git a/packages/js/js-tests/tsconfig-cjs.json b/packages/js/js-tests/tsconfig-cjs.json new file mode 100644 index 00000000000..9dc1d924313 --- /dev/null +++ b/packages/js/js-tests/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} diff --git a/packages/js/js-tests/tsconfig.json b/packages/js/js-tests/tsconfig.json new file mode 100644 index 00000000000..e60932eff8a --- /dev/null +++ b/packages/js/js-tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module", + "composite": true + } +} diff --git a/packages/js/navigation/.eslintrc.js b/packages/js/navigation/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/navigation/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/navigation/.npmrc b/packages/js/navigation/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/navigation/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/navigation/CHANGELOG.md b/packages/js/navigation/CHANGELOG.md new file mode 100644 index 00000000000..b3c71df825e --- /dev/null +++ b/packages/js/navigation/CHANGELOG.md @@ -0,0 +1,88 @@ +# Unreleased + +- Update dependency `@wordpress/hooks` to ^3.5.0 +- Added Typescript type declarations. #32615 +# 7.0.1 + +- Add missing dependencies. #8349 +- Update all js packages with minor/patch version changes. #8392 +# 7.0.0 + +## Breaking changes + +- Update dependencies to support react 17. #8305 +- Drop support for IE11. #8305 + +## 6.1.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 +- Add `getSetOfIdsFromQuery` util. +- Fix `getIdsFromQuery` support for `0` as a valid id. +# 6.0.1 + +- Update dependencies. + +# 6.0.0 + +- Moving `addHistoryListener()` to this package, which supports adding a listener that is executed for history changes. +- Update dependencies. +- Add management of persisted queries to navigation. +- Add page parameter to getNewPath to override default page wc-admin #5821 + +## Breaking changes + +- Move Lodash to a peer dependency. + +# 5.3.0 + +- `getQueryExcludedScreens` Return a list of screens that should be excluded from persisted query logic. +- `getScreenFromPath` Given a path (defaulting to current), return simple screen "name" + +# 5.2.0 + +- Add slot/fill components WooNavigationItem, NavSlotFillProvider, and useNavSlot. + +# 5.1.1 + +- Version bump to undeprecate the package. + +# 5.1.0 + +- Support multiple advanced filter instances in getActiveFiltersFromQuery() and getQueryFromActiveFilters(). + +# 5.0.0 + +- `getPersistedQuery` Add a filter for extensions to add a persisted query, `woocommerce_admin_persisted_queries`. + +# 4.0.0 + +## Breaking Changes + +- decouples `wcSettings` from the package (#3294) +- `getAdminLink` is no longer available from this package. It is exported on the `wcSettings` global via the woo-blocks plugin (v2.5 or WC 3.9) when enqueued via the `wc-settings` handle. + +# 3.0.0 + +- `getHistory` updated to reflect path parameters in url query. +- `getNewPath` also updated to reflect path parameters in url query. +- `stringifyQuery` method is no longer available, instead use `addQueryArgs` from `@wordpress/url` package. +- Added a new `<Form />` component. +- Stepper component: Add new `content` and `description` props. +- Remove `getAdminLink()` and dependency on global settings object. + +# 2.1.1 + +- Update license to GPL-3.0-or-later + +# 2.1.0 + +- New method `getSearchWords` that extracts search words given a query object. +- Bump dependency versions. + +# 2.0.0 + +- Replace `history` export with `getHistory` (allows for lazy-create of history) + +# 1.1.0 + +- Rename `getTimeRelatedQuery` to `getPersistedQuery` diff --git a/packages/js/navigation/README.md b/packages/js/navigation/README.md new file mode 100644 index 00000000000..45c13af859e --- /dev/null +++ b/packages/js/navigation/README.md @@ -0,0 +1,150 @@ +# Navigation + +A collection of navigation-related functions for handling query parameter objects, serializing query parameters, updating query parameters, and triggering path changes. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/navigation --save +``` + +## Usage + +### getHistory + +A single history object used to perform path changes. This needs to be passed into ReactRouter to use the other path functions from this library. + +```jsx +import { getHistory } from '@woocommerce/navigation'; + +render() { + return ( + <Router history={ getHistory() }> + … + </Router> + ); +} +``` + +### getPath() ⇒ <code>String</code> +Get the current path from history. + +**Returns**: <code>String</code> - Current path. + +### getTimeRelatedQuery(query) ⇒ <code>Object</code> +Gets time related parameters from a query. + +**Returns**: <code>Object</code> - Object containing the time related queries. + +| Param | Type | Description | +| --- | --- | --- | +| query | <code>Object</code> | Query containing the parameters. | + +### getIdsFromQuery(queryString) ⇒ <code>Array</code> +Get an array of IDs from a comma-separated query parameter. + +**Returns**: <code>Array</code> - List of IDs converted to an array of unique integers. + +| Param | Type | Description | +| --- | --- | --- | +| queryString | <code>string</code> | string value extracted from URL. | + +### getNewPath(query, path, currentQuery) ⇒ <code>String</code> +Return a URL with set query parameters. + +**Returns**: <code>String</code> - Updated URL merging query params into existing params. + +| Param | Type | Description | +| --- | --- | --- | +| query | <code>Object</code> | object of params to be updated. | +| path | <code>String</code> | Relative path (defaults to current path). | +| currentQuery | <code>Object</code> | object of current query params (defaults to current querystring). | + +### getQuery() ⇒ <code>Object</code> +Get the current query string, parsed into an object, from history. + +**Returns**: <code>Object</code> - Current query object, defaults to empty object. + +### onQueryChange(param, path, query) ⇒ <code>function</code> +This function returns an event handler for the given `param` + +**Returns**: <code>function</code> - A callback which will update `param` to the passed value when called. + +| Param | Type | Description | +| --- | --- | --- | +| param | <code>string</code> | The parameter in the querystring which should be updated (ex `page`, `per_page`) | +| path | <code>string</code> | Relative path (defaults to current path). | +| query | <code>string</code> | object of current query params (defaults to current querystring). | + +### updateQueryString(query, path, currentQuery) +Updates the query parameters of the current page. + +| Param | Type | Description | +| --- | --- | --- | +| query | <code>Object</code> | object of params to be updated. | +| path | <code>String</code> | Relative path (defaults to current path). | +| currentQuery | <code>Object</code> | object of current query params (defaults to current querystring). | + +### flattenFilters(filters) ⇒ <code>Array</code> +Collapse an array of filter values with subFilters into a 1-dimensional array. + +**Returns**: <code>Array</code> - Flattened array of all filters. + +| Param | Type | Description | +| --- | --- | --- | +| filters | <code>Array</code> | Set of filters with possible subfilters. | + +### getActiveFiltersFromQuery(query, config) ⇒ <code>Array.<activeFilters></code> +Given a query object, return an array of activeFilters, if any. + +**Returns**: <code>Array.<activeFilters></code> - - array of activeFilters + +| Param | Type | Description | +| --- | --- | --- | +| query | <code>object</code> | query oject | +| config | <code>object</code> | config object | + +### getDefaultOptionValue(config, options) ⇒ <code>string</code> \| <code>undefined</code> +Get the default option's value from the configuration object for a given filter. The first option is used as default if no <code>defaultOption</code> is provided. + +**Returns**: <code>string</code> \| <code>undefined</code> - - the value of the default option. + +| Param | Type | Description | +| --- | --- | --- | +| config | <code>object</code> | a filter config object. | +| options | <code>array</code> | select options. | + +### getQueryFromActiveFilters(activeFilters, query, config) ⇒ <code>object</code> +Given activeFilters, create a new query object to update the url. Use previousFilters to +Remove unused params. + +**Returns**: <code>object</code> - - query object representing the new parameters + +| Param | Type | Description | +| --- | --- | --- | +| activeFilters | <code>Array.<activeFilters></code> | activeFilters shown in the UI | +| query | <code>object</code> | the current url query object | +| config | <code>object</code> | config object | + +### getUrlKey(key, rule) ⇒ <code>string</code> +Get the url query key from the filter key and rule. + +**Returns**: <code>string</code> - - url query key. + +| Param | Type | Description | +| --- | --- | --- | +| key | <code>string</code> | filter key. | +| rule | <code>string</code> | filter rule. | + +### activeFilter : <code>Object</code> +Describe activeFilter object. + +**Properties** + +| Name | Type | Description | +| --- | --- | --- | +| key | <code>string</code> | filter key. | +| [rule] | <code>string</code> | a modifying rule for a filter, eg 'includes' or 'is_not'. | +| value | <code>string</code> | filter value(s). | diff --git a/packages/js/navigation/jest.config.json b/packages/js/navigation/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/navigation/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/navigation/package.json b/packages/js/navigation/package.json new file mode 100644 index 00000000000..b61d3793a94 --- /dev/null +++ b/packages/js/navigation/package.json @@ -0,0 +1,68 @@ +{ + "name": "@woocommerce/navigation", + "version": "7.0.1", + "description": "WooCommerce navigation utilities.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "navigation" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/navigation/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "types": "build-types", + "react-native": "src/index", + "dependencies": { + "@wordpress/api-fetch": "^6.0.1", + "@wordpress/components": "^19.5.0", + "@wordpress/compose": "^5.1.2", + "@wordpress/element": "^4.1.1", + "@wordpress/hooks": "^3.5.0", + "@wordpress/notices": "^3.3.2", + "@wordpress/url": "^3.4.1", + "history": "^4.10.1", + "qs": "^6.10.3" + }, + "peerDependencies": { + "lodash": "^4.17.0" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "start": "tsc --build --watch", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "@babel/runtime": "^7.17.2", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/navigation/project.json b/packages/js/navigation/project.json new file mode 100644 index 00000000000..0ae146500eb --- /dev/null +++ b/packages/js/navigation/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/navigation", + "sourceRoot": "packages/js/navigation/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/navigation" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/navigation/src/filters.js b/packages/js/navigation/src/filters.js new file mode 100644 index 00000000000..4446b6f34fc --- /dev/null +++ b/packages/js/navigation/src/filters.js @@ -0,0 +1,182 @@ +/** + * External dependencies + */ +import { find, get, omit } from 'lodash'; + +/** + * Collapse an array of filter values with subFilters into a 1-dimensional array. + * + * @param {Array} filters Set of filters with possible subfilters. + * @return {Array} Flattened array of all filters. + */ +export function flattenFilters( filters ) { + const allFilters = []; + filters.forEach( ( f ) => { + if ( ! f.subFilters ) { + allFilters.push( f ); + } else { + allFilters.push( omit( f, 'subFilters' ) ); + const subFilters = flattenFilters( f.subFilters ); + allFilters.push( ...subFilters ); + } + } ); + return allFilters; +} + +/** + * Describe activeFilter object. + * + * @typedef {Object} activeFilter + * @property {string} key - filter key. + * @property {string} [rule] - a modifying rule for a filter, eg 'includes' or 'is_not'. + * @property {string} value - filter value(s). + */ + +/** + * Given a query object, return an array of activeFilters, if any. + * + * @param {Object} query - query oject + * @param {Object} config - config object + * @return {Array} - array of activeFilters + */ +export function getActiveFiltersFromQuery( query, config ) { + return Object.keys( config ).reduce( ( activeFilters, configKey ) => { + const filter = config[ configKey ]; + + if ( filter.rules ) { + // Get all rules found in the query string. + const matches = filter.rules.filter( ( rule ) => + query.hasOwnProperty( getUrlKey( configKey, rule.value ) ) + ); + + if ( matches.length ) { + if ( filter.allowMultiple ) { + // If rules were found in the query string, and this filter supports + // multiple instances, add all matches to the active filters array. + matches.forEach( ( match ) => { + const value = + query[ getUrlKey( configKey, match.value ) ]; + + value.forEach( ( filterValue ) => { + activeFilters.push( { + key: configKey, + rule: match.value, + value: filterValue, + } ); + } ); + } ); + } else { + // If the filter is a single instance, just process the first rule match. + const value = + query[ getUrlKey( configKey, matches[ 0 ].value ) ]; + activeFilters.push( { + key: configKey, + rule: matches[ 0 ].value, + value, + } ); + } + } + } else if ( query[ configKey ] ) { + // If the filter doesn't have rules, but allows multiples. + if ( filter.allowMultiple ) { + const value = query[ configKey ]; + + value.forEach( ( filterValue ) => { + activeFilters.push( { + key: configKey, + value: filterValue, + } ); + } ); + } else { + // Filter with no rules and only one instance. + activeFilters.push( { + key: configKey, + value: query[ configKey ], + } ); + } + } + + return activeFilters; + }, [] ); +} + +/** + * Get the default option's value from the configuration object for a given filter. The first + * option is used as default if no `defaultOption` is provided. + * + * @param {Object} config - a filter config object. + * @param {Array} options - select options. + * @return {string|undefined} - the value of the default option. + */ +export function getDefaultOptionValue( config, options ) { + const { defaultOption } = config.input; + if ( config.input.defaultOption ) { + const option = find( options, { value: defaultOption } ); + if ( ! option ) { + /* eslint-disable no-console */ + console.warn( + `invalid defaultOption ${ defaultOption } supplied to ${ config.labels.add }` + ); + /* eslint-enable */ + return undefined; + } + return option.value; + } + return get( options, [ 0, 'value' ] ); +} + +/** + * Given activeFilters, create a new query object to update the url. Use previousFilters to + * Remove unused params. + * + * @param {Array} activeFilters - Array of activeFilters shown in the UI + * @param {Object} query - the current url query object + * @param {Object} config - config object + * @return {Object} - query object representing the new parameters + */ +export function getQueryFromActiveFilters( activeFilters, query, config ) { + const previousFilters = getActiveFiltersFromQuery( query, config ); + const previousData = previousFilters.reduce( ( data, filter ) => { + data[ getUrlKey( filter.key, filter.rule ) ] = undefined; + return data; + }, {} ); + const nextData = activeFilters.reduce( ( data, filter ) => { + if ( + filter.rule === 'between' && + ( ! Array.isArray( filter.value ) || + filter.value.some( ( value ) => ! value ) ) + ) { + return data; + } + + if ( filter.value ) { + const urlKey = getUrlKey( filter.key, filter.rule ); + + if ( config[ filter.key ] && config[ filter.key ].allowMultiple ) { + if ( ! data.hasOwnProperty( urlKey ) ) { + data[ urlKey ] = []; + } + data[ urlKey ].push( filter.value ); + } else { + data[ urlKey ] = filter.value; + } + } + return data; + }, {} ); + + return { ...previousData, ...nextData }; +} + +/** + * Get the url query key from the filter key and rule. + * + * @param {string} key - filter key. + * @param {string} rule - filter rule. + * @return {string} - url query key. + */ +export function getUrlKey( key, rule ) { + if ( rule && rule.length ) { + return `${ key }_${ rule }`; + } + return key; +} diff --git a/packages/js/navigation/src/history.js b/packages/js/navigation/src/history.js new file mode 100644 index 00000000000..e2c80358e09 --- /dev/null +++ b/packages/js/navigation/src/history.js @@ -0,0 +1,68 @@ +/** + * External dependencies + */ +import { createBrowserHistory } from 'history'; +import { parse } from 'qs'; + +// See https://github.com/ReactTraining/react-router/blob/master/FAQ.md#how-do-i-access-the-history-object-outside-of-components + +let _history; + +/** + * Recreate `history` to coerce React Router into accepting path arguments found in query + * parameter `path`, allowing a url hash to be avoided. Since hash portions of the url are + * not sent server side, full route information can be detected by the server. + * + * `<Router />` and `<Switch />` components use `history.location()` to match a url with a route. + * Since they don't parse query arguments, recreate `get location` to return a `pathname` with the + * query path argument's value. + * + * @return {Object} React-router history object with `get location` modified. + */ +function getHistory() { + if ( ! _history ) { + const path = document.location.pathname; + const browserHistory = createBrowserHistory( { + basename: path.substring( 0, path.lastIndexOf( '/' ) ), + } ); + _history = { + get length() { + return browserHistory.length; + }, + get action() { + return browserHistory.action; + }, + get location() { + const { location } = browserHistory; + const query = parse( location.search.substring( 1 ) ); + const pathname = query.path || '/'; + + return { + ...location, + pathname, + }; + }, + createHref: ( ...args ) => + browserHistory.createHref.apply( browserHistory, args ), + push: ( ...args ) => + browserHistory.push.apply( browserHistory, args ), + replace: ( ...args ) => + browserHistory.replace.apply( browserHistory, args ), + go: ( ...args ) => browserHistory.go.apply( browserHistory, args ), + goBack: ( ...args ) => + browserHistory.goBack.apply( browserHistory, args ), + goForward: ( ...args ) => + browserHistory.goForward.apply( browserHistory, args ), + block: ( ...args ) => + browserHistory.block.apply( browserHistory, args ), + listen( listener ) { + return browserHistory.listen( () => { + listener( this.location, this.action ); + } ); + }, + }; + } + return _history; +} + +export { getHistory }; diff --git a/packages/js/navigation/src/index.js b/packages/js/navigation/src/index.js new file mode 100644 index 00000000000..5b8c1b611a3 --- /dev/null +++ b/packages/js/navigation/src/index.js @@ -0,0 +1,283 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { addQueryArgs } from '@wordpress/url'; +import { parse } from 'qs'; +import { pick } from 'lodash'; +import { applyFilters } from '@wordpress/hooks'; +import { Slot, Fill } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { getHistory } from './history'; +import * as navUtils from './index'; +// For the above, import the module into itself. Functions consumed from this import can be mocked in tests. + +// Expose history so all uses get the same history object. +export { getHistory }; + +// Export all filter utilities +export * from './filters'; + +const TIME_EXCLUDED_SCREENS_FILTER = 'woocommerce_admin_time_excluded_screens'; + +/** + * Get the current path from history. + * + * @return {string} Current path. + */ +export const getPath = () => getHistory().location.pathname; + +/** + * Gets query parameters that should persist between screens or updates + * to reports, such as filtering. + * + * @param {Object} query Query containing the parameters. + * @return {Object} Object containing the persisted queries. + */ +export const getPersistedQuery = ( query = navUtils.getQuery() ) => { + /** + * Filter persisted queries. These query parameters remain in the url when other parameters are updated. + * + * @filter woocommerce_admin_persisted_queries + * @param {Array.<string>} persistedQueries Array of persisted queries. + */ + const params = applyFilters( 'woocommerce_admin_persisted_queries', [ + 'period', + 'compare', + 'before', + 'after', + 'interval', + 'type', + ] ); + return pick( query, params ); +}; + +/** + * Get array of screens that should ignore persisted queries + * + * @return {Array} Array containing list of screens + */ +export const getQueryExcludedScreens = () => + applyFilters( TIME_EXCLUDED_SCREENS_FILTER, [ + 'stock', + 'settings', + 'customers', + 'homescreen', + ] ); + +/** + * Given a path, return whether it is an excluded screen + * + * @param {Object} path Path to check + * + * @return {boolean} Boolean representing whether path is excluded + */ +export const pathIsExcluded = ( path ) => + getQueryExcludedScreens().includes( getScreenFromPath( path ) ); + +/** + * Retrieve a string 'name' representing the current screen + * + * @param {Object} path Path to resolve, default to current + * @return {string} Screen name + */ +export const getScreenFromPath = ( path = getPath() ) => { + return path === '/' + ? 'homescreen' + : path.replace( '/analytics', '' ).replace( '/', '' ); +}; + +/** + * Get an array of IDs from a comma-separated query parameter. + * + * @param {string} [queryString=''] string value extracted from URL. + * @return {Array<number>} List of IDs converted to an array of unique integers. + */ +export function getIdsFromQuery( queryString = '' ) { + return [ ...getSetOfIdsFromQuery( queryString ) ]; +} + +/** + * Get an array of IDs from a comma-separated query parameter. + * + * @param {string} [queryString=''] string value extracted from URL. + * @return {Set<number>} List of IDs converted to a set of integers. + */ +export function getSetOfIdsFromQuery( queryString = '' ) { + return new Set( // Return only unique ids. + queryString + .split( ',' ) + .map( ( id ) => parseInt( id, 10 ) ) + .filter( ( id ) => ! isNaN( id ) ) + ); +} + +/** + * Get an array of searched words given a query. + * + * @param {Object} query Query object. + * @return {Array} List of search words. + */ +export function getSearchWords( query = navUtils.getQuery() ) { + if ( typeof query !== 'object' ) { + throw new Error( + 'Invalid parameter passed to getSearchWords, it expects an object or no parameters.' + ); + } + const { search } = query; + if ( ! search ) { + return []; + } + if ( typeof search !== 'string' ) { + throw new Error( + "Invalid 'search' type. getSearchWords expects query's 'search' property to be a string." + ); + } + return search + .split( ',' ) + .map( ( searchWord ) => searchWord.replace( '%2C', ',' ) ); +} + +/** + * Return a URL with set query parameters. + * + * @param {Object} query object of params to be updated. + * @param {string} path Relative path (defaults to current path). + * @param {Object} currentQuery object of current query params (defaults to current querystring). + * @param {string} page Page key (defaults to "wc-admin") + * @return {string} Updated URL merging query params into existing params. + */ +export function getNewPath( + query, + path = getPath(), + currentQuery = getQuery(), + page = 'wc-admin' +) { + const args = { page, ...currentQuery, ...query }; + if ( path !== '/' ) { + args.path = path; + } + return addQueryArgs( 'admin.php', args ); +} + +/** + * Get the current query string, parsed into an object, from history. + * + * @return {Object} Current query object, defaults to empty object. + */ +export function getQuery() { + const search = getHistory().location.search; + if ( search.length ) { + return parse( search.substring( 1 ) ) || {}; + } + return {}; +} + +/** + * This function returns an event handler for the given `param` + * + * @param {string} param The parameter in the querystring which should be updated (ex `page`, `per_page`) + * @param {string} path Relative path (defaults to current path). + * @param {string} query object of current query params (defaults to current querystring). + * @return {Function} A callback which will update `param` to the passed value when called. + */ +export function onQueryChange( param, path = getPath(), query = getQuery() ) { + switch ( param ) { + case 'sort': + return ( key, dir ) => + updateQueryString( { orderby: key, order: dir }, path, query ); + case 'compare': + return ( key, queryParam, ids ) => + updateQueryString( + { + [ queryParam ]: `compare-${ key }`, + [ key ]: ids, + search: undefined, + }, + path, + query + ); + default: + return ( value ) => + updateQueryString( { [ param ]: value }, path, query ); + } +} + +/** + * Updates the query parameters of the current page. + * + * @param {Object} query object of params to be updated. + * @param {string} path Relative path (defaults to current path). + * @param {Object} currentQuery object of current query params (defaults to current querystring). + * @param {string} page Page key (defaults to "wc-admin") + */ +export function updateQueryString( + query, + path = getPath(), + currentQuery = getQuery(), + page = 'wc-admin' +) { + const newPath = getNewPath( query, path, currentQuery, page ); + getHistory().push( newPath ); +} + +/** + * Adds a listener that runs on history change. + * + * @param {Function} listener Listener to add on history change. + * @return {Function} Function to remove listeners. + */ +export const addHistoryListener = ( listener ) => { + // Monkey patch pushState to allow trigger the pushstate event listener. + if ( window.wcNavigation && ! window.wcNavigation.historyPatched ) { + ( ( history ) => { + /* global CustomEvent */ + const pushState = history.pushState; + const replaceState = history.replaceState; + history.pushState = function ( state ) { + const pushStateEvent = new CustomEvent( 'pushstate', { + state, + } ); + window.dispatchEvent( pushStateEvent ); + return pushState.apply( history, arguments ); + }; + history.replaceState = function ( state ) { + const replaceStateEvent = new CustomEvent( 'replacestate', { + state, + } ); + window.dispatchEvent( replaceStateEvent ); + return replaceState.apply( history, arguments ); + }; + window.wcNavigation.historyPatched = true; + } )( window.history ); + } + + window.addEventListener( 'popstate', listener ); + window.addEventListener( 'pushstate', listener ); + window.addEventListener( 'replacestate', listener ); + + return () => { + window.removeEventListener( 'popstate', listener ); + window.removeEventListener( 'pushstate', listener ); + window.removeEventListener( 'replacestate', listener ); + }; +}; + +/** + * A Fill for extensions to add client facing custom Navigation Items. + * + * @slotFill WooNavigationItem + * @scope woocommerce-navigation + * @param {Object} props React props. + * @param {Array} props.children Node children. + * @param {string} props.item Navigation item slug. + */ +export const WooNavigationItem = ( { children, item } ) => { + return <Fill name={ 'woocommerce_navigation_' + item }>{ children }</Fill>; +}; +WooNavigationItem.Slot = ( { name } ) => ( + <Slot name={ 'woocommerce_navigation_' + name } /> +); diff --git a/packages/js/navigation/src/test/filters.js b/packages/js/navigation/src/test/filters.js new file mode 100644 index 00000000000..a467263e691 --- /dev/null +++ b/packages/js/navigation/src/test/filters.js @@ -0,0 +1,326 @@ +/** + * Internal dependencies + */ +import { + getActiveFiltersFromQuery, + getDefaultOptionValue, + getQueryFromActiveFilters, + getUrlKey, +} from '../filters'; + +const config = { + with_select: { + labels: { add: 'Order Status' }, + rules: [ { value: 'is' } ], + input: { + component: 'SelectControl', + options: [ { value: 'pending' } ], + }, + }, + with_search: { + labels: { add: 'Search' }, + rules: [ { value: 'includes' } ], + input: { + component: 'Search', + }, + }, + with_no_rules: { + labels: { add: 'Order Status' }, + input: { + component: 'SelectControl', + options: [ { value: 'pending' } ], + }, + }, +}; + +describe( 'getUrlKey', () => { + it( 'should return a correctly formatted string', () => { + const key = getUrlKey( 'key', 'rule' ); + expect( key ).toBe( 'key_rule' ); + } ); + + it( 'should return a correctly formatted string with no rule', () => { + const key = getUrlKey( 'key' ); + expect( key ).toBe( 'key' ); + } ); +} ); + +describe( 'getActiveFiltersFromQuery', () => { + it( 'should return activeFilters from a query', () => { + const query = { + with_select_is: 'pending', + with_search_includes: '1,2,3', + with_no_rules: 'pending', + }; + + const activeFilters = getActiveFiltersFromQuery( query, config ); + expect( Array.isArray( activeFilters ) ).toBeTruthy(); + expect( activeFilters.length ).toBe( 3 ); + + const withSelect = activeFilters[ 0 ]; + expect( withSelect.key ).toBe( 'with_select' ); + expect( withSelect.rule ).toBe( 'is' ); + expect( withSelect.value ).toBe( 'pending' ); + + const withSearch = activeFilters[ 1 ]; + expect( withSearch.key ).toBe( 'with_search' ); + expect( withSearch.rule ).toBe( 'includes' ); + expect( withSearch.value ).toEqual( '1,2,3' ); + + const withNoRules = activeFilters[ 2 ]; + expect( withNoRules.key ).toBe( 'with_no_rules' ); + expect( withNoRules.rule ).toBeUndefined(); + expect( withNoRules.value ).toEqual( 'pending' ); + } ); + + it( 'should handle multiple filter instances', () => { + const filterConfig = { + status: { + allowMultiple: true, + input: { + component: 'SelectControl', + }, + }, + attribute: { + allowMultiple: true, + rules: [ + { + value: 'is', + }, + { + value: 'is_not', + }, + ], + input: { + component: 'ProductAttribute', + }, + }, + }; + const query = { + status: [ 'pending', 'processing' ], + attribute_is: [ [ 1, 2 ] ], + attribute_is_not: [ + [ 1, 3 ], + [ 2, 4 ], + ], + }; + + const activeFilters = getActiveFiltersFromQuery( query, filterConfig ); + + expect( activeFilters ).toEqual( [ + { + key: 'status', + value: 'pending', + }, + { + key: 'status', + value: 'processing', + }, + { + key: 'attribute', + rule: 'is', + value: [ 1, 2 ], + }, + { + key: 'attribute', + rule: 'is_not', + value: [ 1, 3 ], + }, + { + key: 'attribute', + rule: 'is_not', + value: [ 2, 4 ], + }, + ] ); + } ); + + it( 'should ignore irrelevant query parameters', () => { + const query = { + with_select: 'pending', // no rule associated + status: 45, + }; + + const activeFilters = getActiveFiltersFromQuery( query, config ); + expect( activeFilters.length ).toBe( 0 ); + } ); + + it( 'should return an empty array with no relevant parameters', () => { + const query = {}; + + const activeFilters = getActiveFiltersFromQuery( query, config ); + expect( Array.isArray( activeFilters ) ).toBe( true ); + expect( activeFilters.length ).toBe( 0 ); + } ); +} ); + +describe( 'getQueryFromActiveFilters', () => { + it( 'should return a query object from activeFilters', () => { + const activeFilters = [ + { key: 'status', rule: 'is', value: 'open' }, + { + key: 'things', + rule: 'includes', + value: '1,2,3', + }, + { key: 'customer', value: 'new' }, + ]; + + const query = {}; + const nextQuery = getQueryFromActiveFilters( + activeFilters, + query, + config + ); + expect( nextQuery.status_is ).toBe( 'open' ); + expect( nextQuery.things_includes ).toBe( '1,2,3' ); + expect( nextQuery.customer ).toBe( 'new' ); + } ); + + it( 'should remove parameters from the previous filters', () => { + const activeFilters = []; + const query = { + with_select_is: 'complete', + with_search_includes: '45', + }; + + const nextQuery = getQueryFromActiveFilters( + activeFilters, + query, + config + ); + expect( nextQuery.with_select_is ).toBeUndefined(); + expect( nextQuery.with_search_includes ).toBeUndefined(); + } ); + + it( 'should only reflect complete filters with multiple values', () => { + const activeFilters = [ + { + key: 'valid_date', + rule: 'between', + value: [ '2018-04-04', '2018-04-10' ], + }, + { + key: 'invalid_date_1', + rule: 'between', + value: [ '2018-04-04', undefined ], + }, + { key: 'invalid_date_2', rule: 'between', value: '2018-04-04' }, + ]; + const query = {}; + const nextQuery = getQueryFromActiveFilters( + activeFilters, + query, + config + ); + + expect( nextQuery.valid_date_between ).toBeDefined(); + expect( nextQuery.invalid_date_1_between ).toBeUndefined(); + expect( nextQuery.invalid_date_2_between ).toBeUndefined(); + } ); + + it( 'should handle filters with multiple instances', () => { + const filterConfig = { + status: { + allowMultiple: true, + input: { + component: 'SelectControl', + }, + }, + attribute: { + allowMultiple: true, + rules: [ + { + value: 'is', + }, + { + value: 'is_not', + }, + ], + input: { + component: 'ProductAttribute', + }, + }, + }; + const activeFilters = [ + { + key: 'status', + value: 'pending', + }, + { + key: 'status', + value: 'processing', + }, + { + key: 'attribute', + rule: 'is', + value: [ 1, 2 ], + }, + { + key: 'attribute', + rule: 'is_not', + value: [ 1, 3 ], + }, + { + key: 'attribute', + rule: 'is_not', + value: [ 2, 4 ], + }, + ]; + const query = {}; + const nextQuery = getQueryFromActiveFilters( + activeFilters, + query, + filterConfig + ); + + expect( nextQuery ).toEqual( { + status: [ 'pending', 'processing' ], + attribute_is: [ [ 1, 2 ] ], + attribute_is_not: [ + [ 1, 3 ], + [ 2, 4 ], + ], + } ); + } ); +} ); + +describe( 'getDefaultOptionValue', () => { + it( 'should return the default option value', () => { + const options = [ { value: 'new' }, { value: 'returning' } ]; + const currentFilter = { + labels: { add: 'Customer type' }, + input: { + component: 'SelectControl', + options, + defaultOption: 'returning', + }, + }; + const value = getDefaultOptionValue( currentFilter, options ); + expect( value ).toBe( 'returning' ); + } ); + + it( 'should return the first option value when no default option', () => { + const options = [ { value: 'new' }, { value: 'returning' } ]; + const currentFilter = { + labels: { add: 'Customer type' }, + input: { + component: 'SelectControl', + options, + }, + }; + const value = getDefaultOptionValue( currentFilter, options ); + expect( value ).toBe( 'new' ); + } ); + + it( 'should return undefined when no options are provided', () => { + const options = []; + const currentFilter = { + labels: { add: 'Product' }, + input: { + component: 'Search', + }, + }; + const value = getDefaultOptionValue( currentFilter, options ); + expect( value ).toBeUndefined(); + } ); +} ); diff --git a/packages/js/navigation/src/test/index.js b/packages/js/navigation/src/test/index.js new file mode 100644 index 00000000000..f56e1266a5a --- /dev/null +++ b/packages/js/navigation/src/test/index.js @@ -0,0 +1,262 @@ +/** + * Internal dependencies + */ +import { + getIdsFromQuery, + getSetOfIdsFromQuery, + getHistory, + getPersistedQuery, + getSearchWords, + getNewPath, + addHistoryListener, +} from '../index'; + +global.window = Object.create( window ); +global.window.wcNavigation = {}; + +describe( 'getPersistedQuery', () => { + beforeEach( () => { + getHistory().push( + getNewPath( + { + filter: 'advanced', + product_includes: 127, + period: 'year', + compare: 'previous_year', + after: '2018-02-01', + before: '2018-01-01', + interval: 'day', + search: 'lorem', + }, + '/', + {} + ) + ); + } ); + + it( "should return an empty object it the query doesn't contain any time related parameters", () => { + const query = { + filter: 'advanced', + product_includes: 127, + }; + const persistedQuery = {}; + + expect( getPersistedQuery( query ) ).toEqual( persistedQuery ); + } ); + + it( 'should return time related parameters', () => { + const query = { + filter: 'advanced', + product_includes: 127, + period: 'year', + compare: 'previous_year', + after: '2018-02-01', + before: '2018-01-01', + type: 'bar', + interval: 'day', + }; + const persistedQuery = { + period: 'year', + compare: 'previous_year', + after: '2018-02-01', + before: '2018-01-01', + type: 'bar', + interval: 'day', + }; + + expect( getPersistedQuery( query ) ).toEqual( persistedQuery ); + } ); + + it( 'should get the query from getQuery() when none is provided in the params', () => { + const persistedQuery = { + period: 'year', + compare: 'previous_year', + after: '2018-02-01', + before: '2018-01-01', + interval: 'day', + }; + + expect( getPersistedQuery() ).toEqual( persistedQuery ); + } ); +} ); + +describe( 'getSearchWords', () => { + it( 'should get the search words from a query object', () => { + const query = { + search: 'lorem,dolor sit', + }; + const searchWords = [ 'lorem', 'dolor sit' ]; + + expect( getSearchWords( query ) ).toEqual( searchWords ); + } ); + + it( 'should parse `%2C` as commas', () => { + const query = { + search: 'lorem%2Cipsum,dolor sit', + }; + const searchWords = [ 'lorem,ipsum', 'dolor sit' ]; + + expect( getSearchWords( query ) ).toEqual( searchWords ); + } ); + + it( 'should return an empty array if the query has no `search` property', () => { + const query = {}; + const searchWords = []; + + expect( getSearchWords( query ) ).toEqual( searchWords ); + } ); + + it( 'should use the persisted query when it receives no params', () => { + const searchWords = [ 'lorem' ]; + + expect( getSearchWords() ).toEqual( searchWords ); + } ); + + it( 'should throw an error if the param is not an object', () => { + expect( () => getSearchWords( 'lorem' ) ).toThrow( Error ); + } ); + + it( 'should throw an error if the `search` property is not a string', () => { + const query = { + search: new Object(), + }; + + expect( () => getSearchWords( query ) ).toThrow( Error ); + } ); +} ); + +describe( 'getNewPath', () => { + it( 'should have default page as "wc-admin"', () => { + const path = getNewPath( {}, '', {} ); + + expect( path ).toEqual( 'admin.php?page=wc-admin&path=' ); + } ); + + it( 'should override default page when page parameter is specified', () => { + const path = getNewPath( {}, '', {}, 'custom-page' ); + + expect( path ).toEqual( 'admin.php?page=custom-page&path=' ); + } ); + + it( 'should override default page by query parameter over page parameter', () => { + const path = getNewPath( + { + page: 'custom-page', + }, + '', + {}, + 'default-page' + ); + + expect( path ).toEqual( 'admin.php?page=custom-page&path=' ); + } ); +} ); + +describe( 'addHistoryListener', () => { + it( 'should add a custom event to the browser pushState', () => { + const mockCallback = jest.fn(); + const removeListener = addHistoryListener( mockCallback ); + window.history.pushState( {}, 'Test pushState' ); + window.history.pushState( {}, 'Test pushState 2' ); + + expect( mockCallback.mock.calls.length ).toBe( 2 ); + + // Check that events are no longer called after removing the listener. + removeListener(); + window.history.pushState( {}, 'Test pushState 3' ); + expect( mockCallback.mock.calls.length ).toBe( 2 ); + } ); + + it( 'should add a custom event to the browser replaceState', () => { + const mockCallback = jest.fn(); + const removeListener = addHistoryListener( mockCallback ); + window.history.replaceState( {}, 'Test replaceState' ); + window.history.replaceState( {}, 'Test replaceState 2' ); + + expect( mockCallback.mock.calls.length ).toBe( 2 ); + + // Check that events are no longer called after removing the listener. + removeListener(); + window.history.replaceState( {}, 'Test replaceState 3' ); + expect( mockCallback.mock.calls.length ).toBe( 2 ); + } ); +} ); + +describe( 'getIdsFromQuery', () => { + it( 'if the given query is empty, should return an empty array', () => { + expect( getIdsFromQuery( '' ) ).toEqual( [] ); + } ); + + it( 'if the given query is undefined, should return an empty array', () => { + expect( getIdsFromQuery( undefined ) ).toEqual( [] ); + } ); + + it( 'if the given query is does not contain any coma-separated numbers, should return an empty array', () => { + expect( getIdsFromQuery( 'foo123,bar,baz1.' ) ).toEqual( [] ); + } ); + + describe( 'if the given query contains numbers', () => { + it( 'should return an array of them', () => { + expect( getIdsFromQuery( '77,8,-1' ) ).toContain( 77, 8, -1 ); + } ); + it( 'should consider `0` a valid id', () => { + expect( getIdsFromQuery( '0' ) ).toContain( 0 ); + } ); + it( 'should map floats to integers', () => { + expect( getIdsFromQuery( '77,8.54' ) ).toEqual( [ 77, 8 ] ); + } ); + it( 'should ignore duplicates', () => { + expect( getIdsFromQuery( '77,8,8' ) ).toEqual( [ 77, 8 ] ); + // Consider two floats that maps to the same integer a duplicate. + expect( getIdsFromQuery( '77,8.5,8.4' ) ).toEqual( [ 77, 8 ] ); + } ); + it( 'should ignore non numbers entries in the coma-separated list', () => { + expect( getIdsFromQuery( '77,,8,foo,null,9' ) ).toEqual( [ + 77, + 8, + 9, + ] ); + } ); + } ); +} ); + +describe( 'getSetOfIdsFromQuery', () => { + it( 'if the given query is empty, should return an empty set', () => { + expect( getSetOfIdsFromQuery( '' ) ).toEqual( new Set() ); + } ); + + it( 'if the given query is undefined, should return an empty set', () => { + expect( getSetOfIdsFromQuery( undefined ) ).toEqual( new Set() ); + } ); + + it( 'if the given query is does not contain any coma-separated numbers, should return an empty set', () => { + expect( getSetOfIdsFromQuery( 'foo123,bar,baz1.' ) ).toEqual( + new Set() + ); + } ); + + describe( 'if the given query contains numbers', () => { + it( 'should return a set of them', () => { + expect( getSetOfIdsFromQuery( '77,8,-1' ) ).toEqual( + new Set( [ 77, 8, -1 ] ) + ); + } ); + it( 'should consider `0` a valid id', () => { + expect( getSetOfIdsFromQuery( '0' ) ).toContain( 0 ); + expect( getSetOfIdsFromQuery( '77,0,1' ) ).toContain( 0 ); + } ); + it( 'should map floats to integers', () => { + expect( getSetOfIdsFromQuery( '77,8.54' ) ).toEqual( + new Set( [ 77, 8 ] ) + ); + } ); + it( 'should ignore duplicates', () => { + expect( getSetOfIdsFromQuery( '77,8,8' ) ).toBeInstanceOf( Set ); + } ); + it( 'should ignore non numbers entries in the coma-separated list', () => { + expect( getSetOfIdsFromQuery( '77,,8,foo,null,9' ) ).toEqual( + new Set( [ 77, 8, 9 ] ) + ); + } ); + } ); +} ); diff --git a/packages/js/navigation/tsconfig-cjs.json b/packages/js/navigation/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/navigation/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/navigation/tsconfig.json b/packages/js/navigation/tsconfig.json new file mode 100644 index 00000000000..ea9f201d401 --- /dev/null +++ b/packages/js/navigation/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module", + "declaration": true, + "declarationMap": true, + "declarationDir": "./build-types" + } +} diff --git a/packages/js/notices/.eslintrc.js b/packages/js/notices/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/notices/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/notices/.npmrc b/packages/js/notices/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/notices/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/notices/CHANGELOG.md b/packages/js/notices/CHANGELOG.md new file mode 100644 index 00000000000..e0a22a3fc5c --- /dev/null +++ b/packages/js/notices/CHANGELOG.md @@ -0,0 +1,64 @@ +<!-- Learn how to maintain this file at https://github.com/WordPress/gutenberg/tree/master/packages#maintaining-changelogs. --> + +# Unreleased + +- Update dependency `@wordpress/a11y` to ^3.5.0 + +# 4.0.1 + +- Update all js packages with minor/patch version changes. #8392 + +# 4.0.0 + +## Breaking changes + +- Update dependencies to support react 17. #8305 +- Drop support for IE11. #8305 + +## 3.1.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 +## 3.0.0 (2021-06-03) + +## Breaking changes + +- Move Lodash to a peer dependency. +## 2.0.0 (2020-02-10) + +### Breaking Change + +- A notices message is no longer spoken as a result of notice creation, but rather by its display in the interface by its corresponding [`Notice` component](https://github.com/WordPress/gutenberg/tree/master/packages/components/src/notice). + +## 1.5.0 (2019-06-12) + +### New Features + +- Support a new `snackbar` notice type in the `createNotice` action. + +## 1.1.2 (2019-01-03) + +## 1.1.1 (2018-12-12) + +## 1.1.0 (2018-11-20) + +### New Feature + +- New option `speak` enables control as to whether the notice content is announced to screen readers (defaults to `true`) + +### Bug Fixes + +- While `createNotice` only explicitly supported content of type `string`, it was not previously enforced. This has been corrected. + +## 1.0.5 (2018-11-15) + +## 1.0.4 (2018-11-09) + +## 1.0.3 (2018-11-09) + +## 1.0.2 (2018-11-03) + +## 1.0.1 (2018-10-30) + +## 1.0.0 (2018-10-29) + +- Initial release. diff --git a/packages/js/notices/README.md b/packages/js/notices/README.md new file mode 100644 index 00000000000..c3a5610e0d5 --- /dev/null +++ b/packages/js/notices/README.md @@ -0,0 +1,30 @@ +# Notices + +State management for notices. + +NOTE: This has been copied from Gutenberg so that we can iterate on it faster +than if we were relying on Gutenberg releasing a new version with our +requirements. Once Gutenberg supports our requirements this package should be +removed. + +**Update:** Changes required have been shipped in the Gutenberg package released with WP 5.7, so this package will be removed when WP 5.9 becomes available. Please use the Gutenberg version instead. + +## Installation + +Install the module + +```bash +pnpm install @wordpress/notices +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +When imported, the notices module registers a data store on the `core/notices` namespace. In WordPress, this is accessed from `wp.data.dispatch( 'core/notices' )`. + +For more information about consuming from a data store, refer to [the `@wordpress/data` documentation on _Data Access and Manipulation_](/packages/data/README.md#data-access-and-manipulation). + +For a full list of actions and selectors available in the `core/notices` namespace, refer to the [_Notices Data_ Handbook page](/docs/designers-developers/developers/data/data-core-notices.md). + +<br/><br/><p align="center"><img src="https://s.w.org/style/images/codeispoetry.png?1" alt="Code is Poetry." /></p> diff --git a/packages/js/notices/package.json b/packages/js/notices/package.json new file mode 100644 index 00000000000..7de5f5eb01c --- /dev/null +++ b/packages/js/notices/package.json @@ -0,0 +1,60 @@ +{ + "name": "@woocommerce/notices", + "version": "4.0.1", + "description": "State management for notices.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "notices" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/master/packages/notices/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/notices" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@wordpress/a11y": "^3.5.0", + "@wordpress/data": "^6.3.0", + "@wordpress/notices": "^3.3.2" + }, + "peerDependencies": { + "lodash": "^4.17.0", + "react": "^17.0.0", + "react-dom": "^17.0.0" + }, + "publishConfig": { + "access": "public" + }, + "private": true, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "start": "tsc --build --watch", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] + } +} diff --git a/packages/js/notices/project.json b/packages/js/notices/project.json new file mode 100644 index 00000000000..5c2dca521e3 --- /dev/null +++ b/packages/js/notices/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/notices", + "sourceRoot": "packages/js/notices/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/notices" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/notices/src/index.js b/packages/js/notices/src/index.js new file mode 100644 index 00000000000..33b97a4a873 --- /dev/null +++ b/packages/js/notices/src/index.js @@ -0,0 +1,9 @@ +/** + * External dependencies + */ +import '@wordpress/notices'; + +/** + * Internal dependencies + */ +import './store'; diff --git a/packages/js/notices/src/store/actions.js b/packages/js/notices/src/store/actions.js new file mode 100644 index 00000000000..59fa585e0ef --- /dev/null +++ b/packages/js/notices/src/store/actions.js @@ -0,0 +1,165 @@ +/** + * External dependencies + */ +import { uniqueId } from 'lodash'; + +/** + * Internal dependencies + */ +import { DEFAULT_CONTEXT, DEFAULT_STATUS } from './constants'; + +/** + * @typedef {Object} WPNoticeAction Object describing a user action option associated with a notice. + * + * @property {string} label Message to use as action label. + * @property {?string} url Optional URL of resource if action incurs + * browser navigation. + * @property {?Function} onClick Optional function to invoke when action is + * triggered by user. + * + */ + +/** + * Returns an action object used in signalling that a notice is to be created. + * + * @param {string} [status='info'] Notice status. + * @param {string} content Notice message. + * @param {Object} [options] Notice options. + * @param {string} [options.context='global'] Context under which to + * group notice. + * @param {string} [options.id] Identifier for notice. + * Automatically assigned + * if not specified. + * @param {boolean} [options.isDismissible=true] Whether the notice can + * be dismissed by user. + * @param {string} [options.type='default'] Type of notice, one of + * `default`, or `snackbar`. + * @param {boolean} [options.speak=true] Whether the notice + * content should be + * announced to screen + * readers. + * @param {Array<WPNoticeAction>} [options.actions] User actions to be + * presented with notice. + * @param {Object} [options.icon] An icon displayed with the notice. + * @param {boolean} [options.explicitDismiss] Whether the notice includes + * an explict dismiss button and + * can't be dismissed by clicking + * the body of the notice. + * @param {Function} [options.onDismiss] Called when the notice is dismissed. + * + * @return {Object} Action object. + */ +export function createNotice( status = DEFAULT_STATUS, content, options = {} ) { + const { + speak = true, + isDismissible = true, + context = DEFAULT_CONTEXT, + id = uniqueId( context ), + actions = [], + type = 'default', + __unstableHTML, + icon = null, + explicitDismiss = false, + onDismiss = null, + } = options; + + // The supported value shape of content is currently limited to plain text + // strings. To avoid setting expectation that e.g. a WPElement could be + // supported, cast to a string. + content = String( content ); + + return { + type: 'CREATE_NOTICE', + context, + notice: { + id, + status, + content, + spokenMessage: speak ? content : null, + __unstableHTML, + isDismissible, + actions, + type, + icon, + explicitDismiss, + onDismiss, + }, + }; +} + +/** + * Returns an action object used in signalling that a success notice is to be + * created. Refer to `createNotice` for options documentation. + * + * @see createNotice + * + * @param {string} content Notice message. + * @param {Object} [options] Optional notice options. + * + * @return {Object} Action object. + */ +export function createSuccessNotice( content, options ) { + return createNotice( 'success', content, options ); +} + +/** + * Returns an action object used in signalling that an info notice is to be + * created. Refer to `createNotice` for options documentation. + * + * @see createNotice + * + * @param {string} content Notice message. + * @param {Object} [options] Optional notice options. + * + * @return {Object} Action object. + */ +export function createInfoNotice( content, options ) { + return createNotice( 'info', content, options ); +} + +/** + * Returns an action object used in signalling that an error notice is to be + * created. Refer to `createNotice` for options documentation. + * + * @see createNotice + * + * @param {string} content Notice message. + * @param {Object} [options] Optional notice options. + * + * @return {Object} Action object. + */ +export function createErrorNotice( content, options ) { + return createNotice( 'error', content, options ); +} + +/** + * Returns an action object used in signalling that a warning notice is to be + * created. Refer to `createNotice` for options documentation. + * + * @see createNotice + * + * @param {string} content Notice message. + * @param {Object} [options] Optional notice options. + * + * @return {Object} Action object. + */ +export function createWarningNotice( content, options ) { + return createNotice( 'warning', content, options ); +} + +/** + * Returns an action object used in signalling that a notice is to be removed. + * + * @param {string} id Notice unique identifier. + * @param {string} [context='global'] Optional context (grouping) in which the notice is + * intended to appear. Defaults to default context. + * + * @return {Object} Action object. + */ +export function removeNotice( id, context = DEFAULT_CONTEXT ) { + return { + type: 'REMOVE_NOTICE', + id, + context, + }; +} diff --git a/packages/js/notices/src/store/constants.js b/packages/js/notices/src/store/constants.js new file mode 100644 index 00000000000..2949bde0577 --- /dev/null +++ b/packages/js/notices/src/store/constants.js @@ -0,0 +1,15 @@ +/** + * Default context to use for notice grouping when not otherwise specified. Its + * specific value doesn't hold much meaning, but it must be reasonably unique + * and, more importantly, referenced consistently in the store implementation. + * + * @type {string} + */ +export const DEFAULT_CONTEXT = 'global'; + +/** + * Default notice status. + * + * @type {string} + */ +export const DEFAULT_STATUS = 'info'; diff --git a/packages/js/notices/src/store/controls.js b/packages/js/notices/src/store/controls.js new file mode 100644 index 00000000000..704352289d5 --- /dev/null +++ b/packages/js/notices/src/store/controls.js @@ -0,0 +1,10 @@ +/** + * External dependencies + */ +import { speak } from '@wordpress/a11y'; + +export default { + SPEAK( action ) { + speak( action.message, action.ariaLive || 'assertive' ); + }, +}; diff --git a/packages/js/notices/src/store/index.js b/packages/js/notices/src/store/index.js new file mode 100644 index 00000000000..d4ebaff9a6a --- /dev/null +++ b/packages/js/notices/src/store/index.js @@ -0,0 +1,19 @@ +/** + * External dependencies + */ +import { registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as actions from './actions'; +import * as selectors from './selectors'; + +// NOTE: This uses core/notices2, if this file is copied back upstream +// to Gutenberg this needs to be changed back to core/notices. +export default registerStore( 'core/notices2', { + reducer, + actions, + selectors, +} ); diff --git a/packages/js/notices/src/store/reducer.js b/packages/js/notices/src/store/reducer.js new file mode 100644 index 00000000000..ca68877d8d0 --- /dev/null +++ b/packages/js/notices/src/store/reducer.js @@ -0,0 +1,36 @@ +/** + * External dependencies + */ +import { reject } from 'lodash'; + +/** + * Internal dependencies + */ +import onSubKey from './utils/on-sub-key'; + +/** + * Reducer returning the next notices state. The notices state is an object + * where each key is a context, its value an array of notice objects. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +const notices = onSubKey( 'context' )( ( state = [], action ) => { + switch ( action.type ) { + case 'CREATE_NOTICE': + // Avoid duplicates on ID. + return [ + ...reject( state, { id: action.notice.id } ), + action.notice, + ]; + + case 'REMOVE_NOTICE': + return reject( state, { id: action.id } ); + } + + return state; +} ); + +export default notices; diff --git a/packages/js/notices/src/store/selectors.js b/packages/js/notices/src/store/selectors.js new file mode 100644 index 00000000000..d03d8812d53 --- /dev/null +++ b/packages/js/notices/src/store/selectors.js @@ -0,0 +1,56 @@ +/** + * Internal dependencies + */ +import { DEFAULT_CONTEXT } from './constants'; + +/** @typedef {import('./actions').WPNoticeAction} WPNoticeAction */ + +/** + * The default empty set of notices to return when there are no notices + * assigned for a given notices context. This can occur if the getNotices + * selector is called without a notice ever having been created for the + * context. A shared value is used to ensure referential equality between + * sequential selector calls, since otherwise `[] !== []`. + * + * @type {Array} + */ +const DEFAULT_NOTICES = []; + +/** + * @typedef {Object} WPNotice Notice object. + * + * @property {string} id Unique identifier of notice. + * @property {string} status Status of notice, one of `success`, + * `info`, `error`, or `warning`. Defaults + * to `info`. + * @property {string} content Notice message. + * @property {string} spokenMessage Audibly announced message text used by + * assistive technologies. + * @property {string} __unstableHTML Notice message as raw HTML. Intended to + * serve primarily for compatibility of + * server-rendered notices, and SHOULD NOT + * be used for notices. It is subject to + * removal without notice. + * @property {boolean} isDismissible Whether the notice can be dismissed by + * user. Defaults to `true`. + * @property {string} type Type of notice, one of `default`, + * or `snackbar`. Defaults to `default`. + * @property {boolean} speak Whether the notice content should be + * announced to screen readers. Defaults to + * `true`. + * @property {WPNoticeAction[]} actions User actions to present with notice. + * + */ + +/** + * Returns all notices as an array, optionally for a given context. Defaults to + * the global context. + * + * @param {Object} state Notices state. + * @param {?string} context Optional grouping context. + * + * @return {WPNotice[]} Array of notices. + */ +export function getNotices( state, context = DEFAULT_CONTEXT ) { + return state[ context ] || DEFAULT_NOTICES; +} diff --git a/packages/js/notices/src/store/utils/on-sub-key.js b/packages/js/notices/src/store/utils/on-sub-key.js new file mode 100644 index 00000000000..24adf06b773 --- /dev/null +++ b/packages/js/notices/src/store/utils/on-sub-key.js @@ -0,0 +1,33 @@ +/** + * Higher-order reducer creator which creates a combined reducer object, keyed + * by a property on the action object. + * + * @param {string} actionProperty Action property by which to key object. + * + * @return {Function} Higher-order reducer. + */ +export const onSubKey = ( actionProperty ) => ( reducer ) => ( + state = {}, + action +) => { + // Retrieve subkey from action. Do not track if undefined; useful for cases + // where reducer is scoped by action shape. + const key = action[ actionProperty ]; + if ( key === undefined ) { + return state; + } + + // Avoid updating state if unchanged. Note that this also accounts for a + // reducer which returns undefined on a key which is not yet tracked. + const nextKeyState = reducer( state[ key ], action ); + if ( nextKeyState === state[ key ] ) { + return state; + } + + return { + ...state, + [ key ]: nextKeyState, + }; +}; + +export default onSubKey; diff --git a/packages/js/notices/tsconfig-cjs.json b/packages/js/notices/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/notices/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/notices/tsconfig.json b/packages/js/notices/tsconfig.json new file mode 100644 index 00000000000..e8f14a25fa4 --- /dev/null +++ b/packages/js/notices/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module" + } +} \ No newline at end of file diff --git a/packages/js/number/.eslintrc.js b/packages/js/number/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/number/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/number/.npmrc b/packages/js/number/.npmrc new file mode 100644 index 00000000000..9cf9495031e --- /dev/null +++ b/packages/js/number/.npmrc @@ -0,0 +1 @@ +package-lock=false \ No newline at end of file diff --git a/packages/js/number/CHANGELOG.md b/packages/js/number/CHANGELOG.md new file mode 100644 index 00000000000..4853be4ca21 --- /dev/null +++ b/packages/js/number/CHANGELOG.md @@ -0,0 +1,45 @@ +# Unreleased + +# 2.2.1 + +- Update all js packages with minor/patch version changes. #8392 + +# 2.2.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 + +# 2.1.1 + +- Update dependencies. +- Add TypeScript support. + +# 2.1.0 + +- Update to @wordpress/eslint coding standards. + +# 2.0.0 + +- Remove lodash dependency. + +## Breaking Changes + +- Decouple from global wcSettings object. +- Exported methods of the number package have been rewritten to accept a configuration object as their first parameter. + +# 1.0.4 + +- Update dependencies. + +# 1.0.3 + +- Update license to GPL-3.0-or-later. + +# 1.0.2 + +- Bump dependency versions. + +# 1.0.1 + +# 1.0.0 + +- Initial release exports `numberFormat`, `formatValue`, and `calculateDelta` diff --git a/packages/js/number/README.md b/packages/js/number/README.md new file mode 100644 index 00000000000..ce7a51c8404 --- /dev/null +++ b/packages/js/number/README.md @@ -0,0 +1,43 @@ +# Number + +A collection of utilities to propery localize numerical values in WooCommerce + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/number --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for ES2015+ such as lower versions of IE then using [core-js](https://github.com/zloirock/core-js) or [@babel/polyfill](https://babeljs.io/docs/en/next/babel-polyfill) will add support for these methods. Learn more about it in [Babel docs](https://babeljs.io/docs/en/next/caveats)._ + +## Usage + +```JS +import { formatNumber, formatValue, calculateDelta } from '@woocommerce/number'; + +// It's best to retrieve the site currency settings and compose them with the format functions. +import { partial } from 'lodash'; +// Retrieve this from the API or a global settings object. +const siteNumberOptions = { + precision: 2, + decimalSeparator: '.', + thousandSeparator: ',', +}; +// Compose. +const formatStoreNumber = partial( siteNumberOptions, formatNumber ); +const formatStoreValue = partial( siteNumberOptions, formatValue ); + +// Formats a number using site's current locale. +const localizedNumber = formatStoreNumber( 1337 ); // '1,377' + +// formatValue's second argument is a type: average, or number +// The third argument is the number/value to format +// (The first argument is the config object we composed with earlier) +const formattedAverage = formatStoreValue( 'average', '10.5' ); // 11 just uses Math.round +const formattedNumber = formatStoreValue( 'number', '1337' ); // 1,337 calls formatNumber ( see above ) + +// Get a rounded percent change/delta between two numbers +const delta = calculateDelta( 10, 8 ); // '25' +``` diff --git a/packages/js/number/jest.config.json b/packages/js/number/jest.config.json new file mode 100644 index 00000000000..72834f732cd --- /dev/null +++ b/packages/js/number/jest.config.json @@ -0,0 +1,4 @@ +{ + "rootDir": "./src", + "preset": "../../js-tests/jest.config.js" +} diff --git a/packages/js/number/package.json b/packages/js/number/package.json new file mode 100644 index 00000000000..1abd24f9f50 --- /dev/null +++ b/packages/js/number/package.json @@ -0,0 +1,55 @@ +{ + "name": "@woocommerce/number", + "version": "2.2.1", + "description": "Number formatting utilities for WooCommerce.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/number/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "locutus": "^2.0.16" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "start": "tsc --build --watch", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src", + "test": "pnpm run build && pnpm run test:nobuild", + "test:nobuild": "jest --config ./jest.config.json", + "test-staged": "jest --bail --config ./jest.config.json --findRelatedTests" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "@babel/runtime": "^7.17.2", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix", + "pnpm test-staged" + ] + } +} diff --git a/packages/js/number/project.json b/packages/js/number/project.json new file mode 100644 index 00000000000..67d6f84f3d0 --- /dev/null +++ b/packages/js/number/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/number", + "sourceRoot": "packages/js/number/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/number" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/number/src/index.js b/packages/js/number/src/index.js new file mode 100644 index 00000000000..6d8bd21d376 --- /dev/null +++ b/packages/js/number/src/index.js @@ -0,0 +1,92 @@ +const numberFormatter = require( 'locutus/php/strings/number_format' ); + +/** + * Number formatting configuration object + * + * @typedef {Object} NumberConfig + * @property {number} [precision] Decimal precision. + * @property {string} [decimalSeparator] Decimal separator. + * @property {string} [thousandSeparator] Character used to separate thousands groups. + */ + +/** + * Formats a number using site's current locale + * + * @see http://locutus.io/php/strings/number_format/ + * @param {NumberConfig} numberConfig Number formatting configuration object. + * @param {number|string} number number to format + * @return {string} A formatted string. + */ +export function numberFormat( + { precision = null, decimalSeparator = '.', thousandSeparator = ',' }, + number +) { + if ( typeof number !== 'number' ) { + number = parseFloat( number ); + } + + if ( isNaN( number ) ) { + return ''; + } + + let parsedPrecision = parseInt( precision, 10 ); + + if ( isNaN( parsedPrecision ) ) { + const [ , decimals ] = number.toString().split( '.' ); + parsedPrecision = decimals ? decimals.length : 0; + } + + return numberFormatter( + number, + parsedPrecision, + decimalSeparator, + thousandSeparator + ); +} + +/** + * Formats a number as average or number string according to the given `type`. + * - `type = 'average'` returns a rounded `Number` + * - `type = 'number'` returns a formatted `String` + * + * @param {NumberConfig} numberConfig number formatting configuration object. + * @param {string} type of number to format, `'average'` or `'number'` + * @param {number} value to format. + * @return {string | number | null} A formatted string. + */ +export function formatValue( numberConfig, type, value ) { + if ( ! Number.isFinite( value ) ) { + return null; + } + + switch ( type ) { + case 'average': + return Math.round( value ); + case 'number': + return numberFormat( { ...numberConfig, precision: null }, value ); + } +} + +/** + * Calculates the delta/percentage change between two numbers. + * + * @param {number} primaryValue the value to calculate change for. + * @param {number} secondaryValue the baseline which to calculdate the change against. + * @return {?number} Percent change between the primaryValue from the secondaryValue. + */ +export function calculateDelta( primaryValue, secondaryValue ) { + if ( + ! Number.isFinite( primaryValue ) || + ! Number.isFinite( secondaryValue ) + ) { + return null; + } + + if ( secondaryValue === 0 ) { + return 0; + } + + return Math.round( + ( ( primaryValue - secondaryValue ) / secondaryValue ) * 100 + ); +} diff --git a/packages/js/number/src/test/index.js b/packages/js/number/src/test/index.js new file mode 100644 index 00000000000..f2ec8b55053 --- /dev/null +++ b/packages/js/number/src/test/index.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { partial } from 'lodash'; + +/** + * Internal dependencies + */ +import { numberFormat } from '../index'; + +const defaultNumberFormat = partial( numberFormat, {} ); + +describe( 'numberFormat', () => { + it( 'should default to precision=null decimal=. thousands=,', () => { + expect( defaultNumberFormat( 1000 ) ).toBe( '1,000' ); + } ); + + it( 'should return an empty string if no argument is passed', () => { + expect( defaultNumberFormat() ).toBe( '' ); + } ); + + it( 'should accept a string', () => { + expect( defaultNumberFormat( '10000' ) ).toBe( '10,000' ); + } ); + + it( 'maintains all decimals if no precision specified', () => { + expect( defaultNumberFormat( '10000.123456' ) ).toBe( '10,000.123456' ); + } ); + + it( 'maintains all decimals if invalid precision specified', () => { + expect( + numberFormat( { precision: 'not a number' }, '10000.123456' ) + ).toBe( '10,000.123456' ); + } ); + + it( 'calculates the correct decimals based on precision passed in', () => { + expect( numberFormat( { precision: 2 }, '1337.4498' ) ).toBe( + '1,337.45' + ); + } ); + + it( 'uses store currency settings, not locale', () => { + const config = { + decimalSeparator: ',', + thousandSeparator: '.', + precision: 3, + }; + expect( numberFormat( config, '12345.6789' ) ).toBe( '12.345,679' ); + } ); +} ); diff --git a/packages/js/number/tsconfig-cjs.json b/packages/js/number/tsconfig-cjs.json new file mode 100644 index 00000000000..04a2967c800 --- /dev/null +++ b/packages/js/number/tsconfig-cjs.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "declaration": true, + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/number/tsconfig.json b/packages/js/number/tsconfig.json new file mode 100644 index 00000000000..e5c6c24d575 --- /dev/null +++ b/packages/js/number/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "declaration": true, + "rootDir": "src", + "outDir": "build-module" + } +} \ No newline at end of file diff --git a/packages/js/onboarding/.eslintrc.js b/packages/js/onboarding/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/onboarding/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/onboarding/.npmrc b/packages/js/onboarding/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/onboarding/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/onboarding/CHANGELOG.md b/packages/js/onboarding/CHANGELOG.md new file mode 100644 index 00000000000..e4554bd240d --- /dev/null +++ b/packages/js/onboarding/CHANGELOG.md @@ -0,0 +1,39 @@ +# Unreleased + +- Update TaskList types. +- Added Typescript type declarations. #32615 +# 3.0.1 + +- Add missing dependency. + +# 3.0.0 + +## Breaking changes + +- Update dependencies to support react 17. #8305 +- Drop support for IE11. #8305 + +# 2.2.2 + +- Retry fix for missing build-module folder + +# 2.2.1 + +- Fix missing build-module folder + +# 2.2.0 + +- Update WCPayCard CSS to handle @wordpress/card updates. #7412 + +# 2.1.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 +- Import createElement to fix build issues with SlotFill #7403 + +# 2.0.0 + +- Renaming exports for payment gateway Slotfill components #7251 + +# 1.0.0 + +- Initial package diff --git a/packages/js/onboarding/README.md b/packages/js/onboarding/README.md new file mode 100644 index 00000000000..95a870a4a76 --- /dev/null +++ b/packages/js/onboarding/README.md @@ -0,0 +1,11 @@ +# Onboarding + +A collection of onboarding related components and utilities. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/onboarding --save +``` diff --git a/packages/js/onboarding/package.json b/packages/js/onboarding/package.json new file mode 100644 index 00000000000..10c26033dd6 --- /dev/null +++ b/packages/js/onboarding/package.json @@ -0,0 +1,69 @@ +{ + "name": "@woocommerce/onboarding", + "version": "3.0.1", + "description": "Onboarding utilities.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "onboarding" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/onboarding/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "types": "build-types", + "react-native": "src/index", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@automattic/interpolate-components": "^1.2.0", + "@woocommerce/components": "workspace:*", + "@woocommerce/experimental": "workspace:*", + "@woocommerce/tracks": "workspace:*", + "@wordpress/components": "^19.5.0", + "@wordpress/element": "^4.1.1", + "@wordpress/i18n": "^4.3.1", + "concurrently": "^7.0.0", + "gridicons": "^3.4.0" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@woocommerce/style-build": "workspace:*", + "@wordpress/browserslist-config": "^4.1.1", + "@wordpress/eslint-plugin": "^11.0.0", + "css-loader": "^3.6.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "postcss-loader": "^3.0.0", + "rimraf": "^3.0.2", + "sass-loader": "^10.2.1", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2", + "webpack": "^5.70.0", + "webpack-cli": "^3.3.12" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "pnpm run build:js && pnpm run build:css", + "build:js": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "build:css": "webpack", + "start": "concurrently \"tsc --build --watch\" \"webpack --watch\"", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] + } +} diff --git a/packages/js/onboarding/project.json b/packages/js/onboarding/project.json new file mode 100644 index 00000000000..7c96ce73a49 --- /dev/null +++ b/packages/js/onboarding/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/onboarding", + "sourceRoot": "packages/js/onboarding/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/onboarding" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/onboarding/src/components/RecommendedRibbon/RecommendedRibbon.js b/packages/js/onboarding/src/components/RecommendedRibbon/RecommendedRibbon.js new file mode 100644 index 00000000000..48f8fa441d8 --- /dev/null +++ b/packages/js/onboarding/src/components/RecommendedRibbon/RecommendedRibbon.js @@ -0,0 +1,17 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { createElement } from '@wordpress/element'; + +export const RecommendedRibbon = ( { isLocalPartner = false } ) => { + const text = isLocalPartner + ? __( 'Local Partner', 'woocommerce' ) + : __( 'Recommended', 'woocommerce' ); + + return ( + <div className={ 'woocommerce-task-payment__recommended-ribbon' }> + <span>{ text }</span> + </div> + ); +}; diff --git a/packages/js/onboarding/src/components/RecommendedRibbon/RecommendedRibbon.scss b/packages/js/onboarding/src/components/RecommendedRibbon/RecommendedRibbon.scss new file mode 100644 index 00000000000..1a820852e4c --- /dev/null +++ b/packages/js/onboarding/src/components/RecommendedRibbon/RecommendedRibbon.scss @@ -0,0 +1,28 @@ +.woocommerce-task-payment__recommended-ribbon { + position: absolute; + transform: rotate(-45deg) translate(-50%, -50%); + background: $studio-gray-80; + color: $studio-white; + font-size: 11px; + line-height: 20px; + position: absolute; + top: 0; + left: 0; + line-height: 1; + padding: 7px $gap-largest; + transform-origin: top left; + margin-top: 32px; + margin-left: 32px; + + span { + max-width: 70px; + } +} + +@include breakpoint( '<600px' ) { + .woocommerce-task-payment__recommended-ribbon { + margin-top: 24px; + margin-left: 24px; + font-size: 9px; + } +} diff --git a/packages/js/onboarding/src/components/RecommendedRibbon/index.js b/packages/js/onboarding/src/components/RecommendedRibbon/index.js new file mode 100644 index 00000000000..ab716887636 --- /dev/null +++ b/packages/js/onboarding/src/components/RecommendedRibbon/index.js @@ -0,0 +1 @@ +export * from './RecommendedRibbon'; diff --git a/packages/js/onboarding/src/components/SetupRequired.js b/packages/js/onboarding/src/components/SetupRequired.js new file mode 100644 index 00000000000..bafdc574dcb --- /dev/null +++ b/packages/js/onboarding/src/components/SetupRequired.js @@ -0,0 +1,18 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; +import { __ } from '@wordpress/i18n'; +import { Text } from '@woocommerce/experimental'; + +export const SetupRequired = () => { + return ( + <span className="woocommerce-task-payment__setup_required"> + <NoticeOutlineIcon /> + <Text variant="small" size="14" lineHeight="20px"> + { __( 'Setup required', 'woocommerce' ) } + </Text> + </span> + ); +}; diff --git a/packages/js/onboarding/src/components/WCPayAcceptedMethods.js b/packages/js/onboarding/src/components/WCPayAcceptedMethods.js new file mode 100644 index 00000000000..f104c24e4e9 --- /dev/null +++ b/packages/js/onboarding/src/components/WCPayAcceptedMethods.js @@ -0,0 +1,41 @@ +/** + * External dependencies + */ +import { createElement, Fragment } from '@wordpress/element'; +import { Text } from '@woocommerce/experimental'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import Visa from '../images/cards/visa.js'; +import MasterCard from '../images/cards/mastercard.js'; +import Maestro from '../images/cards/maestro.js'; +import Amex from '../images/cards/amex.js'; +import ApplePay from '../images/cards/applepay.js'; +import CB from '../images/cards/cb.js'; +import DinersClub from '../images/cards/diners.js'; +import Discover from '../images/cards/discover.js'; +import JCB from '../images/cards/jcb.js'; +import UnionPay from '../images/cards/unionpay.js'; + +export const WCPayAcceptedMethods = () => ( + <> + <Text as="h3" variant="label" weight="600" size="12" lineHeight="16px"> + { __( 'Accepted payment methods', 'woocommerce' ) } + </Text> + + <div className="woocommerce-task-payment-wcpay__accepted"> + <Visa /> + <MasterCard /> + <Maestro /> + <Amex /> + <DinersClub /> + <CB /> + <Discover /> + <UnionPay /> + <JCB /> + <ApplePay /> + </div> + </> +); diff --git a/packages/js/onboarding/src/components/WCPayCard/WCPayCard.js b/packages/js/onboarding/src/components/WCPayCard/WCPayCard.js new file mode 100644 index 00000000000..88a79a7ad1c --- /dev/null +++ b/packages/js/onboarding/src/components/WCPayCard/WCPayCard.js @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import { Card, CardBody, CardHeader, CardFooter } from '@wordpress/components'; +import { Text } from '@woocommerce/experimental'; +import { createElement } from '@wordpress/element'; +import { Link } from '@woocommerce/components'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { WCPayAcceptedMethods } from '../WCPayAcceptedMethods'; +import WCPayLogo from '../../images/wcpay-logo'; + +export const WCPayCardHeader = ( { + logoWidth = 196, + logoHeight = 41, + children, +} ) => ( + <CardHeader as="h2"> + <WCPayLogo width={ logoWidth } height={ logoHeight } /> + { children } + </CardHeader> +); + +export const WCPayCardBody = ( { + description, + heading, + onLinkClick = () => {}, +} ) => ( + <CardBody> + { heading && <Text as="h2">{ heading }</Text> } + + <Text + className="woocommerce-task-payment-wcpay__description" + as="p" + lineHeight="1.5em" + > + { description } + <br /> + <Link + target="_blank" + type="external" + rel="noreferrer" + href="https://woocommerce.com/payments/?utm_medium=product" + onClick={ onLinkClick } + > + { __( 'Learn more', 'woocommerce' ) } + </Link> + </Text> + + <WCPayAcceptedMethods /> + </CardBody> +); + +export const WCPayCardFooter = ( { children } ) => ( + <CardFooter>{ children }</CardFooter> +); + +export const WCPayCard = ( { children } ) => { + return <Card className="woocommerce-task-payment-wcpay">{ children }</Card>; +}; diff --git a/packages/js/onboarding/src/components/WCPayCard/WCPayCard.scss b/packages/js/onboarding/src/components/WCPayCard/WCPayCard.scss new file mode 100644 index 00000000000..c21e835ff68 --- /dev/null +++ b/packages/js/onboarding/src/components/WCPayCard/WCPayCard.scss @@ -0,0 +1,43 @@ +.woocommerce-task-payments .woocommerce-task-payment-wcpay { + .woocommerce-task-payment-wcpay__description { + font-size: 16px; + margin-bottom: $gap-large; + } + + .components-card__header { + margin-bottom: $gap-small; + justify-content: flex-start; + padding: 25px; + + .woocommerce-pill { + margin-left: $gap-small; + } + } + + .components-card__footer { + flex-direction: column; + align-items: flex-start; + + .components-button { + margin-top: $gap; + margin-left: 0; + } + } + + .components-card__body { + h2 { + margin: 0 0 20px 0; + } + } + + .woocommerce-task-payment-wcpay__accepted { + display: flex; + margin-top: $gap-small; + flex-wrap: wrap; + gap: $gap-small; + + h3 { + color: #40464d; + } + } +} diff --git a/packages/js/onboarding/src/components/WCPayCard/index.js b/packages/js/onboarding/src/components/WCPayCard/index.js new file mode 100644 index 00000000000..3c22a1c0647 --- /dev/null +++ b/packages/js/onboarding/src/components/WCPayCard/index.js @@ -0,0 +1 @@ +export * from './WCPayCard'; diff --git a/packages/js/onboarding/src/components/WooOnboardingTask/WooOnboardingTask.js b/packages/js/onboarding/src/components/WooOnboardingTask/WooOnboardingTask.js new file mode 100644 index 00000000000..b2e05c6226d --- /dev/null +++ b/packages/js/onboarding/src/components/WooOnboardingTask/WooOnboardingTask.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { createElement, useEffect } from '@wordpress/element'; +import { recordEvent } from '@woocommerce/tracks'; +import { Slot, Fill } from '@wordpress/components'; + +export const trackView = ( taskId ) => { + const activePlugins = wp.data + .select( 'wc/admin/plugins' ) + .getActivePlugins(); + + const installedPlugins = wp.data + .select( 'wc/admin/plugins' ) + .getInstalledPlugins(); + + const isJetpackConnected = wp.data + .select( 'wc/admin/plugins' ) + .isJetpackConnected(); + + recordEvent( 'task_view', { + task_name: taskId, + wcs_installed: installedPlugins.includes( 'woocommerce-services' ), + wcs_active: activePlugins.includes( 'woocommerce-services' ), + jetpack_installed: installedPlugins.includes( 'jetpack' ), + jetpack_active: activePlugins.includes( 'jetpack' ), + jetpack_connected: isJetpackConnected, + } ); +}; + +/** + * A Fill for adding Onboarding tasks. + * + * @slotFill WooOnboardingTask + * @scope woocommerce-tasks + * @param {Object} props React props. + * @param {string} props.id Task id. + */ +const WooOnboardingTask = ( { id, ...props } ) => { + return <Fill name={ 'woocommerce_onboarding_task_' + id } { ...props } />; +}; + +WooOnboardingTask.Slot = ( { id, fillProps } ) => { + // The Slot is a React component and this hook works as expected. + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect( () => { + trackView( id ); + }, [ id ] ); + + return ( + <Slot + name={ 'woocommerce_onboarding_task_' + id } + fillProps={ fillProps } + /> + ); +}; + +export { WooOnboardingTask }; diff --git a/packages/js/onboarding/src/components/WooOnboardingTask/index.js b/packages/js/onboarding/src/components/WooOnboardingTask/index.js new file mode 100644 index 00000000000..06482c0ab9e --- /dev/null +++ b/packages/js/onboarding/src/components/WooOnboardingTask/index.js @@ -0,0 +1 @@ +export * from './WooOnboardingTask'; diff --git a/packages/js/onboarding/src/components/WooOnboardingTaskListItem/WooOnboardingTaskListItem.js b/packages/js/onboarding/src/components/WooOnboardingTaskListItem/WooOnboardingTaskListItem.js new file mode 100644 index 00000000000..098fe7ecc70 --- /dev/null +++ b/packages/js/onboarding/src/components/WooOnboardingTaskListItem/WooOnboardingTaskListItem.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { Slot, Fill } from '@wordpress/components'; + +/** + * A Fill for adding Onboarding Task List items. + * + * @slotFill WooOnboardingTaskListItem + * @scope woocommerce-tasks + * @param {Object} props React props. + * @param {string} props.id Task id. + */ +export const WooOnboardingTaskListItem = ( { id, ...props } ) => ( + <Fill name={ 'woocommerce_onboarding_task_list_item_' + id } { ...props } /> +); + +WooOnboardingTaskListItem.Slot = ( { id, fillProps } ) => ( + <Slot + name={ 'woocommerce_onboarding_task_list_item_' + id } + fillProps={ fillProps } + /> +); diff --git a/packages/js/onboarding/src/components/WooOnboardingTaskListItem/index.js b/packages/js/onboarding/src/components/WooOnboardingTaskListItem/index.js new file mode 100644 index 00000000000..b84633d3ea0 --- /dev/null +++ b/packages/js/onboarding/src/components/WooOnboardingTaskListItem/index.js @@ -0,0 +1 @@ +export * from './WooOnboardingTaskListItem'; diff --git a/packages/js/onboarding/src/components/WooPaymentGatewayConfigure/README.md b/packages/js/onboarding/src/components/WooPaymentGatewayConfigure/README.md new file mode 100644 index 00000000000..f3d4bec6bc8 --- /dev/null +++ b/packages/js/onboarding/src/components/WooPaymentGatewayConfigure/README.md @@ -0,0 +1,40 @@ +# WooPaymentGatewayConfigure Slot & Fill + +A Slotfill component that will replace the <DynamicForm /> component involved in displaying the form while adding a gateway via the payment task. + +## Usage + +```jsx +<WooPaymentGatewayConfigure id={ key }> + {({defaultForm: DefaultForm}) => { + return <> + <p> + Fill Content + </p> + { defaultForm } + </>; +}} +</WooPaymentGatewayConfigure> + +<WooPaymentGatewayConfigure.Slot id={ key } /> +``` + +### WooPaymentGatewayConfigure (fill) + +This is the fill component. You must provide the `id` prop to identify the slot that this will occupy. If you provide a function as the child of your fill (as shown above), you will receive some helper props to assist in creating your fill: + +| Name | Type | Description | +| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | +| `defaultForm` | Element | The default instance of the <DynamicForm> component. Can overwrite props using React.cloneElement(defaultForm, newProps) | +| `defaultSubmit` | Function | The default submit handler that is provided to the <Form> component | +| `defaultFields` | Array | An array of the field configuration objects provided by the API | +| `markConfigured` | Function | A helper function that will mark your gateway as configured | +| `paymentGateway` | Object | An object describing all of the relevant data pertaining to this payment gateway | + +### WooPaymentGatewayConfigure.Slot (slot) + +This is the slot component, and will not be used as frequently. It must also receive the required `id` prop that will be identical to the fill `id`. + +| Name | Type | Description | +| ----------- | ------ | ---------------------------------------------------------------------------------- | +| `fillProps` | Object | The props that will be provided to the fills, by default these are described above | diff --git a/packages/js/onboarding/src/components/WooPaymentGatewayConfigure/WooPaymentGatewayConfigure.js b/packages/js/onboarding/src/components/WooPaymentGatewayConfigure/WooPaymentGatewayConfigure.js new file mode 100644 index 00000000000..002e88f0d24 --- /dev/null +++ b/packages/js/onboarding/src/components/WooPaymentGatewayConfigure/WooPaymentGatewayConfigure.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { Slot, Fill } from '@wordpress/components'; + +/** + * WooCommerce Payment Gateway configuration + * + * @slotFill WooPaymentGatewayConfigure + * @scope woocommerce-admin + * @param {Object} props React props. + * @param {string} props.id gateway id. + */ +export const WooPaymentGatewayConfigure = ( { id, ...props } ) => ( + <Fill name={ 'woocommerce_payment_gateway_configure_' + id } { ...props } /> +); + +WooPaymentGatewayConfigure.Slot = ( { id, fillProps } ) => ( + <Slot + name={ 'woocommerce_payment_gateway_configure_' + id } + fillProps={ fillProps } + /> +); diff --git a/packages/js/onboarding/src/components/WooPaymentGatewayConfigure/index.js b/packages/js/onboarding/src/components/WooPaymentGatewayConfigure/index.js new file mode 100644 index 00000000000..6f526bebb49 --- /dev/null +++ b/packages/js/onboarding/src/components/WooPaymentGatewayConfigure/index.js @@ -0,0 +1 @@ +export * from './WooPaymentGatewayConfigure'; diff --git a/packages/js/onboarding/src/components/WooPaymentGatewaySetup/README.md b/packages/js/onboarding/src/components/WooPaymentGatewaySetup/README.md new file mode 100644 index 00000000000..50313ed7ab6 --- /dev/null +++ b/packages/js/onboarding/src/components/WooPaymentGatewaySetup/README.md @@ -0,0 +1,32 @@ +# WooPaymentGatewaySetup Slot & Fill + +A Slotfill component that will replace the <Stepper /> involved in the installation for a gateway via the payment task. + +## Usage + +```jsx +<WooPaymentGatewaySetup id={ key }> + {({defaultStepper: DefaultStepper}) => <p>Fill Content</p>} +</WooPaymentGatewaySetup> + +<WooPaymentGatewaySetup.Slot id={ key } /> +``` + +### WooPaymentGatewaySetup (fill) + +This is the fill component. You must provide the `id` prop to identify the slot that this will occupy. If you provide a function as the child of your fill (as shown above), you will receive some helper props to assist in creating your fill: + +| Name | Type | Description | +| -------------------- | --------- | ---------------------------------------------------------------------------------------------------- | +| `defaultStepper` | Component | The default instance of the <Stepper> component. Any provided props will override the given defaults | +| `defaultInstallStep` | Object | The object that describes the default step configuration for installation of the gateway | +| `defaultConnectStep` | Object | The object that describes the default step configuration for configuration of the gateway | +| `paymentGateway` | Object | An object describing all of the relevant data pertaining to this payment gateway | + +### WooPaymentGatewaySetup.Slot (slot) + +This is the slot component, and will not be used as frequently. It must also receive the required `id` prop that will be identical to the fill `id`. + +| Name | Type | Description | +| ----------- | ------ | ---------------------------------------------------------------------------------- | +| `fillProps` | Object | The props that will be provided to the fills, by default these are described above | diff --git a/packages/js/onboarding/src/components/WooPaymentGatewaySetup/WooPaymentGatewaySetup.js b/packages/js/onboarding/src/components/WooPaymentGatewaySetup/WooPaymentGatewaySetup.js new file mode 100644 index 00000000000..ad1635de6dd --- /dev/null +++ b/packages/js/onboarding/src/components/WooPaymentGatewaySetup/WooPaymentGatewaySetup.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; +import { Slot, Fill } from '@wordpress/components'; + +/** + * WooCommerce Payment Gateway setup. + * + * @slotFill WooPaymentGatewaySetup + * @scope woocommerce-admin + * @param {Object} props React props. + * @param {string} props.id Setup id. + */ +export const WooPaymentGatewaySetup = ( { id, ...props } ) => ( + <Fill name={ 'woocommerce_payment_gateway_setup_' + id } { ...props } /> +); + +WooPaymentGatewaySetup.Slot = ( { id, fillProps } ) => ( + <Slot + name={ 'woocommerce_payment_gateway_setup_' + id } + fillProps={ fillProps } + /> +); diff --git a/packages/js/onboarding/src/components/WooPaymentGatewaySetup/index.js b/packages/js/onboarding/src/components/WooPaymentGatewaySetup/index.js new file mode 100644 index 00000000000..ea52bddf96c --- /dev/null +++ b/packages/js/onboarding/src/components/WooPaymentGatewaySetup/index.js @@ -0,0 +1 @@ +export * from './WooPaymentGatewaySetup'; diff --git a/packages/js/onboarding/src/images/cards/amex.js b/packages/js/onboarding/src/images/cards/amex.js new file mode 100644 index 00000000000..daa085352db --- /dev/null +++ b/packages/js/onboarding/src/images/cards/amex.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="52" + height="35" + viewBox="0 0 52 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="1.18945" + y="0.5" + width="50" + height="34" + rx="3.5" + fill="#006FCF" + stroke="#F3F3F3" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M11.1205 25.2823V18.0771H19.3189L20.1985 19.1441L21.1072 18.0771H50.8653V24.7854C50.8653 24.7854 50.0871 25.2751 49.187 25.2823H32.7093L31.7176 24.1465V25.2823H28.4679V23.3435C28.4679 23.3435 28.0239 23.6141 27.0642 23.6141H25.9581V25.2823H21.0376L20.1593 24.1924L19.2675 25.2823H11.1205ZM1.56836 12.6465L3.41294 8.63574H6.60294L7.64976 10.8824V8.63574H11.6152L12.2384 10.2596L12.8425 8.63574H30.6434V9.4521C30.6434 9.4521 31.5792 8.63574 33.1171 8.63574L38.8928 8.65457L39.9215 10.8718V8.63574H43.24L44.1534 9.90939V8.63574H47.5023V15.841H44.1534L43.2781 14.5632V15.841H38.4025L37.9121 14.7052H36.6014L36.1191 15.841H32.8126C31.4893 15.841 30.6434 15.0413 30.6434 15.0413V15.841H25.658L24.6685 14.7052V15.841H6.13036L5.64039 14.7052H4.33383L3.84732 15.841H1.56836V12.6465ZM1.5779 14.9189L4.06583 9.52391H5.95199L8.43755 14.9189H6.7821L6.32542 13.8386H3.65672L3.19767 14.9189H1.5779ZM5.79982 12.6674L4.98636 10.7795L4.17053 12.6674H5.79982ZM8.60869 14.9182V9.52317L10.9105 9.53115L12.2493 13.0095L13.556 9.52317H15.8394V14.9182H14.3933V10.9429L12.8603 14.9182H11.592L10.0548 10.9429V14.9182H8.60869ZM16.8289 14.9182V9.52317H21.5479V10.73H18.2902V11.6528H21.4717V12.7886H18.2902V13.7469H21.5479V14.9182H16.8289ZM22.3851 14.9189V9.52391H25.6033C26.6696 9.52391 27.625 10.1389 27.625 11.2742C27.625 12.2447 26.8195 12.8698 26.0385 12.9313L27.9413 14.9189H26.1741L24.4402 13.0023H23.8313V14.9189H22.3851ZM25.4843 10.7306H23.8313V11.8664H25.5057C25.7956 11.8664 26.1694 11.6569 26.1694 11.2985C26.1694 11.0199 25.8809 10.7306 25.4843 10.7306ZM29.692 14.9182H28.2154V9.52317H29.692V14.9182ZM33.1931 14.9182H32.8744C31.3323 14.9182 30.396 13.7851 30.396 12.2429C30.396 10.6626 31.3218 9.52317 33.2692 9.52317H34.8676V10.8009H33.2108C32.4202 10.8009 31.8611 11.3763 31.8611 12.2562C31.8611 13.301 32.5004 13.7398 33.4215 13.7398H33.802L33.1931 14.9182ZM33.8521 14.9189L36.34 9.52391H38.2262L40.7117 14.9189H39.0563L38.5996 13.8386H35.9309L35.4719 14.9189H33.8521ZM38.074 12.6674L37.2605 10.7795L36.4447 12.6674H38.074ZM40.8805 14.9182V9.52317H42.7191L45.0667 12.9128V9.52317H46.5128V14.9182H44.7337L42.3267 11.4398V14.9182H40.8805ZM12.1099 24.3594V18.9643H16.8289V20.1711H13.5713V21.0939H16.7528V22.2297H13.5713V23.1881H16.8289V24.3594H12.1099ZM35.2329 24.3594V18.9643H39.9519V20.1711H36.6943V21.0939H39.8606V22.2297H36.6943V23.1881H39.9519V24.3594H35.2329ZM17.0121 24.3594L19.3097 21.6951L16.9574 18.9643H18.7793L20.1803 20.6525L21.586 18.9643H23.3366L21.0151 21.6618L23.317 24.3594H21.4953L20.1351 22.6978L18.8079 24.3594H17.0121ZM23.4887 24.3603V18.9653H26.6831C27.9938 18.9653 28.7595 19.7531 28.7595 20.7799C28.7595 22.0193 27.7832 22.6566 26.4952 22.6566H24.9729V24.3603H23.4887ZM26.5761 20.1853H24.973V21.4276H26.5714C26.9937 21.4276 27.2897 21.1665 27.2897 20.8064C27.2897 20.4232 26.9922 20.1853 26.5761 20.1853ZM29.3875 24.3594V18.9643H32.6056C33.672 18.9643 34.6274 19.5793 34.6274 20.7146C34.6274 21.6851 33.8218 22.3102 33.0409 22.3717L34.9437 24.3594H33.1765L31.4426 22.4427H30.8337V24.3594H29.3875ZM32.4867 20.171H30.8337V21.3068H32.5082C32.798 21.3068 33.1718 21.0974 33.1718 20.7389C33.1718 20.4603 32.8833 20.171 32.4867 20.171ZM40.6217 24.3594V23.1881H43.5159C43.9441 23.1881 44.1295 22.9722 44.1295 22.7355C44.1295 22.5087 43.9447 22.2794 43.5159 22.2794H42.208C41.0712 22.2794 40.4381 21.6334 40.4381 20.6636C40.4381 19.7985 41.0178 18.9643 42.7072 18.9643H45.5233L44.9144 20.1782H42.4788C42.0132 20.1782 41.8699 20.4061 41.8699 20.6237C41.8699 20.8473 42.047 21.0939 42.4027 21.0939H43.7727C45.04 21.0939 45.5899 21.7644 45.5899 22.6424C45.5899 23.5863 44.9772 24.3594 43.7038 24.3594H40.6217ZM45.7176 24.3594V23.1881H48.6118C49.04 23.1881 49.2254 22.9722 49.2254 22.7355C49.2254 22.5087 49.0406 22.2794 48.6118 22.2794H47.3039C46.1671 22.2794 45.534 21.6334 45.534 20.6636C45.534 19.7985 46.1138 18.9643 47.8031 18.9643H50.6192L50.0103 20.1782H47.5747C47.1092 20.1782 46.9658 20.4061 46.9658 20.6237C46.9658 20.8473 47.1429 21.0939 47.4986 21.0939H48.8687C50.1359 21.0939 50.6858 21.7644 50.6858 22.6424C50.6858 23.5863 50.0731 24.3594 48.7997 24.3594H45.7176Z" + fill="white" + /> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/applepay.js b/packages/js/onboarding/src/images/cards/applepay.js new file mode 100644 index 00000000000..74febe62299 --- /dev/null +++ b/packages/js/onboarding/src/images/cards/applepay.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="52" + height="35" + viewBox="0 0 52 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="0.878906" + y="0.5" + width="50" + height="34" + rx="3.5" + fill="white" + stroke="#F3F3F3" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M15.8352 13.0607C15.4642 13.5024 14.8707 13.8507 14.2771 13.8009C14.2029 13.2038 14.4935 12.5693 14.8336 12.1774C15.2045 11.7233 15.8537 11.3999 16.3792 11.375C16.4411 11.997 16.1999 12.6066 15.8352 13.0607ZM16.373 13.9192C15.8501 13.8889 15.373 14.0774 14.9876 14.2297C14.7396 14.3277 14.5296 14.4106 14.3698 14.4106C14.1905 14.4106 13.9718 14.3232 13.7263 14.2251C13.4046 14.0965 13.0367 13.9495 12.651 13.9565C11.7669 13.969 10.9446 14.4728 10.4933 15.2753C9.56588 16.8801 10.2522 19.2563 11.1486 20.5626C11.5876 21.2095 12.1131 21.9186 12.8056 21.8937C13.1102 21.8822 13.3294 21.7886 13.5562 21.6918C13.8173 21.5803 14.0885 21.4645 14.512 21.4645C14.9208 21.4645 15.1802 21.5773 15.4292 21.6856C15.6659 21.7885 15.8933 21.8874 16.2308 21.8813C16.948 21.8689 17.3993 21.2344 17.8383 20.5875C18.312 19.8931 18.5202 19.2155 18.5518 19.1127L18.5555 19.1008C18.5547 19.1 18.5488 19.0973 18.5385 19.0926C18.3802 19.0196 17.1698 18.4621 17.1582 16.9672C17.1465 15.7124 18.1182 15.0767 18.2712 14.9766L18.2712 14.9766C18.2805 14.9705 18.2868 14.9664 18.2896 14.9642C17.6713 14.0436 16.7068 13.9441 16.373 13.9192ZM21.3377 21.8128V12.1153H24.9546C26.8217 12.1153 28.1263 13.4091 28.1263 15.3001C28.1263 17.1911 26.797 18.4974 24.9051 18.4974H22.8339V21.8128H21.3377ZM22.8339 13.3841H24.5589C25.8572 13.3841 26.5991 14.0808 26.5991 15.3062C26.5991 16.5317 25.8572 17.2346 24.5527 17.2346H22.8339V13.3841ZM33.0661 20.6496C32.6704 21.4085 31.7986 21.8874 30.8589 21.8874C29.4678 21.8874 28.4971 21.0539 28.4971 19.7974C28.4971 18.5533 29.4368 17.838 31.1742 17.7322L33.0413 17.6203V17.0853C33.0413 16.2953 32.5282 15.8661 31.6131 15.8661C30.8589 15.8661 30.3086 16.258 30.1973 16.8552H28.8495C28.8928 15.5986 30.0675 14.6842 31.6564 14.6842C33.369 14.6842 34.4819 15.5862 34.4819 16.9858V21.8128H33.097V20.6496H33.0661ZM31.2609 20.7368C30.4633 20.7368 29.9563 20.3511 29.9563 19.7602C29.9563 19.1506 30.4448 18.796 31.3784 18.74L33.0415 18.6343V19.1817C33.0415 20.0898 32.2748 20.7368 31.2609 20.7368ZM39.0756 22.1922C38.4759 23.8903 37.7897 24.4502 36.3306 24.4502C36.2193 24.4502 35.8483 24.4377 35.7617 24.4129V23.2496C35.8545 23.2621 36.0832 23.2745 36.2007 23.2745C36.8623 23.2745 37.2332 22.9946 37.462 22.2668L37.598 21.8376L35.0631 14.7775H36.6273L38.3894 20.5065H38.4203L40.1823 14.7775H41.7033L39.0756 22.1922Z" + fill="black" + /> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/cb.js b/packages/js/onboarding/src/images/cards/cb.js new file mode 100644 index 00000000000..da48c9a5b13 --- /dev/null +++ b/packages/js/onboarding/src/images/cards/cb.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="52" + height="35" + viewBox="0 0 52 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="1.18945" + y="0.5" + width="49.6897" + height="34" + rx="2.5" + fill="url(#paint0_linear)" + stroke="#F1F1F1" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M19.7636 16.9816H26.6269C26.5657 15.4963 26.2201 13.9649 25.1717 12.9813C23.9229 11.8096 21.7355 11.375 19.781 11.375C17.7466 11.375 15.4968 11.8517 14.2414 13.1088C13.1588 14.1918 12.9248 15.9341 12.9248 17.4997C12.9248 19.1395 13.3827 21.0469 14.5571 22.1456C15.8059 23.3147 17.8294 23.625 19.781 23.625C21.6767 23.625 23.7302 23.2746 24.9718 22.1647C26.2099 21.0561 26.6377 19.1888 26.6377 17.4997V17.4918H19.7636V16.9816ZM27.0876 17.4921V23.3511H36.6352V23.3432C38.0322 23.267 39.1436 22.0059 39.1436 20.4575C39.1436 18.9084 38.0322 17.5664 36.6352 17.4895V17.4921H27.0876ZM36.5263 11.6203C37.8879 11.6203 38.9687 12.8032 38.9687 14.2957C38.9687 15.7087 37.9762 16.8626 36.7135 16.9816H27.0873V11.6118H36.2251C36.2813 11.6049 36.3468 11.6097 36.4108 11.6144C36.4508 11.6174 36.4901 11.6203 36.5263 11.6203Z" + fill="#FEFEFE" + /> + <defs> + <linearGradient + id="paint0_linear" + x1="14.4385" + y1="-4.43215" + x2="2.09335" + y2="33.4202" + gradientUnits="userSpaceOnUse" + > + <stop stopColor="#222E72" /> + <stop offset="0.591647" stopColor="#40CBFF" /> + <stop offset="1" stopColor="#3CB792" /> + </linearGradient> + </defs> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/diners.js b/packages/js/onboarding/src/images/cards/diners.js new file mode 100644 index 00000000000..039bad8b238 --- /dev/null +++ b/packages/js/onboarding/src/images/cards/diners.js @@ -0,0 +1,258 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="51" + height="35" + viewBox="0 0 51 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="1.18945" + y="0.5" + width="49" + height="34" + rx="3.5" + fill="white" + stroke="#F3F3F3" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M8.30356 21.1794C8.30356 20.493 7.95026 20.5381 7.6123 20.5309V20.3325C7.90535 20.347 8.20582 20.347 8.49964 20.347C8.8153 20.347 9.24386 20.3325 9.80068 20.3325C11.7479 20.3325 12.8085 21.6523 12.8085 23.0038C12.8085 23.7601 12.3724 25.6602 9.71022 25.6602C9.32704 25.6602 8.97313 25.6451 8.61983 25.6451C8.28172 25.6451 7.95026 25.6521 7.6123 25.6602V25.4616C8.06302 25.4154 8.28172 25.4004 8.30356 24.8815V21.1794ZM9.04049 24.759C9.04049 25.3469 9.4545 25.4153 9.82282 25.4153C11.4476 25.4153 11.9807 24.1715 11.9807 23.0344C11.9807 21.6071 11.0785 20.5769 9.62735 20.5769C9.31835 20.5769 9.17617 20.5992 9.04049 20.6074V24.759Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M13.0713 25.4613H13.2138C13.4243 25.4613 13.5748 25.4613 13.5748 25.2088V23.1406C13.5748 22.8053 13.4622 22.7589 13.1836 22.6067V22.4849C13.537 22.377 13.9585 22.233 13.9882 22.2101C14.0412 22.1794 14.0856 22.1712 14.1239 22.1712C14.1608 22.1712 14.1762 22.2171 14.1762 22.2788V25.2088C14.1762 25.4613 14.342 25.4613 14.5528 25.4613H14.6801V25.6599C14.4244 25.6599 14.1608 25.6448 13.8909 25.6448C13.6202 25.6448 13.3493 25.6518 13.0713 25.6599V25.4613ZM13.8758 20.9962C13.6799 20.9962 13.5074 20.813 13.5074 20.6146C13.5074 20.4235 13.6881 20.2476 13.8758 20.2476C14.071 20.2476 14.2445 20.4084 14.2445 20.6146C14.2445 20.8211 14.0786 20.9962 13.8758 20.9962Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M15.3957 23.1866C15.3957 22.9048 15.3126 22.8285 14.9598 22.6833V22.5383C15.2826 22.4316 15.5907 22.3319 15.952 22.1714C15.9748 22.1714 15.9966 22.1866 15.9966 22.2476V22.7439C16.426 22.4316 16.7944 22.1714 17.299 22.1714C17.9373 22.1714 18.1628 22.6447 18.1628 23.2401V25.209C18.1628 25.4614 18.3287 25.4614 18.5391 25.4614H18.6747V25.66C18.4107 25.66 18.1478 25.6449 17.8774 25.6449C17.6065 25.6449 17.3356 25.652 17.0649 25.66V25.4614H17.2005C17.4112 25.4614 17.561 25.4614 17.561 25.209V23.233C17.561 22.7974 17.299 22.5839 16.8699 22.5839C16.629 22.5839 16.2455 22.782 15.9966 22.9507V25.209C15.9966 25.4614 16.1628 25.4614 16.3735 25.4614H16.5085V25.66C16.2455 25.66 15.9822 25.6449 15.711 25.6449C15.4409 25.6449 15.1698 25.652 14.8994 25.66V25.4614H15.0351C15.2454 25.4614 15.3957 25.4614 15.3957 25.209V23.1866Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M19.2463 23.5536C19.2309 23.6223 19.2309 23.7366 19.2463 23.9963C19.2906 24.7212 19.7503 25.3162 20.3512 25.3162C20.7654 25.3162 21.0889 25.0871 21.3666 24.8053L21.4716 24.9122C21.1256 25.3777 20.6972 25.7748 20.0811 25.7748C18.8851 25.7748 18.6445 24.5987 18.6445 24.1106C18.6445 22.6144 19.6369 22.1714 20.1627 22.1714C20.7725 22.1714 21.4272 22.5606 21.4342 23.3699C21.4342 23.4163 21.4342 23.4616 21.4272 23.5075L21.3592 23.5536H19.2463ZM20.5777 23.309C20.7653 23.309 20.7873 23.2097 20.7873 23.1177C20.7873 22.7291 20.5544 22.4161 20.1331 22.4161C19.6748 22.4161 19.3588 22.759 19.2687 23.309H20.5777Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M21.6074 25.4614H21.8106C22.0203 25.4614 22.1709 25.4614 22.1709 25.209V23.0646C22.1709 22.8285 21.8929 22.782 21.78 22.721V22.6069C22.3289 22.3701 22.6298 22.1714 22.6986 22.1714C22.7426 22.1714 22.7652 22.1943 22.7652 22.2711V22.9581H22.781C22.9684 22.6605 23.2848 22.1714 23.7433 22.1714C23.9312 22.1714 24.1715 22.3011 24.1715 22.576C24.1715 22.782 24.0294 22.9661 23.8189 22.9661C23.585 22.9661 23.585 22.782 23.3215 22.782C23.1939 22.782 22.7728 22.9581 22.7728 23.4163V25.209C22.7728 25.4614 22.923 25.4614 23.1337 25.4614H23.5543V25.66C23.1408 25.652 22.8261 25.6449 22.5023 25.6449C22.194 25.6449 21.878 25.652 21.6074 25.66V25.4614Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M24.5026 24.5985C24.6006 25.1022 24.9008 25.5303 25.4508 25.5303C25.8937 25.5303 26.059 25.2552 26.059 24.9882C26.059 24.0871 24.4202 24.3775 24.4202 23.1488C24.4202 22.7208 24.7587 22.1714 25.586 22.1714C25.8262 22.1714 26.1495 22.2406 26.4425 22.3934L26.4953 23.1712H26.3225C26.2473 22.6906 25.9845 22.416 25.5027 22.416C25.2019 22.416 24.9164 22.5913 24.9164 22.9195C24.9164 23.8131 26.6606 23.5378 26.6606 24.7362C26.6606 25.2396 26.2625 25.7746 25.3673 25.7746C25.0668 25.7746 24.7127 25.6675 24.4504 25.5149L24.3672 24.637L24.5026 24.5985Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M33.4502 21.7134H33.2626C33.1195 20.8213 32.4953 20.4621 31.6537 20.4621C30.7879 20.4621 29.5325 21.0494 29.5325 22.8812C29.5325 24.4236 30.6158 25.5305 31.7736 25.5305C32.5173 25.5305 33.1353 25.0112 33.2854 24.2093L33.4584 24.2549L33.2854 25.3696C32.9696 25.5683 32.1196 25.7748 31.6227 25.7748C29.8638 25.7748 28.751 24.622 28.751 22.9048C28.751 21.3396 30.1271 20.2173 31.601 20.2173C32.2099 20.2173 32.7963 20.4163 33.3754 20.6226L33.4502 21.7134Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M33.7217 25.461H33.864C34.0753 25.461 34.2254 25.461 34.2254 25.2086V20.9583C34.2254 20.4617 34.1128 20.4466 33.8267 20.3625V20.2402C34.1273 20.1411 34.4434 20.004 34.6017 19.9117C34.6834 19.8666 34.7441 19.8276 34.7662 19.8276C34.8122 19.8276 34.8271 19.874 34.8271 19.9353V25.2086C34.8271 25.461 34.9927 25.461 35.203 25.461H35.3303V25.6596C35.0754 25.6596 34.8122 25.6445 34.5413 25.6445C34.2707 25.6445 34.0003 25.6516 33.7217 25.6596V25.461Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M38.5497 25.2394C38.5497 25.3773 38.632 25.3846 38.7594 25.3846C38.8502 25.3846 38.9625 25.3773 39.061 25.3773V25.538C38.7374 25.5682 38.1205 25.7285 37.9774 25.7744L37.9399 25.7512V25.1329C37.4892 25.5067 37.1429 25.7744 36.6082 25.7744C36.2023 25.7744 35.7815 25.5067 35.7815 24.8664V22.9118C35.7815 22.7131 35.7516 22.5223 35.3311 22.4847V22.3393C35.6019 22.3317 36.2023 22.2861 36.3003 22.2861C36.3838 22.2861 36.3838 22.3393 36.3838 22.5075V24.4765C36.3838 24.7057 36.3838 25.3614 37.0379 25.3614C37.2931 25.3614 37.6316 25.1635 37.9472 24.8967V22.843C37.9472 22.6905 37.5865 22.6065 37.3162 22.5306V22.3933C37.9923 22.3468 38.4139 22.2861 38.489 22.2861C38.5497 22.2861 38.5497 22.3393 38.5497 22.4235V25.2394Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M40.0461 22.7206C40.3468 22.4617 40.753 22.171 41.1666 22.171C42.0391 22.171 42.5655 22.9427 42.5655 23.7745C42.5655 24.774 41.8433 25.7744 40.7674 25.7744C40.2116 25.7744 39.9182 25.5906 39.7223 25.5066L39.4974 25.6822L39.3397 25.5986C39.4069 25.1484 39.4449 24.7057 39.4449 24.2398V20.9583C39.4449 20.4617 39.3317 20.4466 39.0459 20.3625V20.2402C39.347 20.1411 39.6625 20.004 39.8203 19.9117C39.9033 19.8666 39.9633 19.8276 39.9862 19.8276C40.031 19.8276 40.0461 19.8742 40.0461 19.9353V22.7206ZM40.0461 24.7968C40.0461 25.0867 40.317 25.5756 40.8207 25.5756C41.6252 25.5756 41.9634 24.774 41.9634 24.0944C41.9634 23.2703 41.3475 22.5835 40.7611 22.5835C40.4816 22.5835 40.249 22.767 40.0461 22.9427V24.7968Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M11.6628 29.4042L11.671 29.3961V27.8391C11.671 27.4982 11.4374 27.4484 11.3148 27.4484H11.2246V27.3237C11.4173 27.3237 11.6055 27.3402 11.7976 27.3402C11.9653 27.3402 12.1338 27.3237 12.3009 27.3237V27.4484H12.2401C12.0677 27.4484 11.8753 27.4816 11.8753 27.9758V29.8655C11.8753 30.0111 11.8793 30.1563 11.8995 30.2852H11.744L9.63706 27.9008V29.6124C9.63706 29.974 9.7063 30.098 10.0213 30.098H10.091V30.2228C9.91508 30.2228 9.73929 30.2066 9.56334 30.2066C9.37964 30.2066 9.19115 30.2228 9.00684 30.2228V30.098H9.0643C9.3465 30.098 9.43246 29.9027 9.43246 29.5715V27.8216C9.43246 27.5893 9.24366 27.4484 9.06027 27.4484H9.00684V27.3237C9.16203 27.3237 9.32187 27.3402 9.47707 27.3402C9.6002 27.3402 9.71884 27.3237 9.84151 27.3237L11.6628 29.4042Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M11.899 30.3027L11.7305 30.2969L9.65398 27.9477V29.6124C9.65893 29.9735 9.71299 30.0761 10.0211 30.0801H10.1087V30.2406H10.0906C9.91356 30.2406 9.73746 30.2239 9.56291 30.2239C9.37968 30.2239 9.19165 30.2406 9.00625 30.2406H8.98828V30.0801H9.06387C9.33429 30.0785 9.41158 29.9021 9.41453 29.5714V27.8223C9.41375 27.6011 9.23501 27.4665 9.05999 27.4665H8.98828V27.3057H9.00625C9.16268 27.3057 9.32283 27.3219 9.47632 27.3219C9.59837 27.3219 9.71655 27.3057 9.85455 27.3121L11.6528 29.3667V27.8391C11.6508 27.5092 11.4336 27.4692 11.3145 27.4665H11.2062V27.3057H11.2244C11.4177 27.3057 11.606 27.3219 11.7971 27.3219C11.9636 27.3219 12.1314 27.3057 12.3005 27.3057H12.3185V27.4665H12.2396C12.0707 27.4709 11.8973 27.486 11.8924 27.9758V29.8655C11.8924 30.0109 11.8969 30.1559 11.9165 30.2819L11.9203 30.3027H11.899ZM11.7436 30.2666H11.879C11.8607 30.1416 11.8574 30.0037 11.8574 29.8655V27.9759C11.8574 27.4774 12.0633 27.4304 12.2395 27.4301H12.2826V27.3417C12.1214 27.343 11.9598 27.3581 11.797 27.3581C11.6096 27.3581 11.4275 27.343 11.2425 27.3417L11.2421 27.4301H11.3144C11.4402 27.4304 11.6882 27.4871 11.6882 27.8391L11.6827 29.4092L11.6747 29.4172L11.6607 29.4305L9.84099 27.3417C9.71941 27.3417 9.60107 27.3581 9.47624 27.3581C9.32631 27.3581 9.17266 27.343 9.02428 27.3417L9.02351 27.4301H9.0599C9.25181 27.4304 9.44929 27.5791 9.44929 27.8223V29.5714C9.44929 29.9037 9.35806 30.116 9.06378 30.1171L9.02428 30.116V30.2051C9.20147 30.2037 9.38408 30.1884 9.56282 30.1884C9.7335 30.1884 9.90387 30.2037 10.0727 30.2051V30.1171H10.021C9.69896 30.116 9.61919 29.9735 9.61888 29.6124V27.8544L11.7436 30.2666ZM11.6623 29.4042L11.6752 29.3922L11.6623 29.4042ZM11.6528 29.3961V29.3945L11.6495 29.3918L11.6528 29.3961Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M12.9145 27.5313C12.6072 27.5313 12.5953 27.6064 12.534 27.9092H12.4111C12.4272 27.7928 12.4475 27.6767 12.4602 27.5562C12.4766 27.4394 12.4849 27.3236 12.4849 27.2035H12.5829C12.6159 27.3281 12.7181 27.3236 12.8292 27.3236H14.9396C15.0507 27.3236 15.1528 27.3195 15.1611 27.1948L15.2588 27.2118C15.2431 27.3236 15.2263 27.4358 15.2144 27.5482C15.2063 27.6603 15.2063 27.7721 15.2063 27.8842L15.0834 27.9304C15.075 27.777 15.0547 27.5313 14.7806 27.5313H14.1094V29.7408C14.1094 30.0612 14.2529 30.0978 14.449 30.0978H14.527V30.2227C14.3673 30.2227 14.0808 30.2066 13.8602 30.2066C13.6144 30.2066 13.3277 30.2227 13.1681 30.2227V30.0978H13.2461C13.4716 30.0978 13.5856 30.0774 13.5856 29.7495V27.5313H12.9145Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M14.5273 30.2405C14.366 30.2405 14.0798 30.224 13.8602 30.224C13.6147 30.224 13.3285 30.2405 13.168 30.2405H13.1505V30.08H13.2461C13.4716 30.0746 13.5622 30.0717 13.5673 29.7495V27.5491H12.9144V27.5128H13.6028V29.7495C13.6028 30.083 13.4703 30.1166 13.2461 30.1169H13.1855V30.2047C13.3469 30.2035 13.6226 30.1883 13.8602 30.1883C14.0731 30.1883 14.3465 30.2035 14.5087 30.2047V30.1169H14.4489C14.251 30.1166 14.0917 30.0673 14.0917 29.7409V27.5128H14.7805C15.055 27.5139 15.0914 27.7506 15.0999 27.9052L15.1885 27.872C15.1885 27.7635 15.1889 27.6549 15.1962 27.5458C15.2083 27.4384 15.2232 27.3329 15.2385 27.2265L15.1764 27.216C15.1573 27.3369 15.0419 27.3433 14.9394 27.3417H12.8077C12.7114 27.3419 12.6065 27.3372 12.5697 27.2213H12.5021C12.5015 27.3361 12.4938 27.4475 12.4783 27.5582C12.4661 27.6723 12.4473 27.7819 12.4324 27.892H12.5198C12.5735 27.6022 12.6085 27.5087 12.9144 27.5128V27.5491C12.6113 27.5552 12.618 27.6066 12.5512 27.9135L12.5483 27.9276H12.3906L12.3928 27.9061C12.4095 27.7902 12.4303 27.6736 12.4422 27.553C12.4592 27.4378 12.467 27.3225 12.467 27.2035V27.1851H12.5969L12.6 27.1982C12.6276 27.3029 12.7033 27.3037 12.8077 27.3056H14.9394C15.0534 27.3037 15.1364 27.3021 15.1434 27.1935L15.1448 27.1738L15.1634 27.1776L15.2791 27.1962L15.2761 27.2145C15.2599 27.326 15.2439 27.4378 15.2315 27.5491C15.2239 27.6604 15.2239 27.772 15.2239 27.8843V27.8968L15.212 27.9017L15.0672 27.9555L15.0663 27.9314C15.055 27.7752 15.0392 27.5491 14.7805 27.5491H14.1274V29.7409C14.1314 30.054 14.2537 30.0761 14.4489 30.08H14.5446V30.2405H14.5273Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M15.3896 30.098H15.4473C15.5941 30.098 15.7501 30.0775 15.7501 29.8614V27.6856C15.7501 27.4691 15.5941 27.4486 15.4473 27.4486H15.3896V27.3237C15.6384 27.3237 16.0649 27.3402 16.408 27.3402C16.7523 27.3402 17.1773 27.3237 17.4555 27.3237C17.4484 27.5023 17.4523 27.7771 17.4643 27.9595L17.341 27.9925C17.3214 27.7224 17.2723 27.507 16.8425 27.507H16.2744V28.5944H16.7604C17.0059 28.5944 17.0593 28.4537 17.0836 28.2293H17.2063C17.1981 28.3915 17.1938 28.5534 17.1938 28.7152C17.1938 28.8733 17.1981 29.031 17.2063 29.1885L17.0836 29.2134C17.0593 28.9645 17.0471 28.8026 16.7644 28.8026H16.2744V29.7697C16.2744 30.0401 16.5107 30.0401 16.7728 30.0401C17.2639 30.0401 17.4806 30.0066 17.6034 29.5338L17.7176 29.5624C17.6644 29.7829 17.6157 30.0024 17.579 30.2228C17.3167 30.2228 16.8462 30.2068 16.4786 30.2068C16.1096 30.2068 15.6231 30.2228 15.3896 30.2228V30.098Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M17.5784 30.2406C17.3159 30.2406 16.845 30.2233 16.478 30.2233C16.1093 30.2233 15.623 30.2406 15.3893 30.2406H15.3721V30.0801H15.4469C15.595 30.0776 15.7297 30.0644 15.7316 29.8614V27.6856C15.7297 27.4827 15.595 27.4692 15.4469 27.4665H15.3721V27.3057H15.3893C15.6392 27.3057 16.0649 27.3219 16.4078 27.3219C16.7518 27.3219 17.1766 27.3057 17.4553 27.3057H17.4734L17.4728 27.3248C17.4701 27.3869 17.4684 27.4608 17.4684 27.539C17.4684 27.6825 17.4728 27.8399 17.4808 27.9581L17.4816 27.9729L17.4678 27.9771L17.3239 28.0153L17.3233 27.9939C17.2992 27.7245 17.2635 27.5284 16.8422 27.5243H16.2907L16.2904 28.5762H16.76C16.9959 28.5737 17.038 28.4514 17.0659 28.2274L17.0668 28.2107H17.2242L17.2239 28.2297C17.216 28.392 17.2112 28.5534 17.2112 28.7152C17.2112 28.8722 17.216 29.0299 17.2239 29.1878L17.2242 29.2029L17.2093 29.2063L17.0668 29.2351L17.0659 29.2156C17.0376 28.962 17.0357 28.823 16.7642 28.8201H16.2907V29.7698C16.2913 30.0222 16.5057 30.0208 16.7724 30.0222C17.2651 30.0192 17.4624 29.9947 17.5857 29.5289L17.5899 29.5121L17.6071 29.5149L17.7383 29.5495L17.7345 29.5666C17.6814 29.7864 17.6326 30.006 17.5956 30.2258L17.5925 30.2406H17.5784ZM17.5634 30.2048C17.5992 29.9948 17.6458 29.785 17.696 29.576L17.6155 29.5554C17.4929 30.0199 17.2555 30.0614 16.7726 30.0583C16.5147 30.0583 16.257 30.0583 16.2556 29.7698V28.7846H16.7644C17.0501 28.7816 17.0789 28.9564 17.0994 29.1927L17.1877 29.1743C17.1798 29.0212 17.1757 28.8674 17.1757 28.7152C17.1757 28.5593 17.1798 28.4037 17.1877 28.2469H17.0994C17.0763 28.4639 17.0095 28.6153 16.7602 28.6126H16.2556V27.4884H16.8424C17.2679 27.4849 17.3384 27.7085 17.357 27.9698L17.4451 27.9454C17.438 27.8283 17.4332 27.6777 17.4332 27.539C17.4332 27.4679 17.435 27.4005 17.4373 27.3417C17.1593 27.343 16.7444 27.3581 16.408 27.3581C16.0717 27.3581 15.6579 27.343 15.4074 27.3417V27.4301H15.4471C15.5927 27.4304 15.7667 27.456 15.7677 27.6857V29.8614C15.7667 30.0907 15.5927 30.116 15.4471 30.1171H15.4074V30.2048C15.6456 30.204 16.1186 30.1884 16.4781 30.1884C16.8393 30.1884 17.2996 30.204 17.5634 30.2048Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M18.27 27.7636C18.27 27.4608 18.1062 27.4484 17.9792 27.4484H17.9053V27.3237C18.0363 27.3237 18.29 27.3402 18.5398 27.3402C18.7849 27.3402 18.9816 27.3237 19.1984 27.3237C19.7132 27.3237 20.1725 27.4647 20.1725 28.0549C20.1725 28.4284 19.9268 28.6565 19.6037 28.7861L20.3031 29.8487C20.418 30.0244 20.4991 30.0736 20.7002 30.098V30.2228C20.5648 30.2228 20.4339 30.2068 20.2993 30.2068C20.1725 30.2068 20.041 30.2228 19.9148 30.2228C19.5994 29.8035 19.3291 29.3552 19.0634 28.8768H18.7937V29.7661C18.7937 30.0859 18.9407 30.098 19.1282 30.098H19.2024V30.2228C18.9687 30.2228 18.7324 30.2068 18.4986 30.2068C18.3022 30.2068 18.1099 30.2228 17.9053 30.2228V30.098H17.9792C18.1311 30.098 18.27 30.0277 18.27 29.8743V27.7636ZM18.7938 28.7271H18.9938C19.4035 28.7271 19.624 28.5697 19.624 28.0794C19.624 27.7101 19.3908 27.4733 19.0262 27.4733C18.9033 27.4733 18.851 27.4861 18.7938 27.4901V28.7271Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M20.6994 30.2406C20.5622 30.2406 20.4321 30.2239 20.2975 30.2239C20.1728 30.2239 20.0419 30.2406 19.8994 30.2337C19.5855 29.8155 19.3165 29.3697 19.0522 28.8955H18.8105V29.7653C18.8149 30.078 18.9384 30.0757 19.1275 30.0801H19.2193V30.2406H19.2012C18.9674 30.2406 18.7296 30.2239 18.4977 30.2239C18.3023 30.2239 18.1101 30.2406 17.9045 30.2406H17.8867V30.0801H17.9784C18.1256 30.079 18.2499 30.0139 18.2505 29.8743V27.7636C18.2474 27.4692 18.1054 27.4706 17.9784 27.4665H17.8867V27.3057H17.9045C18.037 27.3057 18.2896 27.3219 18.5391 27.3219C18.7833 27.3219 18.9796 27.3057 19.1977 27.3057C19.7136 27.3068 20.1883 27.4509 20.1895 28.0543C20.1895 28.4291 19.9455 28.6633 19.6299 28.794L20.317 29.8388C20.4315 30.0111 20.5027 30.0541 20.7018 30.0801L20.7166 30.0824V30.2406H20.6994ZM18.7927 28.8589H19.0723L19.0774 28.8682C19.3438 29.3453 19.6127 29.7938 19.9139 30.2051C20.0385 30.2051 20.17 30.1884 20.2974 30.1884C20.428 30.1884 20.5544 30.2029 20.6814 30.2048V30.1136C20.4896 30.0899 20.3995 30.0311 20.2878 29.8589L19.5755 28.7778L19.5956 28.7695C19.9161 28.6414 20.1535 28.4195 20.1537 28.0543C20.1535 27.4782 19.712 27.3445 19.1976 27.3417C18.9821 27.3417 18.7854 27.3585 18.539 27.3585C18.2986 27.3585 18.0558 27.343 17.9219 27.3417V27.4301H17.9783C18.1053 27.4304 18.2861 27.4518 18.2861 27.7637V29.8743C18.2861 30.041 18.1323 30.1168 17.9783 30.1171H17.9219V30.2048C18.1186 30.204 18.3054 30.1884 18.4977 30.1884C18.7261 30.1884 18.9572 30.204 19.1835 30.2048V30.1171H19.1274C18.9411 30.1168 18.7746 30.0933 18.7746 29.7654V28.8589H18.7927ZM18.7926 28.7449H18.7745V27.4733L18.7901 27.472C18.8462 27.4674 18.9013 27.456 19.0251 27.456C19.3978 27.456 19.6405 27.7017 19.6408 28.0804C19.6397 28.5762 19.4055 28.7448 18.9927 28.7449H18.7926ZM18.993 28.7094C19.398 28.7053 19.6021 28.5628 19.6061 28.0803C19.6037 27.7174 19.3817 27.4923 19.0253 27.4908C18.9149 27.4908 18.8627 27.5011 18.8105 27.5063V28.7094H18.993Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M23.4779 29.4042L23.485 29.3961V27.8391C23.485 27.4982 23.2526 27.4484 23.1297 27.4484H23.0401V27.3237C23.2324 27.3237 23.42 27.3402 23.6128 27.3402C23.781 27.3402 23.9475 27.3237 24.1165 27.3237V27.4484H24.0549C23.8829 27.4484 23.6907 27.4816 23.6907 27.9758V29.8655C23.6907 30.0111 23.6948 30.1563 23.7149 30.2852H23.56L21.453 27.9008V29.6124C21.453 29.974 21.522 30.098 21.8371 30.098H21.9068V30.2228C21.731 30.2228 21.5547 30.2066 21.3789 30.2066C21.1941 30.2066 21.0063 30.2228 20.8223 30.2228V30.098H20.8791C21.1616 30.098 21.248 29.9027 21.248 29.5715V27.8216C21.248 27.5893 21.0595 27.4484 20.8755 27.4484H20.8223V27.3237C20.9772 27.3237 21.1379 27.3402 21.2931 27.3402C21.4147 27.3402 21.534 27.3237 21.6569 27.3237L23.4779 29.4042Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M23.7146 30.3027L23.5466 30.2969L21.4697 27.948V29.6122C21.4745 29.974 21.5284 30.0761 21.8369 30.0801H21.9237V30.2406H21.9059C21.729 30.2406 21.5534 30.2233 21.378 30.2233C21.1954 30.2233 21.0063 30.2406 20.822 30.2406H20.8037V30.0801H20.879C21.1493 30.079 21.2267 29.9021 21.2293 29.5704V27.8216C21.2289 27.6003 21.0504 27.4665 20.8754 27.4665H20.8037V27.3057H20.822C20.9783 27.3057 21.1384 27.3219 21.2928 27.3219C21.413 27.3219 21.5315 27.3057 21.6687 27.311L23.4676 29.3667V27.8391C23.4665 27.5092 23.2486 27.4684 23.1296 27.4665H23.0217V27.3057H23.0395C23.2328 27.3057 23.4214 27.3219 23.6127 27.3219C23.7794 27.3219 23.9469 27.3057 24.1156 27.3057H24.1337V27.4665H24.0547C23.8859 27.4706 23.712 27.486 23.708 27.9758V29.8655C23.708 30.0109 23.7115 30.1549 23.7324 30.2822L23.7344 30.3027H23.7146ZM23.5596 30.2666H23.6937C23.6757 30.1424 23.6722 30.0037 23.6722 29.8655V27.9759C23.6725 27.4774 23.8794 27.4312 24.0546 27.4301H24.0976V27.3417C23.9365 27.343 23.7748 27.3581 23.6125 27.3581C23.4245 27.3581 23.2422 27.343 23.0571 27.3417V27.4301H23.1294C23.2555 27.4312 23.5033 27.4871 23.5033 27.8391L23.4974 29.4092L23.49 29.4172L23.4772 29.4309L21.6566 27.3417C21.5349 27.3417 21.4163 27.3581 21.2927 27.3581C21.1409 27.3581 20.988 27.343 20.8393 27.3417V27.4301H20.8752C21.0678 27.4312 21.2646 27.5785 21.2646 27.8217V29.5705C21.2646 29.9037 21.1722 30.116 20.8788 30.1171L20.8393 30.1168V30.2049C21.016 30.2037 21.1983 30.1884 21.3778 30.1884C21.5495 30.1884 21.7195 30.2037 21.8887 30.2049V30.1171H21.8368C21.5148 30.1168 21.4345 29.974 21.4336 29.6123V27.855L23.5596 30.2666ZM23.4775 29.4042L23.4908 29.3922L23.4775 29.4042ZM23.4674 29.3961V29.395L23.4645 29.3918L23.4674 29.3961Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M24.7741 29.6286C24.7329 29.7693 24.6835 29.8784 24.6835 29.9521C24.6835 30.0771 24.8559 30.0977 24.9905 30.0977H25.0362V30.2224C24.8718 30.2134 24.7049 30.2064 24.5402 30.2064C24.3929 30.2064 24.2464 30.2134 24.0986 30.2224V30.0977H24.1236C24.2828 30.0977 24.4183 30.002 24.479 29.8275L25.1337 27.9218C25.1872 27.7677 25.2609 27.5602 25.2858 27.4062C25.4159 27.3607 25.5803 27.2782 25.6577 27.2277C25.6704 27.2235 25.678 27.2192 25.6904 27.2192C25.7028 27.2192 25.7104 27.2192 25.7194 27.2323C25.7314 27.2651 25.7434 27.3026 25.7561 27.3357L26.5093 29.5082C26.5584 29.6531 26.607 29.8067 26.6594 29.932C26.7092 30.0485 26.7952 30.0977 26.9307 30.0977H26.9552V30.2224C26.7709 30.2134 26.5862 30.2064 26.3908 30.2064C26.1902 30.2064 25.985 30.2134 25.7763 30.2224V30.0977H25.8214C25.915 30.0977 26.0756 30.0812 26.0756 29.9773C26.0756 29.9237 26.0386 29.8114 25.9929 29.678L25.8335 29.1964H24.9048L24.7741 29.6286ZM25.3715 27.7887H25.363L24.9827 28.9642H25.7472L25.3715 27.7887Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M26.954 30.2404C26.7702 30.2324 26.5857 30.2231 26.3909 30.2231C26.1903 30.2231 25.9861 30.2324 25.7774 30.2404L25.7585 30.2414V30.0791H25.8215C25.9163 30.0791 26.0569 30.0576 26.0572 29.9776C26.0582 29.9302 26.0215 29.8164 25.9764 29.6844L25.8206 29.2145H24.9181L24.7907 29.6342C24.7495 29.7754 24.6998 29.8851 24.7006 29.9527C24.702 30.0541 24.8547 30.0791 24.9905 30.0791H25.0534V30.2414L25.0348 30.2404C24.8713 30.2324 24.7043 30.2231 24.5406 30.2231C24.3945 30.2231 24.2466 30.2324 24.1001 30.2404L24.0811 30.2414V30.0791H24.1235C24.2756 30.0788 24.4026 29.9906 24.4618 29.8219L25.1171 27.9156C25.1699 27.7619 25.2442 27.5553 25.2796 27.3893C25.408 27.3451 25.5726 27.2613 25.6511 27.2112C25.6626 27.2071 25.6739 27.2017 25.6903 27.2017C25.7004 27.2011 25.7224 27.2038 25.7357 27.2255C25.7473 27.2596 25.7603 27.2969 25.7727 27.3303L26.5261 29.5025C26.5742 29.6479 26.6232 29.8013 26.6768 29.9244C26.7242 30.0351 26.7993 30.0788 26.9308 30.0791H26.9721V30.2414L26.954 30.2404ZM25.7943 30.2036C25.9968 30.1963 26.1955 30.1883 26.391 30.1883C26.5807 30.1883 26.7591 30.1963 26.9369 30.2036V30.1166H26.9309C26.7906 30.1176 26.6941 30.0616 26.6439 29.9391C26.5906 29.8137 26.5409 29.6595 26.4926 29.5141L25.7392 27.3417C25.7268 27.3086 25.7152 27.2715 25.7045 27.2419C25.7005 27.2378 25.7014 27.2378 25.6981 27.2378H25.6904C25.6822 27.2378 25.6772 27.2403 25.6667 27.243C25.588 27.2949 25.424 27.3768 25.3023 27.4092C25.2773 27.5666 25.2036 27.7741 25.1505 27.928L24.4958 29.8338C24.4335 30.0143 24.2896 30.1169 24.1236 30.1166H24.1168V30.2036C24.2571 30.1963 24.3987 30.1883 24.5407 30.1883C24.699 30.1883 24.8604 30.1963 25.017 30.2036V30.1166H24.9906C24.857 30.1147 24.6708 30.1 24.6661 29.9528C24.6665 29.8704 24.7166 29.7642 24.7568 29.6238L24.7741 29.629L24.7568 29.6233L24.8915 29.1795H25.8456L26.0095 29.6727C26.0555 29.8063 26.0926 29.917 26.0926 29.9777C26.0866 30.1048 25.9144 30.1143 25.8216 30.1166H25.7943V30.2036ZM24.958 28.9822L25.3497 27.7704H25.3717V27.7888L25.3674 27.7901L25.3717 27.7888V27.7704H25.384L25.7729 28.9822H24.958ZM25.0067 28.946H25.723L25.3669 27.8331L25.0067 28.946ZM25.3539 27.7943L25.3634 27.7913L25.3539 27.7943Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M27.1347 27.5313C26.8279 27.5313 26.8155 27.6064 26.7538 27.9092H26.6309C26.647 27.7928 26.6679 27.6767 26.6806 27.5562C26.6965 27.4394 26.7044 27.3236 26.7044 27.2035H26.8034C26.8355 27.3281 26.938 27.3236 27.0484 27.3236H29.1601C29.2701 27.3236 29.3723 27.3195 29.3804 27.1948L29.4783 27.2118C29.4631 27.3236 29.4468 27.4358 29.434 27.5482C29.425 27.6603 29.425 27.7721 29.425 27.8842L29.3028 27.9304C29.2955 27.777 29.2749 27.5313 29.0001 27.5313H28.3292V29.7408C28.3292 30.0612 28.4726 30.0978 28.6687 30.0978H28.7469V30.2227C28.5871 30.2227 28.3011 30.2066 28.0797 30.2066C27.8346 30.2066 27.5475 30.2227 27.3879 30.2227V30.0978H27.4658C27.6914 30.0978 27.8055 30.0774 27.8055 29.7495V27.5313H27.1347Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M28.748 30.2405C28.5872 30.2405 28.3003 30.224 28.0804 30.224C27.836 30.224 27.5495 30.2405 27.3892 30.2405H27.371V30.08H27.4669C27.6926 30.0746 27.783 30.0717 27.7885 29.7495L27.7881 27.5491H27.1358V27.5128H27.8242V29.7495C27.8242 30.083 27.6917 30.1158 27.4669 30.1166H27.4067V30.205C27.5684 30.2035 27.8433 30.1883 28.0804 30.1883C28.2943 30.1883 28.5681 30.2035 28.7297 30.205V30.1166H28.6696C28.4724 30.1158 28.313 30.0677 28.3127 29.7409V27.5128H29.0012C29.2761 27.5139 29.3119 27.7506 29.3198 27.9052L29.4087 27.872C29.4087 27.7635 29.4093 27.6549 29.4177 27.5463C29.4287 27.4392 29.4442 27.3329 29.459 27.227L29.3972 27.216C29.3785 27.3369 29.263 27.3436 29.1612 27.3417H27.0286C26.9321 27.3428 26.8277 27.3372 26.7905 27.221H26.7236C26.7227 27.3359 26.7146 27.4475 26.6985 27.5582C26.6874 27.6728 26.6677 27.7827 26.6521 27.892H26.74C26.7938 27.6022 26.8293 27.5082 27.1358 27.5128V27.5491C26.8323 27.5544 26.839 27.6066 26.7723 27.9135L26.7693 27.9276H26.6113L26.6138 27.907C26.631 27.7902 26.6511 27.6736 26.6631 27.554C26.6798 27.4378 26.6874 27.3225 26.6874 27.2035V27.1851H26.8173L26.8207 27.1982C26.8494 27.3029 26.9239 27.3037 27.0286 27.3056H29.1612C29.2744 27.3037 29.357 27.3021 29.3643 27.1938L29.3649 27.1738L29.3839 27.1776L29.5001 27.1962L29.4973 27.2145C29.4804 27.326 29.4649 27.4378 29.4525 27.5491C29.4442 27.6604 29.4442 27.7722 29.4442 27.8843V27.8968L29.4322 27.9017L29.2874 27.9555L29.2868 27.9314C29.2761 27.7752 29.2599 27.5491 29.0012 27.5491H28.3479V29.7409C28.3521 30.054 28.4746 30.0761 28.6696 30.08H28.765V30.2405H28.748Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M29.6299 30.098H29.6872C29.8346 30.098 29.9895 30.0775 29.9895 29.8614V27.6856C29.9895 27.4691 29.8346 27.4486 29.6872 27.4486H29.6299V27.3237C29.7896 27.3237 30.0346 27.3402 30.2349 27.3402C30.4399 27.3402 30.6856 27.3237 30.8783 27.3237V27.4486H30.8208C30.6729 27.4486 30.5174 27.4691 30.5174 27.6856V29.8614C30.5174 30.0775 30.6729 30.098 30.8208 30.098H30.8783V30.2228C30.6817 30.2228 30.4359 30.2068 30.2316 30.2068C30.0307 30.2068 29.7896 30.2228 29.6299 30.2228V30.098Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M30.8792 30.2406C30.6814 30.2406 30.4367 30.2233 30.2325 30.2233C30.0322 30.2233 29.7906 30.2406 29.6308 30.2406H29.6133V30.0801H29.6882C29.8357 30.0776 29.9717 30.0644 29.9726 29.8614V27.6856C29.9717 27.4827 29.8357 27.4692 29.6882 27.4665H29.6133V27.3057H29.6308C29.7906 27.3057 30.0367 27.3219 30.2358 27.3219C30.4397 27.3219 30.6852 27.3057 30.8792 27.3057H30.8962V27.4665H30.8218C30.6721 27.4692 30.5375 27.4827 30.5361 27.6856V29.8614C30.5375 30.0644 30.6721 30.0776 30.8218 30.0801H30.8962V30.2406H30.8792ZM30.8605 30.2048V30.1168H30.8218C30.6749 30.1168 30.5013 30.0907 30.5013 29.8614V27.6857C30.5013 27.456 30.6749 27.4304 30.8218 27.4301H30.8605V27.3417C30.6718 27.343 30.4349 27.3581 30.2357 27.3581C30.0415 27.3581 29.8073 27.343 29.6485 27.3417V27.4301H29.6882C29.8334 27.4304 30.0078 27.456 30.0083 27.6857V29.8614C30.0078 30.0907 29.8334 30.1168 29.6882 30.1168H29.6485V30.2048C29.8062 30.2029 30.0376 30.1884 30.2324 30.1884C30.4308 30.1884 30.6675 30.2037 30.8605 30.2048Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M32.5103 27.2612C33.3826 27.2612 34.0779 27.81 34.0779 28.6947C34.0779 29.65 33.4026 30.285 32.5314 30.285C31.6636 30.285 31.001 29.6868 31.001 28.7937C31.001 27.9303 31.6595 27.2612 32.5103 27.2612ZM32.5722 30.1022C33.3663 30.1022 33.5048 29.3915 33.5048 28.7859C33.5048 28.179 33.1823 27.4442 32.5027 27.4442C31.7868 27.4442 31.5738 28.0922 31.5738 28.6482C31.5738 29.3915 31.9096 30.1022 32.5722 30.1022Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M30.9824 28.7937C30.9841 27.9203 31.6503 27.2446 32.5099 27.2432V27.2795C31.6687 27.2795 31.018 27.9394 31.0171 28.7937C31.0188 29.6767 31.6718 30.266 32.5313 30.2663C33.3937 30.266 34.0592 29.6401 34.0603 28.694C34.0595 27.8204 33.3752 27.2803 32.5099 27.2795V27.2432C33.3884 27.2441 34.0936 27.7983 34.0951 28.694C34.0942 29.6593 33.4109 30.3009 32.5313 30.3024C31.656 30.3009 30.9841 29.6967 30.9824 28.7937ZM31.5555 28.6481C31.5567 28.0892 31.7715 27.4255 32.5021 27.4255C33.1969 27.4271 33.5209 28.1755 33.5223 28.7858C33.5209 29.3914 33.3806 30.1191 32.572 30.1191V30.0839C33.35 30.0833 33.4852 29.3914 33.4864 28.7858C33.4864 28.1842 33.1672 27.4632 32.5021 27.4621C31.8002 27.4629 31.5928 28.0949 31.5909 28.6481C31.5912 29.3879 31.9239 30.0828 32.572 30.0839V30.1191C31.8928 30.1185 31.5567 29.3958 31.5555 28.6481Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M36.8353 29.4042L36.8439 29.3961V27.8391C36.8439 27.4982 36.6098 27.4484 36.4872 27.4484H36.3981V27.3237C36.5899 27.3237 36.7785 27.3402 36.9703 27.3402C37.1385 27.3402 37.3062 27.3237 37.4741 27.3237V27.4484H37.4126C37.2411 27.4484 37.048 27.4816 37.048 27.9758V29.8655C37.048 30.0111 37.0523 30.1563 37.0731 30.2852H36.9174L34.8098 27.9008V29.6124C34.8098 29.974 34.8796 30.098 35.194 30.098H35.2642V30.2228C35.0881 30.2228 34.9124 30.2066 34.7365 30.2066C34.5522 30.2066 34.3638 30.2228 34.1797 30.2228V30.098H34.2371C34.52 30.098 34.6053 29.9027 34.6053 29.5715V27.8216C34.6053 27.5893 34.4177 27.4484 34.2327 27.4484H34.1797V27.3237C34.3352 27.3237 34.4946 27.3402 34.6504 27.3402C34.7729 27.3402 34.8909 27.3237 35.0142 27.3237L36.8353 29.4042Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M37.0729 30.3027L36.9036 30.2969L34.827 27.948V29.6124C34.8318 29.974 34.8859 30.0757 35.1932 30.0793H35.2813V30.2406H35.264C35.0868 30.2406 34.9111 30.2239 34.7361 30.2239C34.5527 30.2239 34.3644 30.2406 34.1788 30.2406H34.1621V30.0793H34.2366C34.5063 30.0785 34.584 29.9021 34.5873 29.5714V27.8216C34.5871 27.6003 34.4082 27.4665 34.2324 27.4665H34.1621V27.3057H34.1788C34.3357 27.3057 34.4953 27.3219 34.6498 27.3219C34.7713 27.3219 34.8888 27.3057 35.0276 27.3121L36.8255 29.3667V27.8391C36.8241 27.5092 36.6062 27.4692 36.4868 27.4665H36.379V27.3057H36.3977C36.5899 27.3057 36.7787 27.3219 36.9694 27.3219C37.1368 27.3219 37.3038 27.3057 37.4739 27.3057H37.4911V27.4665H37.4121C37.2434 27.4709 37.0693 27.4866 37.0659 27.9758V29.8655C37.0659 30.0109 37.0691 30.1559 37.089 30.2822L37.0925 30.3027H37.0729ZM36.9171 30.2666H37.0512C37.0339 30.1416 37.0298 30.0037 37.0298 29.8655V27.9759C37.0298 27.4766 37.2365 27.4312 37.4121 27.4301H37.4564V27.3417C37.2939 27.343 37.133 27.3581 36.9694 27.3581C36.7831 27.3581 36.6002 27.343 36.4149 27.3417V27.4301H36.4868C36.6132 27.4312 36.8602 27.4879 36.8602 27.8391L36.8559 29.4092L36.848 29.4172L36.835 29.4309L35.014 27.3417C34.8929 27.3417 34.7738 27.3581 34.6499 27.3581C34.499 27.3581 34.3454 27.343 34.1965 27.3417V27.4301H34.2325C34.4248 27.4312 34.6225 27.5785 34.6225 27.8217V29.5714C34.6225 29.9037 34.5311 30.116 34.2366 30.1171L34.1965 30.1168V30.2049C34.3742 30.2037 34.5563 30.1884 34.7361 30.1884C34.9068 30.1884 35.0764 30.2037 35.246 30.2049V30.1171H35.1932C34.8721 30.1168 34.7916 29.974 34.7916 29.6124V27.8544L36.9171 30.2666ZM36.8351 29.4042L36.8481 29.3922L36.8351 29.4042ZM36.8256 29.3961V29.395L36.8224 29.3918L36.8256 29.3961Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M38.1311 29.6286C38.091 29.7693 38.0416 29.8784 38.0416 29.9521C38.0416 30.0771 38.214 30.0977 38.3481 30.0977H38.3938V30.2224C38.2299 30.2134 38.062 30.2064 37.8981 30.2064C37.7508 30.2064 37.6037 30.2134 37.457 30.2224V30.0977H37.4803C37.6407 30.0977 37.7762 30.002 37.836 29.8275L38.4921 27.9218C38.5449 27.7677 38.619 27.5602 38.6425 27.4062C38.774 27.3607 38.9373 27.2782 39.0161 27.2277C39.0276 27.2235 39.0358 27.2192 39.0485 27.2192C39.0604 27.2192 39.0681 27.2192 39.0765 27.2323C39.0889 27.2651 39.1011 27.3026 39.1135 27.3357L39.8664 29.5082C39.9152 29.6531 39.9646 29.8067 40.0181 29.932C40.0672 30.0485 40.153 30.0977 40.2879 30.0977H40.3131V30.2224C40.1286 30.2134 39.9443 30.2064 39.7478 30.2064C39.5477 30.2064 39.3428 30.2134 39.1338 30.2224V30.0977H39.1792C39.2728 30.0977 39.4332 30.0812 39.4332 29.9773C39.4332 29.9237 39.3965 29.8114 39.3508 29.678L39.1913 29.1964H38.2626L38.1311 29.6286ZM38.7292 27.7887H38.721L38.3395 28.9642H39.1059L38.7292 27.7887Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M40.3115 30.2407C40.127 30.2333 39.9433 30.224 39.7482 30.224C39.5485 30.224 39.3435 30.2333 39.135 30.2407L39.1169 30.2416V30.0802H39.1791C39.2742 30.0802 39.4152 30.0578 39.4153 29.9778C39.4164 29.9305 39.3794 29.8172 39.3334 29.6846L39.1782 29.2147H38.2749L38.1485 29.6345C38.1073 29.7768 38.059 29.8854 38.0595 29.9527C38.0595 30.0544 38.213 30.0802 38.3482 30.0802H38.4107V30.2416L38.3923 30.2407C38.2289 30.2333 38.0613 30.224 37.8984 30.224C37.7517 30.224 37.6049 30.2333 37.4573 30.2407L37.4395 30.2416V30.0802H37.4805C37.6331 30.0794 37.7607 29.9907 37.8206 29.8222L38.475 27.9159C38.5275 27.7621 38.6014 27.5556 38.6373 27.3902C38.7659 27.3449 38.9302 27.2616 39.0098 27.2114C39.0204 27.2073 39.0318 27.2023 39.0486 27.2023C39.0591 27.2014 39.0806 27.204 39.0932 27.2267C39.1056 27.2594 39.1176 27.2971 39.131 27.3306L39.8834 29.5027C39.9326 29.6482 39.9814 29.8016 40.0346 29.9255C40.0827 30.0354 40.1568 30.0791 40.2876 30.0802H40.3299V30.2416L40.3115 30.2407ZM39.1516 30.204C39.354 30.1964 39.5531 30.1884 39.7482 30.1884C39.9384 30.1884 40.1164 30.1964 40.295 30.2037L40.2945 30.1167H40.2877C40.1491 30.117 40.0526 30.0617 40.0015 29.9392C39.9476 29.8138 39.8989 29.6596 39.8498 29.5146L39.0969 27.3418C39.0849 27.3087 39.0718 27.2716 39.0622 27.2428C39.0588 27.2379 39.0591 27.2379 39.0567 27.2379H39.0486C39.0398 27.2379 39.0353 27.2407 39.0255 27.2436C38.9455 27.2944 38.7817 27.3777 38.6609 27.4093C38.6355 27.5666 38.5612 27.7738 38.5085 27.9281L37.8532 29.8339C37.7909 30.0144 37.648 30.117 37.4806 30.1167H37.4745V30.2037C37.6155 30.1964 37.7561 30.1884 37.8984 30.1884C38.0569 30.1884 38.2187 30.1964 38.3759 30.2037V30.1167H38.3482C38.2152 30.1148 38.0287 30.1005 38.0234 29.9526C38.0245 29.8705 38.0749 29.7646 38.115 29.6239L38.1314 29.6291L38.115 29.6234L38.2496 29.1796H39.2033L39.3677 29.6728C39.4127 29.8064 39.4501 29.9171 39.4501 29.9777C39.4442 30.1049 39.2721 30.1144 39.1792 30.1167H39.1516V30.204ZM38.3155 28.9823L38.7084 27.7707H38.7296V27.7891L38.725 27.7904L38.7296 27.7891V27.7707H38.742L39.1289 28.9823H38.3155ZM38.3649 28.9463H39.0808L38.7249 27.834L38.3649 28.9463ZM38.7127 27.7946L38.7207 27.7916L38.7127 27.7946Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M41.3443 29.8155C41.3443 29.9824 41.458 30.0316 41.5893 30.0489C41.7568 30.0614 41.9411 30.0614 42.1299 30.0401C42.3015 30.0192 42.4488 29.9202 42.5222 29.8155C42.5873 29.7243 42.6241 29.608 42.6492 29.5168H42.7676C42.7225 29.7535 42.6653 29.9864 42.6162 30.2228C42.2569 30.2228 41.8957 30.2068 41.5362 30.2068C41.1758 30.2068 40.816 30.2228 40.4561 30.2228V30.098H40.5127C40.6603 30.098 40.8205 30.0775 40.8205 29.82V27.6856C40.8205 27.4691 40.6603 27.4484 40.5127 27.4484H40.4561V27.3237C40.6726 27.3237 40.8857 27.3402 41.1022 27.3402C41.3113 27.3402 41.5155 27.3237 41.7247 27.3237V27.4484H41.6219C41.4664 27.4484 41.3443 27.4528 41.3443 27.6729V29.8155Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M42.6158 30.2406C42.2549 30.2406 41.8945 30.2239 41.5356 30.2239C41.1757 30.2239 40.8156 30.2406 40.4547 30.2406H40.4375V30.0801H40.5122C40.6601 30.0765 40.8006 30.0659 40.8023 29.8201V27.6849C40.8006 27.4827 40.6607 27.4692 40.5122 27.4665H40.4375V27.3057H40.4547C40.6726 27.3057 40.8856 27.3219 41.1013 27.3219C41.3097 27.3219 41.5135 27.3057 41.7241 27.3057H41.7405V27.4665H41.6213C41.4625 27.4706 41.364 27.4633 41.3605 27.6729V29.8155C41.3615 29.9708 41.4618 30.0123 41.5897 30.0309C41.6617 30.0359 41.7379 30.0386 41.8166 30.0386C41.9167 30.0386 42.0206 30.0339 42.1264 30.0225C42.293 30.0024 42.4364 29.9046 42.5072 29.8056C42.5704 29.7168 42.606 29.603 42.6308 29.5126L42.6342 29.4989H42.7885L42.7849 29.5207C42.7386 29.7581 42.6823 29.9898 42.6327 30.2266L42.6294 30.2406H42.6158ZM42.6009 30.2048C42.6483 29.9792 42.7017 29.7586 42.746 29.5346H42.6616C42.6365 29.625 42.6 29.7365 42.5358 29.8269C42.4593 29.9344 42.3078 30.0359 42.1303 30.0583C42.0233 30.0693 41.9169 30.0749 41.8167 30.0749C41.7374 30.0749 41.6609 30.0713 41.5864 30.066C41.4533 30.0512 41.3246 29.9932 41.3248 29.8155V27.6729C41.3248 27.442 41.4687 27.4301 41.6214 27.4301H41.7056V27.3417C41.5041 27.343 41.3051 27.3585 41.1014 27.3585C40.89 27.3585 40.6826 27.343 40.4732 27.3417V27.4301H40.5123C40.6582 27.4301 40.8367 27.456 40.8367 27.6849V29.8201C40.8367 30.0889 40.6589 30.1168 40.5123 30.1168H40.4732V30.2048C40.8267 30.204 41.1804 30.1884 41.5357 30.1884C41.8915 30.1884 42.2467 30.204 42.6009 30.2048Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M17.1602 12.6614C17.1602 8.77664 20.2628 5.62744 24.09 5.62744C27.9174 5.62744 31.0201 8.77664 31.0201 12.6614C31.0201 16.5462 27.9174 19.6956 24.09 19.6956C20.2628 19.6956 17.1602 16.5462 17.1602 12.6614Z" + fill="#FFFFFE" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M28.2807 12.5227C28.2779 10.7214 27.1686 9.18519 25.6055 8.5768V16.4683C27.1686 15.8592 28.2779 14.3243 28.2807 12.5227ZM22.6238 16.4669V8.57771C21.0621 9.18799 19.9545 10.722 19.9503 12.5229C19.9545 14.3232 21.0621 15.8571 22.6238 16.4669ZM24.1156 5.85259C20.4863 5.85401 17.546 8.83908 17.5454 12.5228C17.546 16.206 20.4863 19.1906 24.1156 19.1913C27.7449 19.1906 30.6858 16.206 30.6867 12.5228C30.6858 8.83908 27.7449 5.85401 24.1156 5.85259ZM24.0995 19.8207C20.1281 19.8399 16.8594 16.5742 16.8594 12.5989C16.8594 8.25425 20.1281 5.24921 24.0995 5.25H25.9606C29.8851 5.24921 33.4668 8.25284 33.4668 12.5989C33.4668 16.5728 29.8851 19.8207 25.9606 19.8207H24.0995Z" + fill="#0069AA" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M7.60352 30.098H7.66098C7.80812 30.098 7.96362 30.0775 7.96362 29.8614V27.6856C7.96362 27.4691 7.80812 27.4486 7.66098 27.4486H7.60352V27.3237C7.76305 27.3237 8.00854 27.3402 8.20958 27.3402C8.41403 27.3402 8.65921 27.3237 8.85158 27.3237V27.4486H8.79381C8.64729 27.4486 8.49147 27.4691 8.49147 27.6856V29.8614C8.49147 30.0775 8.64729 30.098 8.79381 30.098H8.85158V30.2228C8.65534 30.2228 8.40923 30.2068 8.2054 30.2068C8.00451 30.2068 7.76305 30.2228 7.60352 30.2228V30.098Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M8.85181 30.2406C8.65387 30.2406 8.409 30.2239 8.20532 30.2239C8.00506 30.2239 7.76437 30.2406 7.60375 30.2406H7.58594V30.0801H7.6609C7.80944 30.0765 7.9445 30.0644 7.94574 29.8614V27.6856C7.9445 27.4824 7.80944 27.4692 7.6609 27.4665H7.58594V27.3057H7.60375C7.76437 27.3057 8.0097 27.3219 8.20981 27.3219C8.41333 27.3219 8.65836 27.3057 8.85181 27.3057H8.87009V27.4665H8.79482C8.64613 27.4692 8.51029 27.4824 8.50952 27.6856V29.8614C8.51029 30.0644 8.64613 30.0765 8.79482 30.0801H8.87009V30.2406H8.85181ZM8.83398 30.2051L8.83413 30.1168H8.79464C8.64827 30.1168 8.47433 30.0907 8.4734 29.8614V27.6857C8.47433 27.456 8.64827 27.4304 8.79464 27.4304H8.83398V27.3417C8.64517 27.3419 8.40804 27.3585 8.20963 27.3585C8.01525 27.3585 7.77998 27.343 7.62154 27.3417V27.4304H7.66072C7.80662 27.4304 7.98087 27.456 7.98087 27.6857V29.8614C7.98087 30.0907 7.80662 30.1168 7.66072 30.1168H7.62154V30.2051C7.77998 30.2037 8.01107 30.1884 8.20514 30.1884C8.40448 30.1884 8.64099 30.204 8.83398 30.2051Z" + fill="#1A1919" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M42.7169 27.2061C42.9638 27.2061 43.1479 27.3985 43.1479 27.6444C43.1479 27.8901 42.9638 28.0804 42.7169 28.0804C42.4706 28.0804 42.2861 27.8901 42.2861 27.6444C42.2861 27.3985 42.4706 27.2061 42.7169 27.2061ZM42.7168 27.9992C42.9101 27.9992 43.0577 27.8324 43.0577 27.6443C43.0577 27.456 42.9118 27.288 42.7168 27.288C42.5229 27.288 42.3754 27.456 42.3754 27.6443C42.3754 27.8324 42.5229 27.9992 42.7168 27.9992ZM42.5025 27.8751V27.8538C42.5551 27.8461 42.565 27.8475 42.565 27.8149V27.4907C42.565 27.4451 42.5606 27.4294 42.5042 27.4319V27.4096H42.7247C42.8005 27.4096 42.8703 27.4464 42.8703 27.5259C42.8703 27.5908 42.8282 27.6391 42.7684 27.6578L42.8393 27.7581C42.8723 27.8034 42.91 27.8461 42.9341 27.8612V27.8751H42.8503C42.8101 27.8751 42.7745 27.7892 42.6955 27.674H42.6478V27.8189C42.6478 27.8475 42.6577 27.8461 42.7106 27.8538V27.8751H42.5025ZM42.6481 27.6444H42.6989C42.7545 27.6444 42.7802 27.6018 42.7802 27.5328C42.7802 27.4633 42.7406 27.4386 42.6961 27.4386H42.6481V27.6444Z" + fill="#1A1919" + /> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/discover.js b/packages/js/onboarding/src/images/cards/discover.js new file mode 100644 index 00000000000..51479f917aa --- /dev/null +++ b/packages/js/onboarding/src/images/cards/discover.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="52" + height="35" + viewBox="0 0 52 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="0.878906" + y="0.5" + width="50" + height="34" + rx="3.5" + fill="white" + stroke="#F3F3F3" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M17.1461 17.7823C17.1461 19.9191 18.8322 21.576 21.0021 21.576C21.6154 21.576 22.141 21.456 22.7888 21.1524V19.4832C22.2192 20.0506 21.7145 20.2795 21.0685 20.2795C19.6332 20.2795 18.6146 19.2439 18.6146 17.7717C18.6146 16.376 19.6654 15.2751 21.0021 15.2751C21.6818 15.2751 22.1963 15.5163 22.7888 16.0929V14.4246C22.1633 14.1089 21.649 13.978 21.0357 13.978C18.8768 13.978 17.1461 15.6685 17.1461 17.7823ZM13.4892 16.0168C13.4892 16.4097 13.7401 16.6173 14.5953 16.9321C16.2163 17.5222 16.6967 18.0449 16.6967 19.2001C16.6967 20.6072 15.6577 21.5872 14.177 21.5872C13.0926 21.5872 12.304 21.1622 11.6475 20.2031L12.5682 19.3209C12.8962 19.9523 13.4437 20.2907 14.1234 20.2907C14.7593 20.2907 15.2298 19.8542 15.2298 19.2653C15.2298 18.96 15.0873 18.6979 14.8025 18.5128C14.6594 18.4252 14.3754 18.2949 13.8174 18.0988C12.479 17.6197 12.0201 17.1068 12.0201 16.1053C12.0201 14.9155 13.006 14.0224 14.2987 14.0224C15.0997 14.0224 15.8327 14.2948 16.4455 14.8282L15.6998 15.7997C15.3286 15.3857 14.9775 15.211 14.5507 15.211C13.9366 15.211 13.4892 15.559 13.4892 16.0168ZM9.68583 21.4123H11.1109V14.1424H9.68583V21.4123ZM6.77288 19.6035C6.32524 20.006 5.74353 20.1815 4.82283 20.1815H4.44039V15.374H4.82283C5.74353 15.374 6.30238 15.538 6.77288 15.9621C7.26569 16.3986 7.56205 17.0755 7.56205 17.7717C7.56205 18.4697 7.26569 19.1671 6.77288 19.6035ZM5.10834 14.1424H3.0166V21.4121H5.09733C6.20374 21.4121 7.0025 21.1525 7.70389 20.5728C8.53737 19.8867 9.03017 18.8523 9.03017 17.7824C9.03017 15.6369 7.41938 14.1424 5.10834 14.1424ZM32.1394 14.1424L34.0875 19.0255L36.061 14.1424H37.6057L34.4496 21.5988H33.6828L30.5826 14.1424H32.1394ZM38.2501 21.4122H42.2913V20.1815H39.6741V18.2191H42.1951V16.9878H39.6741V15.3742H42.2913V14.1424H38.2501V21.4122ZM44.6585 17.4893H45.0748C45.9851 17.4893 46.4674 17.0958 46.4674 16.365C46.4674 15.6575 45.9851 15.2876 45.0974 15.2876H44.6585V17.4893ZM45.3485 14.1422C46.9918 14.1422 47.9339 14.9275 47.9339 16.2886C47.9339 17.4016 47.3429 18.1325 46.2695 18.3496L48.5695 21.4121H46.817L44.8447 18.4917H44.6587V21.4121H43.2353V14.1422H45.3485Z" + fill="#1D1D1B" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M30.415 19.8859C31.5716 18.0862 31.0433 15.6953 29.235 14.5445C27.4267 13.3937 25.0236 13.9191 23.867 15.7188C22.7107 17.518 23.2391 19.9096 25.0474 21.0604C26.8557 22.2112 29.2587 21.6851 30.415 19.8859Z" + fill="url(#paint0_linear)" + /> + <defs> + <linearGradient + id="paint0_linear" + x1="32.5088" + y1="16.6279" + x2="25.9795" + y2="12.4317" + gradientUnits="userSpaceOnUse" + > + <stop stopColor="#F6A000" /> + <stop offset="0.623918" stopColor="#E47E02" /> + <stop offset="1" stopColor="#D36002" /> + </linearGradient> + </defs> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/googlepay.js b/packages/js/onboarding/src/images/cards/googlepay.js new file mode 100644 index 00000000000..329ec0e3641 --- /dev/null +++ b/packages/js/onboarding/src/images/cards/googlepay.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="36" + height="25" + viewBox="0 0 36 25" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="1.41431" + y="1" + width="33.7586" + height="23" + rx="3.5" + fill="white" + stroke="#F3F3F3" + /> + <path + d="M17.645 14.9708V12.2642V12.2636H19.1074C19.7104 12.264 20.2171 12.0743 20.6276 11.6946C21.0425 11.3342 21.2745 10.816 21.2627 10.2759C21.2709 9.73881 21.0393 9.22448 20.6276 8.86525C20.2207 8.48344 19.6734 8.27511 19.1074 8.28658H16.7598V14.9708H17.645ZM17.6451 11.4427V9.10938V9.10885H19.1295C19.4604 9.09983 19.7793 9.22898 20.0054 9.46351C20.2328 9.678 20.3611 9.97262 20.3611 10.2803C20.3611 10.588 20.2328 10.8826 20.0054 11.0971C19.7766 11.3267 19.4586 11.4522 19.1295 11.4427H17.6451Z" + fill="#5F6368" + /> + <path + d="M24.8518 10.7568C24.4731 10.4176 23.9567 10.248 23.3024 10.248C22.462 10.248 21.8273 10.5467 21.3985 11.144L22.1781 11.6203C22.4662 11.2157 22.8574 11.0134 23.3519 11.0134C23.6672 11.0098 23.9722 11.1216 24.2063 11.3264C24.4397 11.5136 24.5739 11.7927 24.5719 12.0864V12.2827C24.2318 12.096 23.7989 12.0027 23.2733 12.0027C22.6575 12.0034 22.1652 12.1435 21.7965 12.423C21.4278 12.7024 21.2434 13.0788 21.2434 13.552C21.2354 13.983 21.4281 14.3944 21.7679 14.672C22.1176 14.9707 22.5521 15.12 23.0715 15.12C23.68 15.12 24.1674 14.8587 24.534 14.336H24.5725V14.9707H25.4192V12.152C25.4195 11.5611 25.2304 11.096 24.8518 10.7568ZM22.4508 14.1307C22.2654 14.0011 22.156 13.7924 22.1572 13.5707C22.1572 13.3216 22.2776 13.1142 22.5201 12.9435C22.7602 12.7753 23.06 12.6912 23.4196 12.6912C23.9133 12.6912 24.2982 12.7979 24.5742 13.0112C24.5742 13.3718 24.4276 13.6859 24.1343 13.9536C23.8702 14.2099 23.5122 14.3541 23.1386 14.3547C22.8896 14.3592 22.6466 14.2801 22.4508 14.1307Z" + fill="#5F6368" + /> + <path + d="M30.2792 10.3975L27.3235 16.9868H26.4097L27.5065 14.6812L25.563 10.3975H26.5251L27.9299 13.6828H27.9491L29.3154 10.3975H30.2792Z" + fill="#5F6368" + /> + <path + d="M14.0677 11.6812C14.068 11.4195 14.0452 11.1583 13.9995 10.9004H10.2664V12.3793H12.4045C12.3161 12.8566 12.0305 13.2782 11.6139 13.5463V14.5063H12.89C13.6372 13.838 14.0677 12.8497 14.0677 11.6812Z" + fill="#4285F4" + /> + <path + d="M10.2666 15.4332C11.3349 15.4332 12.2344 15.0929 12.8903 14.5063L11.6142 13.5463C11.259 13.7799 10.8016 13.9132 10.2666 13.9132C9.23409 13.9132 8.35771 13.238 8.04432 12.3281H6.72974V13.3175C7.40168 14.6145 8.77018 15.4331 10.2666 15.4332Z" + fill="#34A853" + /> + <path + d="M8.04421 12.3283C7.87853 11.8516 7.87853 11.3353 8.04421 10.8585V9.86914H6.72962C6.1676 10.954 6.1676 12.2328 6.72962 13.3177L8.04421 12.3283Z" + fill="#FBBC04" + /> + <path + d="M10.2666 9.27318C10.8312 9.26424 11.3766 9.47114 11.7852 9.84918L12.915 8.75318C12.1986 8.10042 11.2495 7.74205 10.2666 7.75318C8.77018 7.75325 7.40168 8.57187 6.72974 9.86892L8.04432 10.8582C8.35771 9.94838 9.23409 9.27318 10.2666 9.27318Z" + fill="#EA4335" + /> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/jcb.js b/packages/js/onboarding/src/images/cards/jcb.js new file mode 100644 index 00000000000..ee332408bba --- /dev/null +++ b/packages/js/onboarding/src/images/cards/jcb.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="51" + height="35" + viewBox="0 0 51 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="0.878906" + y="0.5" + width="49" + height="34" + rx="3.5" + fill="white" + stroke="#F3F3F3" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M33.473 17.5973H33.494H33.5346H33.5752H33.6158H33.6564H33.697H33.7376H33.7782H33.8188H33.8594H33.9H33.9406H33.9812H34.0218H34.0624H34.103H34.1436H34.1842H34.2248H34.2654H34.306H34.3466H34.3872H34.4278H34.4684H34.509H34.5496H34.5902H34.6308H34.6714H34.712H34.7527H34.7932H34.8338H34.8745H34.915H34.9556H34.9963H35.0368H35.0774H35.1181H35.1586H35.1992H35.2399H35.2805H35.321H35.3617H35.4023H35.4428H35.4835H35.5241H35.5646H35.6053H35.6459H35.6864H35.7271H35.7677H35.8083H35.8489H35.8895H35.9233L35.9301 17.5975C35.9426 17.5978 35.9563 17.5984 35.9707 17.5991C35.9837 17.5999 35.9973 17.6008 36.0113 17.6018C36.0246 17.6028 36.0383 17.604 36.0519 17.6052C36.0656 17.6065 36.0792 17.6079 36.0925 17.6093C36.1064 17.6109 36.1201 17.6126 36.1331 17.6143C36.1476 17.6163 36.1613 17.6183 36.1737 17.6205C36.1826 17.622 36.1909 17.6235 36.1982 17.6251L36.2143 17.6288C36.2279 17.632 36.2414 17.6354 36.2549 17.6392C36.2685 17.643 36.282 17.647 36.2955 17.6513C36.3091 17.6557 36.3227 17.6603 36.3361 17.6653C36.3497 17.6702 36.3632 17.6755 36.3767 17.681C36.3903 17.6866 36.4039 17.6926 36.4173 17.6987C36.431 17.705 36.4445 17.7116 36.4579 17.7185C36.4716 17.7254 36.4851 17.7328 36.4985 17.7403C36.5122 17.7481 36.5257 17.7562 36.5391 17.7646C36.5528 17.7732 36.5664 17.7821 36.5797 17.7913C36.5934 17.8008 36.607 17.8105 36.6203 17.8206C36.634 17.8311 36.6476 17.8419 36.6609 17.8531C36.6747 17.8646 36.6882 17.8766 36.7015 17.8889C36.7154 17.9017 36.7289 17.9148 36.7421 17.9284C36.7561 17.9426 36.7695 17.9574 36.7827 17.9725C36.7967 17.9885 36.8102 18.005 36.8233 18.0219C36.8374 18.0401 36.8509 18.0587 36.8639 18.0779C36.8781 18.0989 36.8917 18.1205 36.9045 18.1426C36.919 18.1675 36.9325 18.1932 36.9451 18.2196C36.96 18.2508 36.9736 18.283 36.9857 18.3161C37.002 18.3606 37.0156 18.4067 37.0263 18.4544C37.0449 18.5369 37.0549 18.6238 37.0549 18.7145C37.0549 18.8057 37.0449 18.893 37.0263 18.9758C37.0156 19.0235 37.002 19.0698 36.9857 19.1143C36.9736 19.1476 36.96 19.1798 36.9451 19.2111C36.9325 19.2375 36.919 19.2632 36.9045 19.2882C36.8917 19.3103 36.8782 19.3319 36.8639 19.3529C36.8509 19.3721 36.8374 19.3908 36.8233 19.409C36.8102 19.4259 36.7967 19.4423 36.7827 19.4584C36.7695 19.4734 36.756 19.4881 36.7421 19.5024C36.7289 19.516 36.7153 19.5291 36.7015 19.5419C36.6882 19.5541 36.6747 19.566 36.6609 19.5775C36.6476 19.5886 36.6341 19.5995 36.6203 19.6099C36.607 19.62 36.5934 19.6297 36.5797 19.6391C36.5664 19.6483 36.5528 19.6572 36.5391 19.6657C36.5257 19.674 36.5122 19.6821 36.4985 19.6898C36.4851 19.6974 36.4716 19.7046 36.4579 19.7116C36.4445 19.7184 36.431 19.7249 36.4173 19.7312C36.4039 19.7373 36.3903 19.7432 36.3767 19.7488C36.3632 19.7542 36.3497 19.7595 36.3361 19.7644C36.3227 19.7692 36.3091 19.7738 36.2955 19.7781C36.282 19.7824 36.2685 19.7863 36.2549 19.7901C36.2414 19.7937 36.2279 19.7971 36.2143 19.8003L36.1982 19.8039C36.1909 19.8056 36.1826 19.8073 36.1737 19.8089C36.1613 19.8111 36.1476 19.8132 36.1331 19.8152C36.1201 19.8169 36.1064 19.8186 36.0925 19.8202C36.0792 19.8217 36.0656 19.823 36.0519 19.8243C36.0383 19.8255 36.0246 19.8266 36.0113 19.8276C35.9973 19.8286 35.9837 19.8295 35.9707 19.8301C35.9563 19.8309 35.9426 19.8314 35.9301 19.8317L35.9083 19.832H35.8895H35.8489H35.8083H35.7677H35.7271H35.6864H35.6459H35.6053H35.5646H35.5241H35.4835H35.4428H35.4023H35.3617H35.321H35.2805H35.2399H35.1992H35.1586H35.1181H35.0774H35.0368H34.9963H34.9556H34.915H34.8745H34.8338H34.7932H34.7527H34.712H34.6714H34.6308H34.5902H34.5496H34.509H34.4684H34.4278H34.3872H34.3466H34.306H34.2654H34.2248H34.1842H34.1436H34.103H34.0624H34.0218H33.9812H33.9406H33.9H33.8594H33.8188H33.7782H33.7376H33.697H33.6564H33.6158H33.5752H33.5346H33.494H33.473V17.5973ZM36.7423 15.0681C36.7586 15.1431 36.7673 15.2226 36.7673 15.3062C36.7673 15.3898 36.7586 15.4691 36.7423 15.5441C36.7318 15.5923 36.7181 15.6385 36.7016 15.6829C36.6896 15.7153 36.676 15.7467 36.661 15.7769C36.6485 15.8023 36.6349 15.8269 36.6204 15.8508C36.6076 15.8718 36.594 15.8923 36.5798 15.9122C36.5668 15.9303 36.5533 15.9478 36.5392 15.9649C36.5261 15.9807 36.5126 15.9961 36.4986 16.011C36.4855 16.0251 36.472 16.0387 36.4581 16.0518C36.4448 16.0644 36.4313 16.0765 36.4174 16.0883C36.4042 16.0996 36.3906 16.1104 36.3768 16.121C36.3636 16.1311 36.35 16.1409 36.3363 16.1504C36.3229 16.1596 36.3094 16.1684 36.2956 16.1769C36.2823 16.1852 36.2687 16.1931 36.255 16.2007C36.2417 16.2082 36.2281 16.2154 36.2145 16.2222C36.2011 16.2289 36.1875 16.2353 36.1738 16.2415C36.1604 16.2475 36.1469 16.2532 36.1332 16.2586C36.1198 16.2639 36.1063 16.269 36.0926 16.2737C36.0792 16.2784 36.0657 16.2829 36.0521 16.287C36.0386 16.2911 36.0251 16.295 36.0114 16.2985C35.998 16.3021 35.9845 16.3053 35.9708 16.3083C35.9574 16.3113 35.9439 16.3139 35.9303 16.3164L35.9204 16.3182C35.9125 16.3195 35.902 16.3211 35.8896 16.3227C35.8777 16.3242 35.8639 16.3258 35.849 16.3273C35.8362 16.3286 35.8225 16.3299 35.8085 16.3311C35.7952 16.3322 35.7815 16.3332 35.7678 16.334C35.7542 16.3349 35.7405 16.3357 35.7272 16.3362C35.7132 16.3368 35.6995 16.3372 35.6867 16.3372L35.6802 16.3373H35.6461H35.6054H35.5648H35.5243H35.4836H35.443H35.4025H35.3619H35.3212H35.2807H35.2401H35.1994H35.1589H35.1183H35.0776H35.037H34.9965H34.9559H34.9152H34.8747H34.8341H34.7934H34.7529H34.7123H34.6716H34.6311H34.5905H34.5499H34.5092H34.4687H34.4281H34.3874H34.3469H34.3063H34.2657H34.2251H34.1845H34.1439H34.1033H34.0627H34.0221H33.9814H33.9409H33.9003H33.8597H33.8191H33.7785H33.7379H33.6973H33.6567H33.6161H33.5754H33.5349H33.4943H33.473V14.2751H33.4943H33.5349H33.5754H33.6161H33.6567H33.6973H33.7379H33.7785H33.8191H33.8597H33.9003H33.9409H33.9814H34.0221H34.0627H34.1033H34.1439H34.1845H34.2251H34.2657H34.3063H34.3469H34.3874H34.4281H34.4687H34.5092H34.5499H34.5905H34.6311H34.6716H34.7123H34.7529H34.7934H34.8341H34.8747H34.9152H34.9559H34.9965H35.037H35.0776H35.1183H35.1589H35.1994H35.2401H35.2807H35.3212H35.3619H35.4025H35.443H35.4836H35.5243H35.5648H35.6054H35.6461H35.6867H35.7037L35.7272 14.2759C35.7405 14.2764 35.7542 14.2772 35.7678 14.2781C35.7815 14.279 35.7952 14.28 35.8085 14.2811C35.8225 14.2822 35.8362 14.2835 35.849 14.2848C35.8639 14.2862 35.8777 14.2877 35.8896 14.2891C35.902 14.2905 35.9125 14.2919 35.9204 14.293L35.9303 14.2948C35.9439 14.2972 35.9574 14.3 35.9708 14.303C35.9845 14.306 35.998 14.3093 36.0114 14.3129C36.0251 14.3165 36.0386 14.3203 36.0521 14.3245C36.0657 14.3287 36.0792 14.3331 36.0926 14.3378C36.1063 14.3427 36.1198 14.3477 36.1332 14.3531C36.1469 14.3585 36.1604 14.3643 36.1738 14.3703C36.1875 14.3765 36.2011 14.3829 36.2145 14.3896C36.2281 14.3965 36.2417 14.4036 36.255 14.4111C36.2687 14.4187 36.2823 14.4267 36.2956 14.435C36.3094 14.4435 36.3229 14.4523 36.3363 14.4615C36.35 14.4709 36.3636 14.4808 36.3768 14.491C36.3906 14.5015 36.4042 14.5124 36.4174 14.5237C36.4313 14.5355 36.4448 14.5476 36.4581 14.5602C36.472 14.5733 36.4855 14.5869 36.4986 14.601C36.5126 14.6159 36.5261 14.6313 36.5392 14.6471C36.5533 14.6642 36.5668 14.6818 36.5798 14.6999C36.594 14.7197 36.6076 14.7402 36.6204 14.7613C36.6349 14.7851 36.6485 14.8097 36.661 14.8351C36.676 14.8654 36.6896 14.8968 36.7016 14.9292C36.7181 14.9736 36.7318 15.0199 36.7423 15.0681ZM41.7774 4.375H41.7941L41.794 25.5542C41.794 25.6807 41.7882 25.8059 41.7774 25.9296C41.7679 26.0385 41.7543 26.1462 41.7368 26.2526C41.725 26.324 41.7115 26.3948 41.6962 26.465C41.6838 26.5221 41.6703 26.5789 41.6556 26.6351C41.6429 26.6836 41.6293 26.7318 41.615 26.7796C41.602 26.8228 41.5886 26.8658 41.5744 26.9085C41.5614 26.9472 41.5478 26.9855 41.5338 27.0237C41.5207 27.0593 41.5071 27.0946 41.4932 27.1297C41.48 27.1627 41.4665 27.1954 41.4526 27.228C41.4394 27.2588 41.4258 27.2895 41.4119 27.3199C41.3987 27.3489 41.3852 27.3778 41.3714 27.4064C41.3581 27.4339 41.3446 27.4612 41.3307 27.4883C41.3175 27.5143 41.3039 27.5402 41.2901 27.5659C41.2769 27.5906 41.2632 27.615 41.2495 27.6394C41.2362 27.663 41.2227 27.6865 41.2089 27.7098C41.1956 27.7325 41.182 27.755 41.1683 27.7775C41.1549 27.7993 41.1414 27.8211 41.1277 27.8427C41.1144 27.8635 41.1007 27.8842 41.0871 27.9049C41.0737 27.9249 41.0601 27.9448 41.0465 27.9647C41.0331 27.9841 41.0195 28.0036 41.0058 28.0228C40.9924 28.0416 40.979 28.0603 40.9653 28.0789C40.9519 28.097 40.9383 28.1148 40.9246 28.1327C40.9112 28.1503 40.8977 28.1678 40.884 28.1852C40.8706 28.2022 40.8571 28.2191 40.8434 28.2359C40.83 28.2523 40.8164 28.2686 40.8028 28.2849C40.7893 28.3009 40.7759 28.3171 40.7622 28.3329C40.7488 28.3484 40.7352 28.3637 40.7216 28.379C40.7082 28.3941 40.6946 28.4091 40.681 28.424C40.6675 28.4387 40.6541 28.4534 40.6404 28.4679C40.627 28.4821 40.6134 28.4961 40.5998 28.5102C40.5863 28.5241 40.5729 28.5382 40.5592 28.5519C40.5458 28.5654 40.5321 28.5786 40.5186 28.5919C40.5051 28.605 40.4916 28.6182 40.4779 28.6313C40.4645 28.644 40.451 28.6567 40.4374 28.6693C40.4239 28.6818 40.4103 28.6942 40.3967 28.7065C40.3833 28.7187 40.3698 28.7308 40.3561 28.7428C40.3427 28.7546 40.3291 28.7664 40.3155 28.7781C40.302 28.7896 40.2886 28.8012 40.2749 28.8126C40.2615 28.8239 40.2479 28.8349 40.2343 28.846C40.2208 28.857 40.2073 28.8681 40.1937 28.8789C40.1803 28.8896 40.1667 28.9001 40.1531 28.9106C40.1396 28.9211 40.1261 28.9316 40.1125 28.942C40.0991 28.9521 40.0854 28.9621 40.0719 28.9721C40.0583 28.9821 40.0449 28.9921 40.0313 29.002C40.0178 29.0117 40.0042 29.0211 39.9907 29.0306C39.9771 29.0401 39.9637 29.0496 39.95 29.059C39.9366 29.0682 39.923 29.0772 39.9095 29.0862C39.896 29.0953 39.8825 29.1043 39.8688 29.1132C39.8554 29.122 39.8418 29.1306 39.8282 29.1392C39.8147 29.1477 39.8012 29.1563 39.7876 29.1647C39.7742 29.1731 39.7606 29.1813 39.747 29.1895L39.7064 29.2137C39.6929 29.2216 39.6794 29.2295 39.6658 29.2373C39.6524 29.2451 39.6388 29.2526 39.6252 29.2602C39.6117 29.2678 39.5982 29.2754 39.5846 29.2828C39.5711 29.2901 39.5575 29.2972 39.544 29.3044C39.5304 29.3115 39.517 29.3189 39.5034 29.326C39.4899 29.3329 39.4763 29.3395 39.4628 29.3463C39.4492 29.3531 39.4358 29.36 39.4221 29.3666C39.4087 29.3732 39.3951 29.3796 39.3816 29.3861L39.3409 29.4052L39.3003 29.4237C39.2868 29.4298 39.2733 29.4357 39.2597 29.4416C39.2462 29.4475 39.2327 29.4536 39.2191 29.4593C39.2057 29.4651 39.1921 29.4704 39.1785 29.476C39.165 29.4816 39.1515 29.4873 39.1379 29.4927C39.1244 29.4981 39.1109 29.5032 39.0973 29.5085C39.0838 29.5137 39.0703 29.5189 39.0567 29.524L39.0161 29.5391C39.0026 29.544 38.989 29.5487 38.9755 29.5535C38.9619 29.5583 38.9485 29.5632 38.9349 29.5679C38.9214 29.5724 38.9078 29.5767 38.8942 29.5811L38.8537 29.5944C38.8402 29.5987 38.8266 29.6028 38.813 29.607L38.7724 29.6192C38.7589 29.6232 38.7454 29.6273 38.7318 29.6311C38.7184 29.635 38.7048 29.6385 38.6912 29.6422C38.6777 29.6459 38.6642 29.6497 38.6506 29.6533L38.61 29.6636L38.5694 29.6735L38.5288 29.6833C38.5153 29.6864 38.5017 29.6892 38.4882 29.6922C38.4746 29.6952 38.4611 29.6983 38.4476 29.7011L38.407 29.7093L38.3663 29.7171C38.3528 29.7196 38.3393 29.7224 38.3257 29.7248C38.3123 29.7272 38.2987 29.7293 38.2851 29.7316L38.2445 29.7383L38.2039 29.7445L38.1633 29.7502C38.1498 29.7521 38.1363 29.7542 38.1227 29.7559C38.1092 29.7577 38.0956 29.759 38.0821 29.7606C38.0686 29.7622 38.0551 29.7638 38.0415 29.7653C38.028 29.7667 38.0145 29.7682 38.0009 29.7695C37.9874 29.7709 37.9738 29.772 37.9603 29.7732C37.9467 29.7744 37.9332 29.7757 37.9197 29.7768C37.9062 29.7778 37.8926 29.7787 37.8791 29.7796L37.8384 29.7822C37.8249 29.783 37.8114 29.784 37.7978 29.7847C37.7844 29.7854 37.7708 29.7857 37.7572 29.7863L37.7166 29.7878L37.676 29.7889C37.6625 29.7892 37.649 29.7892 37.6354 29.7894L37.5948 29.7899L37.5864 29.79H37.5542H37.5136H37.473H37.4324H37.3917H37.3512H37.3105H37.2699H37.2293H37.1887H37.1481H37.1075H37.0669H37.0263H36.9857H36.9451H36.9045H36.8638H36.8233H36.7826H36.742H36.7014H36.6608H36.6202H36.5796H36.539H36.4984H36.4578H36.4172H36.3766H36.3359H36.2954H36.2547H36.2141H36.1735H36.1329H36.0923H36.0517H36.0111H35.9705H35.9299H35.8893H35.8487H35.808H35.7675H35.7268H35.6862H35.6456H35.605H35.5644H35.5238H35.4832H35.4426H35.402H35.3614H35.3208H35.2801H35.2396H35.1989H35.1583H35.1177H35.0771H35.0365H34.9959H34.9553H34.9147H34.8741H34.8335H34.7929H34.7522H34.7117H34.671H34.6304H34.5898H34.5492H34.5086H34.468H34.4274H34.3868H34.3462H34.3056H34.265H34.2243H34.1838H34.1431H34.1025H34.0619H34.0213H33.9807H33.9401H33.8995H33.8589H33.8183H33.7777H33.7371H33.6964H33.6559H33.6152H33.5746H33.534H33.4934H33.4528H33.4122H33.3716H33.331H33.2904H33.2498H33.2092H33.1685H33.128H33.0873H33.0467H33.0061H32.9655H32.9249H32.8843H32.8437H32.8031H32.7625H32.7219H32.6813H32.6406H32.6H32.5594H32.5188H32.4782H32.4376H32.397H32.3564H32.3158H32.2752H32.2346H32.194H32.1534H32.1127H32.0721H32.0315H31.9909H31.9503H31.9097H31.8691H31.8285H31.7879H31.7473H31.7067H31.666H31.6255H31.5848H31.5442H31.5036H31.416V21.1545H31.5036H31.5442H31.5848H31.6255H31.666H31.7067H31.7473H31.7879H31.8285H31.8691H31.9097H31.9503H31.9909H32.0315H32.0721H32.1127H32.1534H32.194H32.2346H32.2752H32.3158H32.3564H32.397H32.4376H32.4782H32.5188H32.5594H32.6H32.6406H32.6813H32.7219H32.7625H32.8031H32.8437H32.8843H32.9249H32.9655H33.0061H33.0467H33.0873H33.128H33.1685H33.2092H33.2498H33.2904H33.331H33.3716H33.4122H33.4528H33.4934H33.534H33.5746H33.6152H33.6559H33.6964H33.7371H33.7777H33.8183H33.8589H33.8995H33.9401H33.9807H34.0213H34.0619H34.1025H34.1431H34.1838H34.2243H34.265H34.3056H34.3462H34.3868H34.4274H34.468H34.5086H34.5492H34.5898H34.6304H34.671H34.7117H34.7522H34.7929H34.8335H34.8741H34.9147H34.9553H34.9959H35.0365H35.0771H35.1177H35.1583H35.1989H35.2396H35.2801H35.3208H35.3614H35.402H35.4426H35.4832H35.5238H35.5644H35.605H35.6456H35.6862H35.7268H35.7675H35.808H35.8487H35.8893H35.9299H35.9705H36.0111H36.0517H36.0923H36.1329H36.1735H36.2141H36.2547H36.2954H36.3359H36.3766H36.4172H36.4578H36.4984H36.539H36.5796H36.6202H36.6608H36.7014H36.742H36.7826H36.8233H36.8638H36.9045H36.9451H36.9857H37.0263H37.0669H37.1075H37.1481H37.1887H37.2293H37.2699H37.3105H37.3512H37.3917H37.4324H37.473H37.5136H37.5542H37.5948H37.6354H37.676H37.7166H37.7572H37.7978H37.8384H37.8791H37.9197H37.9412L37.9603 21.1543L38.0009 21.154L38.0415 21.1532C38.0551 21.1528 38.0686 21.1526 38.0821 21.1521C38.0957 21.1517 38.1092 21.151 38.1227 21.1504L38.1633 21.1485L38.2039 21.146L38.2445 21.1432L38.2851 21.14L38.3257 21.1363C38.3393 21.135 38.3529 21.1337 38.3663 21.1323C38.3799 21.1309 38.3934 21.1293 38.407 21.1277L38.4476 21.1227C38.4611 21.1209 38.4747 21.1192 38.4882 21.1173C38.5018 21.1154 38.5153 21.1133 38.5288 21.1113C38.5424 21.1092 38.5559 21.1072 38.5694 21.105L38.61 21.0981L38.6506 21.0908C38.6642 21.0882 38.6778 21.0858 38.6912 21.0831C38.7048 21.0804 38.7183 21.0775 38.7318 21.0746L38.7724 21.0658C38.786 21.0627 38.7996 21.0597 38.813 21.0565C38.8267 21.0533 38.8402 21.0498 38.8537 21.0465C38.8672 21.0431 38.8808 21.0396 38.8942 21.0361C38.9078 21.0325 38.9214 21.0288 38.9349 21.0251C38.9485 21.0213 38.962 21.0174 38.9755 21.0134C38.9891 21.0095 39.0026 21.0054 39.0161 21.0013L39.0567 20.9885C39.0703 20.9841 39.0838 20.9796 39.0973 20.975C39.1109 20.9704 39.1245 20.9658 39.1379 20.961C39.1515 20.9562 39.1651 20.9514 39.1785 20.9464C39.1922 20.9413 39.2056 20.9361 39.2191 20.9308C39.2327 20.9256 39.2463 20.9202 39.2597 20.9147C39.2733 20.9092 39.2869 20.9037 39.3003 20.898C39.314 20.8923 39.3275 20.8864 39.3409 20.8804C39.3546 20.8744 39.3681 20.8682 39.3816 20.862C39.3951 20.8557 39.4087 20.8493 39.4221 20.8429C39.4357 20.8363 39.4493 20.8297 39.4628 20.823C39.4764 20.8161 39.4899 20.8091 39.5034 20.802C39.517 20.7949 39.5305 20.7875 39.544 20.7801C39.5576 20.7727 39.5712 20.7651 39.5846 20.7574C39.5982 20.7496 39.6118 20.7417 39.6252 20.7337C39.6388 20.7256 39.6524 20.7174 39.6658 20.709C39.6795 20.7004 39.693 20.6917 39.7064 20.683C39.7201 20.674 39.7336 20.6649 39.747 20.6558C39.7607 20.6464 39.7742 20.6369 39.7876 20.6274C39.8013 20.6176 39.8148 20.6077 39.8282 20.5976C39.8419 20.5874 39.8555 20.577 39.8688 20.5665C39.8825 20.5558 39.8961 20.5449 39.9095 20.5339C39.9232 20.5226 39.9367 20.5112 39.95 20.4996C39.9638 20.4878 39.9773 20.4757 39.9907 20.4636C40.0044 20.451 40.0179 20.4383 40.0313 20.4255C40.045 20.4123 40.0586 20.3989 40.0719 20.3854C40.0857 20.3714 40.0992 20.3572 40.1125 20.3428C40.1263 20.328 40.1398 20.3129 40.1531 20.2977C40.1669 20.2819 40.1805 20.2658 40.1937 20.2496C40.2075 20.2327 40.2211 20.2156 40.2343 20.1983C40.2481 20.1801 40.2618 20.1618 40.2749 20.1432C40.2889 20.1235 40.3023 20.1034 40.3155 20.0831C40.3295 20.0616 40.343 20.0399 40.3561 20.0179C40.3702 19.9942 40.3837 19.9701 40.3967 19.9458C40.4109 19.9193 40.4245 19.8926 40.4374 19.8654C40.4517 19.835 40.4652 19.8041 40.4779 19.7728C40.4926 19.7368 40.5061 19.7004 40.5186 19.6634C40.5338 19.6177 40.5473 19.5711 40.5592 19.5237C40.5765 19.4544 40.5901 19.3833 40.5998 19.3104C40.6116 19.222 40.6178 19.1312 40.6178 19.0378C40.6178 18.9469 40.6116 18.8586 40.5998 18.7728C40.5901 18.7024 40.5765 18.6337 40.5592 18.5668C40.5473 18.5213 40.5338 18.4766 40.5186 18.4327C40.5061 18.3968 40.4926 18.3613 40.4779 18.3266C40.4653 18.2964 40.4517 18.2667 40.4374 18.2375C40.4245 18.2112 40.4109 18.1853 40.3967 18.1598C40.3837 18.1363 40.3702 18.113 40.3561 18.0901C40.3431 18.0689 40.3295 18.0479 40.3155 18.0273C40.3024 18.0078 40.2889 17.9884 40.2749 17.9694C40.2618 17.9514 40.2482 17.9337 40.2343 17.9163C40.2211 17.8995 40.2075 17.883 40.1937 17.8667C40.1804 17.851 40.1669 17.8355 40.1531 17.8202C40.1398 17.8055 40.1263 17.791 40.1125 17.7767C40.0992 17.7628 40.0856 17.7492 40.0719 17.7357C40.0586 17.7226 40.045 17.7097 40.0313 17.697C40.0179 17.6846 40.0044 17.6724 39.9907 17.6604C39.9773 17.6486 39.9638 17.637 39.95 17.6255C39.9367 17.6143 39.9232 17.6033 39.9095 17.5924C39.8961 17.5817 39.8825 17.5712 39.8688 17.5608C39.8554 17.5507 39.8419 17.5407 39.8282 17.5307C39.8148 17.5211 39.8013 17.5115 39.7876 17.502C39.7742 17.4928 39.7607 17.4836 39.747 17.4745C39.7336 17.4657 39.7201 17.4569 39.7064 17.4482C39.693 17.4398 39.6795 17.4314 39.6658 17.4231C39.6524 17.415 39.6389 17.407 39.6252 17.3991C39.6118 17.3913 39.5982 17.3837 39.5846 17.3761C39.5712 17.3687 39.5576 17.3614 39.544 17.3541C39.5305 17.347 39.517 17.3399 39.5034 17.333C39.4899 17.3261 39.4764 17.3193 39.4628 17.3126C39.4493 17.3061 39.4358 17.2995 39.4221 17.2931C39.4087 17.2868 39.3951 17.2807 39.3816 17.2746C39.3681 17.2685 39.3546 17.2626 39.3409 17.2567C39.3275 17.251 39.3139 17.2453 39.3003 17.2396C39.2868 17.2341 39.2734 17.2285 39.2597 17.2231C39.2463 17.2178 39.2327 17.2126 39.2191 17.2074C39.2057 17.2023 39.1921 17.1974 39.1785 17.1925C39.165 17.1876 39.1515 17.1828 39.1379 17.1781C39.1244 17.1734 39.1109 17.1688 39.0973 17.1642C39.0838 17.1598 39.0703 17.1554 39.0567 17.1511C39.0432 17.1468 39.0296 17.1427 39.0161 17.1386L38.9755 17.1266C38.962 17.1227 38.9485 17.1188 38.9349 17.1151C38.9214 17.1114 38.9078 17.1078 38.8942 17.1043C38.8808 17.1008 38.8672 17.0974 38.8537 17.094C38.8402 17.0906 38.8266 17.0873 38.813 17.0841C38.7996 17.0809 38.786 17.0779 38.7724 17.0748C38.759 17.0718 38.7454 17.069 38.7318 17.0661C38.7183 17.0633 38.7048 17.0604 38.6912 17.0577L38.6506 17.0499L38.61 17.0426C38.5965 17.0403 38.583 17.0379 38.5694 17.0357L38.5288 17.0292C38.5153 17.0272 38.5017 17.0252 38.4882 17.0233L38.4476 17.0178L38.407 17.0126L38.3663 17.008L38.3257 17.0037L38.2851 16.9999L38.2445 16.9965L38.207 16.9936L38.207 16.9446L38.2445 16.9387L38.2851 16.932C38.2987 16.9296 38.3123 16.9273 38.3257 16.9248L38.3663 16.9169C38.38 16.9142 38.3935 16.9114 38.407 16.9085L38.4476 16.8996C38.4611 16.8965 38.4747 16.8934 38.4882 16.8902C38.5018 16.8869 38.5153 16.8834 38.5288 16.8799C38.5424 16.8764 38.5559 16.8728 38.5694 16.8691C38.583 16.8654 38.5965 16.8616 38.61 16.8577C38.6236 16.8538 38.6372 16.8498 38.6506 16.8457C38.6642 16.8416 38.6778 16.8373 38.6912 16.833C38.7049 16.8286 38.7184 16.824 38.7318 16.8195C38.7455 16.8148 38.759 16.81 38.7724 16.8052C38.7861 16.8003 38.7996 16.7954 38.813 16.7903C38.8267 16.7852 38.8402 16.7799 38.8537 16.7746C38.8673 16.7692 38.8808 16.7637 38.8942 16.7581C38.9079 16.7524 38.9214 16.7467 38.9349 16.7408C38.9485 16.7348 38.962 16.7287 38.9755 16.7225C38.9891 16.7162 39.0026 16.7098 39.0161 16.7033C39.0297 16.6967 39.0433 16.6899 39.0567 16.6831C39.0703 16.6761 39.0839 16.6691 39.0973 16.662C39.111 16.6547 39.1245 16.6472 39.1379 16.6398C39.1516 16.6321 39.1651 16.6243 39.1785 16.6165C39.1922 16.6084 39.2057 16.6003 39.2191 16.592C39.2328 16.5836 39.2463 16.575 39.2597 16.5664C39.2734 16.5575 39.2869 16.5485 39.3003 16.5394C39.314 16.53 39.3276 16.5206 39.3409 16.511C39.3547 16.5012 39.3682 16.4912 39.3816 16.4812C39.3953 16.4708 39.4088 16.4604 39.4221 16.4498C39.4359 16.4389 39.4494 16.4279 39.4628 16.4167C39.4765 16.4052 39.49 16.3935 39.5034 16.3817C39.5172 16.3695 39.5307 16.3571 39.544 16.3446C39.5577 16.3317 39.5713 16.3186 39.5846 16.3054C39.5984 16.2917 39.6119 16.2779 39.6252 16.2639C39.639 16.2493 39.6526 16.2345 39.6658 16.2195C39.6796 16.2039 39.6932 16.1881 39.7064 16.1721C39.7203 16.1554 39.7338 16.1385 39.747 16.1214C39.7609 16.1033 39.7745 16.085 39.7876 16.0665C39.8016 16.0469 39.8151 16.027 39.8282 16.007C39.8422 15.9856 39.8558 15.9639 39.8688 15.942C39.8829 15.9183 39.8965 15.8942 39.9095 15.8699C39.9237 15.8432 39.9372 15.8162 39.95 15.7888C39.9645 15.7581 39.978 15.7268 39.9907 15.6953C40.0054 15.6586 40.0189 15.6214 40.0313 15.5836C40.0467 15.5362 40.0603 15.488 40.0719 15.4393C40.0906 15.3601 40.1042 15.2793 40.1125 15.1971C40.1191 15.1322 40.1225 15.0665 40.1225 15.0001C40.1225 14.93 40.1191 14.8616 40.1125 14.795C40.1041 14.7098 40.0905 14.6275 40.0719 14.5481C40.0603 14.4985 40.0467 14.45 40.0313 14.4027C40.0189 14.3648 40.0054 14.3277 39.9907 14.2913C39.978 14.26 39.9644 14.2292 39.95 14.199C39.9372 14.172 39.9236 14.1455 39.9095 14.1194C39.8965 14.0955 39.8829 14.072 39.8688 14.0488C39.8557 14.0273 39.8422 14.0061 39.8282 13.9852C39.8151 13.9656 39.8015 13.9464 39.7876 13.9274C39.7745 13.9095 39.7609 13.8917 39.747 13.8743C39.7338 13.8576 39.7203 13.8411 39.7064 13.8249C39.6932 13.8095 39.6796 13.7943 39.6658 13.7793C39.6526 13.7649 39.639 13.7506 39.6252 13.7366C39.6119 13.723 39.5984 13.7097 39.5846 13.6965C39.5713 13.6837 39.5577 13.6711 39.544 13.6587C39.5307 13.6466 39.5171 13.6347 39.5034 13.623C39.49 13.6116 39.4765 13.6004 39.4628 13.5893C39.4494 13.5785 39.4359 13.5678 39.4221 13.5574C39.4088 13.5471 39.3952 13.5371 39.3816 13.5272C39.3682 13.5174 39.3546 13.5079 39.3409 13.4984C39.3275 13.4892 39.314 13.4801 39.3003 13.4711C39.2869 13.4623 39.2734 13.4537 39.2597 13.4451C39.2463 13.4367 39.2328 13.4285 39.2191 13.4204C39.2057 13.4124 39.1922 13.4045 39.1785 13.3968C39.1651 13.3891 39.1516 13.3816 39.1379 13.3742C39.1245 13.3669 39.111 13.3598 39.0973 13.3527C39.0839 13.3458 39.0703 13.339 39.0567 13.3322C39.0432 13.3256 39.0297 13.319 39.0161 13.3126C39.0026 13.3062 38.9891 13.2999 38.9755 13.2938C38.962 13.2878 38.9485 13.282 38.9349 13.2761C38.9214 13.2704 38.9078 13.2647 38.8942 13.2591C38.8808 13.2536 38.8672 13.2482 38.8537 13.2429C38.8402 13.2376 38.8267 13.2324 38.813 13.2273C38.7996 13.2223 38.7861 13.2174 38.7724 13.2126C38.759 13.2078 38.7454 13.2031 38.7318 13.1985C38.7184 13.194 38.7048 13.1895 38.6912 13.1852C38.6778 13.1808 38.6642 13.1766 38.6506 13.1724C38.6371 13.1683 38.6236 13.1641 38.61 13.1602C38.5965 13.1563 38.583 13.1525 38.5694 13.1488C38.5559 13.1451 38.5424 13.1415 38.5288 13.1379L38.4882 13.1275C38.4747 13.1242 38.4612 13.1209 38.4476 13.1177C38.4341 13.1145 38.4206 13.1115 38.407 13.1085L38.3663 13.0998C38.3528 13.097 38.3394 13.0941 38.3257 13.0915C38.3123 13.0888 38.2987 13.0864 38.2851 13.0839L38.2445 13.0767C38.231 13.0744 38.2175 13.0721 38.2039 13.0699L38.1633 13.0637L38.1227 13.0579L38.0821 13.0525L38.0415 13.0477L38.0009 13.0433L37.9603 13.0392L37.9197 13.0356L37.8791 13.0324L37.8384 13.0295L37.7978 13.0272L37.7817 13.0263L37.7572 13.0247L37.7166 13.0223L37.676 13.0201L37.6354 13.0179L37.5948 13.016L37.5542 13.0142L37.5136 13.0127L37.473 13.0114L37.4324 13.0105L37.3917 13.01L37.3694 13.0099H37.3512H37.3105H37.2699H37.2293H37.1887H37.1481H37.1075H37.0669H37.0263H36.9857H36.9451H36.9045H36.8638H36.8233H36.7826H36.742H36.7014H36.6608H36.6202H36.5796H36.539H36.4984H36.4578H36.4172H36.3766H36.3359H36.2954H36.2547H36.2141H36.1735H36.1329H36.0923H36.0517H36.0111H35.9705H35.9299H35.8893H35.8487H35.808H35.7675H35.7268H35.6862H35.6456H35.605H35.5644H35.5238H35.4832H35.4426H35.402H35.3614H35.3208H35.2801H35.2396H35.1989H35.1583H35.1177H35.0771H35.0365H34.9959H34.9553H34.9147H34.8741H34.8335H34.7929H34.7522H34.7117H34.671H34.6304H34.5898H34.5492H34.5086H34.468H34.4274H34.3868H34.3462H34.3056H34.265H34.2243H34.1838H34.1431H34.1025H34.0619H34.0213H33.9807H33.9401H33.8995H33.8589H33.8183H33.7777H33.7371H33.6964H33.6559H33.6152H33.5746H33.534H33.4934H33.4528H33.4122H33.3716H33.331H33.2904H33.2498H33.2092H33.1685H33.128H33.0873H33.0467H33.0061H32.9655H32.9249H32.8843H32.8437H32.8031H32.7625H32.7219H32.6813H32.6406H32.6H32.5594H32.5188H32.4782H32.4376H32.397H32.3564H32.3158H32.2752H32.2346H32.194H32.1534H32.1127H32.0721H32.0315H31.9909H31.9503H31.9097H31.8691H31.8285H31.7879H31.7473H31.7067H31.666H31.6255H31.5848H31.5442H31.5036H31.416V8.61119C31.416 8.31526 31.4463 8.02649 31.5036 7.74768C31.5159 7.68797 31.5295 7.62877 31.5442 7.57005C31.5569 7.5198 31.5705 7.46992 31.5848 7.42041C31.5977 7.37619 31.6112 7.33227 31.6255 7.28866C31.6385 7.24881 31.6519 7.20917 31.666 7.16986C31.6791 7.1334 31.6926 7.09714 31.7067 7.06115C31.7198 7.02754 31.7333 6.99409 31.7473 6.96092C31.7605 6.92961 31.774 6.89843 31.7879 6.8675C31.8011 6.83812 31.8147 6.80894 31.8285 6.77994C31.8417 6.75225 31.8553 6.72477 31.8691 6.69742C31.8824 6.67113 31.896 6.64504 31.9097 6.61905C31.923 6.594 31.9365 6.56913 31.9503 6.5444C31.9636 6.5204 31.9772 6.49662 31.9909 6.47293C32.0043 6.44996 32.0178 6.42708 32.0315 6.40441C32.0449 6.38242 32.0584 6.3606 32.0721 6.33888C32.0854 6.31784 32.0991 6.297 32.1127 6.27622C32.1261 6.25589 32.1396 6.23563 32.1534 6.21554C32.1668 6.19585 32.1802 6.17616 32.194 6.15671C32.2073 6.13787 32.2209 6.11927 32.2346 6.10066C32.2479 6.08236 32.2615 6.0642 32.2752 6.04614C32.2887 6.02835 32.3021 6.01049 32.3158 5.99294C32.3291 5.97586 32.3428 5.95905 32.3564 5.94218C32.3698 5.92554 32.3833 5.90904 32.397 5.89264C32.4105 5.87644 32.4239 5.86024 32.4376 5.84425C32.451 5.82866 32.4646 5.81335 32.4782 5.79796C32.4917 5.78271 32.5051 5.76743 32.5188 5.75239C32.5322 5.73765 32.5458 5.72304 32.5594 5.70847C32.5729 5.6941 32.5864 5.67984 32.6 5.66564C32.6135 5.65164 32.627 5.63765 32.6406 5.62382C32.6541 5.61024 32.6677 5.59685 32.6813 5.58347C32.6948 5.57015 32.7082 5.5567 32.7219 5.54358C32.7352 5.53074 32.7489 5.5182 32.7625 5.50553C32.776 5.49292 32.7894 5.48028 32.8031 5.46785C32.8165 5.45561 32.8301 5.44362 32.8437 5.43159C32.8572 5.41963 32.8707 5.40767 32.8843 5.39587C32.8978 5.38425 32.9113 5.3728 32.9249 5.36134C32.9384 5.34999 32.9519 5.33868 32.9655 5.32749C32.979 5.31641 32.9925 5.30547 33.0061 5.29459L33.0467 5.26243L33.0873 5.23112C33.1008 5.22085 33.1144 5.21066 33.128 5.20056C33.1414 5.19056 33.1549 5.1806 33.1685 5.17074L33.2092 5.1417C33.2226 5.13218 33.2362 5.12272 33.2498 5.11337L33.2904 5.08568L33.331 5.05875C33.3445 5.0499 33.358 5.04109 33.3716 5.03242L33.4122 5.00687C33.4257 4.99843 33.4392 4.98999 33.4528 4.98172C33.4663 4.97352 33.4799 4.96556 33.4934 4.95753C33.507 4.9495 33.5204 4.9414 33.534 4.93354C33.5474 4.92578 33.5611 4.91829 33.5746 4.91067C33.5882 4.90304 33.6016 4.89528 33.6152 4.88783C33.6287 4.88047 33.6423 4.87336 33.6559 4.86614C33.6694 4.85892 33.6828 4.85167 33.6964 4.84459C33.7099 4.83757 33.7235 4.83076 33.7371 4.82392C33.7506 4.81711 33.7641 4.81026 33.7777 4.80359L33.8183 4.78383C33.8318 4.77736 33.8453 4.77106 33.8589 4.76472C33.8724 4.75842 33.8859 4.75201 33.8995 4.74585C33.913 4.73975 33.9266 4.73392 33.9401 4.72795C33.9536 4.72199 33.9671 4.71592 33.9807 4.7101C33.9942 4.70434 34.0078 4.69885 34.0213 4.69322C34.0348 4.68763 34.0483 4.68197 34.0619 4.67651L34.1025 4.66042L34.1431 4.64487C34.1567 4.63972 34.1702 4.6345 34.1838 4.62952C34.1972 4.62457 34.2108 4.61993 34.2243 4.61511C34.2379 4.6103 34.2514 4.60539 34.265 4.60071C34.2784 4.59607 34.292 4.5917 34.3056 4.58719L34.3462 4.57394C34.3597 4.56961 34.3732 4.56523 34.3868 4.56103C34.4003 4.55686 34.4139 4.55293 34.4274 4.5489C34.4409 4.54484 34.4544 4.54063 34.468 4.53674C34.4815 4.53287 34.4951 4.52928 34.5086 4.52555C34.5221 4.52183 34.5356 4.51807 34.5492 4.51447L34.5898 4.5039C34.6033 4.50048 34.6169 4.49723 34.6304 4.49394C34.644 4.49065 34.6574 4.48713 34.671 4.48398C34.6845 4.48086 34.6981 4.47801 34.7117 4.475L34.7522 4.46608L34.7929 4.45765C34.8063 4.45494 34.8199 4.45239 34.8335 4.44982C34.847 4.44721 34.8605 4.44443 34.8741 4.44196C34.8876 4.43948 34.9012 4.43738 34.9147 4.43508L34.9553 4.4283C34.9688 4.42613 34.9824 4.4239 34.9959 4.42186C35.0094 4.41983 35.023 4.41803 35.0365 4.41614C35.0501 4.41424 35.0635 4.41217 35.0771 4.41041C35.0906 4.40865 35.1042 4.40709 35.1177 4.40546L35.1583 4.40079C35.1719 4.3993 35.1854 4.39764 35.1989 4.39628C35.2124 4.39492 35.226 4.39387 35.2396 4.39265L35.2801 4.38903L35.3208 4.38595C35.3343 4.38496 35.3478 4.38418 35.3614 4.38334C35.3749 4.38252 35.3884 4.38147 35.402 4.38076C35.4155 4.38005 35.4291 4.37964 35.4426 4.37907L35.4832 4.37754C35.4968 4.3771 35.5103 4.37652 35.5238 4.37622C35.5373 4.37591 35.5509 4.37588 35.5644 4.37568C35.5779 4.37547 35.5915 4.37524 35.605 4.37517L35.6152 4.37503H35.6456H35.6862H35.7268H35.7675H35.808H35.8487H35.8893H35.9299H35.9705H36.0111H36.0517H36.0923H36.1329H36.1735H36.2141H36.2547H36.2954H36.3359H36.3766H36.4172H36.4578H36.4984H36.539H36.5796H36.6202H36.6608H36.7014H36.742H36.7826H36.8233H36.8638H36.9045H36.9451H36.9857H37.0263H37.0669H37.1075H37.1481H37.1887H37.2293H37.2699H37.3105H37.3512H37.3917H37.4324H37.473H37.5136H37.5542H37.5948H37.6354H37.676H37.7166H37.7572H37.7978H37.8384H37.8791H37.9197H37.9603H38.0009H38.0415H38.0821H38.1227H38.1633H38.2039H38.2445H38.2851H38.3257H38.3663H38.407H38.4476H38.4882H38.5288H38.5694H38.61H38.6506H38.6912H38.7318H38.7724H38.813H38.8537H38.8942H38.9349H38.9755H39.0161H39.0567H39.0973H39.1379H39.1785H39.2191H39.2597H39.3003H39.3409H39.3816H39.4221H39.4628H39.5034H39.544H39.5846H39.6252H39.6658H39.7064H39.747H39.7876H39.8282H39.8688H39.9095H39.95H39.9907H40.0313H40.0719H40.1125H40.1531H40.1937H40.2343H40.2749H40.3155H40.3561H40.3967H40.4374H40.4779H40.5186H40.5592H40.5998H40.6404H40.681H40.7216H40.7622H40.8028H40.8434H40.884H40.9246H40.9653H41.0058H41.0465H41.0871H41.1277H41.1683H41.2089H41.2495H41.2901H41.3307H41.3714H41.4119H41.4526H41.4932H41.5338H41.5744H41.615H41.6556H41.6962H41.7368H41.7774V4.375Z" + fill="#54B230" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M8.1377 19.0723V8.61115C8.1377 8.31608 8.16772 8.02805 8.22476 7.74998C8.23702 7.69027 8.25061 7.63111 8.26532 7.57235C8.27791 7.52203 8.29147 7.47212 8.30584 7.42254C8.31867 7.37836 8.3322 7.33451 8.34637 7.29093C8.35936 7.25105 8.37279 7.21133 8.38692 7.17196C8.40001 7.13553 8.41341 7.09931 8.42744 7.06336C8.44054 7.02974 8.45407 6.99623 8.468 6.96302C8.48116 6.93171 8.49466 6.9006 8.50852 6.8697C8.52172 6.84032 8.53525 6.81118 8.54904 6.78217C8.56224 6.75445 8.57583 6.72694 8.5896 6.69956C8.60283 6.67326 8.61636 6.64717 8.63012 6.62121C8.64338 6.59617 8.65691 6.5713 8.67064 6.54656C8.68394 6.52261 8.69744 6.49879 8.7112 6.47513C8.72453 6.45216 8.73796 6.42925 8.75172 6.40655C8.76505 6.38459 8.77858 6.3628 8.79228 6.34112C8.80554 6.32011 8.81917 6.29923 8.8328 6.27846C8.84616 6.25813 8.85963 6.23787 8.87332 6.21777C8.88679 6.19805 8.90011 6.17836 8.91388 6.15888C8.92717 6.14007 8.94081 6.1215 8.9544 6.1029C8.9678 6.0846 8.98129 6.06644 8.99496 6.04838C9.00839 6.03062 9.02178 6.01273 9.03548 5.99518C9.04881 5.97813 9.06244 5.96136 9.076 5.94452C9.0894 5.92788 9.10289 5.91131 9.11656 5.89487C9.12999 5.87871 9.14342 5.86258 9.15708 5.84662C9.17044 5.831 9.18404 5.81565 9.19764 5.80023C9.21107 5.78495 9.2245 5.7697 9.23816 5.75466C9.25152 5.73992 9.26512 5.72538 9.27868 5.71084C9.29211 5.69644 9.30561 5.68217 9.31924 5.66798C9.33267 5.65398 9.34613 5.63999 9.35976 5.62616C9.37316 5.61257 9.38675 5.59919 9.40032 5.5858C9.41381 5.57249 9.42717 5.55907 9.44084 5.54595C9.4542 5.53311 9.46783 5.52057 9.48136 5.5079C9.49486 5.49526 9.50825 5.48259 9.52192 5.47015C9.53531 5.45795 9.54891 5.44599 9.56244 5.43396C9.57594 5.422 9.58936 5.41 9.603 5.39821C9.61642 5.38659 9.62995 5.37517 9.64352 5.36372C9.65695 5.35237 9.67044 5.34101 9.68404 5.32983C9.69747 5.31878 9.71103 5.30784 9.7246 5.29693C9.73806 5.28612 9.75152 5.27541 9.76512 5.26477C9.77855 5.25423 9.79211 5.24383 9.80567 5.23346C9.81914 5.22319 9.83263 5.21296 9.8462 5.20286L9.88672 5.17311C9.90018 5.16332 9.91368 5.15359 9.92728 5.14397C9.9407 5.13445 9.95423 5.12503 9.9678 5.11567C9.98126 5.10635 9.99476 5.0971 10.0083 5.08795C10.0218 5.07891 10.0353 5.07 10.0489 5.06108C10.0623 5.05224 10.0758 5.04333 10.0894 5.03462C10.1028 5.02601 10.1164 5.01761 10.13 5.00914C10.1435 5.0007 10.1569 4.99216 10.1705 4.98389C10.1839 4.97573 10.1975 4.9678 10.211 4.95977C10.2245 4.95173 10.2379 4.94357 10.2516 4.93567C10.265 4.92791 10.2786 4.92049 10.2921 4.91287C10.3056 4.90524 10.319 4.89752 10.3326 4.89003C10.3461 4.88264 10.3596 4.87553 10.3732 4.86831C10.3867 4.86109 10.4001 4.85384 10.4137 4.84676C10.4271 4.83974 10.4407 4.83286 10.4542 4.82598L10.4948 4.80572C10.5083 4.79905 10.5217 4.79237 10.5353 4.78586C10.5488 4.77939 10.5623 4.77312 10.5758 4.76679C10.5894 4.76045 10.6028 4.75398 10.6164 4.74781C10.6298 4.74168 10.6434 4.73592 10.6569 4.72995C10.6704 4.72399 10.6839 4.71792 10.6974 4.71209C10.7109 4.70633 10.7245 4.70078 10.738 4.69515L10.7785 4.67848L10.819 4.66228L10.8596 4.64676C10.8731 4.64161 10.8865 4.63629 10.9001 4.63128C10.9135 4.62633 10.9272 4.62172 10.9407 4.61691C10.9542 4.6121 10.9676 4.60722 10.9812 4.60254L11.0217 4.58889L11.0623 4.57564C11.0758 4.5713 11.0892 4.56686 11.1028 4.56266C11.1162 4.55849 11.1298 4.55453 11.1433 4.55049C11.1568 4.54643 11.1703 4.54229 11.1839 4.53836C11.1973 4.53447 11.2109 4.53084 11.2244 4.52708L11.265 4.516C11.2784 4.51241 11.2919 4.50875 11.3055 4.50529C11.3189 4.50183 11.3325 4.49868 11.346 4.49536C11.3595 4.49204 11.373 4.48858 11.3866 4.4854C11.4 4.48225 11.4136 4.4793 11.4271 4.47628L11.4676 4.46741C11.4811 4.46449 11.4946 4.46154 11.5082 4.4588C11.5216 4.45609 11.5352 4.45358 11.5487 4.45097C11.5622 4.44836 11.5757 4.44565 11.5892 4.44318C11.6027 4.4407 11.6162 4.43847 11.6298 4.43613L11.6703 4.42935C11.6838 4.42718 11.6973 4.42485 11.7108 4.42278C11.7243 4.42071 11.7379 4.41898 11.7514 4.41705C11.7648 4.41515 11.7783 4.41312 11.7919 4.41133L11.8324 4.40621L11.873 4.40157C11.8865 4.40004 11.8999 4.39828 11.9135 4.39689C11.927 4.3955 11.9405 4.39448 11.954 4.39323L11.9946 4.38964C12.0081 4.38849 12.0216 4.38737 12.0351 4.38639C12.0486 4.3854 12.0621 4.38462 12.0757 4.38378C12.0892 4.38293 12.1027 4.38195 12.1162 4.3812C12.1297 4.38046 12.1432 4.37995 12.1567 4.37934L12.1973 4.37778C12.2108 4.3773 12.2242 4.37659 12.2378 4.37625C12.2513 4.37591 12.2648 4.37595 12.2783 4.37575C12.2918 4.37554 12.3053 4.3753 12.3189 4.37524L12.3594 4.375H12.3999H12.4405H12.481H12.5215H12.5621H12.6026H12.6431H12.6837H12.7242H12.7647H12.8053H12.8458H12.8863H12.9269H12.9674H13.008H13.0485H13.089H13.1296H13.1701H13.2107H13.2512H13.2917H13.3323H13.3728H13.4133H13.4539H13.4944H13.5349H13.5755H13.616H13.6565H13.6971H13.7376H13.7781H13.8187H13.8592H13.8997H13.9403H13.9808H14.0213H14.0619H14.1024H14.143H14.1835H14.224H14.2646H14.3051H14.3457H14.3862H14.4267H14.4673H14.5078H14.5483H14.5889H14.6294H14.6699H14.7105H14.751H14.7915H14.8321H14.8726H14.9131H14.9537H14.9942H15.0347H15.0753H15.1158H15.1563H15.1969H15.2374H15.278H15.3185H15.359H15.3996H15.4401H15.4807H15.5212H15.5617H15.6023H15.6428H15.6833H15.7239H15.7644H15.8049H15.8455H15.886H15.9265H15.9671H16.0076H16.0481H16.0887H16.1292H16.1697H16.2103H16.2508H16.2914H16.3319H16.3724H16.413H16.4535H16.494H16.5346H16.5751H16.6157H16.6562H16.6967H16.7373H16.7778H16.8183H16.8589H16.8994H16.9399H16.9805H17.021H17.0615H17.1021H17.1426H17.1831H17.2237H17.2642H17.3047H17.3453H17.3858H17.4264H17.4669H17.5074H17.548H17.5885H17.629H17.6696H17.7101H17.7506H17.7912H17.8317H17.8722H17.9128H17.9533H17.9938H18.0344H18.0749H18.1154H18.156H18.1965H18.237H18.2776H18.3181H18.3586H18.3992H18.4397H18.4803H18.5147V25.5542C18.5147 25.7384 18.5029 25.9199 18.4803 26.098C18.4694 26.1837 18.4556 26.2685 18.4397 26.3525C18.4276 26.4165 18.4141 26.4799 18.3992 26.5427C18.3866 26.5957 18.3732 26.6483 18.3586 26.7005C18.3459 26.7463 18.3323 26.7917 18.3181 26.8368C18.3052 26.8777 18.2917 26.9182 18.2776 26.9586C18.2646 26.9959 18.2511 27.033 18.237 27.0698C18.224 27.1042 18.2105 27.1384 18.1965 27.1723C18.1834 27.2043 18.1699 27.2361 18.156 27.2676C18.1428 27.2976 18.1293 27.3273 18.1154 27.357C18.1023 27.3852 18.0887 27.4132 18.0749 27.4411C18.0617 27.4679 18.0482 27.4945 18.0344 27.5209C18.0211 27.5463 18.0076 27.5717 17.9938 27.5968C17.9805 27.6211 17.9671 27.6454 17.9533 27.6694C17.94 27.6926 17.9265 27.7156 17.9128 27.7385C17.8995 27.7607 17.8859 27.7826 17.8722 27.8045C17.8589 27.8259 17.8454 27.8471 17.8317 27.8682C17.8183 27.8889 17.8049 27.9095 17.7912 27.9298C17.7778 27.9496 17.7643 27.9692 17.7506 27.9887C17.7373 28.0078 17.7238 28.0267 17.7101 28.0456C17.6967 28.0641 17.6832 28.0826 17.6696 28.1009C17.6562 28.1188 17.6427 28.1365 17.629 28.1542C17.6157 28.1714 17.6021 28.1885 17.5885 28.2056C17.5751 28.2224 17.5616 28.2392 17.548 28.2558C17.5346 28.2721 17.521 28.2881 17.5074 28.3041C17.494 28.3199 17.4805 28.3356 17.4669 28.3512C17.4534 28.3665 17.44 28.382 17.4264 28.3972C17.413 28.412 17.3994 28.4266 17.3858 28.4413C17.3723 28.4558 17.359 28.4704 17.3453 28.4847C17.3319 28.4988 17.3183 28.5126 17.3047 28.5265C17.2913 28.5402 17.2778 28.5539 17.2642 28.5674C17.2508 28.5808 17.2373 28.5941 17.2237 28.6073C17.2103 28.6203 17.1967 28.6331 17.1831 28.6459C17.1697 28.6586 17.1562 28.6713 17.1426 28.6839C17.1292 28.6962 17.1156 28.7082 17.1021 28.7204C17.0886 28.7325 17.0752 28.7447 17.0615 28.7566C17.0482 28.7682 17.0345 28.7796 17.021 28.7911C17.0075 28.8026 16.9941 28.8142 16.9805 28.8255C16.9671 28.8366 16.9535 28.8475 16.9399 28.8584C16.9264 28.8693 16.913 28.8803 16.8994 28.8911C16.886 28.9016 16.8724 28.9119 16.8589 28.9223C16.8454 28.9327 16.832 28.9431 16.8183 28.9533C16.8049 28.9634 16.7913 28.9732 16.7778 28.9831C16.7643 28.993 16.7509 29.003 16.7373 29.0127C16.7239 29.0222 16.7103 29.0316 16.6967 29.041C16.6832 29.0504 16.6698 29.0599 16.6562 29.0692C16.6428 29.0782 16.6292 29.0871 16.6157 29.096C16.6021 29.105 16.5887 29.1141 16.5751 29.1229C16.5617 29.1315 16.5481 29.1398 16.5346 29.1484C16.5211 29.1569 16.5076 29.1655 16.494 29.1738C16.4806 29.1821 16.467 29.1901 16.4535 29.1981C16.44 29.2062 16.4266 29.2143 16.413 29.2223C16.3995 29.2301 16.386 29.2377 16.3724 29.2454C16.359 29.2531 16.3455 29.2608 16.3319 29.2682C16.3184 29.2757 16.3049 29.2831 16.2914 29.2904L16.2508 29.3119C16.2373 29.319 16.2239 29.3261 16.2103 29.333C16.1968 29.3399 16.1833 29.3466 16.1697 29.3533C16.1562 29.36 16.1428 29.367 16.1292 29.3735C16.1158 29.38 16.1022 29.3862 16.0887 29.3926C16.0752 29.399 16.0617 29.4054 16.0481 29.4117C16.0347 29.4178 16.0211 29.4237 16.0076 29.4298L15.9671 29.4476L15.9265 29.4649C15.9131 29.4706 15.8995 29.4761 15.886 29.4816C15.8725 29.4871 15.859 29.4928 15.8455 29.4982C15.832 29.5035 15.8185 29.5085 15.8049 29.5136C15.7914 29.5188 15.778 29.5241 15.7644 29.5292C15.7509 29.5342 15.7374 29.539 15.7239 29.5438L15.6833 29.5582L15.6428 29.5722C15.6293 29.5767 15.6158 29.5811 15.6023 29.5854C15.5887 29.5899 15.5753 29.5944 15.5617 29.5987C15.5483 29.6029 15.5347 29.6068 15.5212 29.6109L15.4807 29.6231L15.4401 29.6347C15.4266 29.6385 15.4131 29.6421 15.3996 29.6457C15.386 29.6494 15.3726 29.6533 15.359 29.6567C15.3456 29.6602 15.332 29.6633 15.3185 29.6667C15.305 29.6701 15.2915 29.6734 15.278 29.6767L15.2374 29.6861L15.1969 29.695C15.1834 29.6979 15.1699 29.7011 15.1563 29.7039C15.1429 29.7067 15.1293 29.709 15.1158 29.7117C15.1023 29.7143 15.0888 29.717 15.0753 29.7195L15.0347 29.7269C15.0213 29.7292 15.0077 29.7314 14.9942 29.7336C14.9807 29.7358 14.9672 29.7383 14.9537 29.7404C14.9402 29.7425 14.9267 29.7442 14.9131 29.7462L14.8726 29.7519C14.8591 29.7538 14.8456 29.7556 14.8321 29.7573C14.8186 29.759 14.805 29.7604 14.7915 29.762C14.778 29.7635 14.7645 29.7652 14.751 29.7667C14.7375 29.7681 14.724 29.7693 14.7105 29.7706L14.6699 29.7742C14.6564 29.7754 14.6429 29.7768 14.6294 29.7778C14.6159 29.7788 14.6024 29.7795 14.5889 29.7804L14.5483 29.783L14.5078 29.7851C14.4943 29.7858 14.4808 29.7862 14.4673 29.7867C14.4537 29.7872 14.4402 29.7878 14.4267 29.7883C14.4132 29.7886 14.3997 29.7888 14.3862 29.789L14.3457 29.7895C14.3331 29.7896 14.3207 29.79 14.3081 29.79H14.2646H14.224H14.1835H14.143H14.1024H14.0619H14.0213H13.9808H13.9403H13.8997H13.8592H13.8187H13.7781H13.7376H13.6971H13.6565H13.616H13.5755H13.5349H13.4944H13.4539H13.4133H13.3728H13.3323H13.2917H13.2512H13.2107H13.1701H13.1296H13.089H13.0485H13.008H12.9674H12.9269H12.8863H12.8458H12.8053H12.7647H12.7242H12.6837H12.6431H12.6026H12.5621H12.5215H12.481H12.4405H12.3999H12.3594H12.3189H12.2783H12.2378H12.1973H12.1567H12.1162H12.0757H12.0351H11.9946H11.954H11.9135H11.873H11.8324H11.7919H11.7514H11.7108H11.6703H11.6298H11.5892H11.5487H11.5082H11.4676H11.4271H11.3866H11.346H11.3055H11.265H11.2244H11.1839H11.1433H11.1028H11.0623H11.0217H10.9812H10.9407H10.9001H10.8596H10.819H10.7785H10.738H10.6974H10.6569H10.6164H10.5758H10.5353H10.4948H10.4542H10.4137H10.3732H10.3326H10.2921H10.2516H10.211H10.1705H10.13H10.0894H10.0489H10.0083H9.9678H9.92728H9.88672H9.8462H9.80567H9.76512H9.7246H9.68404H9.64352H9.603H9.56244H9.52192H9.48136H9.44084H9.40032H9.35976H9.31924H9.27868H9.23816H9.19764H9.15708H9.11656H9.076H9.03548H8.99496H8.9544H8.91388H8.87332H8.8328H8.79228H8.75172H8.7112H8.67064H8.63012H8.5896H8.54904H8.50852H8.468H8.42744H8.38692H8.34637H8.30584H8.26532H8.22476H8.1377V20.8632C8.1377 20.8632 8.16765 20.8715 8.22476 20.886L8.26532 20.8962L8.30584 20.9062L8.34637 20.916L8.38692 20.9256L8.42744 20.9351L8.468 20.9445L8.50852 20.9537L8.54904 20.9628L8.5896 20.9717L8.63012 20.9805L8.67064 20.9893L8.7112 20.9979L8.75172 21.0064L8.79228 21.0147L8.8328 21.023L8.87332 21.0311L8.91388 21.0392L8.9544 21.0471L8.99496 21.055L9.03548 21.0628L9.076 21.0704L9.11656 21.078L9.15708 21.0854L9.19764 21.0928L9.23816 21.1001L9.27868 21.1072L9.31924 21.1143L9.35976 21.1212L9.40032 21.1281L9.44084 21.1349L9.48136 21.1416L9.52192 21.1482L9.56244 21.1547L9.603 21.1612L9.64352 21.1675L9.68404 21.1738L9.7246 21.1799L9.76512 21.186L9.80567 21.1919L9.8462 21.1978L9.88672 21.2036L9.92728 21.2093L9.9678 21.2149L10.0083 21.2205L10.0489 21.2259L10.0894 21.2313L10.13 21.2366L10.1705 21.2417L10.211 21.2468L10.2516 21.2518L10.2921 21.2568L10.3326 21.2615L10.3732 21.2663L10.4137 21.2709L10.4542 21.2755L10.4948 21.28L10.5353 21.2844L10.5758 21.2887L10.6164 21.2929L10.6569 21.297L10.6974 21.301L10.738 21.305L10.7785 21.3089L10.819 21.3126L10.8596 21.3164L10.9001 21.3199L10.9407 21.3234L10.9812 21.3269L11.0217 21.3302L11.0623 21.3335L11.1028 21.3366L11.1433 21.3397L11.1839 21.3426L11.2244 21.3455L11.265 21.3483L11.3055 21.351L11.346 21.3536L11.3866 21.3562L11.4271 21.3586L11.4676 21.361L11.5082 21.3632L11.5487 21.3654L11.5892 21.3675L11.6298 21.3694L11.6703 21.3713L11.7108 21.3731L11.7514 21.3748L11.7919 21.3765L11.8324 21.3779L11.873 21.3793L11.9135 21.3807L11.954 21.3819L11.9946 21.3831L12.0351 21.3841L12.0757 21.385L12.1162 21.386L12.1567 21.3867L12.1973 21.3873L12.2378 21.388L12.2783 21.3884L12.3189 21.3888L12.3594 21.3891L12.3999 21.3892L12.4405 21.3893L12.481 21.3893L12.5215 21.389L12.5621 21.3888L12.6026 21.3885L12.6431 21.3883L12.6837 21.3875L12.7242 21.3868L12.7647 21.3861L12.8053 21.3853L12.8458 21.3843L12.8863 21.383L12.9269 21.3818L12.9674 21.3805L13.008 21.3791L13.0485 21.3773L13.089 21.3756L13.1296 21.3737L13.1701 21.3719L13.2107 21.3697L13.2512 21.3673L13.2917 21.3649L13.3323 21.3626L13.3728 21.3599L13.4133 21.3569L13.4539 21.354L13.4944 21.3511L13.5349 21.3478L13.5755 21.3443L13.616 21.3408L13.6565 21.3372L13.6971 21.3334L13.7376 21.3292L13.7781 21.3251L13.8187 21.3209L13.8592 21.3164L13.8997 21.3116L13.9403 21.3068L13.9808 21.302L14.0213 21.2967L14.0619 21.2912L14.1024 21.2858L14.143 21.2802C14.1566 21.2782 14.1699 21.2761 14.1835 21.2741L14.224 21.2679L14.2646 21.2618L14.3051 21.2553C14.3187 21.2531 14.3321 21.2507 14.3457 21.2484L14.3862 21.2416C14.3996 21.2393 14.4134 21.2371 14.4267 21.2347C14.4404 21.2323 14.4537 21.2296 14.4673 21.2271L14.5078 21.2195L14.5483 21.2119L14.5889 21.2039C14.6025 21.2012 14.6159 21.1983 14.6294 21.1955L14.6699 21.1871L14.7105 21.1785C14.7242 21.1756 14.7374 21.1724 14.751 21.1694L14.7915 21.1601C14.805 21.157 14.8187 21.1541 14.8321 21.151C14.8458 21.1477 14.8591 21.1443 14.8726 21.141L14.9131 21.1309C14.9266 21.1276 14.9403 21.1243 14.9537 21.1209C14.9673 21.1174 14.9807 21.1138 14.9942 21.1102L15.0347 21.0993C15.0482 21.0956 15.0619 21.0921 15.0753 21.0883C15.0889 21.0846 15.1023 21.0806 15.1158 21.0768L15.1563 21.065C15.1698 21.061 15.1835 21.0571 15.1969 21.0531L15.2374 21.0406L15.278 21.0278C15.2914 21.0235 15.3052 21.0193 15.3185 21.015L15.359 21.0014L15.3996 20.9876C15.413 20.9829 15.4268 20.9784 15.4401 20.9737C15.4538 20.9689 15.4671 20.9638 15.4807 20.9589L15.5212 20.9441C15.5347 20.939 15.5483 20.9341 15.5617 20.9289C15.5754 20.9237 15.5887 20.9183 15.6023 20.913C15.6158 20.9077 15.6294 20.9024 15.6428 20.897L15.6833 20.8804L15.7239 20.8632C15.7373 20.8575 15.7511 20.852 15.7644 20.8461C15.7781 20.8401 15.7914 20.8339 15.8049 20.8278L15.8455 20.8094L15.886 20.7904C15.8996 20.784 15.9131 20.7774 15.9265 20.7708C15.94 20.7642 15.9538 20.7577 15.9671 20.751C15.9808 20.7441 15.9941 20.737 16.0076 20.73C16.0211 20.723 16.0348 20.7161 16.0481 20.7091C16.0618 20.7018 16.0752 20.6944 16.0887 20.687C16.1023 20.6796 16.1158 20.6721 16.1292 20.6646C16.1428 20.6569 16.1563 20.6492 16.1697 20.6415C16.1834 20.6336 16.1969 20.6255 16.2103 20.6175C16.2239 20.6094 16.2375 20.6013 16.2508 20.5931C16.2645 20.5847 16.2779 20.5761 16.2914 20.5676C16.3049 20.559 16.3186 20.5504 16.3319 20.5417C16.3456 20.5328 16.359 20.5236 16.3724 20.5145C16.386 20.5053 16.3996 20.4963 16.413 20.487C16.4267 20.4774 16.44 20.4677 16.4535 20.458C16.4671 20.4482 16.4807 20.4384 16.494 20.4286C16.5077 20.4184 16.5211 20.408 16.5346 20.3977C16.5482 20.3872 16.5618 20.3767 16.5751 20.3661C16.5888 20.3552 16.6023 20.3442 16.6157 20.3331C16.6293 20.3218 16.6428 20.3104 16.6562 20.299C16.6698 20.2873 16.6834 20.2757 16.6967 20.2639C16.7105 20.2517 16.7238 20.2393 16.7373 20.2269C16.7508 20.2144 16.7645 20.2019 16.7778 20.1892C16.7915 20.176 16.805 20.1627 16.8183 20.1493C16.832 20.1356 16.8455 20.1218 16.8589 20.108C16.8725 20.0938 16.8861 20.0797 16.8994 20.0653C16.9132 20.0503 16.9265 20.0351 16.9399 20.0199C16.9537 20.0043 16.9672 19.9885 16.9805 19.9727C16.9942 19.9564 17.0078 19.9401 17.021 19.9236C17.0347 19.9064 17.0483 19.8892 17.0615 19.8718C17.0754 19.8536 17.0888 19.8352 17.1021 19.8168C17.1159 19.7976 17.1293 19.7783 17.1426 19.7589C17.1564 19.7386 17.1699 19.7182 17.1831 19.6976C17.197 19.6761 17.2105 19.6543 17.2237 19.6324C17.2375 19.6093 17.2511 19.5861 17.2642 19.5627C17.2781 19.5378 17.2916 19.5128 17.3047 19.4876C17.3187 19.4606 17.3322 19.4335 17.3453 19.4061C17.3594 19.3767 17.3728 19.3471 17.3858 19.3172C17.3999 19.2847 17.4135 19.2521 17.4264 19.2191C17.4406 19.1827 17.4541 19.1461 17.4669 19.1091C17.4814 19.0672 17.4948 19.0248 17.5074 18.982C17.5222 18.9321 17.5356 18.8816 17.548 18.8307C17.5632 18.7675 17.5768 18.7036 17.5885 18.639C17.6059 18.5428 17.6193 18.4451 17.629 18.346C17.6426 18.208 17.6493 18.0674 17.6493 17.9246V13.01H17.5885H17.548H17.5074H17.4669H17.4264H17.3858H17.3453H17.3047H17.2642H17.2237H17.1831H17.1426H17.1021H17.0615H17.021H16.9805H16.9399H16.8994H16.8589H16.8183H16.7778H16.7373H16.6967H16.6562H16.6157H16.5751H16.5346H16.494H16.4535H16.413H16.3724H16.3319H16.2914H16.2508H16.2103H16.1697H16.1292H16.0887H16.0481H16.0076H15.9671H15.9265H15.886H15.8455H15.8049H15.7644H15.7239H15.6833H15.6428H15.6023H15.5617H15.5212H15.4807H15.4401H15.3996H15.359H15.3185H15.278H15.2374H15.1969H15.1563H15.1158H15.0753H15.0347H14.9942H14.9537H14.9131H14.8726H14.8321H14.7915H14.751H14.7105H14.6699H14.6294H14.5889H14.5483H14.5078H14.4673H14.4267H14.3862H14.3457H14.3051H14.2646H14.224H14.1835H14.143H14.1024H14.0619L14.0312 17.9246C14.0312 18.0016 14.0278 18.0773 14.0213 18.1515C14.0131 18.2461 13.9995 18.3383 13.9808 18.4279C13.9693 18.4833 13.9557 18.5377 13.9403 18.591C13.928 18.6335 13.9145 18.6753 13.8997 18.7164C13.8872 18.7514 13.8736 18.7858 13.8592 18.8198C13.8464 18.8501 13.8329 18.8799 13.8187 18.9093C13.8057 18.9361 13.7923 18.9626 13.7781 18.9885C13.7652 19.0125 13.7516 19.0359 13.7376 19.0592C13.7245 19.081 13.711 19.1026 13.6971 19.1238C13.6839 19.1438 13.6704 19.1634 13.6565 19.1828C13.6433 19.2013 13.6299 19.2196 13.616 19.2376C13.6028 19.2547 13.5893 19.2714 13.5755 19.288C13.5622 19.3039 13.5487 19.3196 13.5349 19.3351C13.5217 19.35 13.5082 19.3647 13.4944 19.3792C13.4811 19.3931 13.4676 19.407 13.4539 19.4206C13.4406 19.4337 13.4271 19.4467 13.4133 19.4594C13.4 19.4718 13.3865 19.4839 13.3728 19.4958C13.3595 19.5075 13.346 19.519 13.3323 19.5302C13.3189 19.5412 13.3054 19.552 13.2917 19.5627C13.2784 19.5731 13.2648 19.5833 13.2512 19.5934C13.2378 19.6032 13.2243 19.6129 13.2107 19.6224C13.1973 19.6318 13.1838 19.6409 13.1701 19.65C13.1567 19.6588 13.1432 19.6674 13.1296 19.676C13.1162 19.6843 13.1027 19.6925 13.089 19.7006C13.0757 19.7085 13.0621 19.7162 13.0485 19.7239C13.0351 19.7314 13.0216 19.7386 13.008 19.7459C12.9946 19.753 12.9811 19.76 12.9674 19.7668C12.954 19.7735 12.9405 19.7801 12.9269 19.7866C12.9135 19.793 12.9 19.7993 12.8863 19.8055C12.8729 19.8115 12.8595 19.8175 12.8458 19.8232C12.8325 19.8289 12.8189 19.8344 12.8053 19.8399C12.7919 19.8453 12.7783 19.8505 12.7647 19.8557C12.7513 19.8608 12.7379 19.8657 12.7242 19.8706C12.7108 19.8754 12.6973 19.8802 12.6837 19.8848C12.6703 19.8893 12.6567 19.8937 12.6431 19.898C12.6298 19.9022 12.6162 19.9063 12.6026 19.9103C12.5892 19.9143 12.5757 19.9183 12.5621 19.922C12.5486 19.9258 12.5351 19.9295 12.5215 19.933C12.5081 19.9365 12.4946 19.9399 12.481 19.9432C12.4676 19.9464 12.4541 19.9495 12.4405 19.9526C12.427 19.9556 12.4135 19.9586 12.3999 19.9614C12.3865 19.9642 12.373 19.967 12.3594 19.9696C12.346 19.9722 12.3324 19.9746 12.3189 19.977C12.3054 19.9794 12.2919 19.9817 12.2783 19.9839L12.2378 19.9902C12.2244 19.9921 12.2108 19.9939 12.1973 19.9957C12.1838 19.9975 12.1703 19.9993 12.1567 20.0009L12.1162 20.0054C12.1028 20.0068 12.0892 20.008 12.0757 20.0093C12.0622 20.0105 12.0487 20.0118 12.0351 20.0128C12.0217 20.0139 12.0082 20.0148 11.9946 20.0157C11.9811 20.0165 11.9676 20.0173 11.954 20.018C11.9405 20.0187 11.9271 20.0194 11.9135 20.02C11.9001 20.0205 11.8865 20.0208 11.873 20.0212L11.8324 20.0222C11.819 20.0224 11.8055 20.0225 11.7919 20.0226L11.7514 20.0226L11.7108 20.0223L11.6703 20.0218L11.6298 20.0209L11.5892 20.0198L11.5487 20.0185L11.5082 20.0168L11.4676 20.0149L11.4271 20.0129L11.3866 20.0104L11.346 20.0078L11.3055 20.0049L11.265 20.0017L11.2244 19.9983L11.1839 19.9946L11.1433 19.9907L11.1028 19.9865L11.0623 19.9821L11.0217 19.9775L10.9812 19.9725L10.9407 19.9674L10.9001 19.962L10.8596 19.9563L10.819 19.9504L10.7785 19.9443L10.738 19.9379L10.6974 19.9313L10.6569 19.9244L10.6164 19.9173L10.5758 19.91L10.5353 19.9024L10.4948 19.8946L10.4542 19.8866L10.4137 19.8783L10.3732 19.8698L10.3326 19.8611L10.2921 19.8521L10.2516 19.8429L10.211 19.8335L10.1705 19.8238L10.13 19.8139L10.0894 19.8038L10.0489 19.7936L10.0083 19.7829L9.9678 19.7722L9.92728 19.7612L9.88672 19.75L9.8462 19.7386L9.80567 19.7269L9.76512 19.7151L9.7246 19.703L9.68404 19.6907L9.64352 19.6782L9.603 19.6654L9.56244 19.6525L9.52192 19.6394L9.48136 19.6261L9.44084 19.6125L9.40032 19.5987L9.35976 19.5847L9.31924 19.5706L9.27868 19.5562L9.23816 19.5416L9.19764 19.5269L9.15708 19.5118L9.11656 19.4967L9.076 19.4813L9.03548 19.4657L8.99496 19.4499L8.9544 19.434L8.91388 19.4178L8.87332 19.4014L8.8328 19.3849L8.79228 19.3682L8.75172 19.3513L8.7112 19.3341L8.67064 19.3168L8.63012 19.2993L8.5896 19.2817L8.54904 19.2637L8.50852 19.2457L8.468 19.2275L8.42744 19.2091L8.38692 19.1905L8.34637 19.1717L8.30584 19.1528L8.26532 19.1336L8.22476 19.1144C8.19572 19.1004 8.16671 19.0864 8.1377 19.0723Z" + fill="#006CB9" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M19.8644 14.0838C19.8348 14.1079 19.8057 14.1321 19.7773 14.1567V8.61115C19.7773 8.31625 19.8074 8.02839 19.8644 7.75042C19.8766 7.69075 19.8902 7.63162 19.9049 7.57289C19.9175 7.52251 19.9311 7.47253 19.9455 7.42288C19.9583 7.37866 19.9718 7.33475 19.986 7.29114C19.999 7.25129 20.0124 7.21164 20.0265 7.17233C20.0396 7.1359 20.0531 7.09965 20.0671 7.06369C20.0802 7.03008 20.0937 6.99663 20.1076 6.96343C20.1208 6.93212 20.1343 6.90101 20.1481 6.87007C20.1613 6.84069 20.1749 6.81152 20.1887 6.78251C20.2019 6.75479 20.2154 6.72734 20.2292 6.69997C20.2424 6.67367 20.256 6.64758 20.2698 6.62159C20.283 6.59655 20.2966 6.57167 20.3103 6.54694C20.3236 6.52298 20.3371 6.49912 20.3508 6.47547C20.3642 6.4525 20.3776 6.42963 20.3914 6.40692C20.4047 6.385 20.4182 6.36321 20.4319 6.34156C20.4452 6.32048 20.4588 6.29964 20.4725 6.27883C20.4858 6.25847 20.4993 6.23824 20.513 6.21814C20.5264 6.19842 20.5398 6.1787 20.5535 6.15925C20.5668 6.14041 20.5805 6.12188 20.5941 6.10331C20.6074 6.08501 20.6209 6.06681 20.6346 6.04875C20.648 6.03096 20.6614 6.01314 20.6751 5.99558C20.6884 5.97854 20.7021 5.96173 20.7157 5.94489C20.7291 5.92825 20.7426 5.91168 20.7562 5.89528C20.7696 5.87908 20.7831 5.86295 20.7968 5.84699C20.8101 5.83141 20.8237 5.81602 20.8373 5.80064C20.8507 5.78539 20.8642 5.77007 20.8778 5.75499C20.8912 5.74025 20.9048 5.72568 20.9183 5.71115C20.9317 5.69678 20.9453 5.68251 20.9589 5.66832C20.9723 5.65432 20.9858 5.64033 20.9994 5.6265C21.0128 5.61295 21.0264 5.59956 21.0399 5.58618C21.0534 5.57286 21.0668 5.55944 21.0805 5.54629C21.0938 5.53345 21.1075 5.52091 21.121 5.50824C21.1345 5.4956 21.1479 5.48293 21.1616 5.47049C21.175 5.45829 21.1886 5.4463 21.2021 5.43427C21.2156 5.42227 21.229 5.41031 21.2426 5.39852C21.2561 5.38689 21.2696 5.37548 21.2832 5.36402C21.2966 5.35267 21.3101 5.34132 21.3237 5.3301C21.3372 5.31906 21.3507 5.30811 21.3642 5.29723C21.3777 5.28642 21.3912 5.27568 21.4048 5.26504C21.4182 5.25454 21.4318 5.24413 21.4453 5.23377C21.4588 5.2235 21.4723 5.21326 21.4858 5.20317C21.4993 5.19314 21.5128 5.18324 21.5264 5.17338C21.5399 5.16362 21.5534 5.15386 21.5669 5.14424C21.5804 5.13472 21.5939 5.12533 21.6075 5.11594C21.6209 5.10663 21.6344 5.09734 21.648 5.08819C21.6614 5.07914 21.675 5.07023 21.6886 5.06132C21.702 5.05244 21.7155 5.04356 21.7291 5.03486C21.7425 5.02625 21.7561 5.01785 21.7696 5.00937C21.7831 5.00094 21.7966 4.9924 21.8102 4.98413C21.8236 4.97593 21.8372 4.968 21.8507 4.95997C21.8642 4.95194 21.8776 4.94374 21.8912 4.93588C21.9046 4.92812 21.9183 4.92066 21.9318 4.91304C21.9453 4.90541 21.9587 4.89772 21.9723 4.89027C21.9857 4.88288 21.9993 4.87576 22.0128 4.86851C22.0263 4.86129 22.0398 4.85404 22.0534 4.84696L22.0939 4.82619L22.1345 4.80589C22.1479 4.79925 22.1614 4.79254 22.175 4.78603C22.1885 4.77956 22.202 4.77329 22.2156 4.76696C22.2291 4.76065 22.2425 4.75415 22.2561 4.74798C22.2695 4.74185 22.2831 4.73609 22.2966 4.73012C22.3101 4.72416 22.3236 4.71809 22.3372 4.71226C22.3506 4.7065 22.3641 4.70091 22.3777 4.69529L22.4182 4.67862C22.4317 4.67316 22.4452 4.6677 22.4588 4.66238C22.4722 4.6571 22.4858 4.65202 22.4993 4.64686C22.5128 4.64171 22.5262 4.63639 22.5398 4.63138C22.5533 4.62643 22.5669 4.62182 22.5804 4.61701C22.5939 4.6122 22.6073 4.60732 22.6209 4.60264C22.6344 4.59797 22.6479 4.59349 22.6615 4.58899L22.702 4.57574C22.7155 4.5714 22.729 4.56696 22.7425 4.56273C22.756 4.55856 22.7696 4.55463 22.7831 4.5506C22.7966 4.54653 22.8101 4.54236 22.8236 4.53843C22.8371 4.53453 22.8506 4.53091 22.8641 4.52715L22.9047 4.51607C22.9182 4.51247 22.9317 4.50881 22.9452 4.50536C22.9587 4.50194 22.9722 4.49872 22.9857 4.4954C22.9992 4.49208 23.0127 4.48862 23.0263 4.48543C23.0398 4.48228 23.0533 4.47933 23.0668 4.47632L23.1074 4.46744C23.1209 4.46456 23.1343 4.46158 23.1479 4.45883C23.1614 4.45609 23.1749 4.45361 23.1885 4.45101C23.202 4.4484 23.2154 4.44565 23.229 4.44318L23.2695 4.43613L23.3101 4.42939C23.3236 4.42718 23.337 4.42485 23.3506 4.42278C23.3641 4.42071 23.3776 4.41898 23.3911 4.41705C23.4046 4.41515 23.4181 4.41316 23.4317 4.41136L23.4722 4.40624L23.5127 4.40157C23.5263 4.40004 23.5397 4.39828 23.5533 4.39689C23.5668 4.3955 23.5803 4.39448 23.5938 4.39323L23.6344 4.38964C23.6479 4.38849 23.6614 4.38737 23.6749 4.38639C23.6884 4.3854 23.7019 4.38462 23.7154 4.38378C23.7289 4.38293 23.7424 4.38195 23.756 4.3812C23.7695 4.38046 23.783 4.37995 23.7965 4.37934L23.837 4.37778C23.8506 4.3773 23.864 4.37659 23.8776 4.37625C23.8911 4.37591 23.9046 4.37595 23.9181 4.37575C23.9317 4.37554 23.9452 4.3753 23.9587 4.37524L23.9766 4.375H23.9992H24.0398H24.0803H24.1208H24.1614H24.2019H24.2424H24.283H24.3235H24.364H24.4046H24.4451H24.4856H24.5262H24.5667H24.6073H24.6478H24.6884H24.7289H24.7694H24.81H24.8505H24.891H24.9316H24.9721H25.0127H25.0532H25.0937H25.1343H25.1748H25.2153H25.2559H25.2964H25.3369H25.3775H25.418H25.4585H25.4991H25.5396H25.5802H25.6207H25.6613H25.7018H25.7423H25.7829H25.8234H25.8639H25.9045H25.945H25.9855H26.0261H26.0666H26.1072H26.1477H26.1882H26.2288H26.2693H26.3099H26.3504H26.3909H26.4315H26.472H26.5125H26.5531H26.5936H26.6342H26.6747H26.7152H26.7558H26.7963H26.8368H26.8774H26.9179H26.9584H26.999H27.0395H27.0801H27.1206H27.1612H27.2017H27.2422H27.2828H27.3233H27.3638H27.4044H27.4449H27.4854H27.526H27.5665H27.6071H27.6476H27.6882H27.7287H27.7692H27.8098H27.8503H27.8908H27.9314H27.9719H28.0124H28.053H28.0935H28.1341H28.1746H28.2151H28.2557H28.2962H28.3367H28.3773H28.4178H28.4583H28.4989H28.5394H28.58H28.6205H28.661H28.7016H28.7421H28.7827H28.8232H28.8637H28.9043H28.9448H28.9853H29.0259H29.0664H29.107H29.1475H29.1881H29.2286H29.2691H29.3097H29.3502H29.3907H29.4313H29.4718H29.5123H29.5529H29.5934H29.6339H29.6745H29.715H29.7556H29.7961H29.8366H29.8772H29.9177H29.9582H29.9988H30.0393H30.0799H30.1204H30.1548V25.5542C30.1548 25.7384 30.1431 25.9197 30.1204 26.0977C30.1095 26.1834 30.0958 26.2682 30.0799 26.3522C30.0678 26.4161 30.0543 26.4796 30.0393 26.5425C30.0267 26.5954 30.0133 26.6481 29.9988 26.7003C29.986 26.746 29.9725 26.7915 29.9582 26.8366C29.9454 26.8775 29.9318 26.918 29.9177 26.9583C29.9047 26.9956 29.8912 27.0327 29.8772 27.0696C29.8641 27.1039 29.8506 27.1381 29.8366 27.172C29.8235 27.204 29.81 27.2358 29.7961 27.2674C29.7829 27.2973 29.7694 27.3271 29.7556 27.3567C29.7423 27.3849 29.7288 27.413 29.715 27.4409C29.7018 27.4676 29.6883 27.4942 29.6745 27.5207C29.6612 27.5461 29.6477 27.5715 29.6339 27.5966C29.6206 27.621 29.6072 27.6452 29.5934 27.6692C29.5801 27.6924 29.5666 27.7154 29.5529 27.7382C29.5396 27.7604 29.526 27.7824 29.5123 27.8043C29.499 27.8256 29.4855 27.8469 29.4718 27.868C29.4584 27.8886 29.445 27.9093 29.4313 27.9296C29.4179 27.9494 29.4044 27.969 29.3907 27.9885C29.3774 28.0076 29.3638 28.0265 29.3502 28.0453C29.3368 28.0639 29.3233 28.0824 29.3097 28.1007C29.2963 28.1185 29.2828 28.1362 29.2691 28.1539C29.2558 28.1712 29.2422 28.1883 29.2286 28.2053C29.2151 28.2222 29.2017 28.239 29.1881 28.2556C29.1747 28.2718 29.1611 28.2879 29.1475 28.3039C29.1341 28.3197 29.1206 28.3353 29.107 28.3509C29.0935 28.3663 29.0801 28.3817 29.0664 28.3969C29.0531 28.4118 29.0394 28.4264 29.0259 28.441C29.0124 28.4556 28.999 28.4702 28.9853 28.4845C28.9719 28.4986 28.9584 28.5124 28.9448 28.5263C28.9314 28.54 28.9179 28.5536 28.9043 28.5672C28.8909 28.5806 28.8774 28.5939 28.8637 28.6071C28.8503 28.62 28.8368 28.6329 28.8232 28.6457C28.8097 28.6584 28.7963 28.6712 28.7827 28.6837C28.7693 28.696 28.7557 28.7081 28.7421 28.7202C28.7286 28.7323 28.7152 28.7445 28.7016 28.7564C28.6882 28.7681 28.6746 28.7795 28.661 28.791C28.6475 28.8025 28.6341 28.814 28.6205 28.8253C28.6071 28.8364 28.5935 28.8472 28.58 28.8582C28.5665 28.8691 28.5531 28.8801 28.5394 28.8909C28.526 28.9014 28.5124 28.9117 28.4989 28.9221C28.4854 28.9325 28.472 28.9429 28.4583 28.9532C28.445 28.9632 28.4314 28.9731 28.4178 28.983C28.4043 28.9928 28.3909 29.0028 28.3773 29.0125C28.3639 29.0221 28.3503 29.0314 28.3367 29.0408C28.3232 29.0502 28.3098 29.0597 28.2962 29.069C28.2828 29.0781 28.2692 29.0869 28.2557 29.0959C28.2422 29.1048 28.2288 29.1139 28.2151 29.1227C28.2017 29.1314 28.1881 29.1397 28.1746 29.1482C28.1611 29.1567 28.1477 29.1653 28.1341 29.1737C28.1206 29.1819 28.107 29.1899 28.0935 29.198C28.08 29.206 28.0666 29.2142 28.053 29.2221C28.0395 29.2299 28.026 29.2376 28.0124 29.2453L27.9719 29.2681L27.9314 29.2902L27.8908 29.3118C27.8773 29.3188 27.8639 29.326 27.8503 29.3329C27.8368 29.3398 27.8233 29.3465 27.8098 29.3532C27.7963 29.3599 27.7828 29.3668 27.7692 29.3734C27.7558 29.3799 27.7422 29.3861 27.7287 29.3925C27.7152 29.3988 27.7017 29.4053 27.6882 29.4115C27.6747 29.4177 27.6611 29.4236 27.6476 29.4297L27.6071 29.4475L27.5665 29.4648L27.526 29.4815C27.5125 29.487 27.499 29.4927 27.4854 29.4981C27.472 29.5034 27.4584 29.5084 27.4449 29.5135C27.4314 29.5187 27.418 29.524 27.4044 29.5291C27.3909 29.5341 27.3774 29.5389 27.3638 29.5437L27.3233 29.5581L27.2828 29.5721C27.2693 29.5766 27.2557 29.5809 27.2422 29.5853C27.2287 29.5898 27.2152 29.5944 27.2017 29.5986C27.1883 29.6028 27.1747 29.6067 27.1612 29.6108L27.1206 29.623L27.0801 29.6346C27.0666 29.6384 27.0531 29.642 27.0395 29.6456C27.026 29.6493 27.0126 29.6532 26.999 29.6567C26.9855 29.6602 26.972 29.6633 26.9584 29.6666C26.945 29.67 26.9315 29.6734 26.9179 29.6766L26.8774 29.686L26.8368 29.6949C26.8233 29.6978 26.8099 29.701 26.7963 29.7038C26.7829 29.7066 26.7693 29.709 26.7558 29.7116L26.7152 29.7194L26.6747 29.7268C26.6612 29.7292 26.6477 29.7313 26.6342 29.7336C26.6206 29.7358 26.6072 29.7382 26.5936 29.7403C26.5802 29.7424 26.5666 29.7442 26.5531 29.7462L26.5125 29.7519C26.499 29.7537 26.4855 29.7556 26.472 29.7573C26.4585 29.759 26.445 29.7604 26.4315 29.762C26.418 29.7635 26.4045 29.7652 26.3909 29.7666C26.3775 29.7681 26.3639 29.7693 26.3504 29.7706L26.3099 29.7742C26.2963 29.7754 26.2829 29.7768 26.2693 29.7778C26.2559 29.7788 26.2423 29.7795 26.2288 29.7804L26.1882 29.783L26.1477 29.7851C26.1342 29.7858 26.1207 29.7862 26.1072 29.7867C26.0936 29.7872 26.0802 29.7878 26.0666 29.7882C26.0532 29.7886 26.0396 29.7887 26.0261 29.789L25.9855 29.7895C25.9729 29.7896 25.9604 29.79 25.9477 29.79H25.945H25.9045H25.8639H25.8234H25.7829H25.7423H25.7018H25.6613H25.6207H25.5802H25.5396H25.4991H25.4585H25.418H25.3775H25.3369H25.2964H25.2559H25.2153H25.1748H25.1343H25.0937H25.0532H25.0127H24.9721H24.9316H24.891H24.8505H24.81H24.7694H24.7289H24.6884H24.6478H24.6073H24.5667H24.5262H24.4856H24.4451H24.4046H24.364H24.3235H24.283H24.2424H24.2019H24.1614H24.1208H24.0803H24.0398H23.9992H23.9587H23.9181H23.8776H23.837H23.7965H23.756H23.7154H23.6749H23.6344H23.5938H23.5533H23.5127H23.4722H23.4317H23.3911H23.3506H23.3101H23.2695H23.229H23.1885H23.1479H23.1074H23.0668H23.0263H22.9857H22.9452H22.9047H22.8641H22.8236H22.7831H22.7425H22.702H22.6615H22.6209H22.5804H22.5398H22.4993H22.4588H22.4182H22.3777H22.3372H22.2966H22.2561H22.2156H22.175H22.1345H22.0939H22.0534H22.0128H21.9723H21.9318H21.8912H21.8507H21.8102H21.7696H21.7291H21.6886H21.648H21.6075H21.5669H21.5264H21.4858H21.4453H21.4048H21.3642H21.3237H21.2832H21.2426H21.2021H21.1616H21.121H21.0805H21.0399H20.9994H20.9589H20.9183H20.8778H20.8373H20.7968H20.7562H20.7157H20.6751H20.6346H20.5941H20.5535H20.513H20.4725H20.4319H20.3914H20.3508H20.3103H20.2698H20.2292H20.1887H20.1481H20.1076H20.0671H20.0265H19.986H19.9455H19.9049H19.8644H19.7773V20.01C19.8057 20.0345 19.8348 20.0588 19.8644 20.0828C19.8778 20.0937 19.8913 20.1046 19.9049 20.1153C19.9183 20.126 19.9318 20.1366 19.9455 20.1471C19.9589 20.1575 19.9723 20.1679 19.986 20.1782C19.9994 20.1882 20.013 20.1982 20.0265 20.2081C20.0399 20.218 20.0535 20.2277 20.0671 20.2375C20.0805 20.2471 20.094 20.2566 20.1076 20.2662L20.1481 20.2941C20.1615 20.3032 20.1751 20.3123 20.1887 20.3213L20.2292 20.3479C20.2426 20.3567 20.2561 20.3654 20.2698 20.374C20.2831 20.3825 20.2967 20.3909 20.3103 20.3993L20.3508 20.4241C20.3643 20.4323 20.3778 20.4404 20.3914 20.4484C20.4048 20.4564 20.4183 20.4642 20.4319 20.472L20.4725 20.4952L20.513 20.5178L20.5535 20.5399C20.567 20.5471 20.5804 20.5545 20.5941 20.5617C20.6074 20.5687 20.621 20.5757 20.6346 20.5827L20.6751 20.6034L20.7157 20.6237L20.7562 20.6434C20.7697 20.6499 20.7831 20.6565 20.7968 20.6629L20.8373 20.6818L20.8778 20.7004L20.9183 20.7185L20.9589 20.7363L20.9994 20.7538L21.0399 20.7708L21.0805 20.7875L21.121 20.8038L21.1616 20.8199L21.2021 20.8354C21.2156 20.8406 21.229 20.8458 21.2426 20.8509L21.2832 20.8657C21.2967 20.8707 21.3101 20.8757 21.3237 20.8806C21.3371 20.8854 21.3507 20.8901 21.3642 20.8948L21.4048 20.909L21.4453 20.9227C21.4588 20.9272 21.4722 20.9319 21.4858 20.9364C21.4992 20.9408 21.5129 20.9451 21.5264 20.9494L21.5669 20.9625L21.6075 20.9751L21.648 20.9876L21.6886 20.9996L21.7291 21.0115L21.7696 21.0231L21.8102 21.0345L21.8507 21.0456L21.8912 21.0565L21.9318 21.0672L21.9723 21.0776L22.0128 21.0879L22.0534 21.0978L22.0939 21.1077L22.1345 21.1171L22.175 21.1264L22.2156 21.1355L22.2561 21.1444L22.2966 21.1532L22.3372 21.1616L22.3777 21.17L22.4182 21.178L22.4588 21.186L22.4993 21.1937L22.5398 21.2012L22.5804 21.2087L22.6209 21.2158L22.6615 21.2228L22.702 21.2296L22.7425 21.2363L22.7831 21.2429L22.8236 21.2491L22.8641 21.2553L22.9047 21.2613L22.9452 21.2671L22.9857 21.2728L23.0263 21.2782L23.0668 21.2836L23.1074 21.2888L23.1479 21.2938L23.1885 21.2988L23.229 21.3035L23.2695 21.3081L23.3101 21.3126L23.3506 21.3168L23.3911 21.321L23.4317 21.3251L23.4722 21.3289L23.5127 21.3328L23.5533 21.3363L23.5938 21.3398L23.6344 21.3433L23.6749 21.3464L23.7154 21.3495L23.756 21.3526L23.7965 21.3554L23.837 21.3581L23.8776 21.3608L23.9181 21.3632L23.9587 21.3656L23.9992 21.3679L24.0398 21.3699L24.0803 21.3719L24.1208 21.3739L24.1614 21.3756L24.2019 21.3773L24.2424 21.3789L24.283 21.3803L24.3235 21.3816L24.364 21.3829L24.4046 21.384L24.4451 21.385L24.4856 21.386L24.5262 21.3867L24.5667 21.3874L24.6073 21.3881L24.6478 21.3885L24.6884 21.3889L24.7289 21.3892L24.7694 21.3894L24.81 21.3895L24.8505 21.3895L24.891 21.3894L24.9316 21.3892L24.9721 21.389L25.0127 21.3886L25.0532 21.3881L25.0937 21.3876L25.1343 21.387L25.1748 21.3862L25.2153 21.3853L25.2559 21.3845L25.2964 21.3834L25.3369 21.3823L25.3775 21.3812L25.418 21.38L25.4585 21.3785L25.4991 21.3771L25.5396 21.3757L25.5802 21.374L25.6207 21.3723L25.6613 21.3706L25.6811 21.3698L25.7018 21.3688L25.7423 21.3668L25.7829 21.3646L25.8234 21.3624L25.8639 21.3599L25.9045 21.3574L25.945 21.3547L25.9855 21.3519L26.0261 21.3489L26.0666 21.3458L26.1072 21.3426L26.1477 21.3393L26.1882 21.3358L26.2288 21.3322L26.2693 21.3285L26.3099 21.3246L26.3504 21.3207L26.3909 21.3166L26.4315 21.3125L26.472 21.3082L26.5125 21.3038L26.5531 21.2993L26.5936 21.2947L26.6342 21.29L26.6747 21.2852L26.7152 21.2803L26.7558 21.2753L26.7963 21.2701L26.8368 21.2649L26.8774 21.2597L26.9179 21.2542L26.9584 21.2487L26.999 21.2431L27.0395 21.2374L27.0801 21.2317L27.1206 21.2258L27.1612 21.2198L27.2017 21.2137L27.2422 21.2076L27.2828 21.2014L27.3233 21.1951L27.3638 21.1887L27.4044 21.1822L27.4449 21.1756L27.4854 21.169L27.526 21.1622L27.5665 21.1554L27.6071 21.1485L27.6476 21.1415L27.6882 21.1344L27.7287 21.1273L27.7692 21.12L27.8098 21.1127L27.8503 21.1053L27.8908 21.0979L27.9314 21.0903L27.9719 21.0826L28.0124 21.0749L28.053 21.0671L28.0935 21.0593L28.1341 21.0513L28.1746 21.0433L28.2151 21.0352L28.2557 21.027L28.2962 21.0187L28.3367 21.0103L28.3773 21.0019L28.4178 20.9934L28.4583 20.9848L28.4989 20.976L28.5394 20.9673L28.58 20.9584L28.6205 20.9495L28.661 20.9404L28.7016 20.9313L28.7421 20.9221L28.7827 20.9127L28.8232 20.9033L28.8637 20.8937L28.9043 20.884L28.9448 20.8741L28.9851 20.8641V19.0723L28.9448 19.0925L28.9043 19.1127L28.8637 19.1326L28.8232 19.1524L28.7827 19.1721L28.7421 19.1915L28.7016 19.2107L28.661 19.2297L28.6205 19.2485L28.58 19.2672L28.5394 19.2856L28.4989 19.3038L28.4583 19.3217L28.4178 19.3395L28.3773 19.3571L28.3367 19.3744L28.2962 19.3916L28.2557 19.4085L28.2151 19.4252L28.1746 19.4417L28.1341 19.4579L28.0935 19.474L28.053 19.4897L28.0124 19.5053L27.9719 19.5207L27.9314 19.5358L27.8908 19.5506L27.8503 19.5653L27.8098 19.5797L27.7692 19.5939L27.7287 19.6079L27.6882 19.6216L27.6476 19.6351L27.6071 19.6484L27.5665 19.6614L27.526 19.6742L27.4854 19.6868L27.4449 19.6991L27.4044 19.7112L27.3638 19.7231L27.3233 19.7347L27.2828 19.7461L27.2422 19.7573L27.2017 19.7682L27.1612 19.7789L27.1206 19.7894L27.0801 19.7996L27.0395 19.8096L26.999 19.8195L26.9584 19.829L26.9179 19.8383L26.8774 19.8474L26.8368 19.8563L26.7963 19.8649L26.7558 19.8733L26.7152 19.8815L26.6747 19.8895L26.6342 19.8972L26.5936 19.9047L26.5531 19.912L26.5125 19.919L26.472 19.9258L26.4315 19.9324L26.3909 19.9389L26.3504 19.945L26.3099 19.9509L26.2693 19.9567L26.2288 19.9622L26.1882 19.9674L26.1477 19.9725L26.1072 19.9774L26.0666 19.982L26.0261 19.9864L25.9855 19.9906L25.945 19.9946L25.9045 19.9984L25.8639 20.002L25.8234 20.0053L25.7988 20.0072L25.7829 20.0083L25.7423 20.0111L25.7018 20.0138L25.6613 20.016L25.6207 20.0181L25.5802 20.02L25.5396 20.0213L25.4991 20.0226L25.4585 20.0235L25.418 20.0241L25.3775 20.0246C25.3639 20.0247 25.3505 20.0245 25.3369 20.0245L25.2964 20.0243L25.2559 20.0237L25.2153 20.0228L25.1748 20.0218C25.1612 20.0213 25.1478 20.0207 25.1343 20.0201C25.1208 20.0196 25.1072 20.0191 25.0937 20.0185L25.0532 20.0162L25.0127 20.0138L24.9721 20.0109L24.9316 20.0077C24.9181 20.0066 24.9045 20.0056 24.891 20.0044C24.8774 20.0031 24.864 20.0016 24.8505 20.0003L24.81 19.9962L24.7694 19.9914L24.7289 19.9865L24.6884 19.9811L24.6478 19.9753C24.6343 19.9733 24.6207 19.9714 24.6073 19.9692C24.5937 19.9671 24.5802 19.9648 24.5667 19.9625C24.5532 19.9602 24.5396 19.9581 24.5262 19.9557C24.5126 19.9533 24.4992 19.9506 24.4856 19.9481C24.4721 19.9455 24.4585 19.9431 24.4451 19.9404C24.4315 19.9377 24.4181 19.9347 24.4046 19.9319C24.3911 19.929 24.3775 19.9263 24.364 19.9233C24.3504 19.9203 24.337 19.9171 24.3235 19.9139L24.283 19.9043C24.2694 19.901 24.2559 19.8975 24.2424 19.894C24.2289 19.8905 24.2153 19.8871 24.2019 19.8835C24.1883 19.8798 24.1748 19.876 24.1614 19.8722C24.1478 19.8684 24.1342 19.8645 24.1208 19.8605C24.1072 19.8565 24.0937 19.8523 24.0803 19.8482C24.0667 19.844 24.0532 19.8398 24.0398 19.8354C24.0262 19.831 24.0127 19.8265 23.9992 19.8219C23.9857 19.8174 23.9721 19.8128 23.9587 19.8081C23.9451 19.8033 23.9316 19.7983 23.9181 19.7934C23.9046 19.7884 23.891 19.7835 23.8776 19.7784C23.864 19.7731 23.8505 19.7677 23.837 19.7623C23.8235 19.7569 23.8099 19.7516 23.7965 19.7461C23.7829 19.7404 23.7695 19.7345 23.756 19.7287C23.7424 19.7228 23.7288 19.717 23.7154 19.711C23.7018 19.7049 23.6884 19.6985 23.6749 19.6922C23.6613 19.6858 23.6478 19.6794 23.6344 19.6729C23.6208 19.6663 23.6072 19.6595 23.5938 19.6527C23.5802 19.6458 23.5667 19.6388 23.5533 19.6317C23.5397 19.6246 23.5261 19.6175 23.5127 19.6101C23.4991 19.6027 23.4857 19.5949 23.4722 19.5872C23.4587 19.5795 23.4451 19.5718 23.4317 19.5638C23.418 19.5557 23.4046 19.5474 23.3911 19.5392C23.3775 19.5307 23.364 19.5221 23.3506 19.5135C23.337 19.5048 23.3234 19.4962 23.3101 19.4872C23.2964 19.4781 23.283 19.4686 23.2695 19.4592C23.2559 19.4498 23.2424 19.4401 23.229 19.4304C23.2154 19.4206 23.2018 19.4107 23.1885 19.4006C23.1748 19.3902 23.1613 19.3796 23.1479 19.369C23.1343 19.3582 23.1208 19.3474 23.1074 19.3364C23.0938 19.3252 23.0802 19.314 23.0668 19.3025C23.0531 19.2907 23.0397 19.2787 23.0263 19.2666C23.0126 19.2543 22.9991 19.2419 22.9857 19.2293C22.9721 19.2164 22.9585 19.2035 22.9452 19.1904C22.9316 19.1769 22.918 19.1635 22.9047 19.1498C22.8909 19.1356 22.8775 19.1212 22.8641 19.1067C22.8504 19.0918 22.8369 19.0767 22.8236 19.0615C22.8099 19.0458 22.7964 19.03 22.7831 19.014C22.7694 18.9976 22.7558 18.9809 22.7425 18.9641C22.7288 18.9467 22.7152 18.929 22.702 18.9112C22.6883 18.8928 22.6747 18.8742 22.6615 18.8553C22.6477 18.8357 22.6341 18.8159 22.6209 18.7958C22.6071 18.7748 22.5936 18.7536 22.5804 18.7321C22.5665 18.7096 22.553 18.6868 22.5398 18.6638C22.5259 18.6396 22.5125 18.615 22.4993 18.5902C22.4853 18.5638 22.4719 18.5371 22.4588 18.5101C22.4448 18.4814 22.4313 18.4523 22.4182 18.4228C22.4042 18.3911 22.3906 18.3592 22.3777 18.3266C22.3634 18.2907 22.3501 18.254 22.3372 18.217C22.3228 18.176 22.3093 18.1344 22.2966 18.0921C22.282 18.0431 22.2684 17.9933 22.2561 17.9426C22.2407 17.8795 22.2273 17.8151 22.2156 17.7495C22.1981 17.6522 22.1845 17.5523 22.175 17.4496C22.1641 17.331 22.1585 17.2089 22.1585 17.0832C22.1585 16.9574 22.1641 16.8352 22.175 16.7165C22.1845 16.6138 22.1981 16.5138 22.2156 16.4165C22.2273 16.3509 22.2407 16.2864 22.2561 16.2233C22.2684 16.1726 22.282 16.1228 22.2966 16.0737C22.3093 16.0314 22.3228 15.9898 22.3372 15.9487C22.3501 15.9117 22.3634 15.8751 22.3777 15.8391C22.3906 15.8065 22.4042 15.7745 22.4182 15.7428C22.4313 15.7133 22.4448 15.6842 22.4588 15.6555C22.4719 15.6285 22.4853 15.6017 22.4993 15.5754C22.5125 15.5506 22.5259 15.526 22.5398 15.5017C22.553 15.4787 22.5665 15.4559 22.5804 15.4334C22.5936 15.4119 22.6071 15.3907 22.6209 15.3697C22.6341 15.3496 22.6477 15.3298 22.6615 15.3101C22.6747 15.2912 22.6883 15.2726 22.702 15.2542C22.7152 15.2363 22.7288 15.2188 22.7425 15.2013C22.7558 15.1845 22.7694 15.1678 22.7831 15.1514C22.7964 15.1353 22.8099 15.1195 22.8236 15.1039C22.8369 15.0886 22.8504 15.0735 22.8641 15.0586C22.8775 15.0441 22.8909 15.0296 22.9047 15.0155C22.918 15.0018 22.9316 14.9883 22.9452 14.9749C22.9585 14.9618 22.9721 14.9488 22.9857 14.936C22.9991 14.9234 23.0126 14.9109 23.0263 14.8986C23.0397 14.8865 23.0531 14.8745 23.0668 14.8627C23.0802 14.8512 23.0938 14.8401 23.1074 14.8288C23.1208 14.8178 23.1343 14.8069 23.1479 14.7962C23.1613 14.7855 23.1748 14.7749 23.1885 14.7646C23.2018 14.7545 23.2154 14.7446 23.229 14.7347C23.2424 14.725 23.2559 14.7154 23.2695 14.7059C23.283 14.6965 23.2964 14.6871 23.3101 14.6779C23.3234 14.669 23.337 14.6603 23.3506 14.6516C23.364 14.6429 23.3775 14.6344 23.3911 14.626C23.4046 14.6177 23.418 14.6094 23.4317 14.6013C23.4451 14.5934 23.4587 14.5856 23.4722 14.5779C23.4857 14.5702 23.4991 14.5625 23.5127 14.555C23.5261 14.5476 23.5397 14.5405 23.5533 14.5334C23.5667 14.5263 23.5802 14.5193 23.5938 14.5123C23.6072 14.5055 23.6208 14.4988 23.6344 14.4921C23.6478 14.4856 23.6613 14.4792 23.6749 14.4729C23.6884 14.4666 23.7018 14.4602 23.7154 14.4541C23.7288 14.448 23.7424 14.4422 23.756 14.4364C23.7695 14.4305 23.7829 14.4246 23.7965 14.419C23.8099 14.4134 23.8235 14.4081 23.837 14.4027C23.8505 14.3973 23.864 14.3919 23.8776 14.3867C23.891 14.3815 23.9046 14.3766 23.9181 14.3717C23.9316 14.3667 23.9451 14.3617 23.9587 14.3569C23.9721 14.3522 23.9857 14.3476 23.9992 14.3431C24.0127 14.3385 24.0262 14.334 24.0398 14.3296C24.0532 14.3252 24.0667 14.321 24.0803 14.3168C24.0937 14.3127 24.1072 14.3085 24.1208 14.3045L24.1614 14.2928C24.1748 14.289 24.1883 14.2852 24.2019 14.2815C24.2153 14.2779 24.2289 14.2744 24.2424 14.2709C24.2559 14.2675 24.2694 14.264 24.283 14.2606L24.3235 14.2511C24.337 14.2479 24.3504 14.2447 24.364 14.2417C24.3775 14.2387 24.3911 14.2359 24.4046 14.2331C24.4181 14.2302 24.4315 14.2272 24.4451 14.2245C24.4585 14.2218 24.4721 14.2194 24.4856 14.2169C24.4992 14.2144 24.5126 14.2117 24.5262 14.2092C24.5396 14.2068 24.5532 14.2047 24.5667 14.2025C24.5802 14.2002 24.5937 14.1979 24.6073 14.1957L24.6478 14.1897L24.6884 14.1838L24.7289 14.1784L24.7694 14.1735L24.81 14.1687L24.8505 14.1646C24.864 14.1633 24.8774 14.1618 24.891 14.1605C24.9045 14.1593 24.9181 14.1583 24.9316 14.1572L24.9721 14.154L25.0127 14.1512L25.0532 14.1487L25.0937 14.1464C25.1072 14.1458 25.1208 14.1453 25.1343 14.1448C25.1478 14.1442 25.1612 14.1436 25.1748 14.1431L25.2153 14.1421L25.2559 14.1412L25.2964 14.1406L25.3369 14.1405C25.3505 14.1404 25.3639 14.1402 25.3775 14.1403L25.418 14.1408L25.4585 14.1414L25.4991 14.1423L25.5396 14.1436L25.5802 14.145L25.6207 14.1469L25.6613 14.1489L25.7018 14.1511L25.7423 14.1538L25.7829 14.1566L25.7988 14.1577L25.8234 14.1596L25.8639 14.1629L25.9045 14.1664L25.945 14.1701L25.9855 14.174L26.0261 14.1782L26.0666 14.1826L26.1072 14.1871L26.1477 14.1919L26.1882 14.1969L26.2288 14.2021L26.2693 14.2075L26.3099 14.2132L26.3504 14.219L26.3909 14.2251L26.4315 14.2314L26.472 14.238L26.5125 14.2447L26.5531 14.2517L26.5936 14.2589L26.6342 14.2663L26.6747 14.274L26.7152 14.2819L26.7558 14.29L26.7963 14.2983L26.8368 14.3069L26.8774 14.3157L26.9179 14.3247L26.9584 14.3339L26.999 14.3434L27.0395 14.3532L27.0801 14.3631L27.1206 14.3733L27.1612 14.3837L27.2017 14.3944L27.2422 14.4053L27.2828 14.4164L27.3233 14.4278L27.3638 14.4394L27.4044 14.4513L27.4449 14.4634L27.4854 14.4757L27.526 14.4883L27.5665 14.501L27.6071 14.5141L27.6476 14.5274L27.6882 14.5409L27.7287 14.5546L27.7692 14.5686L27.8098 14.5828L27.8503 14.5973L27.8908 14.612L27.9314 14.6269L27.9719 14.6421L28.0124 14.6575L28.053 14.6732L28.0935 14.689L28.1341 14.7051L28.1746 14.7215L28.2151 14.738L28.2557 14.7548L28.2962 14.7719L28.3367 14.7891L28.3773 14.8066L28.4178 14.8243L28.4583 14.8422L28.4989 14.8603L28.5394 14.8787L28.58 14.8973L28.6205 14.916L28.661 14.935L28.7016 14.9542L28.7421 14.9736L28.7827 14.9932L28.8232 15.013L28.8637 15.033L28.9043 15.0531L28.9448 15.0735L28.9851 15.0939V13.3008L28.9448 13.2908L28.9043 13.2809L28.8637 13.2712L28.8232 13.2617L28.7827 13.2522L28.7421 13.2429L28.7016 13.2336L28.661 13.2245L28.6205 13.2155L28.58 13.2065L28.5394 13.1976L28.4989 13.1889L28.4583 13.1802L28.4178 13.1716L28.3773 13.1631L28.3367 13.1546L28.2962 13.1463L28.2557 13.138L28.2151 13.1298L28.1746 13.1217L28.1341 13.1137L28.0935 13.1057L28.053 13.0979L28.0124 13.0901L27.9719 13.0824L27.9314 13.0747L27.8908 13.0672L27.8503 13.0597L27.8098 13.0523L27.7692 13.045L27.7287 13.0378L27.6882 13.0307L27.6476 13.0236L27.6071 13.0166L27.5665 13.0097L27.526 13.0029L27.4854 12.9962L27.4449 12.9895L27.4044 12.983L27.3638 12.9765L27.3233 12.9701L27.2828 12.9638L27.2422 12.9576L27.2017 12.9514L27.1612 12.9454L27.1206 12.9394L27.0801 12.9336L27.0395 12.9278L26.999 12.9222L26.9584 12.9166L26.9179 12.9111L26.8774 12.9057L26.8368 12.9004L26.7963 12.8952L26.7558 12.8901L26.7152 12.8851L26.6747 12.8802L26.6342 12.8754L26.5936 12.8707L26.5531 12.8661L26.5125 12.8616L26.472 12.8573L26.4315 12.853L26.3909 12.8489L26.3504 12.8448L26.3099 12.8409L26.2693 12.837L26.2288 12.8334L26.1882 12.8298L26.1477 12.8263L26.1072 12.823L26.0666 12.8198L26.0261 12.8168L25.9855 12.8138L25.945 12.811L25.9045 12.8083L25.8639 12.8058L25.8234 12.8034L25.7829 12.8011L25.7423 12.799L25.7018 12.797L25.6811 12.796L25.6613 12.7952L25.6207 12.7935L25.5802 12.7918L25.5396 12.7901L25.4991 12.7887L25.4585 12.7873L25.418 12.7859L25.3775 12.7846L25.3369 12.7835L25.2964 12.7824L25.2559 12.7813L25.2153 12.7805L25.1748 12.7797L25.1343 12.7788L25.0937 12.7783L25.0532 12.7777L25.0127 12.7772L24.9721 12.7769L24.9316 12.7767L24.891 12.7764L24.8505 12.7763L24.81 12.7764L24.7694 12.7764L24.7289 12.7766L24.6884 12.777L24.6478 12.7774L24.6073 12.7778L24.5667 12.7785L24.5262 12.7792L24.4856 12.7799L24.4451 12.7809L24.4046 12.7819L24.364 12.7829L24.3235 12.7842L24.283 12.7856L24.2424 12.787L24.2019 12.7886L24.1614 12.7903L24.1208 12.792L24.0803 12.794L24.0398 12.796L23.9992 12.798L23.9587 12.8003L23.9181 12.8027L23.8776 12.8051L23.837 12.8078L23.7965 12.8105L23.756 12.8133L23.7154 12.8164L23.6749 12.8195L23.6344 12.8227L23.5938 12.8261L23.5533 12.8295L23.5127 12.8332L23.4722 12.837L23.4317 12.8408L23.3911 12.8449L23.3506 12.8491L23.3101 12.8533L23.2695 12.8579L23.229 12.8625L23.1885 12.8672L23.1479 12.8722L23.1074 12.8772L23.0668 12.8824L23.0263 12.8878L22.9857 12.8932L22.9452 12.8989L22.9047 12.9047L22.8641 12.9107L22.8236 12.9169L22.7831 12.9231L22.7425 12.9297L22.702 12.9364L22.6615 12.9432L22.6209 12.9502L22.5804 12.9573L22.5398 12.9648L22.4993 12.9723L22.4588 12.9801L22.4182 12.988L22.3777 12.996L22.3372 13.0045L22.2966 13.0129L22.2561 13.0216L22.2156 13.0306L22.175 13.0396L22.1345 13.049L22.0939 13.0584L22.0534 13.0683L22.0128 13.0782L21.9723 13.0885L21.9318 13.0989L21.8912 13.1096L21.8507 13.1205L21.8102 13.1316L21.7696 13.1431L21.7291 13.1546L21.6886 13.1666L21.648 13.1786L21.6075 13.1911L21.5669 13.2037L21.5264 13.2168C21.5129 13.2212 21.4992 13.2254 21.4858 13.2299L21.4453 13.2435C21.4318 13.2481 21.4182 13.2525 21.4048 13.2572L21.3642 13.2714L21.3237 13.2857C21.3101 13.2905 21.2967 13.2956 21.2832 13.3005C21.2696 13.3055 21.256 13.3104 21.2426 13.3154L21.2021 13.3309C21.1886 13.3361 21.175 13.3412 21.1616 13.3464L21.121 13.3625L21.0805 13.3788L21.0399 13.3956L20.9994 13.4126L20.9589 13.43L20.9183 13.4478L20.8778 13.4659L20.8373 13.4846L20.7968 13.5034C20.7831 13.5099 20.7697 13.5164 20.7562 13.523L20.7157 13.5427L20.6751 13.563L20.6346 13.5837C20.621 13.5908 20.6074 13.5977 20.5941 13.6048C20.5804 13.612 20.567 13.6193 20.5535 13.6266L20.513 13.6487L20.4725 13.6713C20.4588 13.679 20.4454 13.6867 20.4319 13.6945C20.4183 13.7023 20.4048 13.7101 20.3914 13.7181C20.3778 13.7261 20.3643 13.7342 20.3508 13.7424C20.3372 13.7506 20.3237 13.7589 20.3103 13.7672C20.2967 13.7756 20.2831 13.784 20.2698 13.7925C20.2561 13.8012 20.2426 13.8099 20.2292 13.8186L20.1887 13.8453C20.1751 13.8543 20.1615 13.8633 20.1481 13.8724L20.1076 13.9004C20.094 13.91 20.0805 13.9195 20.0671 13.9291C20.0535 13.9389 20.0399 13.9487 20.0265 13.9585C20.013 13.9685 19.9994 13.9784 19.986 13.9885C19.9723 13.9988 19.9589 14.0091 19.9455 14.0195C19.9318 14.0301 19.9183 14.0407 19.9049 14.0513C19.8913 14.0621 19.8778 14.0729 19.8644 14.0839V14.0838Z" + fill="#E10238" + /> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/maestro.js b/packages/js/onboarding/src/images/cards/maestro.js new file mode 100644 index 00000000000..410b1f065da --- /dev/null +++ b/packages/js/onboarding/src/images/cards/maestro.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="51" + height="35" + viewBox="0 0 51 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="0.5" + y="0.5" + width="49.6897" + height="34" + rx="3.5" + fill="white" + stroke="#F3F3F3" + /> + <path + d="M29.9708 22.8244H21.3047V7.35352H29.9708V22.8244Z" + fill="#6C6BBD" + /> + <path + d="M21.8549 15.0891C21.8549 11.9507 23.3341 9.15521 25.6375 7.35365C23.9531 6.03626 21.8272 5.24995 19.5168 5.24995C14.0471 5.24995 9.61328 9.65501 9.61328 15.0891C9.61328 20.5232 14.0471 24.9282 19.5168 24.9282C21.8272 24.9282 23.9531 24.1419 25.6375 22.8245C23.3341 21.023 21.8549 18.2274 21.8549 15.0891Z" + fill="#EB001B" + /> + <path + d="M41.6626 15.0891C41.6626 20.5232 37.2288 24.9282 31.7591 24.9282C29.4487 24.9282 27.3228 24.1419 25.6377 22.8245C27.9418 21.023 29.421 18.2274 29.421 15.0891C29.421 11.9507 27.9418 9.15521 25.6377 7.35365C27.3228 6.03626 29.4487 5.24995 31.7591 5.24995C37.2288 5.24995 41.6626 9.65501 41.6626 15.0891Z" + fill="#0099DF" + /> + <path + d="M32.9036 27.1956C33.0188 27.1956 33.1845 27.2175 33.311 27.2669L33.1347 27.8024C33.0138 27.753 32.8929 27.7367 32.777 27.7367C32.403 27.7367 32.216 27.9769 32.216 28.4085V29.8735H31.6436V27.2613H32.2103V27.5784C32.3589 27.3489 32.5736 27.1956 32.9036 27.1956Z" + fill="#231F20" + /> + <path + d="M30.7887 27.7807H29.8536V28.9611C29.8536 29.2232 29.9468 29.3984 30.2333 29.3984C30.382 29.3984 30.569 29.3489 30.739 29.2507L30.904 29.7368C30.7226 29.8625 30.4367 29.9395 30.1893 29.9395C29.5123 29.9395 29.2762 29.5785 29.2762 28.9717V27.7807H28.7422V27.2615H29.2762V26.469H29.8536V27.2615H30.7887V27.7807Z" + fill="#231F20" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M24.1754 27.1958C24.9128 27.1958 25.4191 27.7532 25.4247 28.5676C25.4247 28.6433 25.4192 28.7135 25.4135 28.7842L25.4134 28.7859H23.4607C23.5432 29.2557 23.8788 29.4252 24.2472 29.4252C24.511 29.4252 24.7919 29.327 25.0116 29.1519L25.2925 29.5729C24.9732 29.8406 24.6105 29.9388 24.2144 29.9388C23.4273 29.9388 22.8662 29.3977 22.8662 28.5676C22.8662 27.7532 23.4052 27.1958 24.1754 27.1958ZM24.1648 27.7036C23.7574 27.7036 23.5269 27.9607 23.4658 28.3379H24.8304C24.77 27.9332 24.5332 27.7036 24.1648 27.7036Z" + fill="#231F20" + /> + <path + d="M27.9386 27.9283C27.7793 27.8295 27.455 27.7038 27.1193 27.7038C26.8057 27.7038 26.6187 27.8189 26.6187 28.0103C26.6187 28.1848 26.8164 28.2342 27.0639 28.2668L27.3334 28.3049C27.9058 28.3875 28.2522 28.6277 28.2522 29.0868C28.2522 29.5841 27.812 29.9395 27.0532 29.9395C26.6237 29.9395 26.2277 29.83 25.9141 29.6004L26.1836 29.1575C26.3763 29.3052 26.6628 29.4309 27.0589 29.4309C27.4493 29.4309 27.6584 29.3164 27.6584 29.1137C27.6584 28.9667 27.5098 28.8842 27.1962 28.841L26.9266 28.8028C26.3379 28.7203 26.0186 28.4582 26.0186 28.0322C26.0186 27.513 26.4481 27.1958 27.1137 27.1958C27.5318 27.1958 27.9115 27.289 28.1861 27.4692L27.9386 27.9283Z" + fill="#231F20" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M35.561 27.3015C35.3872 27.2308 35.1982 27.1958 34.9942 27.1958C34.7902 27.1958 34.6013 27.2308 34.4275 27.3015C34.2537 27.3716 34.1044 27.4685 33.9785 27.5918C33.8526 27.715 33.7537 27.8608 33.6819 28.0284C33.6101 28.1967 33.5742 28.3793 33.5742 28.5764C33.5742 28.7734 33.6101 28.9561 33.6819 29.1243C33.7537 29.292 33.8526 29.4384 33.9785 29.5616C34.1044 29.6848 34.2537 29.7812 34.4275 29.8519C34.6013 29.9219 34.7902 29.9569 34.9942 29.9569C35.1982 29.9569 35.3872 29.9219 35.561 29.8519C35.7348 29.7812 35.8853 29.6848 36.0118 29.5616C36.139 29.4384 36.2379 29.292 36.3097 29.1243C36.3815 28.9561 36.4174 28.7734 36.4174 28.5764C36.4174 28.3793 36.3815 28.1967 36.3097 28.0284C36.2379 27.8608 36.139 27.715 36.0118 27.5918C35.8853 27.4685 35.7348 27.3716 35.561 27.3015ZM34.666 27.7969C34.7674 27.7563 34.8763 27.7356 34.9941 27.7356C35.1118 27.7356 35.2214 27.7563 35.3221 27.7969C35.4235 27.8382 35.5117 27.8958 35.5854 27.9696C35.6603 28.0434 35.7182 28.1322 35.761 28.2354C35.8032 28.3386 35.824 28.4525 35.824 28.5763C35.824 28.7008 35.8032 28.8141 35.761 28.9173C35.7182 29.0205 35.6603 29.1093 35.5854 29.1831C35.5117 29.2569 35.4235 29.3145 35.3221 29.3558C35.2214 29.3971 35.1118 29.4171 34.9941 29.4171C34.8763 29.4171 34.7674 29.3971 34.666 29.3558C34.5652 29.3145 34.4777 29.2569 34.404 29.1831C34.3303 29.1093 34.2724 29.0205 34.2302 28.9173C34.188 28.8141 34.1672 28.7008 34.1672 28.5763C34.1672 28.4525 34.188 28.3386 34.2302 28.2354C34.2724 28.1322 34.3303 28.0434 34.404 27.9696C34.4777 27.8958 34.5652 27.8382 34.666 27.7969Z" + fill="#231F20" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M22.2524 27.2615V28.5676V29.8737H21.6806V29.5566C21.4986 29.7918 21.224 29.9394 20.85 29.9394C20.1126 29.9394 19.5352 29.3652 19.5352 28.5676C19.5352 27.7694 20.1126 27.1958 20.85 27.1958C21.224 27.1958 21.4986 27.3434 21.6806 27.5786V27.2615H22.2524ZM20.9211 27.7312C20.4262 27.7312 20.1233 28.1084 20.1233 28.5675C20.1233 29.0267 20.4262 29.4033 20.9211 29.4033C21.394 29.4033 21.7133 29.0429 21.7133 28.5675C21.7133 28.0921 21.394 27.7312 20.9211 27.7312Z" + fill="#231F20" + /> + <path + d="M19.0293 29.8735V28.234C19.0293 27.6166 18.6332 27.2012 17.9953 27.1956C17.6597 27.19 17.3127 27.2938 17.0709 27.6604C16.8896 27.3707 16.603 27.1956 16.2013 27.1956C15.9211 27.1956 15.6459 27.2775 15.4312 27.5834V27.2613H14.8594V29.8735H15.4368V28.4254C15.4368 27.9719 15.69 27.7311 16.0804 27.7311C16.4601 27.7311 16.6528 27.9769 16.6528 28.4198V29.8735H17.2302V28.4254C17.2302 27.9719 17.4947 27.7311 17.8738 27.7311C18.2649 27.7311 18.4519 27.9769 18.4519 28.4198V29.8735H19.0293V29.8735Z" + fill="#231F20" + /> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/mastercard.js b/packages/js/onboarding/src/images/cards/mastercard.js new file mode 100644 index 00000000000..8fbde439e41 --- /dev/null +++ b/packages/js/onboarding/src/images/cards/mastercard.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="51" + height="35" + viewBox="0 0 51 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="0.5" + y="0.5" + width="50" + height="34" + rx="3.5" + fill="white" + stroke="#F3F3F3" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M18.6846 27.0292V28.3215V29.6137H18.1154V29.2999C17.9349 29.5327 17.661 29.6787 17.2886 29.6787C16.5546 29.6787 15.9791 29.1112 15.9791 28.3215C15.9791 27.5324 16.5546 26.9642 17.2886 26.9642C17.661 26.9642 17.9349 27.1103 18.1154 27.343V27.0292H18.6846ZM17.3594 27.494C16.8667 27.494 16.5652 27.8672 16.5652 28.3215C16.5652 28.7757 16.8667 29.1489 17.3594 29.1489C17.8302 29.1489 18.148 28.7918 18.148 28.3215C18.148 27.8511 17.8302 27.494 17.3594 27.494ZM37.9186 28.3215C37.9186 27.8672 38.2201 27.494 38.7128 27.494C39.1842 27.494 39.5014 27.8511 39.5014 28.3215C39.5014 28.7918 39.1842 29.1489 38.7128 29.1489C38.2201 29.1489 37.9186 28.7757 37.9186 28.3215ZM40.0386 25.9913V28.3215V29.6137H39.4688V29.2999C39.2882 29.5327 39.0143 29.6787 38.642 29.6787C37.9079 29.6787 37.3325 29.1112 37.3325 28.3215C37.3325 27.5324 37.9079 26.9642 38.642 26.9642C39.0143 26.9642 39.2882 27.1103 39.4688 27.343V25.9913H40.0386ZM25.7496 27.4674C26.1163 27.4674 26.352 27.6945 26.4122 28.0943H25.0538C25.1146 27.7211 25.3441 27.4674 25.7496 27.4674ZM24.4571 28.3215C24.4571 27.5157 24.9937 26.9642 25.7609 26.9642C26.4943 26.9642 26.9983 27.5157 27.0039 28.3215C27.0039 28.397 26.9983 28.4675 26.9926 28.5375L25.0488 28.5375C25.1309 29.0029 25.465 29.1706 25.8317 29.1706C26.0944 29.1706 26.374 29.0728 26.5933 28.9001L26.8723 29.3167C26.5545 29.5815 26.1934 29.6787 25.7991 29.6787C25.0156 29.6787 24.4571 29.1434 24.4571 28.3215ZM32.6337 28.3215C32.6337 27.8672 32.9353 27.494 33.4279 27.494C33.8987 27.494 34.2165 27.8511 34.2165 28.3215C34.2165 28.7918 33.8987 29.1489 33.4279 29.1489C32.9353 29.1489 32.6337 28.7757 32.6337 28.3215ZM34.7529 27.0292V28.3215V29.6137H34.1837V29.2999C34.0026 29.5327 33.7293 29.6787 33.3569 29.6787C32.6229 29.6787 32.0475 29.1112 32.0475 28.3215C32.0475 27.5324 32.6229 26.9642 33.3569 26.9642C33.7293 26.9642 34.0026 27.1103 34.1837 27.343V27.0292H34.7529ZM29.4191 28.3215C29.4191 29.1056 29.972 29.6787 30.8157 29.6787C31.21 29.6787 31.4726 29.5921 31.7572 29.3705L31.4839 28.9162C31.2701 29.0679 31.0457 29.1489 30.7988 29.1489C30.3443 29.1434 30.0102 28.8191 30.0102 28.3215C30.0102 27.8239 30.3443 27.4996 30.7988 27.494C31.0457 27.494 31.2701 27.5751 31.4839 27.7267L31.7572 27.2724C31.4726 27.0509 31.21 26.9642 30.8157 26.9642C29.972 26.9642 29.4191 27.5373 29.4191 28.3215ZM36.0674 27.3431C36.2153 27.1159 36.4291 26.9643 36.7575 26.9643C36.8729 26.9643 37.0371 26.986 37.1631 27.0349L36.9876 27.5646C36.8672 27.5157 36.7469 27.4997 36.6315 27.4997C36.2592 27.4997 36.073 27.7373 36.073 28.165V29.6138H35.5032V27.0293H36.0674V27.3431ZM21.4996 27.2347C21.2257 27.0564 20.8483 26.9642 20.4321 26.9642C19.7689 26.9642 19.342 27.278 19.342 27.7917C19.342 28.2132 19.6599 28.4731 20.2453 28.5542L20.5142 28.5919C20.8264 28.6352 20.9737 28.7163 20.9737 28.8624C20.9737 29.0623 20.7656 29.1762 20.377 29.1762C19.9827 29.1762 19.6981 29.0518 19.5063 28.9057L19.238 29.3433C19.5502 29.5704 19.9444 29.6787 20.3713 29.6787C21.1273 29.6787 21.5654 29.3272 21.5654 28.8352C21.5654 28.3809 21.2207 28.1432 20.6509 28.0621L20.3826 28.0238C20.1363 27.9916 19.9388 27.9433 19.9388 27.77C19.9388 27.5806 20.125 27.4674 20.4371 27.4674C20.7712 27.4674 21.0947 27.5918 21.2533 27.689L21.4996 27.2347ZM28.1542 27.3431C28.3015 27.1159 28.5152 26.9643 28.8437 26.9643C28.959 26.9643 29.1233 26.986 29.2493 27.0349L29.0738 27.5646C28.9534 27.5157 28.833 27.4997 28.7177 27.4997C28.3454 27.4997 28.1592 27.7373 28.1592 28.165V29.6138H27.59V27.0293L28.1542 27.0293V27.3431ZM23.9862 27.0292H23.0553V26.2451H22.4799V27.0292H21.949V27.5429H22.4799V28.7219C22.4799 29.3216 22.7156 29.6787 23.3888 29.6787C23.6358 29.6787 23.9204 29.6032 24.1009 29.4788L23.9367 28.9973C23.7668 29.0945 23.5806 29.1434 23.4327 29.1434C23.1481 29.1434 23.0553 28.9701 23.0553 28.7108V27.5429H23.9862V27.0292ZM15.4758 27.9917V29.6138H14.9003V28.1755C14.9003 27.7373 14.7142 27.4941 14.3255 27.4941C13.9475 27.4941 13.6849 27.7324 13.6849 28.1811V29.6138H13.1095V28.1755C13.1095 27.7373 12.9183 27.4941 12.5403 27.4941C12.151 27.4941 11.899 27.7324 11.899 28.1811V29.6138H11.3242V27.0293H11.894V27.348C12.1078 27.0454 12.3811 26.9643 12.6606 26.9643C13.0606 26.9643 13.3451 27.1376 13.5257 27.4242C13.767 27.0615 14.1118 26.9587 14.4459 26.9643C15.0815 26.9699 15.4758 27.3808 15.4758 27.9917Z" + fill="#231F20" + /> + <path + d="M29.9381 22.6376H21.3115V7.33105H29.9381V22.6376Z" + fill="#FF5F00" + /> + <path + d="M21.8586 14.9846C21.8586 11.8796 23.331 9.11372 25.624 7.33129C23.9472 6.02789 21.831 5.24994 19.5311 5.24994C14.0864 5.24994 9.67285 9.60822 9.67285 14.9846C9.67285 20.361 14.0864 24.7192 19.5311 24.7192C21.831 24.7192 23.9472 23.9413 25.624 22.6379C23.331 20.8555 21.8586 18.0896 21.8586 14.9846Z" + fill="#EB001B" + /> + <path + d="M41.5758 14.9846C41.5758 20.361 37.1622 24.7192 31.7175 24.7192C29.4177 24.7192 27.3014 23.9413 25.624 22.6379C27.9176 20.8555 29.3901 18.0896 29.3901 14.9846C29.3901 11.8796 27.9176 9.11372 25.624 7.33129C27.3014 6.02789 29.4177 5.24994 31.7175 5.24994C37.1622 5.24994 41.5758 9.60822 41.5758 14.9846Z" + fill="#F79E1B" + /> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/unionpay.js b/packages/js/onboarding/src/images/cards/unionpay.js new file mode 100644 index 00000000000..0be12014c7b --- /dev/null +++ b/packages/js/onboarding/src/images/cards/unionpay.js @@ -0,0 +1,114 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="52" + height="35" + viewBox="0 0 52 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="0.878906" + y="0.5" + width="50" + height="34" + rx="3.5" + fill="white" + stroke="#F3F3F3" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M44.0545 5.25735L34.3353 5.25488C34.3341 5.25488 34.3328 5.25488 34.3328 5.25488C34.3253 5.25488 34.3179 5.2562 34.3106 5.2562C32.9754 5.29641 31.3124 6.34915 31.0096 7.64726L26.4132 27.6401C26.1104 28.9503 26.9343 30.0165 28.2599 30.0361H38.4703C39.7756 29.9726 41.044 28.932 41.3417 27.6486L45.9382 7.65564C46.2459 6.33208 45.402 5.25735 44.0545 5.25735Z" + fill="#01798A" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M26.4134 27.6401L31.0097 7.64729C31.3126 6.34917 32.9755 5.29643 34.3107 5.25622L30.4464 5.25376L23.484 5.25244C22.1451 5.27936 20.4605 6.33949 20.1577 7.64729L15.5601 27.6401C15.2561 28.9503 16.0813 30.0165 17.4059 30.0361H28.26C26.9345 30.0165 26.1105 28.9503 26.4134 27.6401" + fill="#024381" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M15.5602 27.64L20.1578 7.64714C20.4606 6.33934 22.1452 5.27922 23.4841 5.2523L14.5649 5.25C13.2185 5.25 11.4923 6.32227 11.1846 7.64714L6.58694 27.64C6.55896 27.762 6.54344 27.8815 6.53418 27.9986V28.3695C6.62418 29.3246 7.36619 30.0201 8.43278 30.036H17.406C16.0814 30.0163 15.2562 28.9502 15.5602 27.64Z" + fill="#DD0228" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M23.6716 19.8205H23.8404C23.9955 19.8205 24.0999 19.7693 24.1488 19.668L24.5874 19.0227H25.762L25.5171 19.4472H26.9254L26.7467 20.0975H25.0709C24.8779 20.3829 24.6403 20.5171 24.3547 20.5012H23.4818L23.6716 19.8205H23.6716ZM23.4788 20.7527H26.5643L26.3676 21.4591H25.1268L24.9374 22.1409H26.1449L25.9482 22.8473H24.7407L24.4602 23.8548C24.3908 24.0232 24.4821 24.099 24.7327 24.0818H25.7168L25.5345 24.7382H23.6451C23.287 24.7382 23.1641 24.5368 23.2765 24.1331L23.6351 22.8473H22.8633L23.0593 22.1409H23.8313L24.0205 21.4591H23.2827L23.4788 20.7527H23.4788ZM28.4035 19.018L28.355 19.4315C28.355 19.4315 28.937 19.002 29.4656 19.002H31.4189L30.6719 21.6601C30.61 21.964 30.3443 22.1151 29.8752 22.1151H27.6612L27.1426 23.9817C27.1128 24.0817 27.155 24.133 27.2667 24.133H27.7023L27.5422 24.7124H26.4347C26.0096 24.7124 25.8328 24.5867 25.903 24.3343L27.3684 19.018H28.4035H28.4035ZM30.0576 19.7693H28.3141L28.1056 20.4866C28.1056 20.4866 28.3959 20.2805 28.8811 20.2731C29.365 20.2657 29.9173 20.2731 29.9173 20.2731L30.0576 19.7693ZM29.4261 21.4333C29.555 21.4504 29.6271 21.4003 29.6358 21.282L29.7425 20.9039H27.9964L27.85 21.4333H29.4261ZM28.2483 22.2921H29.2547L29.236 22.7203H29.504C29.6394 22.7203 29.7065 22.6776 29.7065 22.5935L29.7858 22.3166H30.6223L30.5106 22.7203C30.4161 23.057 30.1656 23.2327 29.7586 23.2499H29.2225L29.22 23.9817C29.2101 24.0989 29.318 24.1587 29.54 24.1587H30.0439L29.8813 24.7381H28.6727C28.3339 24.754 28.1678 24.5953 28.1713 24.2587L28.2483 22.2921V22.2921Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M16.0529 15.4764C15.9164 16.1339 15.6 16.639 15.1091 16.9976C14.6227 17.3502 13.9954 17.527 13.2273 17.527C12.5044 17.527 11.9745 17.3465 11.6364 16.9841C11.4018 16.7267 11.2852 16.3998 11.2852 16.0045C11.2852 15.8411 11.3051 15.6654 11.3448 15.4764L12.1631 11.5972H13.3991L12.5919 15.4325C12.5671 15.5386 12.5571 15.6374 12.5584 15.7265C12.5571 15.9229 12.6068 16.0839 12.7073 16.2095C12.8537 16.3962 13.0914 16.4889 13.4221 16.4889C13.8024 16.4889 14.1158 16.3974 14.359 16.2132C14.6022 16.0302 14.761 15.7704 14.8324 15.4325L15.6422 11.5972H16.8719L16.0529 15.4764Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M21.2436 13.9502H22.2116L21.4534 17.4123H20.4873L21.2436 13.9502ZM21.5482 12.689H22.5248L22.3424 13.5293H21.3659L21.5482 12.689Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M23.0688 17.1487C22.8156 16.9109 22.6878 16.59 22.6865 16.1826C22.6865 16.113 22.6908 16.0338 22.7002 15.9471C22.7095 15.8592 22.7214 15.7739 22.738 15.6946C22.8528 15.1323 23.0973 14.6858 23.4739 14.3564C23.8499 14.0258 24.3036 13.8599 24.8347 13.8599C25.2696 13.8599 25.6145 13.9794 25.8672 14.2185C26.1196 14.4589 26.2462 14.7833 26.2462 15.1957C26.2462 15.2664 26.2407 15.3481 26.2313 15.436C26.2201 15.525 26.2066 15.6104 26.1909 15.6946C26.0787 16.2484 25.8349 16.69 25.4583 17.0134C25.0816 17.3391 24.6293 17.5012 24.1019 17.5012C23.6651 17.5012 23.3213 17.3841 23.0688 17.1487M24.9136 16.4631C25.0843 16.2814 25.2065 16.0056 25.2809 15.6385C25.2921 15.5812 25.302 15.5214 25.3082 15.4616C25.3143 15.403 25.3168 15.3482 25.3168 15.2981C25.3168 15.0846 25.2616 14.9188 25.1506 14.8016C25.0402 14.6833 24.8832 14.6248 24.6804 14.6248C24.4122 14.6248 24.1939 14.7174 24.0227 14.9029C23.8501 15.0884 23.7279 15.3689 23.6509 15.7422C23.6404 15.7995 23.6317 15.8569 23.6237 15.913C23.6175 15.9703 23.6157 16.024 23.6168 16.0728C23.6168 16.285 23.6721 16.4485 23.7831 16.5644C23.8935 16.6803 24.0498 16.7376 24.2553 16.7376C24.5246 16.7376 24.743 16.6461 24.9136 16.4631Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M32.5262 19.8496L32.7596 19.0421H33.9397L33.8888 19.3385C33.8888 19.3385 34.4918 19.0421 34.9261 19.0421C35.3606 19.0421 36.3854 19.0421 36.3854 19.0421L36.1535 19.8496H35.9239L34.8231 23.6582H35.0527L34.8343 24.4146H34.6047L34.5092 24.7427H33.3664L33.4617 24.4146H31.207L31.4268 23.6582H31.6527L32.7544 19.8496H32.5262H32.5262ZM33.7993 19.8498L33.4989 20.8805C33.4989 20.8805 34.0128 20.6866 34.4558 20.6318C34.5536 20.2718 34.6815 19.8498 34.6815 19.8498H33.7993V19.8498ZM33.3598 21.3637L33.0585 22.4433C33.0585 22.4433 33.628 22.1676 34.0188 22.1444C34.1317 21.7271 34.2447 21.3637 34.2447 21.3637H33.3598V21.3637ZM33.5808 23.6583L33.8067 22.8751H32.9258L32.6987 23.6583H33.5808ZM36.4352 18.9922H37.5447L37.5918 19.3946C37.5844 19.4971 37.6463 19.546 37.7779 19.546H37.9739L37.7756 20.2279H36.9601C36.6487 20.2437 36.4886 20.1267 36.4738 19.8741L36.4352 18.9922ZM36.1102 20.4548H39.7039L39.493 21.1868H38.3488L38.1526 21.8673H39.2957L39.0835 22.5981H37.8104L37.5224 23.0264H38.1455L38.2894 23.8839C38.3066 23.9693 38.3836 24.0108 38.5151 24.0108H38.7086L38.5053 24.717H37.8202C37.4653 24.7342 37.2818 24.6171 37.2667 24.3646L37.1016 23.5814L36.5346 24.4146C36.4005 24.65 36.1945 24.7599 35.9167 24.7427H34.8705L35.074 24.0363H35.4004C35.5345 24.0363 35.646 23.9778 35.7465 23.8595L36.634 22.5981H35.4898L35.7018 21.8673H36.9428L37.1402 21.1868H35.898L36.1102 20.4548Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M17.1915 13.9492H18.0645L17.9647 14.4493L18.0899 14.3066C18.3729 14.009 18.7166 13.8613 19.1224 13.8613C19.4898 13.8613 19.7547 13.9663 19.921 14.1773C20.0847 14.3884 20.1294 14.6799 20.0519 15.0544L19.571 17.4137H18.6738L19.1081 15.2752C19.1529 15.0544 19.1405 14.8897 19.0715 14.7836C19.0033 14.6774 18.873 14.625 18.685 14.625C18.4542 14.625 18.26 14.6957 18.1017 14.8361C17.9429 14.9776 17.8381 15.174 17.7865 15.424L17.3863 17.4137H16.4873L17.1915 13.9492Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M27.2021 13.9492H28.0758L27.9767 14.4493L28.1006 14.3066C28.3837 14.009 28.7287 13.8613 29.1332 13.8613C29.5005 13.8613 29.766 13.9663 29.931 14.1773C30.0937 14.3884 30.1408 14.6799 30.0614 15.0544L29.5823 17.4137H28.6839L29.1184 15.2752C29.1629 15.0544 29.1506 14.8897 29.0823 14.7836C29.0115 14.6774 28.8836 14.625 28.6964 14.625C28.4655 14.625 28.272 14.6957 28.1119 14.8361C27.953 14.9776 27.8476 15.174 27.798 15.424L27.396 17.4137H26.498L27.2021 13.9492" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M31.5212 11.8018H34.0577C34.5454 11.8018 34.9225 11.9104 35.1818 12.1238C35.44 12.3398 35.5692 12.6497 35.5692 13.0534V13.0656C35.5692 13.1424 35.564 13.229 35.5567 13.3229C35.5441 13.4157 35.5279 13.5095 35.5071 13.6072C35.3954 14.1415 35.1359 14.571 34.7352 14.8967C34.333 15.2211 33.8567 15.3846 33.3082 15.3846H31.9479L31.5274 17.4133H30.3496L31.5212 11.8018M32.1554 14.4087H33.2835C33.5776 14.4087 33.8108 14.3415 33.9809 14.2086C34.1497 14.0744 34.2614 13.8695 34.3234 13.5914C34.3332 13.54 34.3394 13.4937 34.3469 13.451C34.3508 13.4108 34.3556 13.3704 34.3556 13.3315C34.3556 13.1326 34.2838 12.9887 34.1397 12.8984C33.9958 12.8068 33.7701 12.763 33.4572 12.763H32.4991L32.1554 14.4087" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M40.8406 18.0833C40.4683 18.8615 40.1135 19.3152 39.9051 19.5263C39.6964 19.735 39.2833 20.2205 38.2881 20.1839L38.3737 19.5898C39.2112 19.3361 39.6642 18.1929 39.9223 17.6867L39.6146 13.9587L40.2624 13.9502H40.8059L40.8643 16.2888L41.8829 13.9502H42.9143L40.8406 18.0833Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M37.9561 14.232L37.5464 14.509C37.1183 14.1796 36.7274 13.9759 35.9731 14.3199C34.9454 14.7883 34.0868 18.381 36.9161 17.1976L37.0774 17.3855L38.1905 17.4135L38.9215 14.1491L37.9561 14.232M37.3233 16.0168C37.1445 16.5353 36.7451 16.8781 36.4324 16.7805C36.1196 16.6853 36.008 16.1851 36.1891 15.6655C36.3678 15.1458 36.7698 14.8042 37.08 14.9018C37.3927 14.997 37.5056 15.4971 37.3233 16.0168Z" + fill="white" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M34.3328 5.26107L30.4463 5.25342L34.3106 5.26981C34.318 5.26981 34.3253 5.26107 34.3328 5.26107" + fill="#E02F41" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M30.4467 5.27406L23.5378 5.25C23.5204 5.25 23.5024 5.25765 23.4844 5.26531L30.4467 5.27406" + fill="#2E4F7D" + /> + </svg> +); diff --git a/packages/js/onboarding/src/images/cards/visa.js b/packages/js/onboarding/src/images/cards/visa.js new file mode 100644 index 00000000000..cd81d8a8fa3 --- /dev/null +++ b/packages/js/onboarding/src/images/cards/visa.js @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default () => ( + <svg + width="51" + height="35" + viewBox="0 0 51 35" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <rect + x="0.5" + y="0.5" + width="50" + height="34" + rx="3.5" + fill="white" + stroke="#F3F3F3" + /> + <path + d="M22.6435 24.004H19.248L21.3718 11.7534H24.7671L22.6435 24.004Z" + fill="#15195A" + /> + <path + d="M34.952 12.0528C34.2823 11.8049 33.22 11.5312 31.9066 11.5312C28.5534 11.5312 26.1922 13.1993 26.1777 15.5842C26.1499 17.3437 27.8683 18.321 29.1536 18.9077C30.4672 19.5072 30.9138 19.8985 30.9138 20.4329C30.9004 21.2536 29.8522 21.6319 28.8747 21.6319C27.5191 21.6319 26.7927 21.4369 25.6889 20.9803L25.2417 20.7845L24.7666 23.5345C25.563 23.873 27.0302 24.1733 28.5534 24.1865C32.1162 24.1865 34.4356 22.5442 34.4631 20.0028C34.4767 18.6082 33.5693 17.5396 31.613 16.6665C30.4254 16.1059 29.6981 15.728 29.6981 15.1544C29.7121 14.6331 30.3133 14.099 31.6539 14.099C32.7577 14.0729 33.5687 14.3204 34.1831 14.5681L34.4902 14.6982L34.952 12.0528Z" + fill="#15195A" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M41.0301 11.7534H43.6565L46.3957 24.0039H43.2519C43.2519 24.0039 42.9442 22.5963 42.8467 22.1662H38.4873C38.3612 22.4919 37.7747 24.0039 37.7747 24.0039H34.2119L39.2554 12.7699C39.6049 11.9748 40.2202 11.7534 41.0301 11.7534ZM40.8208 16.2365C40.8208 16.2365 39.7448 18.9603 39.4652 19.6641H42.2875C42.1478 19.0516 41.5048 16.1192 41.5048 16.1192L41.2676 15.0636C41.1676 15.3355 41.0231 15.7092 40.9256 15.9612C40.8596 16.1321 40.8151 16.2471 40.8208 16.2365Z" + fill="#15195A" + /> + <path + fillRule="evenodd" + clipRule="evenodd" + d="M4.53636 11.7534H9.99929C10.7398 11.7792 11.3406 12.0008 11.5361 12.7832L12.7233 18.4113C12.7234 18.4118 12.7236 18.4124 12.7238 18.4129L13.0871 20.1072L16.4124 11.7534H20.0028L14.6657 23.991H11.0752L8.04881 13.3464C7.00461 12.7769 5.81289 12.3188 4.48047 12.0009L4.53636 11.7534Z" + fill="#15195A" + /> + </svg> +); diff --git a/packages/js/onboarding/src/images/wcpay-logo.js b/packages/js/onboarding/src/images/wcpay-logo.js new file mode 100644 index 00000000000..a5ce935b747 --- /dev/null +++ b/packages/js/onboarding/src/images/wcpay-logo.js @@ -0,0 +1,64 @@ +/** + * External dependencies + */ +import { createElement } from '@wordpress/element'; + +export default ( { width = 196, height = 41 } ) => ( + <svg + width={ width } + height={ height } + viewBox={ `0 0 ${ width } ${ height }` } + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + <title>WooCommerce Payments + + + + + + + + + + + + +); diff --git a/packages/js/onboarding/src/index.js b/packages/js/onboarding/src/index.js new file mode 100644 index 00000000000..c2e7dddf91d --- /dev/null +++ b/packages/js/onboarding/src/index.js @@ -0,0 +1,14 @@ +export * from './components/WCPayCard'; +export * from './components/RecommendedRibbon'; +export * from './components/SetupRequired'; +export * from './components/WCPayAcceptedMethods'; +export { default as Visa } from './images/cards/visa'; +export { default as MasterCard } from './images/cards/mastercard'; +export { default as Amex } from './images/cards/amex'; +export { default as ApplePay } from './images/cards/applepay'; +export { default as GooglePay } from './images/cards/googlepay'; +export { default as WCPayLogo } from './images/wcpay-logo'; +export { WooPaymentGatewaySetup } from './components/WooPaymentGatewaySetup'; +export { WooPaymentGatewayConfigure } from './components/WooPaymentGatewayConfigure'; +export { WooOnboardingTask } from './components/WooOnboardingTask'; +export { WooOnboardingTaskListItem } from './components/WooOnboardingTaskListItem'; diff --git a/packages/js/onboarding/src/style.scss b/packages/js/onboarding/src/style.scss new file mode 100644 index 00000000000..101ec17b3b1 --- /dev/null +++ b/packages/js/onboarding/src/style.scss @@ -0,0 +1,2 @@ +@import 'components/WCPayCard/WCPayCard.scss'; +@import 'components/RecommendedRibbon/RecommendedRibbon.scss'; diff --git a/packages/js/onboarding/tsconfig-cjs.json b/packages/js/onboarding/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/onboarding/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/onboarding/tsconfig.json b/packages/js/onboarding/tsconfig.json new file mode 100644 index 00000000000..ea9f201d401 --- /dev/null +++ b/packages/js/onboarding/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module", + "declaration": true, + "declarationMap": true, + "declarationDir": "./build-types" + } +} diff --git a/packages/js/onboarding/webpack.config.js b/packages/js/onboarding/webpack.config.js new file mode 100644 index 00000000000..5c56294e0f0 --- /dev/null +++ b/packages/js/onboarding/webpack.config.js @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +const { webpackConfig } = require( '@woocommerce/style-build' ); + +module.exports = { + mode: process.env.NODE_ENV || 'development', + entry: { + 'build-style': __dirname + '/src/style.scss', + }, + output: { + path: __dirname, + }, + module: { + rules: webpackConfig.rules, + }, + plugins: webpackConfig.plugins, +}; diff --git a/packages/js/style-build/.eslintrc.js b/packages/js/style-build/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/style-build/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/style-build/.npmrc b/packages/js/style-build/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/style-build/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/style-build/README.md b/packages/js/style-build/README.md new file mode 100644 index 00000000000..ba44d115ad5 --- /dev/null +++ b/packages/js/style-build/README.md @@ -0,0 +1,30 @@ +# Style Build Helper + +This is a partial [Webpack](https://webpack.js.org/) config for building WooCommece component styles using base styles from Gutenberg. It is used to replace the [`bin/packages/build.js`](https://github.com/woocommerce/woocommerce-admin/blob/6859249/bin/packages/build.js) script. + + +## Usage + +Create a `webpack.config.js` in your package root that defines the `entry` and `output`, making use of the `rules` and `plugins` from `@woocommerce/style-build`. + +***Note:*** The `entry` should be named `'build-style'` so the CSS will get picked up by the main `client/` application's `CopyWebpackPlugin` config. + +```js +// packages//webpack.config.js + +import { webpackConfig } from '@woocommerce/style-build'; + +module.exports = { + mode: process.env.NODE_ENV || 'development', + entry: { + 'build-style': __dirname + '/src/style.scss', + }, + output: { + path: __dirname, + }, + module: { + rules: webpackConfig.rules, + }, + plugins: webpackConfig.plugins, +}; +``` diff --git a/packages/js/style-build/abstracts/_breakpoints.scss b/packages/js/style-build/abstracts/_breakpoints.scss new file mode 100644 index 00000000000..5c5a1984a7b --- /dev/null +++ b/packages/js/style-build/abstracts/_breakpoints.scss @@ -0,0 +1,61 @@ + + +/* stylelint-disable block-closing-brace-newline-after */ + +// Breakpoints +// Forked from https://github.com/Automattic/wp-calypso/blob/46ae24d8800fb85da6acf057a640e60dac988a38/assets/stylesheets/shared/mixins/_breakpoints.scss + +// Think very carefully before adding a new breakpoint. +// The list below is based on wp-admin's main breakpoints +$breakpoints: 320px, 400px, 600px, 782px, 960px, 1280px, 1440px; + +@mixin breakpoint( $sizes... ) { + @each $size in $sizes { + @if type-of( $size ) == string { + $approved-value: 0; + @each $breakpoint in $breakpoints { + $and-larger: '>' + $breakpoint; + $and-smaller: '<' + $breakpoint; + + @if $size == $and-smaller { + $approved-value: 1; + @media (max-width: $breakpoint) { + @content; + } + } @else { + @if $size == $and-larger { + $approved-value: 2; + @media (min-width: $breakpoint + 1) { + @content; + } + } @else { + @each $breakpoint-end in $breakpoints { + $range: $breakpoint + '-' + $breakpoint-end; + @if $size == $range { + $approved-value: 3; + @media (min-width: $breakpoint + 1) and (max-width: $breakpoint-end) { + @content; + } + } + } + } + } + } + @if $approved-value == 0 { + $sizes: ''; + @each $breakpoint in $breakpoints { + $sizes: $sizes + ' ' + $breakpoint; + } + @warn 'ERROR in breakpoint( #{ $size } ) : You can only use these sizes[ #{$sizes} ] using the following syntax [ <#{ nth( $breakpoints, 1 ) } >#{ nth( $breakpoints, 1 ) } #{ nth( $breakpoints, 1 ) }-#{ nth( $breakpoints, 2 ) } ]'; + } + } @else { + $sizes: ''; + @each $breakpoint in $breakpoints { + $sizes: $sizes + ' ' + $breakpoint; + } + @error 'ERROR in breakpoint( #{ $size } ) : Please wrap the breakpoint $size in parenthesis. You can use these sizes[ #{$sizes} ] using the following syntax [ <#{ nth( $breakpoints, 1 ) } >#{ nth( $breakpoints, 1 ) } #{ nth( $breakpoints, 1 ) }-#{ nth( $breakpoints, 2 ) } ]'; + } + } +} + +/* stylelint-enable */ diff --git a/packages/js/style-build/abstracts/_colors.scss b/packages/js/style-build/abstracts/_colors.scss new file mode 100644 index 00000000000..f9e6989e6e4 --- /dev/null +++ b/packages/js/style-build/abstracts/_colors.scss @@ -0,0 +1,35 @@ +@import '@automattic/color-studio/dist/color-variables.scss'; +@import '@wordpress/base-styles/colors.scss'; + +$transparent: rgba(255, 255, 255, 0); + +$gray-text: $gray-700; +$gray-text-hover: $gray-900; + +// Gutenberg +$button-hover: #fafafa; +$button-hover-border: #999; +$button-disabled: #a0a5aa; +$button-disabled-border: #ddd; +$button-focus-inner: #00435d; +$button-focus-outer: #bfe7f3; +$input-active-border: #00a0d2; +$input-active-inner: $button-focus-inner; +$input-active-outer: $button-focus-outer; +$table-border: #e2e4e7; + +// Bright colors +$valid-green: #4ab866; +$notice-yellow: #ffb900; +$error-red: #d94f4f; +$box-shadow-blue: #5b9dd9; +$core-orange: #ca4a1f; + +$button: #f0f2f4; +$button-border: darken($button, 20%); + +// Blues +$core-blue-dark-900: #0071a1; + +// WooCommerce brand colors +$woocommerce-brand-purple: #3c2861; diff --git a/packages/js/style-build/abstracts/_mixins.scss b/packages/js/style-build/abstracts/_mixins.scss new file mode 100644 index 00000000000..aec22045af3 --- /dev/null +++ b/packages/js/style-build/abstracts/_mixins.scss @@ -0,0 +1,203 @@ +// Rem output with px fallback +@mixin font-size( $sizeValue: 16, $lineHeight: false ) { + font-size: $sizeValue + px; + font-size: math.div($sizeValue, 16) + rem; + @if ( $lineHeight ) { + line-height: $lineHeight; + } +} + +@function url-friendly-colour( $color ) { + @return '%23' + str-slice( '#{ $color }', 2, -1 ); +} + +@mixin hover-state { + &:hover, + &:active, + &:focus { + @content; + } +} + +// Adds animation to placeholder section +@mixin placeholder( $lighten-percentage: 30% ) { + animation: loading-fade 1.6s ease-in-out infinite; + background-color: $gray-100; + color: transparent; + + &::after { + content: '\00a0'; + } + + @media screen and ( prefers-reduced-motion: reduce ) { + animation: none; + } +} + +// Adds animation to transforms +@mixin animate-transform( $duration: 0.2s ) { + transition: transform ease $duration; + + @media screen and ( prefers-reduced-motion: reduce ) { + transition: none; + } +} + +// Gutenberg mixins. These are temporary until Gutenberg's mixins are exposed. +/** + * Breakpoint mixins + */ + +@mixin break-huge() { + @media (min-width: #{ ($break-huge) }) { + @content; + } +} + +@mixin break-wide() { + @media (min-width: #{ ($break-wide) }) { + @content; + } +} + +@mixin break-xlarge() { + @media (min-width: #{ ($break-xlarge) }) { + @content; + } +} + +@mixin break-large() { + @media (min-width: #{ ($break-large) }) { + @content; + } +} + +@mixin break-medium() { + @media (min-width: #{ ($break-medium) }) { + @content; + } +} + +@mixin break-small() { + @media (min-width: #{ ($break-small) }) { + @content; + } +} + +@mixin break-mobile() { + @media (min-width: #{ ($break-mobile) }) { + @content; + } +} + +@mixin break-zoomed-in() { + @media (min-width: #{ ($break-zoomed-in) }) { + @content; + } +} + +// Buttons with rounded corners. +@mixin button-style__disabled { + opacity: 0.6; + cursor: default; +} + +@mixin button-style__hover { + 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); +} + +@mixin button-style__active() { + outline: none; + background-color: $studio-white; + color: $gray-900; + box-shadow: inset 0 0 0 1px $gray-400, inset 0 0 0 2px $studio-white; +} + +@mixin button-style__focus-active() { + background-color: $studio-white; + color: $gray-900; + box-shadow: inset 0 0 0 1px $gray-700, inset 0 0 0 2px $studio-white; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + outline-offset: -2px; +} + +/* stylelint-disable block-closing-brace-newline-after */ +@mixin reduce-motion( $property: '' ) { + @if $property == 'transition' { + @media ( prefers-reduced-motion: reduce ) { + transition-duration: 0s; + } + } @else if $property == 'animation' { + @media ( prefers-reduced-motion: reduce ) { + animation-duration: 1ms; + } + } @else { + @media ( prefers-reduced-motion: reduce ) { + transition-duration: 0s; + animation-duration: 1ms; + } + } +} +/* stylelint-enable */ + + +// Gutenberg Switch. +@mixin switch-style__focus-active() { + box-shadow: 0 0 0 2px $studio-white, 0 0 0 3px $gray-700; + + // Windows High Contrast mode will show this outline, but not the box-shadow. + outline: 2px solid transparent; + outline-offset: 2px; +} + +// 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}; + } + } +} + +// Hide an element from sighted users, but availble to screen reader users. +@mixin visually-hidden() { + clip: rect(1px, 1px, 1px, 1px); + clip-path: inset(50%); + height: 1px; + width: 1px; + margin: -1px; + overflow: hidden; + /* Many screen reader and browser combinations announce broken words as they would appear visually. */ + overflow-wrap: normal !important; + word-wrap: normal !important; +} + +// Unhide a visually hidden element +@mixin visually-shown() { + clip: auto; + clip-path: none; + height: auto; + width: auto; + margin: unset; + overflow: hidden; +} + +// Create a string-repeat function +@function str-repeat( $character, $n ) { + @if $n == 0 { + @return ''; + } + $c: ''; + @for $i from 1 through $n { + $c: $c + $character; + } + @return $c; +} diff --git a/packages/js/style-build/abstracts/_variables.scss b/packages/js/style-build/abstracts/_variables.scss new file mode 100644 index 00000000000..4b9d4ee625a --- /dev/null +++ b/packages/js/style-build/abstracts/_variables.scss @@ -0,0 +1,58 @@ +@import '@automattic/color-studio/dist/color-variables.scss'; + +// Import Gutenberg variables +@import '@wordpress/base-styles/colors.scss'; +@import '@wordpress/base-styles/variables.scss'; +@import '@wordpress/base-styles/mixins.scss'; +@import '@wordpress/base-styles/breakpoints.scss'; +@import '@wordpress/base-styles/animations.scss'; +@import '@wordpress/base-styles/z-index.scss'; +@import '@wordpress/base-styles/default-custom-properties.scss'; + +@include wordpress-admin-schemes; + +$fallback-gutter: 24px; +$fallback-gutter-large: 40px; +$gutter: var(--main-gap); +$gutter-large: var(--large-gap); + +$gap-largest: 40px; +$gap-larger: 36px; +$gap-large: 24px; +$gap: 16px; +$gap-small: 12px; +$gap-smaller: 8px; +$gap-smallest: 4px; + +// Header +$header-height: 60px; +$header-scroll-shadow: 0 8px 8px 0 rgba(85, 93, 102, 0.3); + +// Sidebar +$sidebar-width: 272px; + +// @todo Remove this spacing variable +$spacing: 16px; + +// Gutenberg variable overrides. +$white: $studio-white; +$black: $studio-black; +$alert-red: $error-red; +$alert-yellow: $notice-yellow; +$alert-green: $valid-green; + +// WordPress defaults +$adminbar-height: 32px; +$adminbar-height-mobile: 46px; + +// wp-admin colors +$wp-admin-background: #f1f1f1; +$wp-admin-sidebar: #24292d; + +// Muriel +$muriel-box-shadow-1dp: 0 2px 1px -1px rgba(0, 0, 0, 0.2), + 0 1px 1px 0 rgba(0, 0, 0, 0.14), 0 1px 3px 0 rgba(0, 0, 0, 0.12); +$muriel-box-shadow-6dp: 0 3px 5px rgba(0, 0, 0, 0.2), + 0 1px 18px rgba(0, 0, 0, 0.12), 0 6px 10px rgba(0, 0, 0, 0.14); +$muriel-box-shadow-8dp: 0 5px 5px -3px rgba(0, 0, 0, 0.2), + 0 8px 10px 1px rgba(0, 0, 0, 0.14), 0 3px 14px 2px rgba(0, 0, 0, 0.12); diff --git a/packages/js/style-build/index.js b/packages/js/style-build/index.js new file mode 100644 index 00000000000..ec88b20d108 --- /dev/null +++ b/packages/js/style-build/index.js @@ -0,0 +1,60 @@ +/** + * External dependencies + */ +const MiniCssExtractPlugin = require( 'mini-css-extract-plugin' ); +const path = require( 'path' ); +const WebpackRTLPlugin = require( 'webpack-rtl-plugin' ); +const RemoveEmptyScriptsPlugin = require( 'webpack-remove-empty-scripts' ); +const postcssPlugins = require( '@wordpress/postcss-plugins-preset' ); + +const NODE_ENV = process.env.NODE_ENV || 'development'; + +module.exports = { + webpackConfig: { + rules: [ + { + test: /\.s?css$/, + exclude: [ /storybook\/wordpress/, /build-style\/*\/*.css/ ], + use: [ + MiniCssExtractPlugin.loader, + 'css-loader', + { + loader: 'postcss-loader', + options: { + ident: 'postcss', + plugins: postcssPlugins, + }, + }, + { + loader: 'sass-loader', + options: { + sassOptions: { + includePaths: [ + path.resolve( __dirname, 'abstracts' ), + ], + }, + webpackImporter: true, + additionalData: + '@use "sass:math";' + + '@import "_colors"; ' + + '@import "_variables"; ' + + '@import "_breakpoints"; ' + + '@import "_mixins"; ', + }, + }, + ], + }, + ], + plugins: [ + new RemoveEmptyScriptsPlugin(), + new MiniCssExtractPlugin( { + filename: '[name]/style.css', + chunkFilename: 'chunks/[id].style.css', + } ), + new WebpackRTLPlugin( { + filename: '[name]/style-rtl.css', + minify: NODE_ENV === 'development' ? false : { safe: true }, + } ), + ], + }, +}; diff --git a/packages/js/style-build/package.json b/packages/js/style-build/package.json new file mode 100644 index 00000000000..0e2b9db68ec --- /dev/null +++ b/packages/js/style-build/package.json @@ -0,0 +1,51 @@ +{ + "name": "@woocommerce/style-build", + "version": "1.0.0", + "description": "WooCommerce Components SASS Build", + "author": "Automattic", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "woocommerce" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/style-build/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "index.js", + "dependencies": { + "@automattic/color-studio": "^2.5.0", + "@wordpress/base-styles": "^4.3.0", + "@wordpress/postcss-plugins-preset": "^1.6.0", + "css-loader": "^3.6.0", + "mini-css-extract-plugin": "^2.6.0", + "postcss-loader": "^3.0.0", + "sass-loader": "^10.2.1", + "webpack-remove-empty-scripts": "^0.7.3", + "webpack-rtl-plugin": "^2.0.0" + }, + "scripts": { + "lint": "eslint index.js" + }, + "private": true, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2", + "webpack": "^5.70.0" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] + } +} diff --git a/packages/js/style-build/project.json b/packages/js/style-build/project.json new file mode 100644 index 00000000000..b88003dd526 --- /dev/null +++ b/packages/js/style-build/project.json @@ -0,0 +1,14 @@ +{ + "root": "packages/js/style-build", + "sourceRoot": "packages/js/style-build/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/style-build" + } + } + } + } \ No newline at end of file diff --git a/packages/js/tracks/.eslintrc.js b/packages/js/tracks/.eslintrc.js new file mode 100644 index 00000000000..e4d185d8cd1 --- /dev/null +++ b/packages/js/tracks/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, +}; diff --git a/packages/js/tracks/.npmrc b/packages/js/tracks/.npmrc new file mode 100644 index 00000000000..43c97e719a5 --- /dev/null +++ b/packages/js/tracks/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/js/tracks/CHANGELOG.md b/packages/js/tracks/CHANGELOG.md new file mode 100644 index 00000000000..1779b423b2c --- /dev/null +++ b/packages/js/tracks/CHANGELOG.md @@ -0,0 +1,17 @@ +# Unreleased + +# 1.1.1 + +- Update all js packages with minor/patch version changes. #8392 +# 1.1.0 + +- Fix commonjs module build, allow package to be built in isolation. #7286 + +# 1.0.1 + +- Update dependencies. +- Add TypeScript support. + +# 1.0.0 + +- Released package diff --git a/packages/js/tracks/README.md b/packages/js/tracks/README.md new file mode 100644 index 00000000000..edfdbab762e --- /dev/null +++ b/packages/js/tracks/README.md @@ -0,0 +1,64 @@ +# Tracks + +WooCommerce user event tracking utilities for Automattic based projects. + +## Installation + +Install the module + +```bash +pnpm install @woocommerce/tracks --save +``` + +## Usage + +The store must opt-in to allow tracking via the `woocommerce_allow_tracking` setting. +If the store is not opted-in no events be recorded when using the following functions. + +### recordEvent( eventName, eventProperties ) + +Record a user event to Tracks. + +```jsx +import { recordEvent } from '@woocommerce/tracks'; + +recordEvent( 'page_view', { path } ) +``` + +| Param | Type | Description | +| --- | --- | --- | +| eventName | String | The name of the event to record, don't include the `wcadmin_` prefix | +| eventProperties | Object | Event properties to include in the event | + +### queueRecordEvent( eventName, eventProperties ) + +Queue a tracks event. + +This allows you to delay tracks events that would otherwise cause a race condition. +For example, when we trigger `wcadmin_tasklist_appearance_continue_setup` we're simultaneously moving the user to a new page via +`window.location`. This is an example of a race condition that should be avoided by enqueueing the event, +and therefore running it on the next pageview. + +| Param | Type | Description | +| --- | --- | --- | +| eventName | String | The name of the event to record, don't include the `wcadmin_` prefix | +| eventProperties | Object | Event properties to include in the event | + +### recordPageView( eventName, eventProperties ) + +Record a page view to Tracks. + +| Param | Type | Description | +| --- | --- | --- | +| path | String | Path the page/path to record a page view for | +| extraProperties | Object | Extra event properties to include in the event | + +# Debugging + +When debugging is activated info for each recorded Tracks event is logged to the browser console. + +To activate, open up your browser console and add this: + +```js +localStorage.setItem( 'debug', 'wc-admin:*' ); +``` diff --git a/packages/js/tracks/package.json b/packages/js/tracks/package.json new file mode 100644 index 00000000000..fe85d715a72 --- /dev/null +++ b/packages/js/tracks/package.json @@ -0,0 +1,51 @@ +{ + "name": "@woocommerce/tracks", + "version": "1.1.1", + "description": "WooCommerce user event tracking utilities for Automattic based projects.", + "author": "Automattic", + "license": "GPL-3.0-or-later", + "keywords": [ + "wordpress", + "woocommerce", + "tracks" + ], + "homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/tracks/README.md", + "repository": { + "type": "git", + "url": "https://github.com/woocommerce/woocommerce.git" + }, + "bugs": { + "url": "https://github.com/woocommerce/woocommerce/issues" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "debug": "^4.3.3" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "clean": "pnpm exec rimraf tsconfig.tsbuildinfo build build-*", + "build": "tsc --build ./tsconfig.json ./tsconfig-cjs.json", + "start": "tsc --build --watch", + "prepack": "pnpm run clean && pnpm run build", + "lint": "eslint src" + }, + "devDependencies": { + "@babel/core": "^7.17.5", + "@wordpress/eslint-plugin": "^11.0.0", + "eslint": "^8.12.0", + "jest": "^27.5.1", + "jest-cli": "^27.5.1", + "rimraf": "^3.0.2", + "ts-jest": "^27.1.3", + "typescript": "^4.6.2" + }, + "lint-staged": { + "*.(t|j)s?(x)": [ + "eslint --fix" + ] + } +} diff --git a/packages/js/tracks/project.json b/packages/js/tracks/project.json new file mode 100644 index 00000000000..e749f9cd887 --- /dev/null +++ b/packages/js/tracks/project.json @@ -0,0 +1,32 @@ +{ + "root": "packages/js/tracks", + "sourceRoot": "packages/js/tracks/src", + "projectType": "library", + "targets": { + "changelog": { + "executor": "./tools/executors/changelogger:changelog", + "options": { + "action": "add", + "cwd": "packages/js/tracks" + } + }, + "build": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "build" + } + }, + "build-watch": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "start" + } + }, + "test": { + "executor": "@nrwl/workspace:run-script", + "options": { + "script": "test" + } + } + } + } \ No newline at end of file diff --git a/packages/js/tracks/src/index.js b/packages/js/tracks/src/index.js new file mode 100644 index 00000000000..bc42cb60725 --- /dev/null +++ b/packages/js/tracks/src/index.js @@ -0,0 +1,138 @@ +/** + * External dependencies + */ +import debug from 'debug'; + +/** + * Module variables + */ +const tracksDebug = debug( 'wc-admin:tracks' ); + +/** + * Record an event to Tracks + * + * @param {string} eventName The name of the event to record, don't include the wcadmin_ prefix + * @param {Object} eventProperties event properties to include in the event + */ +export function recordEvent( eventName, eventProperties ) { + tracksDebug( 'recordevent %s %o', 'wcadmin_' + eventName, eventProperties, { + _tqk: window._tkq, + shouldRecord: + process.env.NODE_ENV !== 'development' && + !! window._tkq && + !! window.wcTracks && + !! window.wcTracks.isEnabled, + } ); + + if ( + ! window.wcTracks || + typeof window.wcTracks.recordEvent !== 'function' || + process.env.NODE_ENV === 'development' + ) { + return false; + } + + window.wcTracks.recordEvent( eventName, eventProperties ); +} + +const tracksQueue = { + localStorageKey() { + return 'tracksQueue'; + }, + + clear() { + if ( ! window.localStorage ) { + return; + } + + window.localStorage.removeItem( tracksQueue.localStorageKey() ); + }, + + get() { + if ( ! window.localStorage ) { + return []; + } + + let items = window.localStorage.getItem( + tracksQueue.localStorageKey() + ); + + items = items ? JSON.parse( items ) : []; + items = Array.isArray( items ) ? items : []; + + return items; + }, + + add( ...args ) { + if ( ! window.localStorage ) { + // If unable to queue, run it now. + tracksDebug( 'Unable to queue, running now', { args } ); + recordEvent.apply( null, args || undefined ); + return; + } + + let items = tracksQueue.get(); + const newItem = { args }; + + items.push( newItem ); + items = items.slice( -100 ); // Upper limit. + + tracksDebug( 'Adding new item to queue.', newItem ); + window.localStorage.setItem( + tracksQueue.localStorageKey(), + JSON.stringify( items ) + ); + }, + + process() { + if ( ! window.localStorage ) { + return; // Not possible. + } + + const items = tracksQueue.get(); + tracksQueue.clear(); + + tracksDebug( 'Processing items in queue.', items ); + + items.forEach( ( item ) => { + if ( typeof item === 'object' ) { + tracksDebug( 'Processing item in queue.', item ); + recordEvent.apply( null, item.args || undefined ); + } + } ); + }, +}; + +/** + * Queue a tracks event. + * + * This allows you to delay tracks events that would otherwise cause a race condition. + * For example, when we trigger `wcadmin_tasklist_appearance_continue_setup` we're simultaneously moving the user to a new page via + * `window.location`. This is an example of a race condition that should be avoided by enqueueing the event, + * and therefore running it on the next pageview. + * + * @param {string} eventName The name of the event to record, don't include the wcadmin_ prefix + * @param {Object} eventProperties event properties to include in the event + */ + +export function queueRecordEvent( eventName, eventProperties ) { + tracksQueue.add( eventName, eventProperties ); +} + +/** + * Record a page view to Tracks + * + * @param {string} path the page/path to record a page view for + * @param {Object} extraProperties extra event properties to include in the event + */ + +export function recordPageView( path, extraProperties ) { + if ( ! path ) { + return; + } + + recordEvent( 'page_view', { path, ...extraProperties } ); + + // Process queue. + tracksQueue.process(); +} diff --git a/packages/js/tracks/tsconfig-cjs.json b/packages/js/tracks/tsconfig-cjs.json new file mode 100644 index 00000000000..2876a008ecc --- /dev/null +++ b/packages/js/tracks/tsconfig-cjs.json @@ -0,0 +1,6 @@ +{ + "extends": "../tsconfig-cjs", + "compilerOptions": { + "outDir": "build" + } +} \ No newline at end of file diff --git a/packages/js/tracks/tsconfig.json b/packages/js/tracks/tsconfig.json new file mode 100644 index 00000000000..e8f14a25fa4 --- /dev/null +++ b/packages/js/tracks/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig", + "compilerOptions": { + "rootDir": "src", + "outDir": "build-module" + } +} \ No newline at end of file diff --git a/packages/js/tsconfig-cjs.json b/packages/js/tsconfig-cjs.json new file mode 100644 index 00000000000..834e23576a6 --- /dev/null +++ b/packages/js/tsconfig-cjs.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig", + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "declaration": false, + "declarationMap": false, + "declarationDir": null, + "outDir": "build", + "composite": false + } +} \ No newline at end of file diff --git a/packages/js/tsconfig.json b/packages/js/tsconfig.json new file mode 100644 index 00000000000..7be441f7de6 --- /dev/null +++ b/packages/js/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "composite": true, + "target": "es2019", + "module": "esnext", + "moduleResolution": "node", + "allowJs": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "jsx": "react", + "jsxFactory": "createElement", + "jsxFragmentFactory": "Fragment", + "incremental": true + }, + "exclude": [ + "node_modules", + "**/stories", + "**/build", + "**/build-module", + "**/build-types", + "**/test/*", + "**/jest.config.js", + "**/webpack.*.js" + ] +} diff --git a/phpcs.xml b/phpcs.xml index bfd630f6681..70bf2d649ca 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -27,7 +27,7 @@ - + @@ -98,4 +98,46 @@ tests/php/ + + + + src/Internal/Admin/ + src/Admin/ + + + + + src/Internal/Admin/ + src/Admin/ + + + + + src/Internal/Admin/ + src/Admin/ + + + + + plugins/woocommerce/src/Internal/Admin/ + src/Admin/ + + + + + src/Internal/Admin/ + src/Admin/ + + + + + src/Internal/Admin/ + src/Admin/ + + + + + src/Internal/Admin/ + src/Admin/ + diff --git a/plugins/woocommerce-admin/.browserslistrc b/plugins/woocommerce-admin/.browserslistrc new file mode 100644 index 00000000000..cce6f27952e --- /dev/null +++ b/plugins/woocommerce-admin/.browserslistrc @@ -0,0 +1 @@ +extends @wordpress/browserslist-config \ No newline at end of file diff --git a/plugins/woocommerce-admin/.distignore b/plugins/woocommerce-admin/.distignore new file mode 100755 index 00000000000..ccb93b6de48 --- /dev/null +++ b/plugins/woocommerce-admin/.distignore @@ -0,0 +1,34 @@ +# A set of files you probably don't want in your WordPress.org distribution +.distignore +.editorconfig +.git +.gitignore +.gitlab-ci.yml +.travis.yml +.DS_Store +Thumbs.db +behat.yml +bin +circle.yml +composer.json +composer.lock +Gruntfile.js +package.json +package-lock.json +phpunit.xml +phpunit.xml.dist +multisite.xml +multisite.xml.dist +phpcs.xml +phpcs.xml.dist +README.md +wp-cli.local.yml +yarn.lock +tests +packages/admin-e2e-tests +vendor +config +node_modules +*.sql +*.tar.gz +*.zip diff --git a/plugins/woocommerce-admin/.eslintignore b/plugins/woocommerce-admin/.eslintignore new file mode 100644 index 00000000000..a832885d3e1 --- /dev/null +++ b/plugins/woocommerce-admin/.eslintignore @@ -0,0 +1,21 @@ +bin/* +!bin/generate-docs +!.eslintrc.js +build +build-module +coverage +languages +node_modules +vendor +legacy +tests/e2e +build-types + +# These packages have their own eslint config and command +api +e2e-environment +e2e-utils + +# These packages don't have their eslint setup, but have many lint errors. +api-core-tests +e2e-core-tests diff --git a/plugins/woocommerce-admin/.eslintrc.js b/plugins/woocommerce-admin/.eslintrc.js new file mode 100644 index 00000000000..cd20ad02884 --- /dev/null +++ b/plugins/woocommerce-admin/.eslintrc.js @@ -0,0 +1,12 @@ +module.exports = { + extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ], + root: true, + overrides: [ + { + files: [ 'client/**/*.js', 'client/**/*.jsx', 'client/**/*.tsx' ], + rules: { + 'react/react-in-jsx-scope': 'off', + }, + }, + ], +}; diff --git a/plugins/woocommerce-admin/.gitattributes b/plugins/woocommerce-admin/.gitattributes new file mode 100644 index 00000000000..61597e8b8a4 --- /dev/null +++ b/plugins/woocommerce-admin/.gitattributes @@ -0,0 +1,25 @@ +# Line-ending normalization +* text=auto + +/.* export-ignore +/*.md export-ignore +/node_modules* export-ignore +/tests export-ignore +/bin export-ignore +/phpcs.* export-ignore +/phpunit.* export-ignore +/composer.lock export-ignore +/composer.json export-ignore +/renovate.json export-ignore +/webpack.config.js export-ignore +/postcss.config.js export-ignore +/package.json export-ignore +/package-lock.json export-ignore +/babel.config.js export-ignore +/Gruntfile.js export-ignore +/packages* export-ignore +/docs* export-ignore +/config* export-ignore +/client* export-ignore +/docker* export-ignore +/storybook* export-ignore diff --git a/plugins/woocommerce-admin/.gitignore b/plugins/woocommerce-admin/.gitignore new file mode 100755 index 00000000000..9a2ca031712 --- /dev/null +++ b/plugins/woocommerce-admin/.gitignore @@ -0,0 +1,41 @@ + +# Directories/files that may be generated by this project +node_modules/ +/dist +docs/**/dist +build +build-module +build-style +build-types +languages/* +!languages/README.md +woocommerce-admin.zip +includes/feature-config.php +/storybook/wordpress +tests/e2e/screenshots/* +docs/components/storybook/* + +# Directories/files that may appear in your environment +.DS_Store +Thumbs.db +wp-cli.local.yml +*.sql +*.swp +*.tar.gz +*.tgz +*.zip +.idea +.vscode/* +!.vscode/tasks.json +!.vscode/settings.json + +# Composer +/vendor/ +/vendor-bin/ +/bin/composer/**/vendor/ + +# wp-env config +.wp-env.override.json + +# Typescript +*.tsbuildinfo diff --git a/plugins/woocommerce-admin/.npmrc b/plugins/woocommerce-admin/.npmrc new file mode 100644 index 00000000000..6c59086d862 --- /dev/null +++ b/plugins/woocommerce-admin/.npmrc @@ -0,0 +1 @@ +enable-pre-post-scripts=true diff --git a/plugins/woocommerce-admin/.prettierrc.js b/plugins/woocommerce-admin/.prettierrc.js new file mode 100644 index 00000000000..548adf77728 --- /dev/null +++ b/plugins/woocommerce-admin/.prettierrc.js @@ -0,0 +1,3 @@ +// Import the default config file and expose it in the project root. +// Useful for editor integrations. +module.exports = require("@wordpress/prettier-config"); diff --git a/plugins/woocommerce-admin/.stylelintignore b/plugins/woocommerce-admin/.stylelintignore new file mode 100644 index 00000000000..91157afc396 --- /dev/null +++ b/plugins/woocommerce-admin/.stylelintignore @@ -0,0 +1 @@ +storybook diff --git a/plugins/woocommerce-admin/README.md b/plugins/woocommerce-admin/README.md new file mode 100644 index 00000000000..8f8020b53a0 --- /dev/null +++ b/plugins/woocommerce-admin/README.md @@ -0,0 +1,13 @@ +# WooCommerce Admin + +This is a javascript-driven, React-based admin interface for WooCommerce. + +## Development + +Please refer to the [WooCommerce Admin Development](https://github.com/woocommerce/woocommerce/wiki/How-to-set-up-WooCommerce-development-environment#wooCommerce-admin-development) +## End-to-end tests + +Please refer to the [WooCommerce End to End Tests](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce/tests/e2e/README.md) +## Common Issues + +If you're encountering any issue setting things up, chances are we have been there too. Please have a look at our [wiki](https://github.com/woocommerce/woocommerce/wiki/Common-Issues) for a list of common problems. diff --git a/plugins/woocommerce-admin/babel.config.js b/plugins/woocommerce-admin/babel.config.js new file mode 100644 index 00000000000..70215466892 --- /dev/null +++ b/plugins/woocommerce-admin/babel.config.js @@ -0,0 +1,50 @@ +const { + babelConfig: e2eBabelConfig, +} = require( '@woocommerce/e2e-environment' ); + +module.exports = function ( api ) { + api.cache( true ); + + return { + ...e2eBabelConfig, + presets: [ + ...e2eBabelConfig.presets, + '@babel/preset-typescript', + '@wordpress/babel-preset-default', + ], + sourceType: 'unambiguous', + plugins: [ + /** + * This allows arrow functions as class methods so that binding + * methods to `this` in the constructor isn't required. + */ + '@babel/plugin-proposal-class-properties', + ], + 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/bin/hook-reference/README.md b/plugins/woocommerce-admin/bin/hook-reference/README.md new file mode 100644 index 00000000000..3ad83aafd51 --- /dev/null +++ b/plugins/woocommerce-admin/bin/hook-reference/README.md @@ -0,0 +1,29 @@ +# Hook Reference Generator + +Compile a publishable JSON object of WooCommerce's JavaScript filters and slotFill entry points. + +## Usage + +Generate a new reference found at `bin/hook-reference/data.json` by running the following command. + +``` +pnpm run create-hook-reference +``` + +The data includes references to code in the Github repository by commit hash, so it is essential to commit the resulting data in a pull request to `main` so code references are publicly available. + +## DocBlock Requirements + +JavaScript documentation blocks require certain fields in order to be included in the reference. + +### Filter + +| Tag | Description | +| --------- | -------------------------------------------------- | +| `@filter` | Filter string used as `addFilter`'s first argument | + +### SlotFill + +| Tag | Description | +| ----------- | ------------------------- | +| `@slotFill` | The fill component's name | diff --git a/plugins/woocommerce-admin/bin/hook-reference/data.js b/plugins/woocommerce-admin/bin/hook-reference/data.js new file mode 100644 index 00000000000..e62500542db --- /dev/null +++ b/plugins/woocommerce-admin/bin/hook-reference/data.js @@ -0,0 +1,104 @@ +const { readFile } = require( 'fs' ).promises; +const exec = require( 'await-exec' ); +const { parse } = require( 'comment-parser/lib' ); +const { relative, resolve } = require( 'path' ); +const chalk = require( 'chalk' ); + +const dataTypes = [ 'action', 'filter', 'slotFill' ]; + +const getHooks = ( parsedData ) => + parsedData.filter( ( docBlock ) => + docBlock.tags.some( ( tag ) => dataTypes.includes( tag.tag ) ) + ); + +const getSourceFile = ( file, commit, { source } ) => { + const first = source[ 0 ].number + 1; + const last = source[ source.length - 1 ].number + 1; + + return `https://github.com/woocommerce/woocommerce-admin/blob/${ commit }/${ file }#L${ first }-L${ last }`; +}; + +const logProgress = ( fileName, { tags } ) => { + const hook = tags.find( ( tag ) => dataTypes.includes( tag.tag ) ); + console.log( + chalk.green( `@${ hook.tag } ` ) + + chalk.cyan( `${ hook.name } ` ) + + chalk.yellow( 'generated in ' ) + + chalk.yellow.underline( fileName ) + ); +}; + +const addSourceFiles = async ( hooks, fileName ) => { + const { stdout } = await exec( 'git log --pretty="format:%H" -1' ); + const commit = stdout.trim(); + + return hooks.map( ( hook ) => { + logProgress( fileName, hook ); + hook.sourceFile = getSourceFile( fileName, commit, hook ); + return hook; + } ); +}; + +const prepareHooks = async ( path ) => { + const data = await readFile( path, 'utf-8' ).catch( ( err ) => + console.error( 'Failed to read file', err ) + ); + const fileName = relative( resolve( __dirname, '../../' ), path ); + + const parsedData = parse( data ); + const rawHooks = getHooks( parsedData ); + return await addSourceFiles( rawHooks, fileName ); +}; + +const makeDocObjects = async ( path ) => { + const hooks = await prepareHooks( path ); + return hooks.map( ( { description, tags, sourceFile } ) => { + const tag = tags.find( ( tag ) => dataTypes.includes( tag.tag ) ); + + paramTags = tags.reduce( + ( result, { tag, name, type, description } ) => { + if ( tag === 'param' ) { + result.push( { + name, + type, + description, + } ); + } + return result; + }, + [] + ); + + const docObject = { + description, + sourceFile, + name: tag ? tag.name : '', + type: tag.tag, + params: paramTags, + }; + + if ( tag.tag === 'slotFill' ) { + const scopeTab = tags.find( ( tag ) => tag.tag === 'scope' ); + if ( scopeTab ) { + docObject.scope = scopeTab.name; + } else { + console.warn( + `Failed to find "scope" tag for slotFill "${ tag.name }" doc.` + ); + } + } + + return docObject; + } ); +}; + +const createData = async ( paths ) => { + const data = await Promise.all( + paths.map( async ( path ) => { + return await makeDocObjects( path ); + } ) + ); + return data.flat(); +}; + +module.exports = createData; diff --git a/plugins/woocommerce-admin/bin/hook-reference/data.json b/plugins/woocommerce-admin/bin/hook-reference/data.json new file mode 100644 index 00000000000..d6b0ce33035 --- /dev/null +++ b/plugins/woocommerce-admin/bin/hook-reference/data.json @@ -0,0 +1,1003 @@ +[ + { + "description": "Filter an array of help items for the setup task.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/activity-panel/panels/help.js#L354-L361", + "name": "woocommerce_admin_setup_task_help_items", + "type": "filter", + "params": [ + { + "name": "items", + "type": "Array.", + "description": "Array items object based on task." + }, + { + "name": "task", + "type": "('products'|'appearance'|'shipping'|'tax'|'payments'|'marketing')", + "description": "url query parameters." + }, + { + "name": "props", + "type": "Object", + "description": "React component props." + } + ] + }, + { + "description": "Filter report table for the CSV download. Enables manipulation of data used to create the report CSV.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/components/report-table/index.js#L159-L172", + "name": "woocommerce_admin_report_table", + "type": "filter", + "params": [ + { + "name": "reportTableData", + "type": "Object", + "description": "- data used to create the table." + }, + { + "name": "reportTableData.endpoint", + "type": "string", + "description": "- table api endpoint." + }, + { + "name": "reportTableData.headers", + "type": "Array", + "description": "- table headers data." + }, + { + "name": "reportTableData.rows", + "type": "Array", + "description": "- table rows data." + }, + { + "name": "reportTableData.totals", + "type": "Object", + "description": "- total aggregates for request." + }, + { + "name": "reportTableData.summary", + "type": "Array", + "description": "- summary numbers data." + }, + { + "name": "reportTableData.items", + "type": "Object", + "description": "- response from api requerst." + } + ] + }, + { + "description": "Category Report charts filter.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/categories/config.js#L27-L32", + "name": "woocommerce_admin_categories_report_charts", + "type": "filter", + "params": [ + { + "name": "charts", + "type": "Array.", + "description": "Category Report charts." + } + ] + }, + { + "description": "Category Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/categories/config.js#L57-L64", + "name": "woocommerce_admin_category_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Category Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/categories/config.js#L143-L148", + "name": "woocommerce_admin_categories_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Coupons Report charts filter.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/coupons/config.js#L25-L30", + "name": "woocommerce_admin_coupons_report_charts", + "type": "filter", + "params": [ + { + "name": "charts", + "type": "Array.", + "description": "Report charts." + } + ] + }, + { + "description": "Coupons Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/coupons/config.js#L48-L55", + "name": "woocommerce_admin_coupon_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Coupons Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/coupons/config.js#L126-L131", + "name": "woocommerce_admin_coupons_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Customers Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/customers/config.js#L29-L34", + "name": "woocommerce_admin_customers_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Customers Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/customers/config.js#L80-L87", + "name": "woocommerce_admin_customers_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Downloads Report charts filter.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/downloads/config.js#L26-L31", + "name": "woocommerce_admin_downloads_report_charts", + "type": "filter", + "params": [ + { + "name": "charts", + "type": "Array.", + "description": "Report charts." + } + ] + }, + { + "description": "Downloads Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/downloads/config.js#L44-L49", + "name": "woocommerce_admin_downloads_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Downloads Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/downloads/config.js#L68-L75", + "name": "woocommerce_admin_downloads_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Filter Report pages list.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/get-reports.js#L143-L148", + "name": "woocommerce_admin_reports_list", + "type": "filter", + "params": [ + { + "name": "reports", + "type": "Array.", + "description": "Report pages list." + } + ] + }, + { + "description": "Orders Report charts filter.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/orders/config.js#L27-L32", + "name": "woocommerce_admin_orders_report_charts", + "type": "filter", + "params": [ + { + "name": "charts", + "type": "Array.", + "description": "Report charts." + } + ] + }, + { + "description": "Orders Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/orders/config.js#L64-L69", + "name": "woocommerce_admin_orders_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Orders Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/orders/config.js#L88-L95", + "name": "woocommerce_admin_orders_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Products Report charts filter.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/products/config.js#L30-L35", + "name": "woocommerce_admin_products_report_charts", + "type": "filter", + "params": [ + { + "name": "charts", + "type": "Array.", + "description": "Report charts." + } + ] + }, + { + "description": "Produts Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/products/config.js#L182-L189", + "name": "woocommerce_admin_products_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Products Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/products/config.js#L217-L222", + "name": "woocommerce_admin_products_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Revenue Report charts filter.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/revenue/config.js#L17-L22", + "name": "woocommerce_admin_revenue_report_charts", + "type": "filter", + "params": [ + { + "name": "charts", + "type": "Array.", + "description": "Report charts." + } + ] + }, + { + "description": "Revenue Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/revenue/config.js#L88-L95", + "name": "woocommerce_admin_revenue_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Revenue Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/revenue/config.js#L125-L130", + "name": "woocommerce_admin_revenue_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Stock Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/stock/config.js#L13-L20", + "name": "woocommerce_admin_stock_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Stock Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/stock/config.js#L37-L42", + "name": "woocommerce_admin_stock_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Taxes Report charts filter.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/taxes/config.js#L27-L32", + "name": "woocommerce_admin_taxes_report_charts", + "type": "filter", + "params": [ + { + "name": "charts", + "type": "Array.", + "description": "Report charts." + } + ] + }, + { + "description": "Taxes Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/taxes/config.js#L64-L71", + "name": "woocommerce_admin_taxes_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Coupons Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/taxes/config.js#L129-L134", + "name": "woocommerce_admin_taxes_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Variations Report charts filter.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/variations/config.js#L31-L36", + "name": "woocommerce_admin_variations_report_charts", + "type": "filter", + "params": [ + { + "name": "charts", + "type": "Array.", + "description": "Report charts." + } + ] + }, + { + "description": "Variations Report Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/variations/config.js#L65-L70", + "name": "woocommerce_admin_variations_report_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Variations Report Advanced Filters.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/report/variations/config.js#L140-L147", + "name": "woocommerce_admin_variations_report_advanced_filters", + "type": "filter", + "params": [ + { + "name": "advancedFilters", + "type": "Object", + "description": "Report Advanced Filters." + }, + { + "name": "advancedFilters.title", + "type": "string", + "description": "Interpolated component string for Advanced Filters title." + }, + { + "name": "advancedFilters.filters", + "type": "Object", + "description": "An object specifying a report's Advanced Filters." + } + ] + }, + { + "description": "Filter Analytics Report settings. Add a UI element to the Analytics Settings page.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/settings/config.js#L78-L83", + "name": "woocommerce_admin_analytics_settings", + "type": "filter", + "params": [ + { + "name": "reportSettings", + "type": "Object", + "description": "Report settings." + } + ] + }, + { + "description": "Historical data import statuses.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/analytics/settings/historical-data/status.js#L12-L25", + "name": "woocommerce_admin_import_status", + "type": "filter", + "params": [ + { + "name": "statuses", + "type": "Object", + "description": "Import statuses." + }, + { + "name": "statuses.nothing", + "type": "string", + "description": "Nothing to import." + }, + { + "name": "statuses.ready", + "type": "string", + "description": "Ready to import." + }, + { + "name": "statuses.initializing", + "type": "Array", + "description": "Initializing string and spinner." + }, + { + "name": "statuses.customers", + "type": "Array", + "description": "Importing customers string and spinner." + }, + { + "name": "statuses.orders", + "type": "Array", + "description": "Importing orders string and spinner." + }, + { + "name": "statuses.finalizing", + "type": "Array", + "description": "Finalizing string and spinner." + }, + { + "name": "statuses.finished", + "type": "string", + "description": "Message displayed after import." + } + ] + }, + { + "description": "Add Report filters to the dashboard. None are added by default.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/dashboard/customizable.js#L40-L45", + "name": "woocommerce_admin_dashboard_filters", + "type": "filter", + "params": [ + { + "name": "filters", + "type": "Array.", + "description": "Report filters." + } + ] + }, + { + "description": "Dashboard Charts section charts.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/dashboard/dashboard-charts/config.js#L96-L101", + "name": "woocommerce_admin_dashboard_charts_filter", + "type": "filter", + "params": [ + { + "name": "charts", + "type": "Array.", + "description": "Array of visible charts." + } + ] + }, + { + "description": "Default Dashboard sections. Defaults are Store Performance, Charts, and Leaderboards", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/dashboard/default-sections.js#L56-L61", + "name": "woocommerce_dashboard_default_sections", + "type": "filter", + "params": [ + { + "name": "sections", + "type": "Array.
    ", + "description": "Report filters." + } + ] + }, + { + "description": "Filter an array of body components for WooCommerce non-react pages.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/embedded-body-layout/embedded-body-layout.tsx#L40-L46", + "name": "woocommerce_admin_embedded_layout_components", + "type": "filter", + "params": [ + { + "name": "embeddedBodyComponentList", + "type": "Array.", + "description": "Array of body components." + }, + { + "name": "query", + "type": "Object", + "description": "url query parameters." + } + ] + }, + { + "description": "Create a Fill for extensions to add items to the WooCommerce Admin header.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/header/utils.js#L21-L38", + "name": "WooHeaderItem", + "type": "slotFill", + "params": [ + { + "name": "param0", + "type": "Object", + "description": "" + }, + { + "name": "param0.children", + "type": "Array", + "description": "- Node children." + }, + { + "name": "param0.order", + "type": "Array", + "description": "- Node order." + } + ], + "scope": "woocommerce-admin" + }, + { + "description": "Create a Fill for extensions to add items to the WooCommerce Admin navigation area left of the page title.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/header/utils.js#L59-L77", + "name": "WooHeaderNavigationItem", + "type": "slotFill", + "params": [ + { + "name": "param0", + "type": "Object", + "description": "" + }, + { + "name": "param0.children", + "type": "Array", + "description": "- Node children." + }, + { + "name": "param0.order", + "type": "Array", + "description": "- Node order." + } + ], + "scope": "woocommerce-admin" + }, + { + "description": "Create a Fill for extensions to add custom page titles.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/header/utils.js#L98-L114", + "name": "WooHeaderPageTitle", + "type": "slotFill", + "params": [ + { + "name": "param0", + "type": "Object", + "description": "" + }, + { + "name": "param0.children", + "type": "Array", + "description": "- Node children." + } + ], + "scope": "woocommerce-admin" + }, + { + "description": "List of homepage stats enabled by default", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/homescreen/stats-overview/defaults.js#L5-L10", + "name": "woocommerce_admin_homepage_default_stats", + "type": "filter", + "params": [ + { + "name": "stats", + "type": "Array.", + "description": "Array of homepage stat slugs." + } + ] + }, + { + "description": "List of WooCommerce Admin pages.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/layout/controller.js#L230-L235", + "name": "woocommerce_admin_pages_list", + "type": "filter", + "params": [ + { + "name": "pages", + "type": "Array.", + "description": "Array page objects." + } + ] + }, + { + "description": "Filter each transient notice.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/layout/transient-notices/index.js#L61-L66", + "name": "woocommerce_admin_queued_notice_filter", + "type": "filter", + "params": [ + { + "name": "notice", + "type": "Object", + "description": "A transient notice." + } + ] + }, + { + "description": "Filter the currency context. This affects all WooCommerce Admin currency formatting.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/lib/currency-context.js#L16-L22", + "name": "woocommerce_admin_report_currency", + "type": "filter", + "params": [ + { + "name": "config", + "type": "Object", + "description": "Currency configuration." + }, + { + "name": "query", + "type": "Object", + "description": "Url query parameters." + } + ] + }, + { + "description": "Navigation's exit button WooCommerce label.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/navigation/components/container/primary-menu.js#L24-L29", + "name": "woocommerce_navigation_root_back_label", + "type": "filter", + "params": [ + { + "name": "label", + "type": "string", + "description": "Back button label." + } + ] + }, + { + "description": "Navigation's exit button url.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/navigation/components/container/primary-menu.js#L35-L40", + "name": "woocommerce_navigation_root_back_url", + "type": "filter", + "params": [ + { + "name": "url", + "type": "string", + "description": "Back button url." + } + ] + }, + { + "description": "Filter for Onboarding steps configuration.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/profile-wizard/index.js#L140-L145", + "name": "woocommerce_admin_profile_wizard_steps", + "type": "filter", + "params": [ + { + "name": "steps", + "type": "Array.", + "description": "Array of steps for Onboarding Wizard." + } + ] + }, + { + "description": "Store Management extensions links", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/store-management-links/index.js#L171-L176", + "name": "woocommerce_admin_homescreen_quicklinks", + "type": "filter", + "params": [ + { + "name": "links", + "type": "Array.", + "description": "Array of links." + } + ] + }, + { + "description": "Store product templates.", + "sourceFile": "https://github.com/woocommerce/woocommerce-admin/blob/46c8304c425749dfc715b38e59f56198b05e7b46/client/tasks/fills/products/product-template-modal.js#L145-L150", + "name": "woocommerce_admin_onboarding_product_templates", + "type": "filter", + "params": [ + { + "name": "templates", + "type": "Array.