diff --git a/.github/workflows/build-release-zip-file.yml b/.github/workflows/build-release-zip-file.yml index 94ae898d2a6..c6189b41c80 100644 --- a/.github/workflows/build-release-zip-file.yml +++ b/.github/workflows/build-release-zip-file.yml @@ -31,7 +31,7 @@ jobs: run: unzip plugins/woocommerce/woocommerce.zip -d zipfile - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/nightly-builds.yml b/.github/workflows/nightly-builds.yml index 6a3e22fa123..71ce84bf288 100644 --- a/.github/workflows/nightly-builds.yml +++ b/.github/workflows/nightly-builds.yml @@ -4,23 +4,25 @@ on: - cron: '0 0 * * *' # Run at 12 AM UTC. workflow_dispatch: -permissions: {} +env: + SOURCE_REF: trunk + TARGET_REF: nightly + RELEASE_ID: 25945111 + +permissions: { } jobs: build: if: github.repository_owner == 'woocommerce' name: Nightly builds - strategy: - fail-fast: false - matrix: - build: [trunk] + runs-on: ubuntu-20.04 permissions: contents: write steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: - ref: ${{ matrix.build }} + ref: ${{ env.SOURCE_REF }} - name: Setup WooCommerce Monorepo uses: ./.github/actions/setup-woocommerce-monorepo @@ -31,26 +33,31 @@ jobs: working-directory: plugins/woocommerce run: bash bin/build-zip.sh - - name: Deploy nightly build - uses: WebFreak001/deploy-nightly@v1.1.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Upload nightly build + uses: WebFreak001/deploy-nightly@46ecbabd7fad70d3e7d2c97fe8cd54e7a52e215b #v3.2.0 with: - upload_url: https://uploads.github.com/repos/${{ github.repository }}/releases/25945111/assets{?name,label} - release_id: 25945111 + token: ${{ secrets.GITHUB_TOKEN }} + upload_url: https://uploads.github.com/repos/${{ github.repository }}/releases/${{ env.RELEASE_ID }}/assets{?name,label} + release_id: ${{ env.RELEASE_ID }} asset_path: plugins/woocommerce/woocommerce.zip - asset_name: woocommerce-${{ matrix.build }}-nightly.zip + asset_name: woocommerce-${{ env.SOURCE_REF }}-nightly.zip asset_content_type: application/zip max_releases: 1 - update: - name: Update nightly tag commit ref - runs-on: ubuntu-20.04 - permissions: - contents: write - steps: - - name: Update nightly tag - uses: richardsimko/github-tag-action@v1.0.5 + + - name: Update nightly tag commit ref + uses: actions/github-script@v7 with: - tag_name: nightly - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const sourceRef = process.env.SOURCE_REF; + const targetRef = process.env.TARGET_REF; + const branchData = await github.rest.repos.getBranch({ + ...context.repo, + branch: sourceRef, + }); + + await github.rest.git.updateRef({ + ...context.repo, + ref: `tags/${ targetRef }`, + sha: branchData.data.commit.sha, + }); diff --git a/.github/workflows/pr-assess-performance.yml b/.github/workflows/pr-assess-performance.yml new file mode 100644 index 00000000000..b22c1719cd2 --- /dev/null +++ b/.github/workflows/pr-assess-performance.yml @@ -0,0 +1,102 @@ +name: Performance metrics + +on: + pull_request: + paths: + - 'plugins/woocommerce/composer.*' + - 'plugins/woocommerce/client/admin/config/**' + - 'plugins/woocommerce/includes/**' + - 'plugins/woocommerce/lib/**' + - 'plugins/woocommerce/patterns/**' + - 'plugins/woocommerce/src/**' + - 'plugins/woocommerce/templates/**' + - 'plugins/woocommerce/tests/metrics/**' + - 'plugins/woocommerce/.wp-env.json' + - '.github/workflows/pr-assess-performance.yml' + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.head_ref || github.sha }} + cancel-in-progress: true + +env: + WP_ARTIFACTS_PATH: ${{ github.workspace }}/tools/compare-perf/artifacts/ + +jobs: + benchmark: + name: Evaluate performance metrics + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + name: Checkout (${{ github.event_name == 'pull_request' && github.head_ref || github.sha }}) + with: + fetch-depth: 0 + + - uses: ./.github/actions/setup-woocommerce-monorepo + name: Install Monorepo + with: + install: '@woocommerce/plugin-woocommerce...' + build: '@woocommerce/plugin-woocommerce' + build-type: 'full' + pull-playwright-cache: true + pull-package-deps: '@woocommerce/plugin-woocommerce' + + #TODO: Inject WordPress version as per plugin requirements (relying to defaults currently). + - name: Start Test Environment + run: | + pnpm --filter="@woocommerce/plugin-woocommerce" test:e2e:install & + pnpm --filter="@woocommerce/plugin-woocommerce" env:test + + # TODO: cache results if pushed to trunk + - name: Measure performance (@${{ github.sha }}) + run: | + RESULTS_ID="editor_${{ github.sha }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics editor + RESULTS_ID="product-editor_${{ github.sha }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics product-editor + + # In alignment with .github/workflows/scripts/run-metrics.sh, we should checkout 3d7d7f02017383937f1a4158d433d0e5d44b3dc9 + # as baseline. But to avoid switching branches in 'Analyze results' step, we pick 55f855a2e6d769b5ae44305b2772eb30d3e721df + # which introduced reporting mode for the perf utility. + - name: Checkout (55f855a2e6d769b5ae44305b2772eb30d3e721df@trunk, further references as 'baseline') + run: | + git reset --hard && git checkout 55f855a2e6d769b5ae44305b2772eb30d3e721df + echo "WC_TRUNK_SHA=55f855a2e6d769b5ae44305b2772eb30d3e721df" >> $GITHUB_ENV + + # Artifacts download/upload would be more reliable, but we couldn't make it working... + - uses: actions/cache@ab5e6d0c87105b4c9c2047343972218f562e4319 + name: Cache measurements (baseline) + with: + path: tools/compare-perf/artifacts/*_${{ env.WC_TRUNK_SHA }}_* + key: ${{ runner.os }}-woocommerce-performance-measures-${{ env.WC_TRUNK_SHA }} + + - name: Verify cached measurements (baseline) + run: | + if test -n "$(find tools/compare-perf/artifacts/ -maxdepth 1 -name '*_${{ env.WC_TRUNK_SHA }}_*' -print -quit)" + then + echo "WC_MEASURE_BASELINE=no" >> $GITHUB_ENV + else + ls -l tools/compare-perf/artifacts/ + echo "Triggering baseline benchmarking" + echo "WC_MEASURE_BASELINE=yes" >> $GITHUB_ENV + fi + + - name: Build (baseline) + if: ${{ env.WC_MEASURE_BASELINE == 'yes' }} + run: | + git clean -n -d -X ./packages ./plugins | grep -v vendor | grep -v node_modules | sed -e 's/Would remove //g' | tr '\n' '\0' | xargs -0 rm -r + pnpm install --filter='@woocommerce/plugin-woocommerce...' --frozen-lockfile --config.dedupe-peer-dependents=false + pnpm --filter='@woocommerce/plugin-woocommerce' build + + #TODO: is baseline Wordpress version changes, restart environment targeting it. + + - name: Measure performance (@${{ env.WC_TRUNK_SHA }}) + if: ${{ env.WC_MEASURE_BASELINE == 'yes' }} + run: | + RESULTS_ID="editor_${{ env.WC_TRUNK_SHA }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics editor + RESULTS_ID="product-editor_${{ env.WC_TRUNK_SHA }}_round-1" pnpm --filter="@woocommerce/plugin-woocommerce" test:metrics product-editor + + - name: Analyze results + run: | + pnpm install --filter='compare-perf...' --frozen-lockfile --config.dedupe-peer-dependents=false + pnpm --filter="compare-perf" run compare compare-performance ${{ github.sha }} ${{ env.WC_TRUNK_SHA }} --tests-branch ${{ github.sha }} --skip-benchmarking + + # TODO: Publish to CodeVitals (see .github/workflows/scripts/run-metrics.sh) if pushed to trunk diff --git a/.github/workflows/release-code-freeze.yml b/.github/workflows/release-code-freeze.yml index 633000a1801..68a6304ee93 100644 --- a/.github/workflows/release-code-freeze.yml +++ b/.github/workflows/release-code-freeze.yml @@ -186,7 +186,7 @@ jobs: run: bash bin/build-zip.sh - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -216,7 +216,7 @@ jobs: run: bash bin/build-zip.sh - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -231,7 +231,7 @@ jobs: if: ${{ needs.code-freeze-prep.outputs.isTodayMonthlyFreeze == 'yes' }} steps: - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -279,7 +279,7 @@ jobs: working-directory: tools/monorepo-utils - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -300,7 +300,7 @@ jobs: if: ${{ needs.code-freeze-prep.outputs.isTodayAcceleratedFreeze == 'yes' }} steps: - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -348,7 +348,7 @@ jobs: working-directory: tools/monorepo-utils - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -369,7 +369,7 @@ jobs: if: ${{ needs.code-freeze-prep.outputs.isTodayMonthlyFreeze == 'yes' }} steps: - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -380,7 +380,7 @@ jobs: run: unzip ${{ steps.download.outputs.download-path }}/woocommerce.zip -d zipfile - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -395,7 +395,7 @@ jobs: if: ${{ needs.code-freeze-prep.outputs.isTodayAcceleratedFreeze == 'yes' }} steps: - id: download - uses: actions/download-artifact@v3 + uses: actions/download-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -406,7 +406,7 @@ jobs: run: unzip ${{ steps.download.outputs.download-path }}/woocommerce.zip -d zipfile - name: Upload the zip file as an artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/storybook-pages.yml b/.github/workflows/storybook-pages.yml index 0e84b6df80f..1aea3190d65 100644 --- a/.github/workflows/storybook-pages.yml +++ b/.github/workflows/storybook-pages.yml @@ -1,6 +1,8 @@ name: Storybook GitHub Pages on: + schedule: + - cron: '30 2 * * *' workflow_dispatch: permissions: diff --git a/.husky/post-checkout b/.husky/post-checkout new file mode 100755 index 00000000000..cddb5753bc3 --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +. "$(dirname "$0")/_/husky.sh" + +# '1' is branch +CHECKOUT_TYPE=$3 +redColoured='\033[0;31m' +whiteColoured='\033[0m' + +if [ "$CHECKOUT_TYPE" = '1' ]; then + canUpdateDependencies='no' + + # Prompt about pnpm versions mismatch when switching between branches. + currentPnpmVersion=$( ( command -v pnpm > /dev/null && pnpm -v ) || echo 'n/a' ) + targetPnpmVersion=$( grep packageManager package.json | sed -nr 's/.+packageManager.+pnpm@([[:digit:].]+).+/\1/p' ) + if [ "$currentPnpmVersion" != "$targetPnpmVersion" ]; then + printf "${redColoured}pnpm versions mismatch: in use '$currentPnpmVersion', needed '$targetPnpmVersion'. Here some hints how to solve this:\n" + printf "${redColoured}* actualize environment: 'nvm use && pnpm -v' (the most common case)\n" + printf "${redColoured}* install: 'npm install -g pnpm@$targetPnpmVersion'\n" + else + canUpdateDependencies='yes' + fi + + # Auto-refresh dependencies when switching between branches. + changedManifests=$( ( git diff --name-only HEAD ORIG_HEAD | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' ) + if [ -n "$changedManifests" ]; then + printf "${whiteColoured}It was a change in the following file(s) - refreshing dependencies:\n" + printf "${whiteColoured} %s\n" $changedManifests + + if [ "$canUpdateDependencies" = 'yes' ]; then + pnpm install --frozen-lockfile + else + printf "${redColoured}Skipping dependencies refresh. Please actualize pnpm version and execute 'pnpm install --frozen-lockfile' manually.\n" + fi + fi +fi diff --git a/.markdownlint.json b/.markdownlint.json index 5e29a079a84..4a2dd1c3ec4 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -3,8 +3,8 @@ "MD003": { "style": "atx" }, "MD007": { "indent": 4 }, "MD013": { "line_length": 9999 }, - "MD024": { "allow_different_nesting": true }, - "MD033": { "allowed_elements": ["video"] }, + "MD024": { "siblings_only": true }, + "MD033": { "allowed_elements": [ "video" ] }, "no-hard-tabs": false, "whitespace": false } diff --git a/.npmrc b/.npmrc index 9d43c15d3cc..a140ea6b576 100644 --- a/.npmrc +++ b/.npmrc @@ -1,3 +1,5 @@ ; adding this as npm 7 automatically installs peer dependencies but pnpm does not auto-install-peers=true strict-peer-dependencies=false +; See https://github.com/pnpm/pnpm/pull/8363 (we adding the setting now, to not miss when migrating to pnpm 9.7+) +manage-package-manager-versions=true diff --git a/CODEOWNERS b/CODEOWNERS index 614fa2f46be..532335b71de 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1,14 @@ -/.github/ @woocommerce/atlas +# Monorepo CI and package managers manifests. +/.github/ @woocommerce/flux +**/composer.json @woocommerce/flux +**/package.json @woocommerce/flux + +# Monorepo tooling folders. +/bin/ @woocommerce/flux +/tools/ @woocommerce/flux +/packages/js/eslint-plugin/ @woocommerce/flux +/packages/js/dependency-extraction-webpack-plugin/ @woocommerce/flux + +# Files in root of repository +/.* @woocommerce/flux +/*.* @woocommerce/flux diff --git a/README.md b/README.md index a0dc78caa81..4ff249daa69 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ To get up and running within the WooCommerce Monorepo, you will need to make sur Once you've installed all of the prerequisites, you can run the following commands to get everything working. ```bash -# Ensure that you're using the correct version of Node -nvm use +# Ensure that correct version of Node is installed and being used +nvm install # Install the PHP and Composer dependencies for all of the plugins, packages, and tools pnpm install # Build all of the plugins, packages, and tools in the monorepo diff --git a/changelog.txt b/changelog.txt index 19f15145abc..2ba165e2bdc 100644 --- a/changelog.txt +++ b/changelog.txt @@ -145,6 +145,7 @@ * Update - Update core profiler continue button container on extension screen [#50582](https://github.com/woocommerce/woocommerce/pull/50582) * Update - Update Store Alert actions to have unique keys. [#50424](https://github.com/woocommerce/woocommerce/pull/50424) * Update - Update WooCommercePayments task is_supported to use default suggestions [#50585](https://github.com/woocommerce/woocommerce/pull/50585) +* Update - Enhance CSV path and upload handling in product import [#51344](https://github.com/woocommerce/woocommerce/pull/51344) * Dev - Execute test env setup on host instead of wp-env container [#51021](https://github.com/woocommerce/woocommerce/pull/51021) * Dev - Added code docs with examples to the Analytics classes [#49425](https://github.com/woocommerce/woocommerce/pull/49425) * Dev - Add lost password e2e tests [#50611](https://github.com/woocommerce/woocommerce/pull/50611) diff --git a/docs/docs-manifest.json b/docs/docs-manifest.json index f7579ca4cef..d18a4367869 100644 --- a/docs/docs-manifest.json +++ b/docs/docs-manifest.json @@ -874,7 +874,7 @@ "menu_title": "Development environment setup", "tags": "tutorial, setup", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/getting-started/development-environment.md", - "hash": "9e471d3f44a882fe61dcad9e5207d51b280a7220aae1bf6e4ae1fbdd68b7e3d4", + "hash": "bf5d77349ea64d1b8e19fe6b7472be35ed92406c5aafe677ce92363fb13f94d4", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/getting-started/development-environment.md", "id": "9080572a3904349c44c565ca7e1bef1212c58757" }, @@ -1059,7 +1059,7 @@ "menu_title": "DOM Events", "tags": "how-to", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/product-collection-block/dom-events.md", - "hash": "59a4b49eb146774d33229bc60ab7d8f74381493f6e7089ca8f0e2d0eb433a7a4", + "hash": "85cffe1cc273621f16f7362b5efe28ede9689cee0a6e87d0d426014bacc24b05", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/product-collection-block/dom-events.md", "id": "c8d247b91472740075871e6b57a9583d893ac650" } @@ -1229,7 +1229,7 @@ "menu_title": "Core critical flows", "tags": "reference", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/quality-and-best-practices/core-critical-flows.md", - "hash": "472f5a240abe907fec83a8a9f88af6699f2d994aa7ae87faa1716a087baa66db", + "hash": "34109195216ebcb5b23e741391b9f355ba861777a5533d4ef1e341472cb5209e", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/quality-and-best-practices/core-critical-flows.md", "id": "e561b46694dba223c38b87613ce4907e4e14333a" }, @@ -1804,5 +1804,5 @@ "categories": [] } ], - "hash": "77c102c35a45b0681e7b70def9d639d764e4e5068121c2ef4dd23477c0f8784c" + "hash": "47dc2a7e213e1e9d83e93a85dafdf8c7b539cc5474b4166eb6398b734d150ff3" } \ No newline at end of file diff --git a/docs/getting-started/development-environment.md b/docs/getting-started/development-environment.md index 0701be60fc1..989587d4153 100644 --- a/docs/getting-started/development-environment.md +++ b/docs/getting-started/development-environment.md @@ -80,22 +80,16 @@ git clone https://github.com/woocommerce/woocommerce.git cd woocommerce ``` -### Activate the required Node version +### Install and Activate the required Node version ```sh -nvm use -Found '/path/to/woocommerce/.nvmrc' with version -Now using node v12.21.0 (npm v6.14.11) +nvm install +Found '/path/to/woocommerce/.nvmrc' with version +v20.17.0 is already installed. +Now using node v20.17.0 (npm v10.8.2) ``` -Note: if you don't have the required version of Node installed, NVM will alert you so you can install it: - -```sh -Found '/path/to/woocommerce/.nvmrc' with version -N/A: version "v12 -> N/A" is not yet installed. - -You need to run "nvm install v12" to install it before using it. -``` +Note: if you don't have the required version of Node installed, NVM will install it. ### Install dependencies diff --git a/docs/product-collection-block/dom-events.md b/docs/product-collection-block/dom-events.md index 940e49a929d..608a88d71f1 100644 --- a/docs/product-collection-block/dom-events.md +++ b/docs/product-collection-block/dom-events.md @@ -10,13 +10,13 @@ tags: how-to This event is triggered when Product Collection block was rendered or re-rendered (e.g. due to page change). -### `wc-blocks_product_list_rendered` - `detail` parameters +### `detail` parameters | Parameter | Type | Default value | Description | | ------------------ | ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `collection` | string | `undefined` | Collection type. It's `undefined` for "create your own" collections as the type is not specified. For other Core collections it can be one of type: `woocommerce/product-collection/best-sellers`, `woocommerce/product-collection/featured`, `woocommerce/product-collection/new-arrivals`, `woocommerce/product-collection/on-sale`, `woocommerce/product-collection/top-rated`. For custom collections it will hold their name. | -### `wc-blocks_product_list_rendered` - Example usage +### Example usage ```javascript window.document.addEventListener( @@ -32,14 +32,14 @@ window.document.addEventListener( This event is triggered when some blocks are clicked in order to view product (redirect to product page). -### `wc-blocks_viewed_product` - `detail` parameters +### `detail` parameters | Parameter | Type | Default value | Description | | ------------------ | ------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `collection` | string | `undefined` | Collection type. It's `undefined` for "create your own" collections as the type is not specified. For other Core collections it can be one of type: `woocommerce/product-collection/best-sellers`, `woocommerce/product-collection/featured`, `woocommerce/product-collection/new-arrivals`, `woocommerce/product-collection/on-sale`, `woocommerce/product-collection/top-rated`. For custom collections it will hold their name. | | `productId` | number | | Product ID | -### `wc-blocks_viewed_product` Example usage +### Example usage ```javascript window.document.addEventListener( diff --git a/docs/quality-and-best-practices/core-critical-flows.md b/docs/quality-and-best-practices/core-critical-flows.md index d959b4d54e6..11fb8ec2466 100644 --- a/docs/quality-and-best-practices/core-critical-flows.md +++ b/docs/quality-and-best-practices/core-critical-flows.md @@ -147,13 +147,14 @@ These flows will continually evolve as the platform evolves with flows updated, ### Merchant - Settings | User Type | Flow Area | Flow Name | Test File | -| --------- | --------- | -------------------------------------- | ---------------------------------------- | +| --------- | --------- |----------------------------------------|------------------------------------------| | Merchant | Settings | Update General Settings | merchant/settings-general.spec.js | | Merchant | Settings | Add Tax Rates | merchant/settings-tax.spec.js | | Merchant | Settings | Add Shipping Zones | merchant/create-shipping-zones.spec.js | | Merchant | Settings | Add Shipping Classes | merchant/create-shipping-classes.spec.js | | Merchant | Settings | Enable local pickup for checkout block | merchant/settings-shipping.spec.js | | Merchant | Settings | Update payment settings | admin-tasks/payment.spec.js | +| Merchant | Settings | Handle Product Brands | merchant/create-product-brand.spec.js | ### Merchant - Coupons diff --git a/packages/js/data/changelog/fix-51395-reporttable-page b/packages/js/data/changelog/fix-51395-reporttable-page new file mode 100644 index 00000000000..9b02145b01f --- /dev/null +++ b/packages/js/data/changelog/fix-51395-reporttable-page @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Use page query param consistently as string for `getReportTableQuery`. diff --git a/packages/js/data/src/reports/utils.ts b/packages/js/data/src/reports/utils.ts index 065c4847653..39cf4869798 100644 --- a/packages/js/data/src/reports/utils.ts +++ b/packages/js/data/src/reports/utils.ts @@ -561,7 +561,7 @@ export function getReportTableQuery( before: noIntervals ? undefined : appendTimestamp( datesFromQuery.primary.before, 'end' ), - page: query.paged || 1, + page: query.paged || '1', per_page: query.per_page || QUERY_DEFAULTS.pageSize, ...filterQuery, ...tableQuery, diff --git a/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx b/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx index 1691f46c4e3..6b321d07bd9 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/table/actions/install.tsx @@ -51,6 +51,48 @@ export default function Install( props: InstallProps ) { ); }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleInstallError = ( error: any ) => { + loadSubscriptions( false ).then( () => { + let errorMessage = sprintf( + // translators: %s is the product name. + __( '%s couldn’t be installed.', 'woocommerce' ), + props.subscription.product_name + ); + if ( error?.success === false && error?.data.message ) { + errorMessage += ' ' + error.data.message; + } + addNotice( + props.subscription.product_key, + errorMessage, + NoticeStatus.Error, + { + actions: [ + { + label: __( + 'Download and install manually', + 'woocommerce' + ), + url: 'https://woocommerce.com/my-account/downloads/', + }, + ], + } + ); + stopInstall(); + + if ( props.onError ) { + props.onError(); + } + } ); + + recordEvent( 'marketplace_product_install_failed', { + product_zip_slug: props.subscription.zip_slug, + product_id: props.subscription.product_id, + product_current_version: props.subscription.version, + error_message: error?.data?.message, + } ); + }; + const install = () => { recordEvent( 'marketplace_product_install_button_clicked', { product_zip_slug: props.subscription.zip_slug, @@ -90,71 +132,26 @@ export default function Install( props: InstallProps ) { props.onSuccess(); } } ) - .catch( ( error ) => { - loadSubscriptions( false ).then( () => { - let errorMessage = sprintf( - // translators: %s is the product name. - __( '%s couldn’t be installed.', 'woocommerce' ), - props.subscription.product_name - ); - if ( error?.success === false && error?.data.message ) { - errorMessage += ' ' + error.data.message; - } - addNotice( - props.subscription.product_key, - errorMessage, - NoticeStatus.Error, - { - actions: [ - { - label: __( 'Try again', 'woocommerce' ), - onClick: install, - }, - ], - } - ); - stopInstall(); - - if ( props.onError ) { - props.onError(); - } - } ); - - recordEvent( 'marketplace_product_install_failed', { + .catch( handleInstallError ); + } else { + getInstallUrl( props.subscription ) + .then( ( url: string ) => { + recordEvent( 'marketplace_product_install_url', { product_zip_slug: props.subscription.zip_slug, product_id: props.subscription.product_id, product_current_version: props.subscription.version, - error_message: error?.data?.message, + product_install_url: url, } ); - } ); - } else { - getInstallUrl( props.subscription ).then( ( url: string ) => { - recordEvent( 'marketplace_product_install_url', { - product_zip_slug: props.subscription.zip_slug, - product_id: props.subscription.product_id, - product_current_version: props.subscription.version, - product_install_url: url, - } ); - stopInstall(); + stopInstall(); - if ( url ) { - window.open( url, '_self' ); - } else { - addNotice( - props.subscription.product_key, - sprintf( - // translators: %s is the product name. - __( - '%s couldn’t be installed. Please install the product manually.', - 'woocommerce' - ), - props.subscription.product_name - ), - NoticeStatus.Error - ); - } - } ); + if ( url ) { + window.open( url, '_self' ); + } else { + throw new Error(); + } + } ) + .catch( handleInstallError ); } }; diff --git a/plugins/woocommerce-beta-tester/api/remote-logging/remote-logging.php b/plugins/woocommerce-beta-tester/api/remote-logging/remote-logging.php index 80d518e03f2..fa54e7ef426 100644 --- a/plugins/woocommerce-beta-tester/api/remote-logging/remote-logging.php +++ b/plugins/woocommerce-beta-tester/api/remote-logging/remote-logging.php @@ -100,7 +100,10 @@ function log_remote_event() { time(), 'critical', 'Test PHP event from WC Beta Tester', - array( 'source' => 'wc-beta-tester' ) + array( + 'source' => 'wc-beta-tester', + 'remote-logging' => true, + ) ); if ( $result ) { diff --git a/plugins/woocommerce-beta-tester/changelog/enhance-reduce-remote-logging-noise b/plugins/woocommerce-beta-tester/changelog/enhance-reduce-remote-logging-noise new file mode 100644 index 00000000000..c5e1fb58516 --- /dev/null +++ b/plugins/woocommerce-beta-tester/changelog/enhance-reduce-remote-logging-noise @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Set remote-logging context to true in log remote event method diff --git a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx index 0c12494ff70..ad7bb145709 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx +++ b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/form/address-line-2-field.tsx @@ -5,6 +5,7 @@ import { ValidatedTextInput } from '@woocommerce/blocks-components'; import { AddressFormValues, ContactFormValues } from '@woocommerce/settings'; import { useState, Fragment, useCallback } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@ariakit/react'; /** * Internal dependencies @@ -50,7 +51,8 @@ const AddressLine2Field = < T extends AddressFormValues | ContactFormValues >( { /> ) : ( <> - + ) => void; setEmail: ( value: string ) => void; useShippingAsBilling: boolean; + editingBillingAddress: boolean; + editingShippingAddress: boolean; setUseShippingAsBilling: ( useShippingAsBilling: boolean ) => void; + setEditingBillingAddress: ( isEditing: boolean ) => void; + setEditingShippingAddress: ( isEditing: boolean ) => void; defaultFields: AddressFields; showShippingFields: boolean; showBillingFields: boolean; @@ -40,15 +44,25 @@ interface CheckoutAddress { */ export const useCheckoutAddress = (): CheckoutAddress => { const { needsShipping } = useShippingData(); - const { useShippingAsBilling, prefersCollection } = useSelect( - ( select ) => ( { - useShippingAsBilling: - select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(), - prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(), - } ) - ); - const { __internalSetUseShippingAsBilling } = - useDispatch( CHECKOUT_STORE_KEY ); + const { + useShippingAsBilling, + prefersCollection, + editingBillingAddress, + editingShippingAddress, + } = useSelect( ( select ) => ( { + useShippingAsBilling: + select( CHECKOUT_STORE_KEY ).getUseShippingAsBilling(), + prefersCollection: select( CHECKOUT_STORE_KEY ).prefersCollection(), + editingBillingAddress: + select( CHECKOUT_STORE_KEY ).getEditingBillingAddress(), + editingShippingAddress: + select( CHECKOUT_STORE_KEY ).getEditingShippingAddress(), + } ) ); + const { + __internalSetUseShippingAsBilling, + setEditingBillingAddress, + setEditingShippingAddress, + } = useDispatch( CHECKOUT_STORE_KEY ); const { billingAddress, setBillingAddress, @@ -77,6 +91,10 @@ export const useCheckoutAddress = (): CheckoutAddress => { defaultFields, useShippingAsBilling, setUseShippingAsBilling: __internalSetUseShippingAsBilling, + editingBillingAddress, + editingShippingAddress, + setEditingBillingAddress, + setEditingShippingAddress, needsShipping, showShippingFields: ! forcedBillingAddress && needsShipping && ! prefersCollection, diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/address-card/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/address-card/index.tsx index c6b97fe1e51..e4ad1823ecc 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/address-card/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/address-card/index.tsx @@ -11,6 +11,7 @@ import { } from '@woocommerce/types'; import { FormFieldsConfig, getSetting } from '@woocommerce/settings'; import { formatAddress } from '@woocommerce/blocks/checkout/utils'; +import { Button } from '@ariakit/react'; /** * Internal dependencies @@ -82,7 +83,8 @@ const AddressCard = ( { ) } { onEdit && ( - + ) } ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx index bcdcbac3e69..d56938d94df 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx @@ -7,14 +7,12 @@ import { useCheckoutAddress, useEditorContext, noticeContexts, - useShippingData, } from '@woocommerce/base-context'; import Noninteractive from '@woocommerce/base-components/noninteractive'; import type { ShippingAddress, FormFieldsConfig } from '@woocommerce/settings'; import { StoreNoticesContainer } from '@woocommerce/blocks-components'; import { useSelect } from '@wordpress/data'; import { CART_STORE_KEY } from '@woocommerce/block-data'; -import isShallowEqual from '@wordpress/is-shallow-equal'; /** * Internal dependencies @@ -36,14 +34,9 @@ const Block = ( { showPhoneField: boolean; requirePhoneField: boolean; } ): JSX.Element => { - const { - shippingAddress, - billingAddress, - setShippingAddress, - useBillingAsShipping, - } = useCheckoutAddress(); + const { billingAddress, setShippingAddress, useBillingAsShipping } = + useCheckoutAddress(); const { isEditor } = useEditorContext(); - const { needsShipping } = useShippingData(); // Syncs shipping address with billing address if "Force shipping to the customer billing address" is enabled. useEffectOnce( () => { @@ -101,19 +94,6 @@ const Block = ( { }; } ); - // Default editing state for CustomerAddress component comes from the current address and whether or not we're in the editor. - const hasAddress = !! ( - billingAddress.address_1 && - ( billingAddress.first_name || billingAddress.last_name ) - ); - const { email, ...billingAddressWithoutEmail } = billingAddress; - const billingMatchesShipping = isShallowEqual( - billingAddressWithoutEmail, - shippingAddress - ); - const defaultEditingAddress = - isEditor || ! hasAddress || ( needsShipping && billingMatchesShipping ); - return ( <> @@ -121,7 +101,6 @@ const Block = ( { { cartDataLoaded ? ( ) : null } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx index b36708f4314..668fe20cf61 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/customer-address.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useState, useCallback, useEffect } from '@wordpress/element'; +import { useCallback, useEffect } from '@wordpress/element'; import { Form } from '@woocommerce/base-components/cart-checkout'; import { useCheckoutAddress, useStoreEvents } from '@woocommerce/base-context'; import type { @@ -20,19 +20,18 @@ import AddressCard from '../../address-card'; const CustomerAddress = ( { addressFieldsConfig, - defaultEditing = false, }: { addressFieldsConfig: FormFieldsConfig; - defaultEditing?: boolean; } ) => { const { billingAddress, setShippingAddress, setBillingAddress, useBillingAsShipping, + editingBillingAddress: editing, + setEditingBillingAddress: setEditing, } = useCheckoutAddress(); const { dispatchCheckoutEvent } = useStoreEvents(); - const [ editing, setEditing ] = useState( defaultEditing ); // Forces editing state if store has errors. const { hasValidationErrors, invalidProps } = useSelect( ( select ) => { @@ -55,7 +54,7 @@ const CustomerAddress = ( { if ( invalidProps.length > 0 && editing === false ) { setEditing( true ); } - }, [ editing, hasValidationErrors, invalidProps.length ] ); + }, [ editing, hasValidationErrors, invalidProps.length, setEditing ] ); const onChangeAddress = useCallback( ( values: AddressFormValues ) => { @@ -86,7 +85,7 @@ const CustomerAddress = ( { isExpanded={ editing } /> ), - [ billingAddress, addressFieldsConfig, editing ] + [ billingAddress, addressFieldsConfig, editing, setEditing ] ); const renderAddressFormComponent = useCallback( diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/block.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/block.tsx index 2ddbb0d7744..e749fa33afe 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/block.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/block.tsx @@ -47,6 +47,7 @@ const Block = ( { billingAddress, useShippingAsBilling, setUseShippingAsBilling, + setEditingBillingAddress, } = useCheckoutAddress(); const { isEditor } = useEditorContext(); const isGuest = getSetting( 'currentUserId' ) === 0; @@ -116,10 +117,6 @@ const Block = ( { const noticeContext = useShippingAsBilling ? [ noticeContexts.SHIPPING_ADDRESS, noticeContexts.BILLING_ADDRESS ] : [ noticeContexts.SHIPPING_ADDRESS ]; - const hasAddress = !! ( - shippingAddress.address_1 && - ( shippingAddress.first_name || shippingAddress.last_name ) - ); const { cartDataLoaded } = useSelect( ( select ) => { const store = select( CART_STORE_KEY ); @@ -128,9 +125,6 @@ const Block = ( { }; } ); - // Default editing state for CustomerAddress component comes from the current address and whether or not we're in the editor. - const defaultEditingAddress = isEditor || ! hasAddress; - return ( <> @@ -138,7 +132,6 @@ const Block = ( { { cartDataLoaded ? ( ) : null } @@ -151,6 +144,7 @@ const Block = ( { if ( checked ) { syncBillingWithShipping(); } else { + setEditingBillingAddress( true ); clearBillingAddress( billingAddress ); } } } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx index 1c21625c188..fed4e47c7ba 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-address-block/customer-address.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useState, useCallback, useEffect } from '@wordpress/element'; +import { useCallback, useEffect } from '@wordpress/element'; import { Form } from '@woocommerce/base-components/cart-checkout'; import { useCheckoutAddress, useStoreEvents } from '@woocommerce/base-context'; import type { @@ -20,19 +20,18 @@ import AddressCard from '../../address-card'; const CustomerAddress = ( { addressFieldsConfig, - defaultEditing = false, }: { addressFieldsConfig: FormFieldsConfig; - defaultEditing?: boolean; } ) => { const { shippingAddress, setShippingAddress, setBillingAddress, useShippingAsBilling, + editingShippingAddress: editing, + setEditingShippingAddress: setEditing, } = useCheckoutAddress(); const { dispatchCheckoutEvent } = useStoreEvents(); - const [ editing, setEditing ] = useState( defaultEditing ); // Forces editing state if store has errors. const { hasValidationErrors, invalidProps } = useSelect( ( select ) => { @@ -54,7 +53,7 @@ const CustomerAddress = ( { if ( invalidProps.length > 0 && editing === false ) { setEditing( true ); } - }, [ editing, hasValidationErrors, invalidProps.length ] ); + }, [ editing, hasValidationErrors, invalidProps.length, setEditing ] ); const onChangeAddress = useCallback( ( values: AddressFormValues ) => { @@ -85,7 +84,7 @@ const CustomerAddress = ( { isExpanded={ editing } /> ), - [ shippingAddress, addressFieldsConfig, editing ] + [ shippingAddress, addressFieldsConfig, editing, setEditing ] ); const renderAddressFormComponent = useCallback( diff --git a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx index f6b033aa709..a42bdd5a61d 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/checkout/inner-blocks/checkout-shipping-method-block/block.tsx @@ -10,6 +10,7 @@ import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data'; import { useDispatch, useSelect } from '@wordpress/data'; import { isPackageRateCollectable } from '@woocommerce/base-utils'; import { getSetting } from '@woocommerce/settings'; +import { Button } from '@ariakit/react'; /** * Internal dependencies @@ -18,7 +19,6 @@ import { RatePrice, getLocalPickupPrices, getShippingPrices } from './shared'; import type { minMaxPrices } from './shared'; import { defaultLocalPickupText, defaultShippingText } from './constants'; import { shippingAddressHasValidationErrors } from '../../../../data/cart/utils'; -import Button from '../../../../base/components/button'; const SHIPPING_RATE_ERROR = { hidden: true, @@ -44,8 +44,8 @@ const LocalPickupSelector = ( { } ) => { return ( - + { isOpen && (
{ children } diff --git a/plugins/woocommerce-blocks/packages/components/panel/style.scss b/plugins/woocommerce-blocks/packages/components/panel/style.scss index ac4f5722bde..d4651f9b918 100644 --- a/plugins/woocommerce-blocks/packages/components/panel/style.scss +++ b/plugins/woocommerce-blocks/packages/components/panel/style.scss @@ -14,31 +14,27 @@ } .wc-block-components-panel__button { - @include reset-box(); + box-sizing: border-box; height: auto; - line-height: 1; margin-top: em(6px); padding-right: #{24px + $gap-smaller}; padding-top: em($gap-small - 6px); + padding-left: 0 !important; position: relative; text-align: left; width: 100%; word-break: break-word; &[aria-expanded="true"] { - padding-bottom: $gap-smaller; - margin-bottom: $gap-smaller; + margin-bottom: $gap-smaller * 2; } &, &:hover, &:focus, &:active { - @include reset-color(); - @include reset-typography(); - background: transparent; - box-shadow: none; cursor: pointer; + padding-left: 0 !important; } > .wc-block-components-panel__button-icon { @@ -58,21 +54,3 @@ display: none; } } - -// Extra classes for specificity. -.theme-twentytwentyone.theme-twentytwentyone.theme-twentytwentyone -.wc-block-components-panel__button { - background-color: inherit; - color: inherit; -} - -.theme-twentytwenty .wc-block-components-panel__button, -.theme-twentyseventeen .wc-block-components-panel__button { - background: none transparent; - color: inherit; - - &.wc-block-components-panel__button:hover, - &.wc-block-components-panel__button:focus { - background: none transparent; - } -} diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts index 1a87ebeb605..3b94f037eeb 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.page.ts @@ -207,7 +207,8 @@ class ProductCollectionPage { } async chooseProductInEditorProductPickerIfAvailable( - pageReference: Page | FrameLocator + pageReference: Page | FrameLocator, + productName = 'Album' ) { const editorProductPicker = pageReference.locator( SELECTORS.productPicker @@ -217,7 +218,7 @@ class ProductCollectionPage { await editorProductPicker .locator( 'label' ) .filter( { - hasText: 'Album', + hasText: productName, } ) .click(); } diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts index a7ea710f8a4..6fd09e4050c 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/register-product-collection-tester.block_theme.spec.ts @@ -356,4 +356,84 @@ test.describe( 'Product Collection registration', () => { await expect( previewButtonLocator ).toBeHidden(); } ); } ); + + test( 'Product picker should be shown when selected product is deleted', async ( { + pageObject, + admin, + editor, + requestUtils, + page, + } ) => { + // Add a new test product to the database + let testProductId: number | null = null; + const newProduct = await requestUtils.rest( { + method: 'POST', + path: 'wc/v3/products', + data: { + name: 'A Test Product', + price: 10, + }, + } ); + testProductId = newProduct.id; + + await admin.createNewPost(); + await pageObject.insertProductCollection(); + await pageObject.chooseCollectionInPost( + 'myCustomCollectionWithProductContext' + ); + + // Verify that product picker is shown in Editor + const editorProductPicker = editor.canvas.locator( + SELECTORS.productPicker + ); + await expect( editorProductPicker ).toBeVisible(); + + // Once a product is selected, the product picker should be hidden + await pageObject.chooseProductInEditorProductPickerIfAvailable( + editor.canvas, + 'A Test Product' + ); + await expect( editorProductPicker ).toBeHidden(); + + await editor.saveDraft(); + + // Delete the product + await requestUtils.rest( { + method: 'DELETE', + path: `wc/v3/products/${ testProductId }`, + } ); + + // Product picker should be shown in Editor + await admin.page.reload(); + const deletedProductPicker = editor.canvas.getByText( + 'Previously selected product' + ); + await expect( deletedProductPicker ).toBeVisible(); + + // Change status from "trash" to "publish" + await requestUtils.rest( { + method: 'PUT', + path: `wc/v3/products/${ testProductId }`, + data: { + status: 'publish', + }, + } ); + + // Product Picker shouldn't be shown as product is available now + await page.reload(); + await expect( editorProductPicker ).toBeHidden(); + + // Delete the product from database, instead of trashing it + await requestUtils.rest( { + method: 'DELETE', + path: `wc/v3/products/${ testProductId }`, + params: { + // Bypass trash and permanently delete the product + force: true, + }, + } ); + + // Product picker should be shown in Editor + await expect( deletedProductPicker ).toBeVisible(); + } ); } ); diff --git a/plugins/woocommerce/changelog/50279-fix-mongolia-postcode-5 b/plugins/woocommerce/changelog/50279-fix-mongolia-postcode-5 new file mode 100644 index 00000000000..b6f3ac11940 --- /dev/null +++ b/plugins/woocommerce/changelog/50279-fix-mongolia-postcode-5 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Adjust Mongolia postcode validation to be 5 digits or 5 digits followed by 4 digits. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context b/plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context new file mode 100644 index 00000000000..5e5b6821ab3 --- /dev/null +++ b/plugins/woocommerce/changelog/51114-add-44878-product-collection-handling-missing-product-context @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Product Collection: Added Editor UI for missing product reference \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51281-phpcs-fixes b/plugins/woocommerce/changelog/51281-phpcs-fixes new file mode 100644 index 00000000000..9db48f6d016 --- /dev/null +++ b/plugins/woocommerce/changelog/51281-phpcs-fixes @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +Fix PHPCS warnings in OrdersTableQuery.php and ProductQuery.php diff --git a/plugins/woocommerce/changelog/51286-51176-fixed-fatal-is_visible-func b/plugins/woocommerce/changelog/51286-51176-fixed-fatal-is_visible-func new file mode 100644 index 00000000000..b604b3112e5 --- /dev/null +++ b/plugins/woocommerce/changelog/51286-51176-fixed-fatal-is_visible-func @@ -0,0 +1,4 @@ +Significance: patch +Type: fix +Comment: Fixed call to a member function is_visible() on string | content-product.php:23 + diff --git a/plugins/woocommerce/changelog/51303-e2e-external-expand-wpcom-suite b/plugins/woocommerce/changelog/51303-e2e-external-expand-wpcom-suite new file mode 100644 index 00000000000..975d531637f --- /dev/null +++ b/plugins/woocommerce/changelog/51303-e2e-external-expand-wpcom-suite @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Expand the e2e suite we're running on WPCOM. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51375-update-switch-to-ariakit-buttons-in-checkout b/plugins/woocommerce/changelog/51375-update-switch-to-ariakit-buttons-in-checkout new file mode 100644 index 00000000000..ccedf5ae319 --- /dev/null +++ b/plugins/woocommerce/changelog/51375-update-switch-to-ariakit-buttons-in-checkout @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Harden styles for interactive elements in Checkout block to prevent style leakage. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51379-51134-product-attribute-placeholder-change b/plugins/woocommerce/changelog/51379-51134-product-attribute-placeholder-change new file mode 100644 index 00000000000..35d09da84a8 --- /dev/null +++ b/plugins/woocommerce/changelog/51379-51134-product-attribute-placeholder-change @@ -0,0 +1,4 @@ +Significance: patch +Type: fix +Comment: Changed Product attributes placeholder to e.g. length or weight + diff --git a/plugins/woocommerce/changelog/51386-add-control-address-edit-from-data-store b/plugins/woocommerce/changelog/51386-add-control-address-edit-from-data-store new file mode 100644 index 00000000000..38a81b13fff --- /dev/null +++ b/plugins/woocommerce/changelog/51386-add-control-address-edit-from-data-store @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Move address card state management to data stores in Checkout block. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51392-update-add-locale-param-to-jetpack-auth b/plugins/woocommerce/changelog/51392-update-add-locale-param-to-jetpack-auth new file mode 100644 index 00000000000..8e1be059b7d --- /dev/null +++ b/plugins/woocommerce/changelog/51392-update-add-locale-param-to-jetpack-auth @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add `locale` param when redirecting to the Jetpack auth page. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51414-e2e-external-expand-wpcom-suite-part2 b/plugins/woocommerce/changelog/51414-e2e-external-expand-wpcom-suite-part2 new file mode 100644 index 00000000000..806a0b91679 --- /dev/null +++ b/plugins/woocommerce/changelog/51414-e2e-external-expand-wpcom-suite-part2 @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Expand the e2e suite we're running on WPCOM part #2. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51421-update-use-horizon-env b/plugins/woocommerce/changelog/51421-update-use-horizon-env new file mode 100644 index 00000000000..0258515d62b --- /dev/null +++ b/plugins/woocommerce/changelog/51421-update-use-horizon-env @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add use-wp-horizon feature flag to set calpyso_env to horizon \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart b/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart new file mode 100644 index 00000000000..99351de4130 --- /dev/null +++ b/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix bug where manually triggering `added_to_cart` event without a button element caused an Exception. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search b/plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search new file mode 100644 index 00000000000..351aa293cfd --- /dev/null +++ b/plugins/woocommerce/changelog/add-47890_inspector_control_to_product_search @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add inspector controls to Product Search block #51247 diff --git a/plugins/woocommerce/changelog/add-missing-wp-block-classnames-49739 b/plugins/woocommerce/changelog/add-missing-wp-block-classnames-49739 new file mode 100644 index 00000000000..dc4c7059a56 --- /dev/null +++ b/plugins/woocommerce/changelog/add-missing-wp-block-classnames-49739 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Added missing wp-block- classes to order confirmation, store notices, and breadcrumb blocks. diff --git a/plugins/woocommerce/changelog/add-tax-task-completion-filter b/plugins/woocommerce/changelog/add-tax-task-completion-filter new file mode 100644 index 00000000000..89ab40a7240 --- /dev/null +++ b/plugins/woocommerce/changelog/add-tax-task-completion-filter @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Adds a filter for third party tax plugins to indicate that they have completed the tax task diff --git a/plugins/woocommerce/changelog/doc-update-markdown-lint b/plugins/woocommerce/changelog/doc-update-markdown-lint new file mode 100644 index 00000000000..4d3d00c11ea --- /dev/null +++ b/plugins/woocommerce/changelog/doc-update-markdown-lint @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Updating Markdown linter rule + + diff --git a/plugins/woocommerce/changelog/enhance-improve-log-structure b/plugins/woocommerce/changelog/enhance-improve-log-structure new file mode 100644 index 00000000000..f3fe5a6215f --- /dev/null +++ b/plugins/woocommerce/changelog/enhance-improve-log-structure @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Improve remote logging structure and content diff --git a/plugins/woocommerce/changelog/enhance-reduce-remote-logging-noise b/plugins/woocommerce/changelog/enhance-reduce-remote-logging-noise new file mode 100644 index 00000000000..736e9c0d3f8 --- /dev/null +++ b/plugins/woocommerce/changelog/enhance-reduce-remote-logging-noise @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Reducing noise in remote logging diff --git a/plugins/woocommerce/changelog/enhance-remote-logging-reduce-dependency b/plugins/woocommerce/changelog/enhance-remote-logging-reduce-dependency new file mode 100644 index 00000000000..2a0eca872f0 --- /dev/null +++ b/plugins/woocommerce/changelog/enhance-remote-logging-reduce-dependency @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Reduce dependency of remote logging on WC_Tracks diff --git a/plugins/woocommerce/changelog/fix-49759-removed-taxname-export b/plugins/woocommerce/changelog/fix-49759-removed-taxname-export new file mode 100644 index 00000000000..2d76f39f150 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-49759-removed-taxname-export @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Improve performance of tax report export generation and fix tax_code for removed rates. diff --git a/plugins/woocommerce/changelog/fix-51154-product-gallery-fatal b/plugins/woocommerce/changelog/fix-51154-product-gallery-fatal new file mode 100644 index 00000000000..3ed4766c3d0 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-51154-product-gallery-fatal @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix error when adding the Product Gallery (Beta) block into a pattern diff --git a/plugins/woocommerce/changelog/fix-cart-url-page-check-50524 b/plugins/woocommerce/changelog/fix-cart-url-page-check-50524 new file mode 100644 index 00000000000..dbd4b4aa4bc --- /dev/null +++ b/plugins/woocommerce/changelog/fix-cart-url-page-check-50524 @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +wc_get_cart_url should only return current URL if on the cart page. This excludes the usage of WOOCOMMERCE_CART. diff --git a/plugins/woocommerce/changelog/fix-type-mismatch-updateproducts b/plugins/woocommerce/changelog/fix-type-mismatch-updateproducts new file mode 100644 index 00000000000..1af042a90a0 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-type-mismatch-updateproducts @@ -0,0 +1,5 @@ +Significance: patch +Type: fix +Comment: Fix a type mismatch in UpdateProducts.php + + diff --git a/plugins/woocommerce/changelog/fix-use-customer-email-if-available b/plugins/woocommerce/changelog/fix-use-customer-email-if-available new file mode 100644 index 00000000000..58490385da9 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-use-customer-email-if-available @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Set customer email in reports if customer data is available diff --git a/plugins/woocommerce/changelog/fix-wccom-20223-in-app-subs-error-handling b/plugins/woocommerce/changelog/fix-wccom-20223-in-app-subs-error-handling new file mode 100644 index 00000000000..0815a090e50 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-wccom-20223-in-app-subs-error-handling @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Introduce error handling on the in-app my subscriptions page diff --git a/plugins/woocommerce/changelog/merge-brands-in-core b/plugins/woocommerce/changelog/merge-brands-in-core new file mode 100644 index 00000000000..65fd35876a3 --- /dev/null +++ b/plugins/woocommerce/changelog/merge-brands-in-core @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Introduced Product Brands. diff --git a/plugins/woocommerce/changelog/update-refine-error-counting b/plugins/woocommerce/changelog/update-refine-error-counting new file mode 100644 index 00000000000..905988307d8 --- /dev/null +++ b/plugins/woocommerce/changelog/update-refine-error-counting @@ -0,0 +1,4 @@ +Significance: patch +Type: enhancement + +Refine PHP Fatal Error Counting in MC Stat diff --git a/plugins/woocommerce/client/admin/config/core.json b/plugins/woocommerce/client/admin/config/core.json index 3c201f07922..6bfd9954259 100644 --- a/plugins/woocommerce/client/admin/config/core.json +++ b/plugins/woocommerce/client/admin/config/core.json @@ -39,6 +39,7 @@ "launch-your-store": true, "product-editor-template-system": false, "blueprint": false, - "reactify-classic-payments-settings": false + "reactify-classic-payments-settings": false, + "use-wp-horizon": false } } diff --git a/plugins/woocommerce/client/admin/config/development.json b/plugins/woocommerce/client/admin/config/development.json index 802c62fe6a3..da6f07d82dd 100644 --- a/plugins/woocommerce/client/admin/config/development.json +++ b/plugins/woocommerce/client/admin/config/development.json @@ -39,6 +39,7 @@ "launch-your-store": true, "product-editor-template-system": false, "blueprint": true, - "reactify-classic-payments-settings": false + "reactify-classic-payments-settings": false, + "use-wp-horizon": false } } diff --git a/plugins/woocommerce/client/legacy/css/brands-admin.scss b/plugins/woocommerce/client/legacy/css/brands-admin.scss new file mode 100644 index 00000000000..5f9e47fed78 --- /dev/null +++ b/plugins/woocommerce/client/legacy/css/brands-admin.scss @@ -0,0 +1,3 @@ +table.wp-list-table .column-taxonomy-product_brand { + width: 10%; +} diff --git a/plugins/woocommerce/client/legacy/css/brands.scss b/plugins/woocommerce/client/legacy/css/brands.scss new file mode 100644 index 00000000000..060d28a0278 --- /dev/null +++ b/plugins/woocommerce/client/legacy/css/brands.scss @@ -0,0 +1,173 @@ +/* Brand description on archives */ +.tax-product_brand .brand-description { + overflow: hidden; + zoom: 1; +} +.tax-product_brand .brand-description img.brand-thumbnail { + width: 25%; + float: right; +} +.tax-product_brand .brand-description .text { + width: 72%; + float: left; +} + +/* Brand description widget */ +.widget_brand_description img { + -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ + -moz-box-sizing: border-box; /* Firefox, other Gecko */ + box-sizing: border-box; /* Opera/IE 8+ */ + width: 100%; + max-width: none; + height: auto; + margin: 0 0 1em; +} + +/* Brand thumbnails widget */ +ul.brand-thumbnails { + margin-left: 0; + margin-bottom: 0; + clear: both; + list-style: none; +} + +ul.brand-thumbnails:before { + clear: both; + content: ""; + display: table; +} + +ul.brand-thumbnails:after { + clear: both; + content: ""; + display: table; +} + +ul.brand-thumbnails li { + float: left; + margin: 0 3.8% 1em 0; + padding: 0; + position: relative; + width: 22.05%; /* 4 columns */ +} + +ul.brand-thumbnails.fluid-columns li { + width: auto; +} + +ul.brand-thumbnails:not(.fluid-columns) li.first { + clear: both; +} + +ul.brand-thumbnails:not(.fluid-columns) li.last { + margin-right: 0; +} + +ul.brand-thumbnails.columns-1 li { + width: 100%; + margin-right: 0; +} + +ul.brand-thumbnails.columns-2 li { + width: 48%; +} + +ul.brand-thumbnails.columns-3 li { + width: 30.75%; +} + +ul.brand-thumbnails.columns-5 li { + width: 16.95%; +} + +ul.brand-thumbnails.columns-6 li { + width: 13.5%; +} + +.brand-thumbnails li img { + -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ + -moz-box-sizing: border-box; /* Firefox, other Gecko */ + box-sizing: border-box; /* Opera/IE 8+ */ + width: 100%; + max-width: none; + height: auto; + margin: 0; +} + +@media screen and (max-width: 768px) { + ul.brand-thumbnails:not(.fluid-columns) li { + width: 48% !important; + } + + ul.brand-thumbnails:not(.fluid-columns) li.first { + clear: none; + } + + ul.brand-thumbnails:not(.fluid-columns) li.last { + margin-right: 3.8% + } + + ul.brand-thumbnails:not(.fluid-columns) li:nth-of-type(odd) { + clear: both; + } + + ul.brand-thumbnails:not(.fluid-columns) li:nth-of-type(even) { + margin-right: 0; + } +} + +/* Brand thumbnails description */ +.brand-thumbnails-description li { + text-align: center; +} + +.brand-thumbnails-description li .term-thumbnail img { + display: inline; +} + +.brand-thumbnails-description li .term-description { + margin-top: 1em; + text-align: left; +} + +/* A-Z Shortcode */ +#brands_a_z h3:target { + text-decoration: underline; +} +ul.brands_index { + list-style: none outside; + overflow: hidden; + zoom: 1; +} +ul.brands_index li { + float: left; + margin: 0 2px 2px 0; +} +ul.brands_index li a, ul.brands_index li span { + border: 1px solid #ccc; + padding: 6px; + line-height: 1em; + float: left; + text-decoration: none; +} +ul.brands_index li span { + border-color: #eee; + color: #ddd; +} +ul.brands_index li a:hover { + border-width: 2px; + padding: 5px; + text-decoration: none; +} +ul.brands_index li a.active { + border-width: 2px; + padding: 5px; +} +div#brands_a_z a.top { + border: 1px solid #ccc; + padding: 4px; + line-height: 1em; + float: right; + text-decoration: none; + font-size: 0.8em; +} diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js b/plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js new file mode 100644 index 00000000000..270d5b8dc1c --- /dev/null +++ b/plugins/woocommerce/client/legacy/js/admin/wc-brands-enhanced-select.js @@ -0,0 +1,94 @@ +/* global wc_enhanced_select_params */ +/* global wpApiSettings */ +jQuery( function( $ ) { + + function getEnhancedSelectFormatString() { + return { + 'language': { + errorLoading: function() { + // Workaround for https://github.com/select2/select2/issues/4355 instead of i18n_ajax_error. + return wc_enhanced_select_params.i18n_searching; + }, + inputTooLong: function( args ) { + var overChars = args.input.length - args.maximum; + + if ( 1 === overChars ) { + return wc_enhanced_select_params.i18n_input_too_long_1; + } + + return wc_enhanced_select_params.i18n_input_too_long_n.replace( '%qty%', overChars ); + }, + inputTooShort: function( args ) { + var remainingChars = args.minimum - args.input.length; + + if ( 1 === remainingChars ) { + return wc_enhanced_select_params.i18n_input_too_short_1; + } + + return wc_enhanced_select_params.i18n_input_too_short_n.replace( '%qty%', remainingChars ); + }, + loadingMore: function() { + return wc_enhanced_select_params.i18n_load_more; + }, + maximumSelected: function( args ) { + if ( args.maximum === 1 ) { + return wc_enhanced_select_params.i18n_selection_too_long_1; + } + + return wc_enhanced_select_params.i18n_selection_too_long_n.replace( '%qty%', args.maximum ); + }, + noResults: function() { + return wc_enhanced_select_params.i18n_no_matches; + }, + searching: function() { + return wc_enhanced_select_params.i18n_searching; + } + } + }; + } + + try { + $( document.body ) + .on( 'wc-enhanced-select-init', function() { + // Ajax category search boxes + $( ':input.wc-brands-search' ).filter( ':not(.enhanced)' ).each( function() { + var select2_args = $.extend( { + allowClear : $( this ).data( 'allow_clear' ) ? true : false, + placeholder : $( this ).data( 'placeholder' ), + minimumInputLength: $( this ).data( 'minimum_input_length' ) ? $( this ).data( 'minimum_input_length' ) : 3, + escapeMarkup : function( m ) { + return m; + }, + ajax: { + url: wpApiSettings.root + 'wc/v3/products/brands', + dataType: 'json', + delay: 250, + headers: { + 'X-WP-Nonce': wpApiSettings.nonce + }, + data: function( params ) { + return { + hide_empty: 1, + search: params.term + }; + }, + processResults: function( data ) { + const results = data + .map( term => ({ id: term.slug, text: term.name + ' (' + term.count + ')' }) ) + return { + results + }; + }, + cache: true + } + }, getEnhancedSelectFormatString() ); + + $( this ).selectWoo( select2_args ).addClass( 'enhanced' ); + }); + }) + .trigger( 'wc-enhanced-select-init' ); + } catch( err ) { + // If select2 failed (conflict?) log the error but don't stop other scripts breaking. + window.console.log( err ); + } +}); \ No newline at end of file diff --git a/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js b/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js index a99e4394750..dd2e0872c86 100644 --- a/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js +++ b/plugins/woocommerce/client/legacy/js/frontend/add-to-cart.js @@ -173,6 +173,8 @@ jQuery( function( $ ) { * Update cart page elements after add to cart events. */ AddToCartHandler.prototype.updateButton = function( e, fragments, cart_hash, $button ) { + // Some themes and plugins manually trigger added_to_cart without passing a button element, which in turn calls this function. + // If there is no button we don't want to crash. $button = typeof $button === 'undefined' ? false : $button; if ( $button ) { @@ -222,19 +224,25 @@ jQuery( function( $ ) { * Update cart live region message after add/remove cart events. */ AddToCartHandler.prototype.alertCartUpdated = function( e, fragments, cart_hash, $button ) { - var message = $button.data( 'success_message' ); + // Some themes and plugins manually trigger added_to_cart without passing a button element, which in turn calls this function. + // If there is no button we don't want to crash. + $button = typeof $button === 'undefined' ? false : $button; - if ( !message ) { - return; - } + if ( $button ) { + var message = $button.data( 'success_message' ); + + if ( !message ) { + return; + } - // If the response after adding/removing an item to/from the cart is really fast, - // screen readers may not have time to identify the changes in the live region element. - // So, we add a delay to ensure an interval between messages. - e.data.addToCartHandler.$liveRegion - .delay(1000) - .text( message ) - .attr( 'aria-relevant', 'all' ); + // If the response after adding/removing an item to/from the cart is really fast, + // screen readers may not have time to identify the changes in the live region element. + // So, we add a delay to ensure an interval between messages. + e.data.addToCartHandler.$liveRegion + .delay(1000) + .text( message ) + .attr( 'aria-relevant', 'all' ); + } }; /** diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-brands.php b/plugins/woocommerce/includes/admin/class-wc-admin-brands.php new file mode 100644 index 00000000000..a5f65745625 --- /dev/null +++ b/plugins/woocommerce/includes/admin/class-wc-admin-brands.php @@ -0,0 +1,792 @@ +settings_tabs = array( + 'brands' => __( 'Brands', 'woocommerce' ), + ); + + // Hiding setting for future depreciation. Only users who have touched this settings should see it. + $setting_value = get_option( 'wc_brands_show_description' ); + if ( is_string( $setting_value ) ) { + // Add the settings fields to each tab. + $this->init_form_fields(); + add_action( 'woocommerce_get_sections_products', array( $this, 'add_settings_tab' ) ); + add_action( 'woocommerce_get_settings_products', array( $this, 'add_settings_section' ), null, 2 ); + } + + add_action( 'woocommerce_update_options_catalog', array( $this, 'save_admin_settings' ) ); + + /* 2.1 */ + add_action( 'woocommerce_update_options_products', array( $this, 'save_admin_settings' ) ); + + // Add brands filtering to the coupon creation screens. + add_action( 'woocommerce_coupon_options_usage_restriction', array( $this, 'add_coupon_brands_fields' ) ); + add_action( 'woocommerce_coupon_options_save', array( $this, 'save_coupon_brands' ) ); + + // Permalinks. + add_filter( 'pre_update_option_woocommerce_permalinks', array( $this, 'validate_product_base' ) ); + + add_action( 'current_screen', array( $this, 'add_brand_base_setting' ) ); + + // CSV Import/Export Support. + // https://github.com/woocommerce/woocommerce/wiki/Product-CSV-Importer-&-Exporter + // Import. + add_filter( 'woocommerce_csv_product_import_mapping_options', array( $this, 'add_column_to_importer_exporter' ), 10 ); + add_filter( 'woocommerce_csv_product_import_mapping_default_columns', array( $this, 'add_default_column_mapping' ), 10 ); + add_filter( 'woocommerce_product_import_inserted_product_object', array( $this, 'process_import' ), 10, 2 ); + + // Export. + add_filter( 'woocommerce_product_export_column_names', array( $this, 'add_column_to_importer_exporter' ), 10 ); + add_filter( 'woocommerce_product_export_product_default_columns', array( $this, 'add_column_to_importer_exporter' ), 10 ); + add_filter( 'woocommerce_product_export_product_column_brand_ids', array( $this, 'get_column_value_brand_ids' ), 10, 2 ); + } + + /** + * Add the settings for the new "Brands" subtab. + * + * @since 9.4.0 + * + * @param array $settings Settings. + * @param array $current_section Current section. + */ + public function add_settings_section( $settings, $current_section ) { + if ( 'brands' === $current_section ) { + $settings = $this->settings; + } + return $settings; + } + + /** + * Add a new "Brands" subtab to the "Products" tab. + * + * @since 9.4.0 + * @param array $sections Sections. + */ + public function add_settings_tab( $sections ) { + $sections = array_merge( $sections, $this->settings_tabs ); + return $sections; + } + + /** + * Display coupon filter fields relating to brands. + * + * @since 9.4.0 + * @return void + */ + public function add_coupon_brands_fields() { + global $post; + // Brands. + ?> +

+ + +

+ + settings = apply_filters( + 'woocommerce_brands_settings_fields', + array( + array( + 'name' => __( 'Brands Archives', 'woocommerce' ), + 'type' => 'title', + 'desc' => '', + 'id' => 'brands_archives', + ), + array( + 'name' => __( 'Show description', 'woocommerce' ), + 'desc' => __( 'Choose to show the brand description on the archive page. Turn this off if you intend to use the description widget instead. Please note: this is only for themes that do not show the description.', 'woocommerce' ), + 'tip' => '', + 'id' => 'wc_brands_show_description', + 'css' => '', + 'std' => 'yes', + 'type' => 'checkbox', + ), + array( + 'type' => 'sectionend', + 'id' => 'brands_archives', + ), + ) + ); + } + + /** + * Enqueue scripts. + * + * @return void + */ + public function scripts() { + $screen = get_current_screen(); + $version = Constants::get_constant( 'WC_VERSION' ); + $suffix = Constants::is_true( 'SCRIPT_DEBUG' ) ? '' : '.min'; + + if ( 'edit-product' === $screen->id ) { + wp_register_script( + 'wc-brands-enhanced-select', + WC()->plugin_url() . '/assets/js/admin/wc-brands-enhanced-select' . $suffix . '.js', + array( 'jquery', 'selectWoo', 'wc-enhanced-select', 'wp-api' ), + $version, + true + ); + wp_localize_script( + 'wc-brands-enhanced-select', + 'wc_brands_enhanced_select_params', + array( 'ajax_url' => get_rest_url() . 'brands/search' ) + ); + wp_enqueue_script( 'wc-brands-enhanced-select' ); + } + + if ( in_array( $screen->id, array( 'edit-product_brand' ), true ) ) { + wp_enqueue_media(); + wp_enqueue_style( 'woocommerce_admin_styles' ); + } + } + + /** + * Enqueue styles. + * + * @return void + */ + public function styles() { + $version = Constants::get_constant( 'WC_VERSION' ); + wp_enqueue_style( 'brands-admin-styles', WC()->plugin_url() . '/assets/css/brands-admin.css', array(), $version ); + } + + /** + * Admin settings function. + */ + public function admin_settings() { + woocommerce_admin_fields( $this->settings ); + } + + /** + * Save admin settings function. + */ + public function save_admin_settings() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['section'] ) && 'brands' === $_GET['section'] ) { + woocommerce_update_options( $this->settings ); + } + } + + /** + * Category thumbnails. + */ + public function add_thumbnail_field() { + global $woocommerce; + ?> +

+ +
+
+ + + +
+ +
+
+ term_id, 'thumbnail_id', true ); + if ( $thumbnail_id ) { + $image = wp_get_attachment_url( $thumbnail_id ); + } + if ( empty( $image ) ) { + $image = wc_placeholder_img_src(); + } + ?> + + + +
+
+ + + +
+ +
+ + + $brands_column ), + array_slice( $columns, -2, null, true ) + ); + } + + + /** + * Columns function. + * + * @param mixed $columns Columns. + */ + public function columns( $columns ) { + if ( empty( $columns ) ) { + return $columns; + } + + $new_columns = array(); + $new_columns['cb'] = $columns['cb']; + $new_columns['thumb'] = __( 'Image', 'woocommerce' ); + unset( $columns['cb'] ); + $columns = array_merge( $new_columns, $columns ); + return $columns; + } + + /** + * Column function. + * + * @param mixed $columns Columns. + * @param mixed $column Column. + * @param mixed $id ID. + */ + public function column( $columns, $column, $id ) { + if ( 'thumb' === $column ) { + global $woocommerce; + + $image = ''; + $thumbnail_id = get_term_meta( $id, 'thumbnail_id', true ); + + if ( $thumbnail_id ) { + $image = wp_get_attachment_url( $thumbnail_id ); + } + if ( empty( $image ) ) { + $image = wc_placeholder_img_src(); + } + + $columns .= 'Thumbnail'; + + } + return $columns; + } + + /** + * Renders either dropdown or a search field for brands depending on the threshold value of + * woocommerce_product_brand_filter_threshold filter. + */ + public function render_product_brand_filter() { + // phpcs:disable WordPress.Security.NonceVerification + $brands_count = (int) wp_count_terms( 'product_brand' ); + $current_brand_slug = wc_clean( wp_unslash( $_GET['product_brand'] ?? '' ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + + /** + * Filter the brands threshold count. + * + * @since 9.4.0 + * + * @param int $value Threshold. + */ + if ( $brands_count <= apply_filters( 'woocommerce_product_brand_filter_threshold', 100 ) ) { + wc_product_dropdown_categories( + array( + 'pad_counts' => true, + 'show_count' => true, + 'orderby' => 'name', + 'selected' => $current_brand_slug, + 'show_option_none' => __( 'Filter by brand', 'woocommerce' ), + 'option_none_value' => '', + 'value_field' => 'slug', + 'taxonomy' => 'product_brand', + 'name' => 'product_brand', + 'class' => 'dropdown_product_brand', + ) + ); + } else { + $current_brand = $current_brand_slug ? get_term_by( 'slug', $current_brand_slug, 'product_brand' ) : ''; + $selected_option = ''; + if ( $current_brand_slug && $current_brand ) { + $selected_option = ''; + } + $placeholder = esc_attr__( 'Filter by brand', 'woocommerce' ); + ?> + + id ) { + return; + } + + add_settings_field( + 'woocommerce_product_brand_slug', + __( 'Product brand base', 'woocommerce' ), + array( $this, 'product_brand_slug_input' ), + 'permalink', + 'optional' + ); + + $this->save_permalink_settings(); + } + + /** + * Add a slug input box. + */ + public function product_brand_slug_input() { + $permalink = get_option( 'woocommerce_brand_permalink', '' ); + ?> + + 'brand_ids' ); + return array_merge( $mappings, $new_mapping ); + } + + /** + * Add brands to newly imported product. + * + * @param WC_Product $product Product being imported. + * @param array $data Raw CSV data. + */ + public function process_import( $product, $data ) { + if ( empty( $data['brand_ids'] ) ) { + return; + } + + $brand_ids = array_map( 'intval', $this->parse_brands_field( $data['brand_ids'] ) ); + + wp_set_object_terms( $product->get_id(), $brand_ids, 'product_brand' ); + } + + /** + * Parse brands field from a CSV during import. + * + * Based on WC_Product_CSV_Importer::parse_categories_field() + * + * @param string $value Field value. + * @return array + */ + public function parse_brands_field( $value ) { + + // Based on WC_Product_Importer::explode_values(). + $values = str_replace( '\\,', '::separator::', explode( ',', $value ) ); + $row_terms = array(); + foreach ( $values as $row_value ) { + $row_terms[] = trim( str_replace( '::separator::', ',', $row_value ) ); + } + + $brands = array(); + foreach ( $row_terms as $row_term ) { + $parent = null; + + // WC Core uses '>', but for some reason it's already escaped at this point. + $_terms = array_map( 'trim', explode( '>', $row_term ) ); + $total = count( $_terms ); + + foreach ( $_terms as $index => $_term ) { + $term = term_exists( $_term, 'product_brand', $parent ); + + if ( is_array( $term ) ) { + $term_id = $term['term_id']; + } else { + $term = wp_insert_term( $_term, 'product_brand', array( 'parent' => intval( $parent ) ) ); + + if ( is_wp_error( $term ) ) { + break; // We cannot continue if the term cannot be inserted. + } + + $term_id = $term['term_id']; + } + + // Only requires assign the last category. + if ( ( 1 + $index ) === $total ) { + $brands[] = $term_id; + } else { + // Store parent to be able to insert or query brands based in parent ID. + $parent = $term_id; + } + } + } + + return $brands; + } + + /** + * Get brands column value for csv export. + * + * @param string $value What will be exported. + * @param WC_Product $product Product being exported. + * @return string Brands separated by commas and child brands as "parent > child". + */ + public function get_column_value_brand_ids( $value, $product ) { + $brand_ids = wp_parse_id_list( wp_get_post_terms( $product->get_id(), 'product_brand', array( 'fields' => 'ids' ) ) ); + + if ( ! count( $brand_ids ) ) { + return ''; + } + + // Based on WC_CSV_Exporter::format_term_ids(). + $formatted_brands = array(); + foreach ( $brand_ids as $brand_id ) { + $formatted_term = array(); + $ancestor_ids = array_reverse( get_ancestors( $brand_id, 'product_brand' ) ); + + foreach ( $ancestor_ids as $ancestor_id ) { + $term = get_term( $ancestor_id, 'product_brand' ); + if ( $term && ! is_wp_error( $term ) ) { + $formatted_term[] = $term->name; + } + } + + $term = get_term( $brand_id, 'product_brand' ); + + if ( $term && ! is_wp_error( $term ) ) { + $formatted_term[] = $term->name; + } + + $formatted_brands[] = implode( ' > ', $formatted_term ); + } + + // Based on WC_CSV_Exporter::implode_values(). + $values_to_implode = array(); + foreach ( $formatted_brands as $brand ) { + $brand = (string) is_scalar( $brand ) ? $brand : ''; + $values_to_implode[] = str_replace( ',', '\\,', $brand ); + } + + return implode( ', ', $values_to_implode ); + } +} + +$GLOBALS['WC_Brands_Admin'] = new WC_Brands_Admin(); diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-importers.php b/plugins/woocommerce/includes/admin/class-wc-admin-importers.php index 6dfec075b89..3c16a0b07d9 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-importers.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-importers.php @@ -215,114 +215,12 @@ class WC_Admin_Importers { * Ajax callback for importing one batch of products from a CSV. */ public function do_ajax_product_import() { - global $wpdb; - - check_ajax_referer( 'wc-product-import', 'security' ); - - if ( ! $this->import_allowed() || ! isset( $_POST['file'] ) ) { // PHPCS: input var ok. + if ( ! $this->import_allowed() ) { wp_send_json_error( array( 'message' => __( 'Insufficient privileges to import products.', 'woocommerce' ) ) ); } include_once WC_ABSPATH . 'includes/admin/importers/class-wc-product-csv-importer-controller.php'; - include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php'; - - $file = wc_clean( wp_unslash( $_POST['file'] ) ); // PHPCS: input var ok. - $params = array( - 'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok. - 'start_pos' => isset( $_POST['position'] ) ? absint( $_POST['position'] ) : 0, // PHPCS: input var ok. - 'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok. - 'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok. - 'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '', - - /** - * Batch size for the product import process. - * - * @param int $size Batch size. - * - * @since 3.1.0 - */ - 'lines' => apply_filters( 'woocommerce_product_import_batch_size', 30 ), - 'parse' => true, - ); - - // Log failures. - if ( 0 !== $params['start_pos'] ) { - $error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) ); - } else { - $error_log = array(); - } - - $importer = WC_Product_CSV_Importer_Controller::get_importer( $file, $params ); - $results = $importer->import(); - $percent_complete = $importer->get_percent_complete(); - $error_log = array_merge( $error_log, $results['failed'], $results['skipped'] ); - - update_user_option( get_current_user_id(), 'product_import_error_log', $error_log ); - - if ( 100 === $percent_complete ) { - // @codingStandardsIgnoreStart. - $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) ); - $wpdb->delete( $wpdb->posts, array( - 'post_type' => 'product', - 'post_status' => 'importing', - ) ); - $wpdb->delete( $wpdb->posts, array( - 'post_type' => 'product_variation', - 'post_status' => 'importing', - ) ); - // @codingStandardsIgnoreEnd. - - // Clean up orphaned data. - $wpdb->query( - " - DELETE {$wpdb->posts}.* FROM {$wpdb->posts} - LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->posts}.post_parent - WHERE wp.ID IS NULL AND {$wpdb->posts}.post_type = 'product_variation' - " - ); - $wpdb->query( - " - DELETE {$wpdb->postmeta}.* FROM {$wpdb->postmeta} - LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->postmeta}.post_id - WHERE wp.ID IS NULL - " - ); - // @codingStandardsIgnoreStart. - $wpdb->query( " - DELETE tr.* FROM {$wpdb->term_relationships} tr - LEFT JOIN {$wpdb->posts} wp ON wp.ID = tr.object_id - LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id - WHERE wp.ID IS NULL - AND tt.taxonomy IN ( '" . implode( "','", array_map( 'esc_sql', get_object_taxonomies( 'product' ) ) ) . "' ) - " ); - // @codingStandardsIgnoreEnd. - - // Send success. - wp_send_json_success( - array( - 'position' => 'done', - 'percentage' => 100, - 'url' => add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ), - 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, - 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, - 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, - 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, - 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, - ) - ); - } else { - wp_send_json_success( - array( - 'position' => $importer->get_file_position(), - 'percentage' => $percent_complete, - 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, - 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, - 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, - 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, - 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, - ) - ); - } + WC_Product_CSV_Importer_Controller::dispatch_ajax(); } /** diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php index e5fac1cecb9..cadf53a7bc5 100644 --- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php +++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php @@ -103,6 +103,56 @@ class WC_Product_CSV_Importer_Controller { return wc_is_file_valid_csv( $file, $check_path ); } + /** + * Runs before controller actions to check that the file used during the import is valid. + * + * @since 9.3.0 + * + * @param string $path Path to test. + * + * @throws \Exception When file validation fails. + */ + protected static function check_file_path( string $path ): void { + $is_valid_file = false; + + if ( ! empty( $path ) ) { + $path = realpath( $path ); + $is_valid_file = false !== $path; + } + + // File must be readable. + $is_valid_file = $is_valid_file && is_readable( $path ); + + // Check that file is within an allowed location. + if ( $is_valid_file ) { + $in_valid_location = false; + $valid_locations = array(); + $valid_locations[] = ABSPATH; + + $upload_dir = wp_get_upload_dir(); + if ( false === $upload_dir['error'] ) { + $valid_locations[] = $upload_dir['basedir']; + } + + foreach ( $valid_locations as $valid_location ) { + if ( 0 === stripos( $path, trailingslashit( realpath( $valid_location ) ) ) ) { + $in_valid_location = true; + break; + } + } + + $is_valid_file = $in_valid_location; + } + + if ( ! $is_valid_file ) { + throw new \Exception( esc_html__( 'File path provided for import is invalid.', 'woocommerce' ) ); + } + + if ( ! self::is_file_valid_csv( $path ) ) { + throw new \Exception( esc_html__( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); + } + } + /** * Get all the valid filetypes for a CSV file. * @@ -263,17 +313,151 @@ class WC_Product_CSV_Importer_Controller { * Dispatch current step and show correct view. */ public function dispatch() { - // phpcs:ignore WordPress.Security.NonceVerification.Missing - if ( ! empty( $_POST['save_step'] ) && ! empty( $this->steps[ $this->step ]['handler'] ) ) { - call_user_func( $this->steps[ $this->step ]['handler'], $this ); + $output = ''; + + try { + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! empty( $_POST['save_step'] ) && ! empty( $this->steps[ $this->step ]['handler'] ) ) { + if ( is_callable( $this->steps[ $this->step ]['handler'] ) ) { + call_user_func( $this->steps[ $this->step ]['handler'], $this ); + } + } + + ob_start(); + + if ( is_callable( $this->steps[ $this->step ]['view'] ) ) { + call_user_func( $this->steps[ $this->step ]['view'], $this ); + } + + $output = ob_get_clean(); + } catch ( \Exception $e ) { + $this->add_error( $e->getMessage() ); } + $this->output_header(); $this->output_steps(); $this->output_errors(); - call_user_func( $this->steps[ $this->step ]['view'], $this ); + echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- output is HTML we've generated ourselves. $this->output_footer(); } + /** + * Processes AJAX requests related to a product CSV import. + * + * @since 9.3.0 + */ + public static function dispatch_ajax() { + global $wpdb; + + check_ajax_referer( 'wc-product-import', 'security' ); + + try { + $file = wc_clean( wp_unslash( $_POST['file'] ?? '' ) ); // PHPCS: input var ok. + self::check_file_path( $file ); + + $params = array( + 'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok. + 'start_pos' => isset( $_POST['position'] ) ? absint( $_POST['position'] ) : 0, // PHPCS: input var ok. + 'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok. + 'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok. + 'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '', + + /** + * Batch size for the product import process. + * + * @param int $size Batch size. + * + * @since 3.1.0 + */ + 'lines' => apply_filters( 'woocommerce_product_import_batch_size', 1 ), + 'parse' => true, + ); + + // Log failures. + if ( 0 !== $params['start_pos'] ) { + $error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) ); + } else { + $error_log = array(); + } + + include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php'; + + $importer = self::get_importer( $file, $params ); + $results = $importer->import(); + $percent_complete = $importer->get_percent_complete(); + $error_log = array_merge( $error_log, $results['failed'], $results['skipped'] ); + + update_user_option( get_current_user_id(), 'product_import_error_log', $error_log ); + + if ( 100 === $percent_complete ) { + // @codingStandardsIgnoreStart. + $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) ); + $wpdb->delete( $wpdb->posts, array( + 'post_type' => 'product', + 'post_status' => 'importing', + ) ); + $wpdb->delete( $wpdb->posts, array( + 'post_type' => 'product_variation', + 'post_status' => 'importing', + ) ); + // @codingStandardsIgnoreEnd. + + // Clean up orphaned data. + $wpdb->query( + " + DELETE {$wpdb->posts}.* FROM {$wpdb->posts} + LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->posts}.post_parent + WHERE wp.ID IS NULL AND {$wpdb->posts}.post_type = 'product_variation' + " + ); + $wpdb->query( + " + DELETE {$wpdb->postmeta}.* FROM {$wpdb->postmeta} + LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->postmeta}.post_id + WHERE wp.ID IS NULL + " + ); + // @codingStandardsIgnoreStart. + $wpdb->query( " + DELETE tr.* FROM {$wpdb->term_relationships} tr + LEFT JOIN {$wpdb->posts} wp ON wp.ID = tr.object_id + LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE wp.ID IS NULL + AND tt.taxonomy IN ( '" . implode( "','", array_map( 'esc_sql', get_object_taxonomies( 'product' ) ) ) . "' ) + " ); + // @codingStandardsIgnoreEnd. + + // Send success. + wp_send_json_success( + array( + 'position' => 'done', + 'percentage' => 100, + 'url' => add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ), + 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, + 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, + 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, + 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, + 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, + ) + ); + } else { + wp_send_json_success( + array( + 'position' => $importer->get_file_position(), + 'percentage' => $percent_complete, + 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, + 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, + 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, + 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, + 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, + ) + ); + } + } catch ( \Exception $e ) { + wp_send_json_error( array( 'message' => $e->getMessage() ) ); + } + } + /** * Output information about the uploading process. */ @@ -314,60 +498,20 @@ class WC_Product_CSV_Importer_Controller { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce already verified in WC_Product_CSV_Importer_Controller::upload_form_handler() $file_url = isset( $_POST['file_url'] ) ? wc_clean( wp_unslash( $_POST['file_url'] ) ) : ''; - if ( empty( $file_url ) ) { - if ( ! isset( $_FILES['import'] ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_file_empty', __( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.', 'woocommerce' ) ); + try { + if ( ! empty( $file_url ) ) { + $path = ABSPATH . $file_url; + self::check_file_path( $path ); + } else { + $csv_import_util = wc_get_container()->get( Automattic\WooCommerce\Internal\Admin\ImportExport\CSVUploadHelper::class ); + $upload = $csv_import_util->handle_csv_upload( 'product', 'import', self::get_valid_csv_filetypes() ); + $path = $upload['file']; } - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated - if ( ! self::is_file_valid_csv( wc_clean( wp_unslash( $_FILES['import']['name'] ) ), false ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_file_invalid', __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); - } - - $overrides = array( - 'test_form' => false, - 'mimes' => self::get_valid_csv_filetypes(), - ); - $import = $_FILES['import']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $upload = wp_handle_upload( $import, $overrides ); - - if ( isset( $upload['error'] ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_error', $upload['error'] ); - } - - // Construct the object array. - $object = array( - 'post_title' => basename( $upload['file'] ), - 'post_content' => $upload['url'], - 'post_mime_type' => $upload['type'], - 'guid' => $upload['url'], - 'context' => 'import', - 'post_status' => 'private', - ); - - // Save the data. - $id = wp_insert_attachment( $object, $upload['file'] ); - - /* - * Schedule a cleanup for one day from now in case of failed - * import or missing wp_import_cleanup() call. - */ - wp_schedule_single_event( time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( $id ) ); - - return $upload['file']; - } elseif ( - ( 0 === stripos( realpath( ABSPATH . $file_url ), ABSPATH ) ) && - file_exists( ABSPATH . $file_url ) - ) { - if ( ! self::is_file_valid_csv( ABSPATH . $file_url ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_file_invalid', __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); - } - - return ABSPATH . $file_url; + return $path; + } catch ( \Exception $e ) { + return new \WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', $e->getMessage() ); } - // phpcs:enable - - return new WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', __( 'Please upload or provide the link to a valid CSV file.', 'woocommerce' ) ); } /** @@ -375,6 +519,8 @@ class WC_Product_CSV_Importer_Controller { */ protected function mapping_form() { check_admin_referer( 'woocommerce-csv-importer' ); + self::check_file_path( $this->file ); + $args = array( 'lines' => 1, 'delimiter' => $this->delimiter, @@ -412,18 +558,7 @@ class WC_Product_CSV_Importer_Controller { // Displaying this page triggers Ajax action to run the import with a valid nonce, // therefore this page needs to be nonce protected as well. check_admin_referer( 'woocommerce-csv-importer' ); - - if ( ! self::is_file_valid_csv( $this->file ) ) { - $this->add_error( __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); - $this->output_errors(); - return; - } - - if ( ! is_file( $this->file ) ) { - $this->add_error( __( 'The file does not exist, please try again.', 'woocommerce' ) ); - $this->output_errors(); - return; - } + self::check_file_path( $this->file ); if ( ! empty( $_POST['map_from'] ) && ! empty( $_POST['map_to'] ) ) { $mapping_from = wc_clean( wp_unslash( $_POST['map_from'] ) ); diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-attribute-inner.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-attribute-inner.php index ef38a24cb54..94112796bb9 100644 --- a/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-attribute-inner.php +++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-product-attribute-inner.php @@ -20,7 +20,7 @@ if ( ! defined( 'ABSPATH' ) ) { get_name() ) ); ?> - + diff --git a/plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php b/plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php new file mode 100644 index 00000000000..19844bc9d66 --- /dev/null +++ b/plugins/woocommerce/includes/blocks/class-wc-brands-block-template-utils-duplicated.php @@ -0,0 +1,369 @@ + 'block-templates', + 'DEPRECATED_TEMPLATE_PARTS' => 'block-template-parts', + 'TEMPLATES' => 'templates', + 'TEMPLATE_PARTS' => 'parts', + ); + + /** + * WooCommerce plugin slug + * + * This is used to save templates to the DB which are stored against this value in the wp_terms table. + * + * @var string + */ + protected const PLUGIN_SLUG = 'woocommerce/woocommerce'; + + /** + * Returns an array containing the references of + * the passed blocks and their inner blocks. + * + * @param array $blocks array of blocks. + * + * @return array block references to the passed blocks and their inner blocks. + */ + public static function gutenberg_flatten_blocks( &$blocks ) { + $all_blocks = array(); + $queue = array(); + foreach ( $blocks as &$block ) { + $queue[] = &$block; + } + $queue_count = count( $queue ); + + while ( $queue_count > 0 ) { + $block = &$queue[0]; + array_shift( $queue ); + $all_blocks[] = &$block; + + if ( ! empty( $block['innerBlocks'] ) ) { + foreach ( $block['innerBlocks'] as &$inner_block ) { + $queue[] = &$inner_block; + } + } + + $queue_count = count( $queue ); + } + + return $all_blocks; + } + + /** + * Parses wp_template content and injects the current theme's + * stylesheet as a theme attribute into each wp_template_part + * + * @param string $template_content serialized wp_template content. + * + * @return string Updated wp_template content. + */ + public static function gutenberg_inject_theme_attribute_in_content( $template_content ) { + $has_updated_content = false; + $new_content = ''; + $template_blocks = parse_blocks( $template_content ); + + $blocks = self::gutenberg_flatten_blocks( $template_blocks ); + foreach ( $blocks as &$block ) { + if ( + 'core/template-part' === $block['blockName'] && + ! isset( $block['attrs']['theme'] ) + ) { + $block['attrs']['theme'] = wp_get_theme()->get_stylesheet(); + $has_updated_content = true; + } + } + + if ( $has_updated_content ) { + foreach ( $template_blocks as &$block ) { + $new_content .= serialize_block( $block ); + } + + return $new_content; + } + + return $template_content; + } + + /** + * Build a unified template object based a post Object. + * + * @param \WP_Post $post Template post. + * + * @return \WP_Block_Template|\WP_Error Template. + */ + public static function gutenberg_build_template_result_from_post( $post ) { + $terms = get_the_terms( $post, 'wp_theme' ); + + if ( is_wp_error( $terms ) ) { + return $terms; + } + + if ( ! $terms ) { + return new \WP_Error( 'template_missing_theme', __( 'No theme is defined for this template.', 'woocommerce' ) ); + } + + $theme = $terms[0]->name; + $has_theme_file = true; + + $template = new \WP_Block_Template(); + $template->wp_id = $post->ID; + $template->id = $theme . '//' . $post->post_name; + $template->theme = $theme; + $template->content = $post->post_content; + $template->slug = $post->post_name; + $template->source = 'custom'; + $template->type = $post->post_type; + $template->description = $post->post_excerpt; + $template->title = $post->post_title; + $template->status = $post->post_status; + $template->has_theme_file = $has_theme_file; + $template->is_custom = false; + $template->post_types = array(); // Don't appear in any Edit Post template selector dropdown. + + if ( 'wp_template_part' === $post->post_type ) { + $type_terms = get_the_terms( $post, 'wp_template_part_area' ); + if ( ! is_wp_error( $type_terms ) && false !== $type_terms ) { + $template->area = $type_terms[0]->name; + } + } + + // We are checking 'woocommerce' to maintain legacy templates which are saved to the DB, + // prior to updating to use the correct slug. + // More information found here: https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/5423. + if ( self::PLUGIN_SLUG === $theme || 'woocommerce' === strtolower( $theme ) ) { + $template->origin = 'plugin'; + } + + return $template; + } + + /** + * Build a unified template object based on a theme file. + * + * @param array|object $template_file Theme file. + * @param string $template_type wp_template or wp_template_part. + * + * @return \WP_Block_Template Template. + */ + public static function gutenberg_build_template_result_from_file( $template_file, $template_type ) { + $template_file = (object) $template_file; + + // If the theme has an archive-products.html template but does not have product taxonomy templates + // then we will load in the archive-product.html template from the theme to use for product taxonomies on the frontend. + $template_is_from_theme = 'theme' === $template_file->source; + $theme_name = wp_get_theme()->get( 'TextDomain' ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $template_content = file_get_contents( $template_file->path ); + $template = new \WP_Block_Template(); + $template->id = $template_is_from_theme ? $theme_name . '//' . $template_file->slug : self::PLUGIN_SLUG . '//' . $template_file->slug; + $template->theme = $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG; + $template->content = self::gutenberg_inject_theme_attribute_in_content( $template_content ); + // Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909. + $template->source = $template_file->source ? $template_file->source : 'plugin'; + $template->slug = $template_file->slug; + $template->type = $template_type; + $template->title = ! empty( $template_file->title ) ? $template_file->title : self::convert_slug_to_title( $template_file->slug ); + $template->status = 'publish'; + $template->has_theme_file = true; + $template->origin = $template_file->source; + $template->is_custom = false; // Templates loaded from the filesystem aren't custom, ones that have been edited and loaded from the DB are. + $template->post_types = array(); // Don't appear in any Edit Post template selector dropdown. + $template->area = 'uncategorized'; + return $template; + } + + /** + * Build a new template object so that we can make Woo Blocks default templates available in the current theme should they not have any. + * + * @param string $template_file Block template file path. + * @param string $template_type wp_template or wp_template_part. + * @param string $template_slug Block template slug e.g. single-product. + * @param bool $template_is_from_theme If the block template file is being loaded from the current theme instead of Woo Blocks. + * + * @return object Block template object. + */ + public static function create_new_block_template_object( $template_file, $template_type, $template_slug, $template_is_from_theme = false ) { + $theme_name = wp_get_theme()->get( 'TextDomain' ); + + $new_template_item = array( + 'slug' => $template_slug, + 'id' => $template_is_from_theme ? $theme_name . '//' . $template_slug : self::PLUGIN_SLUG . '//' . $template_slug, + 'path' => $template_file, + 'type' => $template_type, + 'theme' => $template_is_from_theme ? $theme_name : self::PLUGIN_SLUG, + // Plugin was agreed as a valid source value despite existing inline docs at the time of creating: https://github.com/WordPress/gutenberg/issues/36597#issuecomment-976232909. + 'source' => $template_is_from_theme ? 'theme' : 'plugin', + 'title' => self::convert_slug_to_title( $template_slug ), + 'description' => '', + 'post_types' => array(), // Don't appear in any Edit Post template selector dropdown. + ); + + return (object) $new_template_item; + } + + + /** + * Converts template slugs into readable titles. + * + * @param string $template_slug The templates slug (e.g. single-product). + * @return string Human friendly title converted from the slug. + */ + public static function convert_slug_to_title( $template_slug ) { + switch ( $template_slug ) { + case 'single-product': + return __( 'Single Product', 'woocommerce' ); + case 'archive-product': + return __( 'Product Archive', 'woocommerce' ); + case 'taxonomy-product_cat': + return __( 'Product Category', 'woocommerce' ); + case 'taxonomy-product_tag': + return __( 'Product Tag', 'woocommerce' ); + default: + // Replace all hyphens and underscores with spaces. + return ucwords( preg_replace( '/[\-_]/', ' ', $template_slug ) ); + } + } + + + /** + * Gets the first matching template part within themes directories + * + * Since [Gutenberg 12.1.0](https://github.com/WordPress/gutenberg/releases/tag/v12.1.0), the conventions for + * block templates and parts directory has changed from `block-templates` and `block-templates-parts` + * to `templates` and `parts` respectively. + * + * This function traverses all possible combinations of directory paths where a template or part + * could be located and returns the first one which is readable, prioritizing the new convention + * over the deprecated one, but maintaining that one for backwards compatibility. + * + * @param string $template_slug The slug of the template (i.e. without the file extension). + * @param string $template_type Either `wp_template` or `wp_template_part`. + * + * @return string|null The matched path or `null` if no match was found. + */ + public static function get_theme_template_path( $template_slug, $template_type = 'wp_template' ) { + $template_filename = $template_slug . '.html'; + $possible_templates_dir = 'wp_template' === $template_type ? array( + self::DIRECTORY_NAMES['TEMPLATES'], + self::DIRECTORY_NAMES['DEPRECATED_TEMPLATES'], + ) : array( + self::DIRECTORY_NAMES['TEMPLATE_PARTS'], + self::DIRECTORY_NAMES['DEPRECATED_TEMPLATE_PARTS'], + ); + + // Combine the possible root directory names with either the template directory + // or the stylesheet directory for child themes. + $possible_paths = array_reduce( + $possible_templates_dir, + function ( $carry, $item ) use ( $template_filename ) { + $filepath = DIRECTORY_SEPARATOR . $item . DIRECTORY_SEPARATOR . $template_filename; + + $carry[] = get_template_directory() . $filepath; + $carry[] = get_stylesheet_directory() . $filepath; + + return $carry; + }, + array() + ); + + // Return the first matching. + foreach ( $possible_paths as $path ) { + if ( is_readable( $path ) ) { + return $path; + } + } + + return null; + } + + /** + * Check if the theme has a template. So we know if to load our own in or not. + * + * @param string $template_name name of the template file without .html extension e.g. 'single-product'. + * @return boolean + */ + public static function theme_has_template( $template_name ) { + return (bool) self::get_theme_template_path( $template_name, 'wp_template' ); + } + + /** + * Check if the theme has a template. So we know if to load our own in or not. + * + * @param string $template_name name of the template file without .html extension e.g. 'single-product'. + * @return boolean + */ + public static function theme_has_template_part( $template_name ) { + return (bool) self::get_theme_template_path( $template_name, 'wp_template_part' ); + } + + /** + * Checks to see if they are using a compatible version of WP, or if not they have a compatible version of the Gutenberg plugin installed. + * + * @return boolean + */ + public static function supports_block_templates() { + if ( + ( ! function_exists( 'wp_is_block_theme' ) || ! wp_is_block_theme() ) && + ( ! function_exists( 'gutenberg_supports_block_templates' ) || ! gutenberg_supports_block_templates() ) + ) { + return false; + } + + return true; + } + + /** + * Returns whether the blockified templates should be used or not. + * + * First, we need to make sure WordPress version is higher than 6.1 (lowest that supports Products block). + * Then, if the option is not stored on the db, we need to check if the current theme is a block one or not. + * + * @return boolean + */ + public static function should_use_blockified_product_grid_templates() { + $minimum_wp_version = '6.1'; + + if ( version_compare( $GLOBALS['wp_version'], $minimum_wp_version, '<' ) ) { + return false; + } + + $use_blockified_templates = wc_string_to_bool( get_option( Options::WC_BLOCK_USE_BLOCKIFIED_PRODUCT_GRID_BLOCK_AS_TEMPLATE ) ); + + if ( false === $use_blockified_templates ) { + return function_exists( 'wc_current_theme_is_fse_theme' ) && wc_current_theme_is_fse_theme(); + } + + return $use_blockified_templates; + } +} diff --git a/plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php b/plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php new file mode 100644 index 00000000000..efba2807519 --- /dev/null +++ b/plugins/woocommerce/includes/blocks/class-wc-brands-block-templates.php @@ -0,0 +1,156 @@ + 'taxonomy-product_brand', + 'post_type' => 'wp_template', + 'post_status' => 'publish', + 'posts_per_page' => 1, + ) + ); + + if ( count( $posts ) ) { + return $posts[0]; + } + + return null; + } + + /** + * Fixes a bug regarding taxonomies and FSE. + * Without this, the system will always load archive-product.php version instead of taxonomy_product_brand.html + * it will show a deprecation error if that happens. + * + * Triggered by woocommerce_has_block_template filter + * + * @param bool $has_template True if the template is available. + * @param string $template_name The name of the template. + * + * @return bool True if the system is checking archive-product + */ + public function has_block_template( $has_template, $template_name ) { + if ( 'archive-product' === $template_name || 'taxonomy-product_brand' === $template_name ) { + $has_template = true; + } + + return $has_template; + } + + /** + * Get the block template for Taxonomy Product Brand. First it attempts to load the last version from DB + * Otherwise it loads the file based template. + * + * @param string $template_type The post_type for the template. Normally wp_template or wp_template_part. + * + * @return WP_Block_Template The taxonomy-product_brand template. + */ + private function get_product_brands_template( $template_type ) { + $template_db = $this->get_product_brand_template_db(); + + if ( $template_db ) { + return BlockTemplateUtilsDuplicated::gutenberg_build_template_result_from_post( $template_db ); + } + + $template_path = BlockTemplateUtilsDuplicated::should_use_blockified_product_grid_templates() + ? WC()->plugin_path() . '/templates/templates/blockified/taxonomy-product_brand.html' + : WC()->plugin_path() . '/templates/templates/taxonomy-product_brand.html'; + + $template_file = BlockTemplateUtilsDuplicated::create_new_block_template_object( $template_path, $template_type, 'taxonomy-product_brand', false ); + + return BlockTemplateUtilsDuplicated::gutenberg_build_template_result_from_file( $template_file, $template_type ); + } + + /** + * Function to check if a template name is woocommerce/taxonomy-product_brand + * + * Notice depending on the version of WooCommerce this could be: + * + * woocommerce//taxonomy-product_brand + * woocommerce/woocommerce//taxonomy-product_brand + * + * @param String $id The string to check if contains the template name. + * + * @return bool True if the template is woocommerce/taxonomy-product_brand + */ + private function is_taxonomy_product_brand_template( $id ) { + return strpos( $id, 'woocommerce//taxonomy-product_brand' ) !== false; + } + + /** + * Get the block template for Taxonomy Product Brand if requested. + * Triggered by get_block_file_template action + * + * @param WP_Block_Template|null $block_template The current Block Template loaded, if any. + * @param string $id The template id normally in the format theme-slug//template-slug. + * @param string $template_type The post_type for the template. Normally wp_template or wp_template_part. + * + * @return WP_Block_Template|null The taxonomy-product_brand template. + */ + public function get_block_file_template( $block_template, $id, $template_type ) { + if ( $this->is_taxonomy_product_brand_template( $id ) && is_null( $block_template ) ) { + $block_template = $this->get_product_brands_template( $template_type ); + } + + return $block_template; + } + + /** + * Add the Block template in the template query results needed by FSE + * Triggered by get_block_templates action + * + * @param array $query_result The list of templates to render in the query. + * @param array $query The current query parameters. + * @param string $template_type The post_type for the template. Normally wp_template or wp_template_part. + * + * @return WP_Block_Template[] Array of the matched Block Templates to render. + */ + public function get_block_templates( $query_result, $query, $template_type ) { + // We don't want to run this if we are looking for template-parts. Like the header. + if ( 'wp_template' !== $template_type ) { + return $query_result; + } + + $post_id = isset( $_REQUEST['postId'] ) ? wc_clean( wp_unslash( $_REQUEST['postId'] ) ) : null; // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $slugs = $query['slug__in'] ?? array(); + + // Only add the template if asking for Product Brands. + if ( + in_array( 'taxonomy-product_brand', $slugs, true ) || + ( ! $post_id && ! count( $slugs ) ) || + ( ! count( $slugs ) && $this->is_taxonomy_product_brand_template( $post_id ) ) + ) { + $query_result[] = $this->get_product_brands_template( $template_type ); + } + + return $query_result; + } +} + +new WC_Brands_Block_Templates(); diff --git a/plugins/woocommerce/includes/class-wc-autoloader.php b/plugins/woocommerce/includes/class-wc-autoloader.php index 6f3b3f51be1..3c9c9570eee 100644 --- a/plugins/woocommerce/includes/class-wc-autoloader.php +++ b/plugins/woocommerce/includes/class-wc-autoloader.php @@ -77,6 +77,11 @@ class WC_Autoloader { return; } + // If the class is already loaded from a merged package, prevent autoloader from loading it as well. + if ( \Automattic\WooCommerce\Packages::should_load_class( $class ) ) { + return; + } + $file = $this->get_file_name_from_class( $class ); $path = ''; diff --git a/plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php b/plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php new file mode 100644 index 00000000000..5b68df7e4df --- /dev/null +++ b/plugins/woocommerce/includes/class-wc-brands-brand-settings-manager.php @@ -0,0 +1,68 @@ +get_id(); + + // Check if the brand settings are already set for this coupon. + if ( isset( self::$brand_settings[ $coupon_id ] ) ) { + return; + } + + $included_brands = get_post_meta( $coupon_id, 'product_brands', true ); + $included_brands = ! empty( $included_brands ) ? $included_brands : array(); + + $excluded_brands = get_post_meta( $coupon_id, 'exclude_product_brands', true ); + $excluded_brands = ! empty( $excluded_brands ) ? $excluded_brands : array(); + + // Store these settings in the static array. + self::$brand_settings[ $coupon_id ] = array( + 'included_brands' => $included_brands, + 'excluded_brands' => $excluded_brands, + ); + } + + /** + * Get brand settings for a coupon. + * + * @param WC_Coupon $coupon Coupon object. + * @return array Brand settings (included and excluded brands). + */ + public static function get_brand_settings_on_coupon( $coupon ) { + $coupon_id = $coupon->get_id(); + + if ( isset( self::$brand_settings[ $coupon_id ] ) ) { + return self::$brand_settings[ $coupon_id ]; + } + + // Default return value if no settings are found. + return array( + 'included_brands' => array(), + 'excluded_brands' => array(), + ); + } +} diff --git a/plugins/woocommerce/includes/class-wc-brands-coupons.php b/plugins/woocommerce/includes/class-wc-brands-coupons.php new file mode 100644 index 00000000000..065ec3e0de7 --- /dev/null +++ b/plugins/woocommerce/includes/class-wc-brands-coupons.php @@ -0,0 +1,189 @@ +set_brand_settings_on_coupon( $coupon ); + + // Only check if coupon has brand restrictions on it. + $brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon ); + + $brand_restrictions = ! empty( $brand_coupon_settings['included_brands'] ) || ! empty( $brand_coupon_settings['excluded_brands'] ); + if ( ! $brand_restrictions ) { + return $valid; + } + + $included_brands_match = false; + $excluded_brands_matches = 0; + + $items = $discounts->get_items(); + + foreach ( $items as $item ) { + $product_brands = $this->get_product_brands( $this->get_product_id( $item->product ) ); + + if ( ! empty( array_intersect( $product_brands, $brand_coupon_settings['included_brands'] ) ) ) { + $included_brands_match = true; + } + + if ( ! empty( array_intersect( $product_brands, $brand_coupon_settings['excluded_brands'] ) ) ) { + ++$excluded_brands_matches; + } + } + + // 1) Coupon has a brand requirement but no products in the cart have the brand. + if ( ! $included_brands_match && ! empty( $brand_coupon_settings['included_brands'] ) ) { + throw new Exception( WC_Coupon::E_WC_COUPON_NOT_APPLICABLE ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // 2) All products in the cart match brand exclusion rule. + if ( count( $items ) === $excluded_brands_matches ) { + throw new Exception( self::E_WC_COUPON_EXCLUDED_BRANDS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + // 3) For a cart discount, there is at least one product in cart that matches exclusion rule. + if ( $coupon->is_type( 'fixed_cart' ) && $excluded_brands_matches > 0 ) { + throw new Exception( self::E_WC_COUPON_EXCLUDED_BRANDS ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped + } + + return $valid; + } + + /** + * Check if a coupon is valid for a product. + * + * This allows percentage and product discounts to apply to only + * the correct products in the cart. + * + * @param bool $valid Whether the product should get the coupon's discounts. + * @param WC_Product $product WC Product Object. + * @param WC_Coupon $coupon Coupon object. + * @return bool $valid + */ + public function is_valid_for_product( $valid, $product, $coupon ) { + + if ( ! is_a( $product, 'WC_Product' ) ) { + return $valid; + } + $this->set_brand_settings_on_coupon( $coupon ); + + $product_id = $this->get_product_id( $product ); + $product_brands = $this->get_product_brands( $product_id ); + + // Check if coupon has a brand requirement and if this product has that brand attached. + $brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon ); + if ( ! empty( $brand_coupon_settings['included_brands'] ) && empty( array_intersect( $product_brands, $brand_coupon_settings['included_brands'] ) ) ) { + return false; + } + + // Check if coupon has a brand exclusion and if this product has that brand attached. + if ( ! empty( $brand_coupon_settings['excluded_brands'] ) && ! empty( array_intersect( $product_brands, $brand_coupon_settings['excluded_brands'] ) ) ) { + return false; + } + + return $valid; + } + + /** + * Display a custom error message when a cart discount coupon does not validate + * because an excluded brand was found in the cart. + * + * @param string $err The error message. + * @param string $err_code The error code. + * @return string + */ + public function brand_exclusion_error( $err, $err_code ) { + if ( self::E_WC_COUPON_EXCLUDED_BRANDS !== $err_code ) { + return $err; + } + + return __( 'Sorry, this coupon is not applicable to the brands of selected products.', 'woocommerce' ); + } + + /** + * Get a list of brands that are assigned to a specific product + * + * @param int $product_id Product id. + * @return array brands + */ + private function get_product_brands( $product_id ) { + return wp_get_post_terms( $product_id, 'product_brand', array( 'fields' => 'ids' ) ); + } + + /** + * Set brand settings as properties on coupon object. These properties are + * lists of included product brand IDs and list of excluded brand IDs. + * + * @param WC_Coupon $coupon Coupon object. + * + * @return void + */ + private function set_brand_settings_on_coupon( $coupon ) { + $brand_coupon_settings = WC_Brands_Brand_Settings_Manager::get_brand_settings_on_coupon( $coupon ); + + if ( ! empty( $brand_coupon_settings['included_brands'] ) && ! empty( $brand_coupon_settings['excluded_brands'] ) ) { + return; + } + + $included_brands = get_post_meta( $coupon->get_id(), 'product_brands', true ); + if ( empty( $included_brands ) ) { + $included_brands = array(); + } + + $excluded_brands = get_post_meta( $coupon->get_id(), 'exclude_product_brands', true ); + if ( empty( $excluded_brands ) ) { + $excluded_brands = array(); + } + + // Store these for later to avoid multiple look-ups. + WC_Brands_Brand_Settings_Manager::set_brand_settings_on_coupon( $coupon ); + } + + /** + * Returns the product (or variant) ID. + * + * @param WC_Product $product WC Product Object. + * @return int Product ID + */ + private function get_product_id( $product ) { + return $product->is_type( 'variation' ) ? $product->get_parent_id() : $product->get_id(); + } +} + +new WC_Brands_Coupons(); diff --git a/plugins/woocommerce/includes/class-wc-brands.php b/plugins/woocommerce/includes/class-wc-brands.php new file mode 100644 index 00000000000..71e1fa71299 --- /dev/null +++ b/plugins/woocommerce/includes/class-wc-brands.php @@ -0,0 +1,1070 @@ +template_url = apply_filters( 'woocommerce_template_url', 'woocommerce/' ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + + add_action( 'plugins_loaded', array( $this, 'register_hooks' ), 2 ); + + $this->register_shortcodes(); + } + + /** + * Register our hooks + */ + public function register_hooks() { + add_action( 'woocommerce_register_taxonomy', array( __CLASS__, 'init_taxonomy' ) ); + add_action( 'widgets_init', array( $this, 'init_widgets' ) ); + + if ( ! wc_current_theme_is_fse_theme() ) { + add_filter( 'template_include', array( $this, 'template_loader' ) ); + } + + add_action( 'wp_enqueue_scripts', array( $this, 'styles' ) ); + add_action( 'wp', array( $this, 'body_class' ) ); + + add_action( 'woocommerce_product_meta_end', array( $this, 'show_brand' ) ); + add_filter( 'woocommerce_structured_data_product', array( $this, 'add_structured_data' ), 20 ); + + // duplicate product brands. + add_action( 'woocommerce_product_duplicate_before_save', array( $this, 'duplicate_store_temporary_brands' ), 10, 2 ); + add_action( 'woocommerce_new_product', array( $this, 'duplicate_add_product_brand_terms' ) ); + add_action( 'woocommerce_new_product', array( $this, 'invalidate_wc_layered_nav_counts_cache' ), 10, 0 ); + add_action( 'woocommerce_update_product', array( $this, 'invalidate_wc_layered_nav_counts_cache' ), 10, 0 ); + add_action( 'transition_post_status', array( $this, 'reset_layered_nav_counts_on_status_change' ), 10, 3 ); + + add_filter( 'post_type_link', array( $this, 'post_type_link' ), 11, 2 ); + + if ( 'yes' === get_option( 'wc_brands_show_description' ) ) { + add_action( 'woocommerce_archive_description', array( $this, 'brand_description' ) ); + } + + add_filter( 'woocommerce_product_query_tax_query', array( $this, 'update_product_query_tax_query' ), 10, 1 ); + + // REST API. + add_action( 'rest_api_init', array( $this, 'rest_api_register_routes' ) ); + add_action( 'woocommerce_rest_insert_product', array( $this, 'rest_api_maybe_set_brands' ), 10, 2 ); + add_filter( 'woocommerce_rest_prepare_product', array( $this, 'rest_api_prepare_brands_to_product' ), 10, 2 ); // WC 2.6.x. + add_filter( 'woocommerce_rest_prepare_product_object', array( $this, 'rest_api_prepare_brands_to_product' ), 10, 2 ); // WC 3.x. + add_action( 'woocommerce_rest_insert_product', array( $this, 'rest_api_add_brands_to_product' ), 10, 3 ); // WC 2.6.x. + add_action( 'woocommerce_rest_insert_product_object', array( $this, 'rest_api_add_brands_to_product' ), 10, 3 ); // WC 3.x. + add_filter( 'woocommerce_rest_product_object_query', array( $this, 'rest_api_filter_products_by_brand' ), 10, 2 ); + add_filter( 'rest_product_collection_params', array( $this, 'rest_api_product_collection_params' ), 10, 2 ); + + // Layered nav widget compatibility. + add_filter( 'woocommerce_layered_nav_term_html', array( $this, 'woocommerce_brands_update_layered_nav_link' ), 10, 4 ); + + // Filter the list of taxonomies overridden for the original term count. + add_filter( 'woocommerce_change_term_counts', array( $this, 'add_brands_to_terms' ) ); + add_action( 'woocommerce_product_set_stock_status', array( $this, 'recount_after_stock_change' ) ); + add_action( 'woocommerce_update_options_products_inventory', array( $this, 'recount_all_brands' ) ); + + // Product Editor compatibility. + add_action( 'woocommerce_layout_template_after_instantiation', array( $this, 'wc_brands_on_block_template_register' ), 10, 3 ); + } + + /** + * Add product_brand to the taxonomies overridden for the original term count. + * + * @param array $taxonomies List of taxonomies. + * + * @return array + */ + public function add_brands_to_terms( $taxonomies ) { + $taxonomies[] = 'product_brand'; + return $taxonomies; + } + + /** + * Recount the brands after the stock amount changes. + * + * @param int $product_id Product ID. + */ + public function recount_after_stock_change( $product_id ) { + if ( 'yes' !== get_option( 'woocommerce_hide_out_of_stock_items' ) || empty( $product_id ) ) { + return; + } + + $product_terms = get_the_terms( $product_id, 'product_brand' ); + + if ( $product_terms ) { + $product_brands = array(); + + foreach ( $product_terms as $term ) { + $product_brands[ $term->term_id ] = $term->parent; + } + + _wc_term_recount( $product_brands, get_taxonomy( 'product_brand' ), false, false ); + } + } + + /** + * Recount all brands. + */ + public function recount_all_brands() { + $product_brands = get_terms( + array( + 'taxonomy' => 'product_brand', + 'hide_empty' => false, + 'fields' => 'id=>parent', + ) + ); + _wc_term_recount( $product_brands, get_taxonomy( 'product_brand' ), true, false ); + } + + /** + * Update the main product fetch query to filter by selected brands. + * + * @param array $tax_query array of current taxonomy filters. + * + * @return array + */ + public function update_product_query_tax_query( array $tax_query ) { + if ( isset( $_GET['filter_product_brand'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $filter_product_brand = wc_clean( wp_unslash( $_GET['filter_product_brand'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $brands_filter = array_filter( array_map( 'absint', explode( ',', $filter_product_brand ) ) ); + + if ( $brands_filter ) { + $tax_query[] = array( + 'taxonomy' => 'product_brand', + 'terms' => $brands_filter, + 'operator' => 'IN', + ); + } + } + + return $tax_query; + } + + /** + * Filter to allow product_brand in the permalinks for products. + * + * @param string $permalink The existing permalink URL. + * @param WP_Post $post The post. + * @return string + */ + public function post_type_link( $permalink, $post ) { + // Abort if post is not a product. + if ( 'product' !== $post->post_type ) { + return $permalink; + } + + // Abort early if the placeholder rewrite tag isn't in the generated URL. + if ( false === strpos( $permalink, '%' ) ) { + return $permalink; + } + + // Get the custom taxonomy terms in use by this post. + $terms = get_the_terms( $post->ID, 'product_brand' ); + + if ( empty( $terms ) ) { + // If no terms are assigned to this post, use a string instead (can't leave the placeholder there). + $product_brand = _x( 'uncategorized', 'slug', 'woocommerce' ); + } else { + // Replace the placeholder rewrite tag with the first term's slug. + $first_term = array_shift( $terms ); + $product_brand = $first_term->slug; + } + + $find = array( + '%product_brand%', + ); + + $replace = array( + $product_brand, + ); + + $replace = array_map( 'sanitize_title', $replace ); + + $permalink = str_replace( $find, $replace, $permalink ); + + return $permalink; + } + + /** + * Adds filter for introducing CSS classes. + */ + public function body_class() { + if ( is_tax( 'product_brand' ) ) { + add_filter( 'body_class', array( $this, 'add_body_class' ) ); + } + } + + /** + * Adds classes to brand taxonomy pages. + * + * @param array $classes Classes array. + */ + public function add_body_class( $classes ) { + $classes[] = 'woocommerce'; + $classes[] = 'woocommerce-page'; + return $classes; + } + + /** + * Enqueues styles. + */ + public function styles() { + $version = Constants::get_constant( 'WC_VERSION' ); + wp_enqueue_style( 'brands-styles', WC()->plugin_url() . '/assets/css/brands.css', array(), $version ); + } + + /** + * Initializes brand taxonomy. + */ + public static function init_taxonomy() { + $shop_page_id = wc_get_page_id( 'shop' ); + + $base_slug = $shop_page_id > 0 && get_page( $shop_page_id ) ? get_page_uri( $shop_page_id ) : 'shop'; + $category_base = get_option( 'woocommerce_prepend_shop_page_to_urls' ) === 'yes' ? trailingslashit( $base_slug ) : ''; + + $slug = $category_base . __( 'brand', 'woocommerce' ); + if ( '' === $category_base ) { + $slug = get_option( 'woocommerce_brand_permalink', '' ); + } + + // Can't provide transatable string as get_option default. + if ( '' === $slug ) { + $slug = __( 'brand', 'woocommerce' ); + } + + register_taxonomy( + 'product_brand', + array( 'product' ), + /** + * Filter the brand taxonomy. + * + * @since 9.4.0 + * + * @param array $args Args. + */ + apply_filters( + 'register_taxonomy_product_brand', + array( + 'hierarchical' => true, + 'update_count_callback' => '_update_post_term_count', + 'label' => __( 'Brands', 'woocommerce' ), + 'labels' => array( + 'name' => __( 'Brands', 'woocommerce' ), + 'singular_name' => __( 'Brand', 'woocommerce' ), + 'search_items' => __( 'Search Brands', 'woocommerce' ), + 'all_items' => __( 'All Brands', 'woocommerce' ), + 'parent_item' => __( 'Parent Brand', 'woocommerce' ), + 'parent_item_colon' => __( 'Parent Brand:', 'woocommerce' ), + 'edit_item' => __( 'Edit Brand', 'woocommerce' ), + 'update_item' => __( 'Update Brand', 'woocommerce' ), + 'add_new_item' => __( 'Add New Brand', 'woocommerce' ), + 'new_item_name' => __( 'New Brand Name', 'woocommerce' ), + 'not_found' => __( 'No Brands Found', 'woocommerce' ), + 'back_to_items' => __( '← Go to Brands', 'woocommerce' ), + ), + + 'show_ui' => true, + 'show_admin_column' => true, + 'show_in_nav_menus' => true, + 'show_in_rest' => true, + 'capabilities' => array( + 'manage_terms' => 'manage_product_terms', + 'edit_terms' => 'edit_product_terms', + 'delete_terms' => 'delete_product_terms', + 'assign_terms' => 'assign_product_terms', + ), + + 'rewrite' => array( + 'slug' => $slug, + 'with_front' => false, + 'hierarchical' => true, + ), + ) + ) + ); + } + + /** + * Initializes brand widgets. + */ + public function init_widgets() { + // Include. + require_once WC()->plugin_path() . '/includes/widgets/class-wc-widget-brand-description.php'; + require_once WC()->plugin_path() . '/includes/widgets/class-wc-widget-brand-nav.php'; + require_once WC()->plugin_path() . '/includes/widgets/class-wc-widget-brand-thumbnails.php'; + + // Register. + register_widget( 'WC_Widget_Brand_Description' ); + register_widget( 'WC_Widget_Brand_Nav' ); + register_widget( 'WC_Widget_Brand_Thumbnails' ); + } + + /** + * + * Handles template usage so that we can use our own templates instead of the themes. + * + * Templates are in the 'templates' folder. woocommerce looks for theme + * overides in /theme/woocommerce/ by default + * + * For beginners, it also looks for a woocommerce.php template first. If the user adds + * this to the theme (containing a woocommerce() inside) this will be used for all + * woocommerce templates. + * + * @param string $template Template. + */ + public function template_loader( $template ) { + $find = array( 'woocommerce.php' ); + $file = ''; + + if ( is_tax( 'product_brand' ) ) { + + $term = get_queried_object(); + + $file = 'taxonomy-' . $term->taxonomy . '.php'; + $find[] = 'taxonomy-' . $term->taxonomy . '-' . $term->slug . '.php'; + $find[] = $this->template_url . 'taxonomy-' . $term->taxonomy . '-' . $term->slug . '.php'; + $find[] = $file; + $find[] = $this->template_url . $file; + + } + + if ( $file ) { + $template = locate_template( $find ); + if ( ! $template ) { + $template = WC()->plugin_path() . '/templates/brands/' . $file; + } + } + + return $template; + } + + /** + * Displays brand description. + */ + public function brand_description() { + if ( ! is_tax( 'product_brand' ) ) { + return; + } + + if ( ! get_query_var( 'term' ) ) { + return; + } + + $thumbnail = ''; + + $term = get_term_by( 'slug', get_query_var( 'term' ), 'product_brand' ); + $thumbnail = wc_get_brand_thumbnail_url( $term->term_id, 'full' ); + + wc_get_template( + 'brand-description.php', + array( + 'thumbnail' => $thumbnail, + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + } + + /** + * Displays brand. + */ + public function show_brand() { + global $post; + + if ( is_singular( 'product' ) ) { + $terms = get_the_terms( $post->ID, 'product_brand' ); + $brand_count = is_array( $terms ) ? count( $terms ) : 0; + + $taxonomy = get_taxonomy( 'product_brand' ); + $labels = $taxonomy->labels; + + /* translators: %s - Label name */ + echo wc_get_brands( $post->ID, ', ', ' ' . sprintf( _n( '%s: ', '%s: ', $brand_count, 'woocommerce' ), $labels->singular_name, $labels->name ), '' ); // phpcs:ignore WordPress.Security.EscapeOutput + } + } + + /** + * Add structured data to product page. + * + * @param array $markup Markup. + * @return array $markup + */ + public function add_structured_data( $markup ) { + global $post; + + if ( array_key_exists( 'brand', $markup ) ) { + return $markup; + } + + $brands = get_the_terms( $post->ID, 'product_brand' ); + + if ( ! empty( $brands ) && is_array( $brands ) ) { + // Can only return one brand, so pick the first. + $markup['brand'] = array( + '@type' => 'Brand', + 'name' => $brands[0]->name, + ); + } + + return $markup; + } + + /** + * Registers shortcodes. + */ + public function register_shortcodes() { + add_shortcode( 'product_brand', array( $this, 'output_product_brand' ) ); + add_shortcode( 'product_brand_thumbnails', array( $this, 'output_product_brand_thumbnails' ) ); + add_shortcode( 'product_brand_thumbnails_description', array( $this, 'output_product_brand_thumbnails_description' ) ); + add_shortcode( 'product_brand_list', array( $this, 'output_product_brand_list' ) ); + add_shortcode( 'brand_products', array( $this, 'output_brand_products' ) ); + } + + /** + * Displays product brand. + * + * @param array $atts Attributes from the shortcode. + * @return string The generated output. + */ + public function output_product_brand( $atts ) { + global $post; + + $args = shortcode_atts( + array( + 'width' => '', + 'height' => '', + 'class' => 'aligncenter', + 'post_id' => '', + ), + $atts + ); + + if ( ! $args['post_id'] && ! $post ) { + return ''; + } + + if ( ! $args['post_id'] ) { + $args['post_id'] = $post->ID; + } + + $brands = wp_get_post_terms( $args['post_id'], 'product_brand', array( 'fields' => 'ids' ) ); + + // Bail early if we don't have any brands registered. + if ( 0 === count( $brands ) ) { + return ''; + } + + ob_start(); + + foreach ( $brands as $brand ) { + $thumbnail = wc_get_brand_thumbnail_url( $brand ); + if ( empty( $thumbnail ) ) { + continue; + } + + $args['thumbnail'] = $thumbnail; + $args['term'] = get_term_by( 'id', $brand, 'product_brand' ); + + if ( $args['width'] || $args['height'] ) { + $args['width'] = ! empty( $args['width'] ) ? $args['width'] : 'auto'; + $args['height'] = ! empty( $args['height'] ) ? $args['height'] : 'auto'; + } + + wc_get_template( + 'shortcodes/single-brand.php', + $args, + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + } + + return ob_get_clean(); + } + + /** + * Displays product brand list. + * + * @param array $atts Attributes from the shortcode. + * @return string + */ + public function output_product_brand_list( $atts ) { + $args = shortcode_atts( + array( + 'show_top_links' => true, + 'show_empty' => true, + 'show_empty_brands' => false, + ), + $atts + ); + + $show_top_links = $args['show_top_links']; + $show_empty = $args['show_empty']; + $show_empty_brands = $args['show_empty_brands']; + + if ( 'false' === $show_top_links ) { + $show_top_links = false; + } + + if ( 'false' === $show_empty ) { + $show_empty = false; + } + + if ( 'false' === $show_empty_brands ) { + $show_empty_brands = false; + } + + $product_brands = array(); + //phpcs:disable + $terms = get_terms( array( 'taxonomy' => 'product_brand', 'hide_empty' => ( $show_empty_brands ? false : true ) ) ); + $alphabet = apply_filters( 'woocommerce_brands_list_alphabet', range( 'a', 'z' ) ); + $numbers = apply_filters( 'woocommerce_brands_list_numbers', '0-9' ); + + /** + * Check for empty brands and remove them from the list. + */ + if ( ! $show_empty_brands ) { + $terms = $this->remove_terms_with_empty_products( $terms ); + } + + foreach ( $terms as $term ) { + $term_letter = $this->get_brand_name_first_character( $term->name ); + + // Allow a locale to be set for ctype_alpha(). + if ( has_filter( 'woocommerce_brands_list_locale' ) ) { + setLocale( LC_CTYPE, apply_filters( 'woocommerce_brands_list_locale', 'en_US.UTF-8' ) ); + } + + if ( ctype_alpha( $term_letter ) ) { + + foreach ( $alphabet as $i ) { + if ( $i == $term_letter ) { + $product_brands[ $i ][] = $term; + break; + } + } + } else { + $product_brands[ $numbers ][] = $term; + } + } + + ob_start(); + + wc_get_template( + 'shortcodes/brands-a-z.php', + array( + 'terms' => $terms, + 'index' => array_merge( $alphabet, array( $numbers ) ), + 'product_brands' => $product_brands, + 'show_empty' => $show_empty, + 'show_top_links' => $show_top_links, + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + return ob_get_clean(); + } + + /** + * Get the first letter of the brand name, returning lowercase and without accents. + * + * @param string $name + * + * @return string + * @since 9.4.0 + */ + private function get_brand_name_first_character( $name ) { + // Convert to lowercase and remove accents. + $clean_name = strtolower( sanitize_title( $name ) ); + // Return the first letter of the name. + return substr( $clean_name, 0, 1 ); + } + + /** + * Displays brand thumbnails. + * + * @param mixed $atts + * @return void + */ + public function output_product_brand_thumbnails( $atts ) { + $args = shortcode_atts( + array( + 'show_empty' => true, + 'columns' => 4, + 'hide_empty' => 0, + 'orderby' => 'name', + 'exclude' => '', + 'number' => '', + 'fluid_columns' => false, + ), + $atts + ); + + $exclude = array_map( 'intval', explode( ',', $args['exclude'] ) ); + $order = 'name' === $args['orderby'] ? 'asc' : 'desc'; + + if ( 'true' === $args['show_empty'] ) { + $hide_empty = false; + } else { + $hide_empty = true; + } + + $brands = get_terms( + 'product_brand', + array( + 'hide_empty' => $hide_empty, + 'orderby' => $args['orderby'], + 'exclude' => $exclude, + 'number' => $args['number'], + 'order' => $order, + ) + ); + + if ( ! $brands ) { + return; + } + + if ( $hide_empty ) { + $brands = $this->remove_terms_with_empty_products( $brands ); + } + + ob_start(); + + wc_get_template( + 'widgets/brand-thumbnails.php', + array( + 'brands' => $brands, + 'columns' => is_numeric( $args['columns'] ) ? intval( $args['columns'] ) : 4, + 'fluid_columns' => wp_validate_boolean( $args['fluid_columns'] ), + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + return ob_get_clean(); + } + + /** + * Displays brand thumbnails description. + * + * @param mixed $atts + * @return void + */ + public function output_product_brand_thumbnails_description( $atts ) { + $args = shortcode_atts( + array( + 'show_empty' => true, + 'columns' => 1, + 'hide_empty' => 0, + 'orderby' => 'name', + 'exclude' => '', + 'number' => '', + ), + $atts + ); + + $exclude = array_map( 'intval', explode( ',', $args['exclude'] ) ); + $order = 'name' === $args['orderby'] ? 'asc' : 'desc'; + + if ( 'true' === $args['show_empty'] ) { + $hide_empty = false; + } else { + $hide_empty = true; + } + + $brands = get_terms( + 'product_brand', + array( + 'hide_empty' => $args['hide_empty'], + 'orderby' => $args['orderby'], + 'exclude' => $exclude, + 'number' => $args['number'], + 'order' => $order, + ) + ); + + if ( ! $brands ) { + return; + } + + if ( $hide_empty ) { + $brands = $this->remove_terms_with_empty_products( $brands ); + } + + ob_start(); + + wc_get_template( + 'widgets/brand-thumbnails-description.php', + array( + 'brands' => $brands, + 'columns' => $args['columns'], + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + return ob_get_clean(); + } + + /** + * Displays brand products. + * + * @param array $atts + * @return string + */ + public function output_brand_products( $atts ) { + if ( empty( $atts['brand'] ) ) { + return ''; + } + + // Add the brand attributes and query arguments. + add_filter( 'shortcode_atts_brand_products', array( __CLASS__, 'add_brand_products_shortcode_atts' ), 10, 4 ); + add_filter( 'woocommerce_shortcode_products_query', array( __CLASS__, 'get_brand_products_query_args' ), 10, 3 ); + + $shortcode = new WC_Shortcode_Products( $atts, 'brand_products' ); + + // Remove the brand attributes and query arguments. + remove_filter( 'shortcode_atts_brand_products', array( __CLASS__, 'add_brand_products_shortcode_atts' ), 10 ); + remove_filter( 'woocommerce_shortcode_products_query', array( __CLASS__, 'get_brand_products_query_args' ), 10 ); + + return $shortcode->get_content(); + } + + /** + * Adds the taxonomy query to the WooCommerce products shortcode query arguments. + * + * @param array $query_args + * @param array $attributes + * @param string $type + * + * @return array + */ + public static function get_brand_products_query_args( $query_args, $attributes, $type ) { + if ( 'brand_products' !== $type || empty( $attributes['brand'] ) ) { + return $query_args; + } + + $query_args['tax_query'][] = array( + 'taxonomy' => 'product_brand', + 'terms' => array_map( 'sanitize_title', explode( ',', $attributes['brand'] ) ), + 'field' => 'slug', + 'operator' => 'IN', + ); + + return $query_args; + } + + /** + * Adds the "brand" attribute to the list of WooCommerce products shortcode attributes. + * + * @param array $out The output array of shortcode attributes. + * @param array $pairs The supported attributes and their defaults. + * @param array $atts The user defined shortcode attributes. + * @param string $shortcode The shortcode name. + * + * @return array The output array of shortcode attributes. + */ + public static function add_brand_products_shortcode_atts( $out, $pairs, $atts, $shortcode ) { + $out['brand'] = array_key_exists( 'brand', $atts ) ? $atts['brand'] : ''; + + return $out; + } + + /** + * Register REST API route for /products/brands. + * + * @since 9.4.0 + * + * @return void + */ + public function rest_api_register_routes() { + require_once WC()->plugin_path() . '/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php'; + require_once WC()->plugin_path() . '/includes/rest-api/Controllers/Version3/class-wc-rest-product-brands-controller.php'; + + $controllers = array( + 'WC_REST_Product_Brands_V2_Controller', + 'WC_REST_Product_Brands_Controller' + ); + + foreach ( $controllers as $controller ) { + ( new $controller() )->register_routes(); + } + } + + /** + * Maybe set brands when requesting PUT /products/. + * + * @since 9.4.0 + * + * @param WP_Post $post Post object + * @param WP_REST_Request $request Request object + * + * @return void + */ + public function rest_api_maybe_set_brands( $post, $request ) { + if ( isset( $request['brands'] ) && is_array( $request['brands'] ) ) { + $terms = array_map( 'absint', $request['brands'] ); + wp_set_object_terms( $post->ID, $terms, 'product_brand' ); + } + } + + /** + * Prepare brands in product response. + * + * @param WP_REST_Response $response The response object. + * @param WP_Post|WC_Data $post Post object or WC object. + * @version 9.4.0 + * @return WP_REST_Response + */ + public function rest_api_prepare_brands_to_product( $response, $post ) { + $post_id = is_callable( array( $post, 'get_id' ) ) ? $post->get_id() : ( ! empty( $post->ID ) ? $post->ID : null ); + + if ( empty( $response->data['brands'] ) ) { + $terms = array(); + + foreach ( wp_get_post_terms( $post_id, 'product_brand' ) as $term ) { + $terms[] = array( + 'id' => $term->term_id, + 'name' => $term->name, + 'slug' => $term->slug, + ); + } + + $response->data['brands'] = $terms; + } + + return $response; + } + + /** + * Add brands in product response. + * + * @param WC_Data $product Inserted product object. + * @param WP_REST_Request $request Request object. + * @param boolean $creating True when creating object, false when updating. + * @version 9.4.0 + */ + public function rest_api_add_brands_to_product( $product, $request, $creating = true ) { + $product_id = is_callable( array( $product, 'get_id' ) ) ? $product->get_id() : ( ! empty( $product->ID ) ? $product->ID : null ); + $params = $request->get_params(); + $brands = isset( $params['brands'] ) ? $params['brands'] : array(); + + if ( ! empty( $brands ) ) { + if ( is_array( $brands[0] ) && array_key_exists( 'id', $brands[0] ) ) { + $brands = array_map( + function ( $brand ) { + return absint( $brand['id'] ); + }, + $brands + ); + } else { + $brands = array_map( 'absint', $brands ); + } + wp_set_object_terms( $product_id, $brands, 'product_brand' ); + } + } + + /** + * Filters products by taxonomy product_brand. + * + * @param array $args Request args. + * @param WP_REST_Request $request Request data. + * @return array Request args. + * @version 9.4.0 + */ + public function rest_api_filter_products_by_brand( $args, $request ) { + if ( ! empty( $request['brand'] ) ) { + $args['tax_query'][] = array( + 'taxonomy' => 'product_brand', + 'field' => 'term_id', + 'terms' => $request['brand'], + ); + } + + return $args; + } + + /** + * Documents additional query params for collections of products. + * + * @param array $params JSON Schema-formatted collection parameters. + * @param WP_Post_Type $post_type Post type object. + * @return array JSON Schema-formatted collection parameters. + * @version 9.4.0 + */ + public function rest_api_product_collection_params( $params, $post_type ) { + $params['brand'] = array( + 'description' => __( 'Limit result set to products assigned a specific brand ID.', 'woocommerce' ), + 'type' => 'string', + 'sanitize_callback' => 'wp_parse_id_list', + 'validate_callback' => 'rest_validate_request_arg', + ); + + return $params; + } + + /** + * Injects Brands filters into layered nav links. + * + * @param string $term_html Original link html. + * @param mixed $term Term that is currently added. + * @param string $link Original layered nav item link. + * @param number $count Number of items in that filter. + * @return string Term html. + * @version 9.4.0 + */ + public function woocommerce_brands_update_layered_nav_link( $term_html, $term, $link, $count ) { + if ( empty( $_GET['filter_product_brand'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return $term_html; + } + + $filter_product_brand = wc_clean( wp_unslash( $_GET['filter_product_brand'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $current_attributes = array_map( 'intval', explode( ',', $filter_product_brand ) ); + $current_values = ! empty( $current_attributes ) ? $current_attributes : array(); + $link = add_query_arg( + array( + 'filtering' => '1', + 'filter_product_brand' => implode( ',', $current_values ), + ), + wp_specialchars_decode( $link ) + ); + $term_html = '' . esc_html( $term->name ) . ''; + $term_html .= ' ' . apply_filters( 'woocommerce_layered_nav_count', '(' . absint( $count ) . ')', $count, $term ); + return $term_html; + } + + /** + * Temporarily tag a post with meta before it is saved in order + * to allow us to be able to use the meta when the product is saved to add + * the brands when an ID has been generated. + * + * + * @param WC_Product $duplicate + * @return WC_Product $original + */ + public function duplicate_store_temporary_brands( $duplicate, $original ) { + $terms = get_the_terms( $original->get_id(), 'product_brand' ); + if ( ! is_array( $terms ) ) { + return; + } + + $ids = array(); + foreach ( $terms as $term ) { + $ids[] = $term->term_id; + } + $duplicate->add_meta_data( 'duplicate_temp_brand_ids', $ids ); + } + + /** + * After product was added check if there are temporary brands and + * add them officially and remove the temporary brands. + * + * @since 9.4.0 + * + * @param int $product_id + */ + public function duplicate_add_product_brand_terms( $product_id ) { + $product = wc_get_product( $product_id ); + // Bail if product isn't found. + if ( ! $product instanceof WC_Product ) { + return; + } + $term_ids = $product->get_meta( 'duplicate_temp_brand_ids' ); + if ( empty( $term_ids ) ) { + return; + } + $term_taxonomy_ids = wp_set_object_terms( $product_id, $term_ids, 'product_brand' ); + $product->delete_meta_data( 'duplicate_temp_brand_ids' ); + $product->save(); + } + + /** + * Remove terms with empty products. + * + * @param WP_Term[] $terms The terms array that needs to be removed of empty products. + * + * @return WP_Term[] + */ + private function remove_terms_with_empty_products( $terms ) { + return array_filter( + $terms, + function ( $term ) { + return $term->count > 0; + } + ); + } + + /** + * Invalidates the layered nav counts cache. + * + * @return void + */ + public function invalidate_wc_layered_nav_counts_cache() { + $taxonomy = 'product_brand'; + delete_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) ); + } + + /** + * Reset Layered Nav cached counts on product status change. + * + * @param $new_status + * @param $old_status + * @param $post + * + * @return void + */ + function reset_layered_nav_counts_on_status_change( $new_status, $old_status, $post ) { + if ( $post->post_type === 'product' && $old_status !== $new_status ) { + $this->invalidate_wc_layered_nav_counts_cache(); + } + } + + /** + * Add a new block to the template. + * + * @param string $template_id Template ID. + * @param string $template_area Template area. + * @param BlockTemplateInterface $template Template instance. + */ + public function wc_brands_on_block_template_register( $template_id, $template_area, $template ) { + + if ( 'simple-product' === $template->get_id() ) { + $section = $template->get_section_by_id( 'product-catalog-section' ); + if ( $section !== null ) { + $section->add_block( + array( + 'id' => 'woocommerce-brands-select', + 'blockName' => 'woocommerce/product-taxonomy-field', + 'order' => 15, + 'attributes' => array( + 'label' => __( 'Brands', 'woocommerce-brands' ), + 'createTitle' => __( 'Create new brand', 'woocommerce-brands' ), + 'slug' => 'product_brand', + 'property' => 'brands', + ), + ) + ); + } + } + } +} + +$GLOBALS['WC_Brands'] = new WC_Brands(); diff --git a/plugins/woocommerce/includes/class-woocommerce.php b/plugins/woocommerce/includes/class-woocommerce.php index 41104fcbd65..85ce78999c1 100644 --- a/plugins/woocommerce/includes/class-woocommerce.php +++ b/plugins/woocommerce/includes/class-woocommerce.php @@ -27,7 +27,6 @@ use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods; use Automattic\WooCommerce\Internal\Utilities\LegacyRestApiStub; use Automattic\WooCommerce\Internal\Utilities\WebhookUtil; use Automattic\WooCommerce\Internal\Admin\Marketplace; -use Automattic\WooCommerce\Internal\McStats; use Automattic\WooCommerce\Proxies\LegacyProxy; use Automattic\WooCommerce\Utilities\{LoggingUtil, RestApiUtil, TimeUtil}; use Automattic\WooCommerce\Internal\Logging\RemoteLogger; @@ -384,8 +383,10 @@ final class WooCommerce { unset( $error_copy['message'] ); $context = array( - 'source' => 'fatal-errors', - 'error' => $error_copy, + 'source' => 'fatal-errors', + 'error' => $error_copy, + // Indicate that this error should be logged remotely if remote logging is enabled. + 'remote-logging' => true, ); if ( false !== strpos( $message, 'Stack trace:' ) ) { @@ -407,12 +408,6 @@ final class WooCommerce { $context ); - // Record fatal error stats. - $container = wc_get_container(); - $mc_stats = $container->get( McStats::class ); - $mc_stats->add( 'error', 'fatal-errors-during-shutdown' ); - $mc_stats->do_server_side_stats(); - /** * Action triggered when there are errors during shutdown. * diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php new file mode 100644 index 00000000000..dc66380c2cd --- /dev/null +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-product-brands-v2-controller.php @@ -0,0 +1,40 @@ +term_id, 'thumbnail_id', true ); + + if ( '' === $size || 'brand-thumb' === $size ) { + /** + * Filter the brand's thumbnail size. + * + * @since 9.4.0 + * + * @param string $size Brand's thumbnail size. + */ + $size = apply_filters( 'woocommerce_brand_thumbnail_size', 'shop_catalog' ); + } + + if ( $thumbnail_id ) { + $image_src = wp_get_attachment_image_src( $thumbnail_id, $size ); + $image_src = $image_src[0]; + $dimensions = wc_get_image_size( $size ); + $image_srcset = function_exists( 'wp_get_attachment_image_srcset' ) ? wp_get_attachment_image_srcset( $thumbnail_id, $size ) : false; + $image_sizes = function_exists( 'wp_get_attachment_image_sizes' ) ? wp_get_attachment_image_sizes( $thumbnail_id, $size ) : false; + } else { + $image_src = wc_placeholder_img_src(); + $dimensions = wc_get_image_size( $size ); + $image_srcset = false; + $image_sizes = false; + } + + // Add responsive image markup if available. + if ( $image_srcset && $image_sizes ) { + $image = '' . esc_attr( $brand->name ) . ''; + } else { + $image = '' . esc_attr( $brand->name ) . ''; + } + + return $image; +} + +/** + * Retrieves product's brands. + * + * @param int $post_id Post ID (default: 0). + * @param string $sep Seperator (default: '). + * @param string $before Before item (default: ''). + * @param string $after After item (default: ''). + * @return array List of terms + */ +function wc_get_brands( $post_id = 0, $sep = ', ', $before = '', $after = '' ) { + global $post; + + if ( ! $post_id ) { + $post_id = $post->ID; + } + + return get_the_term_list( $post_id, 'product_brand', $before, $sep, $after ); +} + +/** + * Polyfills for backwards compatibility with the WooCommerce Brands plugin. + */ + +if ( ! function_exists( 'get_brand_thumbnail_url' ) ) { + + /** + * Polyfill for get_brand_thumbnail_image. + * + * @param int $brand_id Brand ID. + * @param string $size Thumbnail image size. + * @return string + */ + function get_brand_thumbnail_url( $brand_id, $size = 'full' ) { + return wc_get_brand_thumbnail_url( $brand_id, $size ); + } +} + +if ( ! function_exists( 'get_brand_thumbnail_image' ) ) { + + /** + * Polyfill for get_brand_thumbnail_image. + * + * @param object $brand Brand term. + * @param string $size Thumbnail image size. + * @return string + */ + function get_brand_thumbnail_image( $brand, $size = '' ) { + return wc_get_brand_thumbnail_image( $brand, $size ); + } +} + +if ( ! function_exists( 'get_brands' ) ) { + + /** + * Polyfill for get_brands. + * + * @param int $post_id Post ID (default: 0). + * @param string $sep Seperator (default: '). + * @param string $before Before item (default: ''). + * @param string $after After item (default: ''). + * @return array List of terms + */ + function get_brands( $post_id = 0, $sep = ', ', $before = '', $after = '' ) { + return wc_get_brands( $post_id, $sep, $before, $after ); + } +} diff --git a/plugins/woocommerce/includes/wc-core-functions.php b/plugins/woocommerce/includes/wc-core-functions.php index 4158fd900e0..34d4781f00c 100644 --- a/plugins/woocommerce/includes/wc-core-functions.php +++ b/plugins/woocommerce/includes/wc-core-functions.php @@ -1484,7 +1484,11 @@ function wc_transaction_query( $type = 'start', $force = false ) { * @return string Url to cart page */ function wc_get_cart_url() { - if ( is_cart() && isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) { + // We don't use is_cart() here because that also checks for a defined constant. We are only interested in the page. + $page_id = wc_get_page_id( 'cart' ); + $is_cart_page = ( $page_id && is_page( $page_id ) ) || wc_post_content_has_shortcode( 'woocommerce_cart' ); + + if ( $is_cart_page && isset( $_SERVER['HTTP_HOST'], $_SERVER['REQUEST_URI'] ) ) { $protocol = is_ssl() ? 'https' : 'http'; $current_url = esc_url_raw( $protocol . '://' . wp_unslash( $_SERVER['HTTP_HOST'] ) . wp_unslash( $_SERVER['REQUEST_URI'] ) ); $cart_url = remove_query_arg( array( 'remove_item', 'add-to-cart', 'added-to-cart', 'order_again', '_wpnonce' ), $current_url ); diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php new file mode 100644 index 00000000000..6f117274f5c --- /dev/null +++ b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-description.php @@ -0,0 +1,130 @@ +woo_widget_name = __( 'WooCommerce Brand Description', 'woocommerce' ); + $this->woo_widget_description = __( 'When viewing a brand archive, show the current brands description.', 'woocommerce' ); + $this->woo_widget_idbase = 'wc_brands_brand_description'; + $this->woo_widget_cssclass = 'widget_brand_description'; + + /* Widget settings. */ + $widget_ops = array( + 'classname' => $this->woo_widget_cssclass, + 'description' => $this->woo_widget_description, + ); + + /* Create the widget. */ + parent::__construct( $this->woo_widget_idbase, $this->woo_widget_name, $widget_ops ); + } + + /** + * Echoes the widget content. + * + * @see WP_Widget + * + * @param array $args Display arguments including 'before_title', 'after_title', + * 'before_widget', and 'after_widget'. + * @param array $instance The settings for the particular instance of the widget. + */ + public function widget( $args, $instance ) { + extract( $args ); // phpcs:ignore WordPress.PHP.DontExtract.extract_extract + + if ( ! is_tax( 'product_brand' ) ) { + return; + } + + if ( ! get_query_var( 'term' ) ) { + return; + } + + $thumbnail = ''; + $term = get_term_by( 'slug', get_query_var( 'term' ), 'product_brand' ); + + $thumbnail = wc_get_brand_thumbnail_url( $term->term_id, 'large' ); + + echo $before_widget . $before_title . $term->name . $after_title; // phpcs:ignore WordPress.Security.EscapeOutput + + wc_get_template( + 'widgets/brand-description.php', + array( + 'thumbnail' => $thumbnail, + 'brand' => $term, + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + echo $after_widget; // phpcs:ignore WordPress.Security.EscapeOutput + } + + /** + * Updates widget instance. + * + * @see WP_Widget->update + * + * @param array $new_instance New widget instance. + * @param array $old_instance Old widget instance. + */ + public function update( $new_instance, $old_instance ) { + $instance['title'] = wp_strip_all_tags( stripslashes( $new_instance['title'] ) ); + return $instance; + } + + /** + * Outputs the settings update form. + * + * @param array $instance Current settings. + */ + public function form( $instance ) { + ?> +

+ + +

+ widget_cssclass = 'woocommerce widget_brand_nav widget_layered_nav'; + $this->widget_description = __( 'Shows brands in a widget which lets you narrow down the list of products when viewing products.', 'woocommerce' ); + $this->widget_id = 'woocommerce_brand_nav'; + $this->widget_name = __( 'WooCommerce Brand Layered Nav', 'woocommerce' ); + + add_filter( 'woocommerce_product_subcategories_args', array( $this, 'filter_out_cats' ) ); + + /* Create the widget. */ + parent::__construct(); + } + + /** + * Filter out all categories and not display them + * + * @param array $cat_args Category arguments. + */ + public function filter_out_cats( $cat_args ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! empty( $_GET['filter_product_brand'] ) ) { + return array( 'taxonomy' => '' ); + } + + return $cat_args; + } + + /** + * Return the currently viewed taxonomy name. + * + * @return string + */ + protected function get_current_taxonomy() { + return is_tax() ? get_queried_object()->taxonomy : ''; + } + + /** + * Return the currently viewed term ID. + * + * @return int + */ + protected function get_current_term_id() { + return absint( is_tax() ? get_queried_object()->term_id : 0 ); + } + + /** + * Return the currently viewed term slug. + * + * @return int + */ + protected function get_current_term_slug() { + return absint( is_tax() ? get_queried_object()->slug : 0 ); + } + + /** + * Widget function. + * + * @see WP_Widget + * + * @param array $args Arguments. + * @param array $instance Widget instance. + * @return void + */ + public function widget( $args, $instance ) { + $attribute_array = array(); + $attribute_taxonomies = wc_get_attribute_taxonomies(); + + if ( ! empty( $attribute_taxonomies ) ) { + foreach ( $attribute_taxonomies as $tax ) { + if ( taxonomy_exists( wc_attribute_taxonomy_name( $tax->attribute_name ) ) ) { + $attribute_array[ $tax->attribute_name ] = $tax->attribute_name; + } + } + } + + if ( ! is_post_type_archive( 'product' ) && ! is_tax( array_merge( is_array( $attribute_array ) ? $attribute_array : array(), array( 'product_cat', 'product_tag' ) ) ) ) { + return; + } + + $_chosen_attributes = WC_Query::get_layered_nav_chosen_attributes(); + + $current_term = $attribute_array && is_tax( $attribute_array ) ? get_queried_object()->term_id : ''; + $current_tax = $attribute_array && is_tax( $attribute_array ) ? get_queried_object()->taxonomy : ''; + + /** + * Filter the widget's title. + * + * @since 9.4.0 + * + * @param string $title Widget title + * @param array $instance The settings for the particular instance of the widget. + * @param string $woo_widget_idbase The widget's id base. + */ + $title = apply_filters( 'widget_title', $instance['title'], $instance, $this->id_base ); + $taxonomy = 'product_brand'; + $display_type = isset( $instance['display_type'] ) ? $instance['display_type'] : 'list'; + + if ( ! taxonomy_exists( $taxonomy ) ) { + return; + } + + // Get only parent terms. Methods will recursively retrieve children. + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => true, + 'parent' => 0, + ) + ); + + if ( empty( $terms ) ) { + return; + } + + ob_start(); + + $this->widget_start( $args, $instance ); + + if ( 'dropdown' === $display_type ) { + $found = $this->layered_nav_dropdown( $terms, $taxonomy ); + } else { + $found = $this->layered_nav_list( $terms, $taxonomy ); + } + + $this->widget_end( $args ); + + // Force found when option is selected - do not force found on taxonomy attributes. + if ( ! is_tax() && is_array( $_chosen_attributes ) && array_key_exists( $taxonomy, $_chosen_attributes ) ) { + $found = true; + } + + if ( ! $found ) { + ob_end_clean(); + } else { + echo ob_get_clean(); // phpcs:ignore WordPress.Security.EscapeOutput + } + } + + /** + * Update function. + * + * @see WP_Widget->update + * + * @param array $new_instance The new settings for the particular instance of the widget. + * @param array $old_instance The old settings for the particular instance of the widget. + * @return array + */ + public function update( $new_instance, $old_instance ) { + global $woocommerce; + + if ( empty( $new_instance['title'] ) ) { + $new_instance['title'] = __( 'Brands', 'woocommerce' ); + } + + $instance['title'] = wp_strip_all_tags( stripslashes( $new_instance['title'] ) ); + $instance['display_type'] = stripslashes( $new_instance['display_type'] ); + + return $instance; + } + + /** + * Form function. + * + * @see WP_Widget->form + * + * @param array $instance Widget instance. + * @return void + */ + public function form( $instance ) { + global $woocommerce; + + if ( ! isset( $instance['display_type'] ) ) { + $instance['display_type'] = 'list'; + } + ?> +

+ +

+ +

+

+ $data ) { + if ( $name === $taxonomy ) { + continue; + } + $filter_name = sanitize_title( str_replace( 'pa_', '', $name ) ); + if ( ! empty( $data['terms'] ) ) { + $link = add_query_arg( 'filter_' . $filter_name, implode( ',', $data['terms'] ), $link ); + } + if ( 'or' === $data['query_type'] ) { + $link = add_query_arg( 'query_type_' . $filter_name, 'or', $link ); + } + } + } + + // phpcs:enable WordPress.Security.NonceVerification.Recommended + return esc_url( $link ); + } + + /** + * Gets the currently selected attributes + * + * @return array + */ + public function get_chosen_attributes() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! empty( $_GET['filter_product_brand'] ) ) { + $filter_product_brand = wc_clean( wp_unslash( $_GET['filter_product_brand'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return array_map( 'intval', explode( ',', $filter_product_brand ) ); + } + + return array(); + } + + /** + * Show dropdown layered nav. + * + * @param array $terms Terms. + * @param string $taxonomy Taxonomy. + * @param int $depth Depth. + * @return bool Will nav display? + */ + protected function layered_nav_dropdown( $terms, $taxonomy, $depth = 0 ) { + $found = false; + + if ( $taxonomy !== $this->get_current_taxonomy() ) { + $term_counts = $this->get_filtered_term_product_counts( wp_list_pluck( $terms, 'term_id' ), $taxonomy, 'or' ); + $_chosen_attributes = $this->get_chosen_attributes(); + + if ( 0 === $depth ) { + echo ''; + + wc_enqueue_js( + " + jQuery( '.wc-brand-dropdown-layered-nav-" . esc_js( $taxonomy ) . "' ).change( function() { + var slug = jQuery( this ).val(); + location.href = '" . preg_replace( '%\/page\/[0-9]+%', '', str_replace( array( '&', '%2C' ), array( '&', ',' ), esc_js( add_query_arg( 'filtering', '1', $link ) ) ) ) . '&filter_' . esc_js( $taxonomy ) . "=' + jQuery( '.wc-brand-dropdown-layered-nav-" . esc_js( $taxonomy ) . "' ).val(); + }); + " + ); + } + } + + return $found; + } + + /** + * Show list based layered nav. + * + * @param array $terms Terms. + * @param string $taxonomy Taxonomy. + * @param int $depth Depth. + * @return bool Will nav display? + */ + protected function layered_nav_list( $terms, $taxonomy, $depth = 0 ) { + // List display. + echo '
    '; + + $term_counts = $this->get_filtered_term_product_counts( wp_list_pluck( $terms, 'term_id' ), $taxonomy, 'or' ); + $_chosen_attributes = $this->get_chosen_attributes(); + $current_values = ! empty( $_chosen_attributes ) ? $_chosen_attributes : array(); + $found = false; + + $filter_name = 'filter_' . $taxonomy; + + foreach ( $terms as $term ) { + $option_is_set = in_array( $term->term_id, $current_values, true ); + $count = isset( $term_counts[ $term->term_id ] ) ? $term_counts[ $term->term_id ] : 0; + + // skip the term for the current archive. + if ( $this->get_current_term_id() === $term->term_id ) { + continue; + } + + // Only show options with count > 0. + if ( 0 < $count ) { + $found = true; + } elseif ( 0 === $count && ! $option_is_set ) { + continue; + } + + $current_filter = isset( $_GET[ $filter_name ] ) ? explode( ',', wc_clean( wp_unslash( $_GET[ $filter_name ] ) ) ) : array(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $current_filter = array_map( 'intval', $current_filter ); + + if ( ! in_array( $term->term_id, $current_filter, true ) ) { + $current_filter[] = $term->term_id; + } + + $link = $this->get_page_base_url( $taxonomy ); + + // Add current filters to URL. + foreach ( $current_filter as $key => $value ) { + // Exclude query arg for current term archive term. + if ( $value === $this->get_current_term_id() ) { + unset( $current_filter[ $key ] ); + } + + // Exclude self so filter can be unset on click. + if ( $option_is_set && $value === $term->term_id ) { + unset( $current_filter[ $key ] ); + } + } + + if ( ! empty( $current_filter ) ) { + $link = add_query_arg( + array( + 'filtering' => '1', + $filter_name => implode( ',', $current_filter ), + ), + $link + ); + } + + echo '
  • '; + + echo ( $count > 0 || $option_is_set ) ? '' : ''; // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + + echo esc_html( $term->name ); + + echo ( $count > 0 || $option_is_set ) ? ' ' : ' '; + + echo wp_kses_post( apply_filters( 'woocommerce_layered_nav_count', '(' . absint( $count ) . ')', $count, $term ) );// phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + + $child_terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => true, + 'parent' => $term->term_id, + ) + ); + + if ( ! empty( $child_terms ) ) { + $found |= $this->layered_nav_list( $child_terms, $taxonomy, $depth + 1 ); + } + + echo '
  • '; + } + + echo '
'; + + return $found; + } + + /** + * Count products within certain terms, taking the main WP query into consideration. + * + * @param array $term_ids Term IDs. + * @param string $taxonomy Taxonomy. + * @param string $query_type Query type. + * @return array + */ + protected function get_filtered_term_product_counts( $term_ids, $taxonomy, $query_type = 'and' ) { + global $wpdb; + + $tax_query = WC_Query::get_main_tax_query(); + $meta_query = WC_Query::get_main_meta_query(); + + if ( 'or' === $query_type ) { + foreach ( $tax_query as $key => $query ) { + if ( is_array( $query ) && $taxonomy === $query['taxonomy'] ) { + unset( $tax_query[ $key ] ); + } + } + } + + $meta_query = new WP_Meta_Query( $meta_query ); + $tax_query = new WP_Tax_Query( $tax_query ); + $meta_query_sql = $meta_query->get_sql( 'post', $wpdb->posts, 'ID' ); + $tax_query_sql = $tax_query->get_sql( $wpdb->posts, 'ID' ); + + // Generate query. + $query = array(); + $query['select'] = "SELECT COUNT( DISTINCT {$wpdb->posts}.ID ) as term_count, terms.term_id as term_count_id"; + $query['from'] = "FROM {$wpdb->posts}"; + $query['join'] = " + INNER JOIN {$wpdb->term_relationships} AS term_relationships ON {$wpdb->posts}.ID = term_relationships.object_id + INNER JOIN {$wpdb->term_taxonomy} AS term_taxonomy USING( term_taxonomy_id ) + INNER JOIN {$wpdb->terms} AS terms USING( term_id ) + " . $tax_query_sql['join'] . $meta_query_sql['join']; + $query['where'] = " + WHERE {$wpdb->posts}.post_type IN ( 'product' ) + AND {$wpdb->posts}.post_status = 'publish' + " . $tax_query_sql['where'] . $meta_query_sql['where'] . ' + AND terms.term_id IN (' . implode( ',', array_map( 'absint', $term_ids ) ) . ') + '; + $query['group_by'] = 'GROUP BY terms.term_id'; + $query = apply_filters( 'woocommerce_get_filtered_term_product_counts_query', $query ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + $query = implode( ' ', $query ); + + // We have a query - let's see if cached results of this query already exist. + $query_hash = md5( $query ); + + $cache = apply_filters( 'woocommerce_layered_nav_count_maybe_cache', true ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment + if ( true === $cache ) { + $cached_counts = (array) get_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ) ); + } else { + $cached_counts = array(); + } + + if ( ! isset( $cached_counts[ $query_hash ] ) ) { + $results = $wpdb->get_results( $query, ARRAY_A ); // @codingStandardsIgnoreLine + $counts = array_map( 'absint', wp_list_pluck( $results, 'term_count', 'term_count_id' ) ); + $cached_counts[ $query_hash ] = $counts; + if ( true === $cache ) { + set_transient( 'wc_layered_nav_counts_' . sanitize_title( $taxonomy ), $cached_counts, HOUR_IN_SECONDS ); + } + } + + return array_map( 'absint', (array) $cached_counts[ $query_hash ] ); + } +} diff --git a/plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php new file mode 100644 index 00000000000..fd6a07e38f8 --- /dev/null +++ b/plugins/woocommerce/includes/widgets/class-wc-widget-brand-thumbnails.php @@ -0,0 +1,235 @@ +woo_widget_name = __( 'WooCommerce Brand Thumbnails', 'woocommerce' ); + $this->woo_widget_description = __( 'Show a grid of brand thumbnails.', 'woocommerce' ); + $this->woo_widget_idbase = 'wc_brands_brand_thumbnails'; + $this->woo_widget_cssclass = 'widget_brand_thumbnails'; + + /* Widget settings. */ + $widget_ops = array( + 'classname' => $this->woo_widget_cssclass, + 'description' => $this->woo_widget_description, + ); + + /* Create the widget. */ + parent::__construct( $this->woo_widget_idbase, $this->woo_widget_name, $widget_ops ); + } + + /** + * Echoes the widget content. + * + * @see WP_Widget + * + * @param array $args Display arguments including 'before_title', 'after_title', + * 'before_widget', and 'after_widget'. + * @param array $instance The settings for the particular instance of the widget. + */ + public function widget( $args, $instance ) { + $instance = wp_parse_args( + $instance, + array( + 'title' => '', + 'columns' => 1, + 'exclude' => '', + 'orderby' => 'name', + 'hide_empty' => 0, + 'number' => '', + ) + ); + + $exclude = array_map( 'intval', explode( ',', $instance['exclude'] ) ); + $order = 'name' === $instance['orderby'] ? 'asc' : 'desc'; + + $brands = get_terms( + array( + 'taxonomy' => 'product_brand', + 'hide_empty' => $instance['hide_empty'], + 'orderby' => $instance['orderby'], + 'exclude' => $exclude, + 'number' => $instance['number'], + 'order' => $order, + ) + ); + + if ( ! $brands ) { + return; + } + + /** + * Filter the widget's title. + * + * @since 9.4.0 + * + * @param string $title Widget title + * @param array $instance The settings for the particular instance of the widget. + * @param string $woo_widget_idbase The widget's id base. + */ + $title = apply_filters( 'widget_title', $instance['title'], $instance, $this->woo_widget_idbase ); + + echo $args['before_widget']; // phpcs:ignore WordPress.Security.EscapeOutput + if ( '' !== $title ) { + echo $args['before_title'] . $title . $args['after_title']; // phpcs:ignore WordPress.Security.EscapeOutput + } + + wc_get_template( + 'widgets/brand-thumbnails.php', + array( + 'brands' => $brands, + 'columns' => (int) $instance['columns'], + 'fluid_columns' => ! empty( $instance['fluid_columns'] ) ? true : false, + ), + 'woocommerce', + WC()->plugin_path() . '/templates/brands/' + ); + + echo $args['after_widget']; // phpcs:ignore WordPress.Security.EscapeOutput + } + + /** + * Update widget instance. + * + * @param array $new_instance The new settings for the particular instance of the widget. + * @param array $old_instance The old settings for the particular instance of the widget. + * + * @see WP_Widget->update + */ + public function update( $new_instance, $old_instance ) { + $instance['title'] = wp_strip_all_tags( stripslashes( $new_instance['title'] ) ); + $instance['columns'] = wp_strip_all_tags( stripslashes( $new_instance['columns'] ) ); + $instance['fluid_columns'] = ! empty( $new_instance['fluid_columns'] ) ? true : false; + $instance['orderby'] = wp_strip_all_tags( stripslashes( $new_instance['orderby'] ) ); + $instance['exclude'] = wp_strip_all_tags( stripslashes( $new_instance['exclude'] ) ); + $instance['hide_empty'] = wp_strip_all_tags( stripslashes( (string) $new_instance['hide_empty'] ) ); + $instance['number'] = wp_strip_all_tags( stripslashes( $new_instance['number'] ) ); + + if ( ! $instance['columns'] ) { + $instance['columns'] = 1; + } + + if ( ! $instance['orderby'] ) { + $instance['orderby'] = 'name'; + } + + if ( ! $instance['exclude'] ) { + $instance['exclude'] = ''; + } + + if ( ! $instance['hide_empty'] ) { + $instance['hide_empty'] = 0; + } + + if ( ! $instance['number'] ) { + $instance['number'] = ''; + } + + return $instance; + } + + /** + * Outputs the settings update form. + * + * @param array $instance Current settings. + */ + public function form( $instance ) { + if ( ! isset( $instance['hide_empty'] ) ) { + $instance['hide_empty'] = 0; + } + + if ( ! isset( $instance['orderby'] ) ) { + $instance['orderby'] = 'name'; + } + + if ( empty( $instance['fluid_columns'] ) ) { + $instance['fluid_columns'] = false; + } + + ?> +

+ + +

+ +

+ + +

+ +

+ + id="get_field_id( 'fluid_columns' ) ); ?>" name="get_field_name( 'fluid_columns' ) ); ?>" /> +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ +

+ + +

+ get_param( 'redirect_url' ); - $calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, [ 'development', 'wpcalypso', 'horizon', 'stage' ], true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production'; + $calypso_env = defined( 'WOOCOMMERCE_CALYPSO_ENVIRONMENT' ) && in_array( WOOCOMMERCE_CALYPSO_ENVIRONMENT, array( 'development', 'wpcalypso', 'horizon', 'stage' ), true ) ? WOOCOMMERCE_CALYPSO_ENVIRONMENT : 'production'; + + $authorization_url = $manager->get_authorization_url( null, $redirect_url ); + $authorization_url = add_query_arg( 'locale', $this->get_wpcom_locale(), $authorization_url ); + + if ( Features::is_enabled( 'use-wp-horizon' ) ) { + $calypso_env = 'horizon'; + } return [ 'success' => ! $errors->has_errors(), 'errors' => $errors->get_error_messages(), 'url' => add_query_arg( - [ + array( 'from' => $request->get_param( 'from' ), 'calypso_env' => $calypso_env, - ], - $manager->get_authorization_url( null, $redirect_url ) + ), + $authorization_url, ), ]; } + /** + * Return a locale string for wpcom. + * + * @return string + */ + private function get_wpcom_locale() { + // List of locales that should be used with region code. + $locale_to_lang = array( + 'bre' => 'br', + 'de_AT' => 'de-at', + 'de_CH' => 'de-ch', + 'de' => 'de_formal', + 'el' => 'el-po', + 'en_GB' => 'en-gb', + 'es_CL' => 'es-cl', + 'es_MX' => 'es-mx', + 'fr_BE' => 'fr-be', + 'fr_CA' => 'fr-ca', + 'nl_BE' => 'nl-be', + 'nl' => 'nl_formal', + 'pt_BR' => 'pt-br', + 'sr' => 'sr_latin', + 'zh_CN' => 'zh-cn', + 'zh_HK' => 'zh-hk', + 'zh_SG' => 'zh-sg', + 'zh_TW' => 'zh-tw', + ); + + $system_locale = get_locale(); + if ( isset( $locale_to_lang[ $system_locale ] ) ) { + // Return the locale with region code if it's in the list. + return $locale_to_lang[ $system_locale ]; + } + + // If the locale is not in the list, return the language code only. + return explode( '_', $system_locale )[0]; + } + /** * Check whether the current user has permission to install plugins * @@ -400,7 +446,7 @@ class OnboardingPlugins extends WC_REST_Data_Controller { ), $slug ), - 'type' => 'plugin_info_api_error', + 'type' => 'plugin_info_api_error', 'slug' => $slug, 'api_version' => $api->version, 'api_download_link' => $api->download_link, diff --git a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php index e13ed41c47c..7f740eff74e 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Customers/DataStore.php @@ -615,6 +615,11 @@ class DataStore extends ReportsDataStore implements DataStoreInterface { if ( is_null( $customer_user ) ) { $customer_user = new \WC_Customer( $user_id ); } + + // Set email as customer email instead of Order Billing Email if we have a customer. + $data['email'] = $customer_user->get_email( 'edit' ); + + // Adding other relevant customer data. $data['user_id'] = $user_id; $data['username'] = $customer_user->get_username( 'edit' ); $data['date_registered'] = $customer_user->get_date_created( 'edit' ) ? $customer_user->get_date_created( 'edit' )->date( TimeInterval::$sql_datetime_format ) : null; diff --git a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php index d31db463c0c..a794fee018f 100644 --- a/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php +++ b/plugins/woocommerce/src/Admin/API/Reports/Taxes/Controller.php @@ -244,7 +244,15 @@ class Controller extends GenericController implements ExportableInterface { */ public function prepare_item_for_export( $item ) { return array( - 'tax_code' => \WC_Tax::get_rate_code( $item['tax_rate_id'] ), + 'tax_code' => \WC_Tax::get_rate_code( + (object) array( + 'tax_rate_id' => $item['tax_rate_id'], + 'tax_rate_country' => $item['country'], + 'tax_rate_state' => $item['state'], + 'tax_rate_name' => $item['name'], + 'tax_rate_priority' => $item['priority'], + ) + ), 'rate' => $item['tax_rate'], 'total_tax' => self::csv_number_format( $item['total_tax'] ), 'order_tax' => self::csv_number_format( $item['order_tax'] ), diff --git a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php index ba33d9b2362..b0a65b969e6 100644 --- a/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php +++ b/plugins/woocommerce/src/Admin/Features/OnboardingTasks/Tasks/Tax.php @@ -15,6 +15,7 @@ class Tax extends Task { /** * Used to cache is_complete() method result. + * * @var null */ private $is_complete_result = null; @@ -109,12 +110,16 @@ class Tax extends Task { */ public function is_complete() { if ( $this->is_complete_result === null ) { - $wc_connect_taxes_enabled = get_option( 'wc_connect_taxes_enabled' ); + $wc_connect_taxes_enabled = get_option( 'wc_connect_taxes_enabled' ); $is_wc_connect_taxes_enabled = ( $wc_connect_taxes_enabled === 'yes' ) || ( $wc_connect_taxes_enabled === true ); // seems that in some places boolean is used, and other places 'yes' | 'no' is used + // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- We will replace this with a formal system by WC 9.6 so lets not advertise it yet. + $third_party_complete = apply_filters( 'woocommerce_admin_third_party_tax_setup_complete', false ); + $this->is_complete_result = $is_wc_connect_taxes_enabled || count( TaxDataStore::get_taxes( array() ) ) > 0 || - get_option( 'woocommerce_no_sales_tax' ) !== false; + get_option( 'woocommerce_no_sales_tax' ) !== false || + $third_party_complete; } return $this->is_complete_result; diff --git a/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php b/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php index 571cb08029e..3ad62a779bb 100644 --- a/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php +++ b/plugins/woocommerce/src/Blocks/AIContent/UpdateProducts.php @@ -4,6 +4,7 @@ namespace Automattic\WooCommerce\Blocks\AIContent; use Automattic\WooCommerce\Blocks\AI\Connection; use WP_Error; + /** * Pattern Images class. * @@ -473,11 +474,11 @@ class UpdateProducts { /** * Update the product with the new content. * - * @param \WC_Product $product The product. - * @param int $product_image_id The product image ID. - * @param string $product_title The product title. - * @param string $product_description The product description. - * @param int $product_price The product price. + * @param \WC_Product $product The product. + * @param int|string|WP_Error $product_image_id The product image ID. + * @param string $product_title The product title. + * @param string $product_description The product description. + * @param int $product_price The product price. * * @return int|\WP_Error */ diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/Breadcrumbs.php b/plugins/woocommerce/src/Blocks/BlockTypes/Breadcrumbs.php index dc25d2258bd..fb0e0b1cc0e 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/Breadcrumbs.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/Breadcrumbs.php @@ -34,14 +34,16 @@ class Breadcrumbs extends AbstractBlock { return; } - $classname = $attributes['className'] ?? ''; $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); return sprintf( - '
%4$s
', - esc_attr( $classes_and_styles['classes'] ), - esc_attr( $classname ), - esc_attr( $classes_and_styles['styles'] ), + '
%2$s
', + get_block_wrapper_attributes( + array( + 'class' => 'wc-block-breadcrumbs woocommerce ' . esc_attr( $classes_and_styles['classes'] ), + 'style' => $classes_and_styles['styles'], + ) + ), $breadcrumb ); } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/AbstractOrderConfirmationBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/AbstractOrderConfirmationBlock.php index 4887dd04e30..66b263dbe82 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/AbstractOrderConfirmationBlock.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/OrderConfirmation/AbstractOrderConfirmationBlock.php @@ -55,11 +55,12 @@ abstract class AbstractOrderConfirmationBlock extends AbstractBlock { } return $block_content ? sprintf( - '
%3$s
', + '
%3$s
', esc_attr( trim( $classname ) ), esc_attr( $classes_and_styles['styles'] ), $block_content, - esc_attr( $this->block_name ) + esc_attr( $this->block_name ), + esc_attr( $this->namespace ) ) : ''; } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php index 812a3baaf98..9d52abacc8a 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductGallery.php @@ -110,11 +110,14 @@ class ProductGallery extends AbstractBlock { * @return string Rendered block type output. */ protected function render( $attributes, $content, $block ) { - $post_id = $block->context['postId'] ?? ''; + $post_id = $block->context['postId'] ?? ''; + $product = wc_get_product( $post_id ); + if ( ! $product instanceof \WC_Product ) { + return ''; + } + $product_gallery_images = ProductGalleryUtils::get_product_gallery_images( $post_id, 'thumbnail', array() ); $classname_single_image = ''; - // This is a temporary solution. We have to refactor this code when the block will have to be addable on every page/post https://github.com/woocommerce/woocommerce-blocks/issues/10882. - global $product; if ( count( $product_gallery_images ) < 2 ) { // The gallery consists of a single image. @@ -124,8 +127,6 @@ class ProductGallery extends AbstractBlock { $number_of_thumbnails = $block->attributes['thumbnailsNumberOfThumbnails'] ?? 0; $classname = $attributes['className'] ?? ''; $dialog = isset( $attributes['mode'] ) && 'full' !== $attributes['mode'] ? $this->render_dialog() : ''; - $post_id = $block->context['postId'] ?? ''; - $product = wc_get_product( $post_id ); $product_gallery_first_image = ProductGalleryUtils::get_product_gallery_image_ids( $product, 1 ); $product_gallery_first_image_id = reset( $product_gallery_first_image ); $product_id = strval( $product->get_id() ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/StoreNotices.php b/plugins/woocommerce/src/Blocks/BlockTypes/StoreNotices.php index c9c20d21548..9addff1a630 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/StoreNotices.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/StoreNotices.php @@ -45,17 +45,15 @@ class StoreNotices extends AbstractBlock { return; } - $classname = isset( $attributes['className'] ) ? $attributes['className'] : ''; $classes_and_styles = StyleAttributesUtils::get_classes_and_styles_by_attributes( $attributes ); - if ( isset( $attributes['align'] ) ) { - $classname .= " align{$attributes['align']}"; - } - return sprintf( - '
%3$s
', - esc_attr( $classes_and_styles['classes'] ), - esc_attr( $classname ), + '
%2$s
', + get_block_wrapper_attributes( + array( + 'class' => 'wc-block-store-notices woocommerce ' . esc_attr( $classes_and_styles['classes'] ), + ) + ), wc_kses_notice( $notices ) ); } diff --git a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php index 78c6cefe9e0..bb8eee65ae9 100644 --- a/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php +++ b/plugins/woocommerce/src/Blocks/Utils/ProductGalleryUtils.php @@ -26,7 +26,7 @@ class ProductGalleryUtils { $product_gallery_images = array(); $product = wc_get_product( $post_id ); - if ( $product ) { + if ( $product instanceof \WC_Product ) { $all_product_gallery_image_ids = self::get_product_gallery_image_ids( $product ); if ( 'full' === $size || 'full' !== $size && count( $all_product_gallery_image_ids ) > 1 ) { diff --git a/plugins/woocommerce/src/Container.php b/plugins/woocommerce/src/Container.php index 56d3b5b1e98..17ceac62783 100644 --- a/plugins/woocommerce/src/Container.php +++ b/plugins/woocommerce/src/Container.php @@ -32,6 +32,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\BatchP use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\LayoutTemplatesServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ComingSoonServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\StatsServiceProvider; +use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ImportExportServiceProvider; /** * PSR11 compliant dependency injection container for WooCommerce. @@ -83,6 +84,7 @@ final class Container { EnginesServiceProvider::class, ComingSoonServiceProvider::class, StatsServiceProvider::class, + ImportExportServiceProvider::class, ); /** diff --git a/plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php b/plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php new file mode 100644 index 00000000000..c1759bc5e93 --- /dev/null +++ b/plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php @@ -0,0 +1,175 @@ +get_import_subdir_name(); + if ( $create ) { + FilesystemUtil::mkdir_p_not_indexable( $upload_dir ); + } + return $upload_dir; + } + + /** + * Handles a CSV file upload. + * + * @param string $import_type Type of upload or context. + * @param string $files_index $_FILES index that contains the file to upload. + * @param array|null $allowed_mime_types List of allowed MIME types. + * @return array { + * Details for the uploaded file. + * + * @type int $id Attachment ID. + * @type string $file Full path to uploaded file. + * } + * + * @throws \Exception In case of error. + */ + public function handle_csv_upload( string $import_type, string $files_index = 'import', ?array $allowed_mime_types = null ): array { + $import_type = sanitize_key( $import_type ); + if ( ! $import_type ) { + throw new \Exception( 'Import type is invalid.' ); + } + + if ( ! $allowed_mime_types ) { + $allowed_mime_types = array( + 'csv' => 'text/csv', + 'txt' => 'text/plain', + ); + } + + $file = $_FILES[ $files_index ] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing + if ( ! isset( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) { + throw new \Exception( esc_html__( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.', 'woocommerce' ) ); + } + + if ( ! function_exists( 'wp_import_handle_upload' ) ) { + require_once ABSPATH . 'wp-admin/includes/import.php'; + } + + // Make sure upload dir exists. + $this->get_import_dir(); + + // Add prefix. + $file['name'] = $import_type . '-' . $file['name']; + + $overrides_callback = function ( $overrides_ ) use ( $allowed_mime_types ) { + $overrides_['test_form'] = false; + $overrides_['test_type'] = true; + $overrides_['mimes'] = $allowed_mime_types; + return $overrides_; + }; + + self::add_filter( 'upload_dir', array( $this, 'override_upload_dir' ) ); + self::add_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0, 2 ); + self::add_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 ); + self::add_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 ); + + $orig_files_import = $_FILES['import'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing + $_FILES['import'] = $file; // wp_import_handle_upload() expects the file to be in 'import'. + + $upload = wp_import_handle_upload(); + + remove_filter( 'upload_dir', array( $this, 'override_upload_dir' ) ); + remove_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0 ); + remove_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 ); + remove_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 ); + + if ( $orig_files_import ) { + $_FILES['import'] = $orig_files_import; + } else { + unset( $_FILES['import'] ); + } + + if ( ! empty( $upload['error'] ) ) { + throw new \Exception( esc_html( $upload['error'] ) ); + } + + if ( ! wc_is_file_valid_csv( $upload['file'], false ) ) { + wp_delete_attachment( $file['id'], true ); + throw new \Exception( esc_html__( 'Invalid file type for a CSV import.', 'woocommerce' ) ); + } + + return $upload; + } + + /** + * Hooked onto 'upload_dir' to override the default upload directory for a CSV upload. + * + * @param array $uploads WP upload dir details. + * @return array + */ + private function override_upload_dir( $uploads ): array { + $new_subdir = '/' . $this->get_import_subdir_name(); + + $uploads['path'] = $uploads['basedir'] . $new_subdir; + $uploads['url'] = $uploads['baseurl'] . $new_subdir; + $uploads['subdir'] = $new_subdir; + + return $uploads; + } + + /** + * Adds a random string to the name of an uploaded CSV file to make it less discoverable. Hooked onto 'wp_unique_filename'. + * + * @param string $filename File name. + * @param string $ext File extension. + * @return string + */ + private function override_unique_filename( string $filename, string $ext ): string { + $length = min( 10, 255 - strlen( $filename ) - 1 ); + if ( 1 < $length ) { + $suffix = strtolower( wp_generate_password( $length, false, false ) ); + $filename = substr( $filename, 0, strlen( $filename ) - strlen( $ext ) ) . '-' . $suffix . $ext; + } + + return $filename; + } + + /** + * `wp_import_handle_upload()` appends .txt to any file name. This function is hooked onto 'wp_handle_upload_prefilter' + * to remove those extra characters. + * + * @param array $file File details in the form of a $_FILES entry. + * @return array Modified file details. + */ + private function remove_txt_from_uploaded_file( array $file ): array { + $file['name'] = substr( $file['name'], 0, -4 ); + return $file; + } +} diff --git a/plugins/woocommerce/src/Internal/Brands.php b/plugins/woocommerce/src/Internal/Brands.php new file mode 100644 index 00000000000..7ac3cdbab05 --- /dev/null +++ b/plugins/woocommerce/src/Internal/Brands.php @@ -0,0 +1,61 @@ + '', 'month' => '', 'day' => '', @@ -373,10 +373,12 @@ class OrdersTableQuery { /** * Returns UTC-based date query arguments for a combination of local time dates and a date shorthand operator. * - * @param array $dates_raw Array of dates (in local time) to use in combination with the operator. + * @param array $dates_raw Array of dates (in local time) to use in combination with the operator. * @param string $operator One of the operators supported by date queries (<, <=, =, ..., >, >=). * @return array Partial date query arg with relevant dates now UTC-based. * + * @throws \Exception If an invalid date shorthand operator is specified. + * * @since 8.2.0 */ private function local_time_to_gmt_date_query( $dates_raw, $operator ) { @@ -387,7 +389,7 @@ class OrdersTableQuery { $raw_date = is_numeric( $raw_date ) ? $raw_date : strtotime( get_gmt_from_date( date( 'Y-m-d', strtotime( $raw_date ) ) ) ); } - $date1 = end( $dates_raw ); + $date1 = end( $dates_raw ); switch ( $operator ) { case '>': @@ -410,9 +412,9 @@ class OrdersTableQuery { 'inclusive' => true, ), array( - 'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ), - 'inclusive' => false, - ) + 'before' => $this->date_to_date_query_arg( $date1 + DAY_IN_SECONDS ), + 'inclusive' => false, + ), ); break; case '<=': @@ -474,7 +476,6 @@ class OrdersTableQuery { foreach ( $date_keys as $date_key ) { $is_local = in_array( $date_key, $local_date_keys, true ); $date_value = $this->args[ $date_key ]; - $operator = '='; $dates_raw = array(); $dates = array(); diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php new file mode 100644 index 00000000000..58984a41fbf --- /dev/null +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php @@ -0,0 +1,35 @@ +share( CSVUploadHelper::class ); + } +} diff --git a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php index a15792fb29b..19a04d84fec 100644 --- a/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php +++ b/plugins/woocommerce/src/Internal/Logging/RemoteLogger.php @@ -5,8 +5,10 @@ namespace Automattic\WooCommerce\Internal\Logging; use Automattic\WooCommerce\Utilities\FeaturesUtil; use Automattic\WooCommerce\Utilities\StringUtil; +use Automattic\WooCommerce\Internal\McStats; use WC_Rate_Limiter; use WC_Log_Levels; +use Jetpack_Options; /** * WooCommerce Remote Logger @@ -71,9 +73,16 @@ class RemoteLogger extends \WC_Log_Handler { 'php_version' => phpversion(), 'wp_version' => get_bloginfo( 'version' ), 'request_uri' => $this->sanitize_request_uri( filter_input( INPUT_SERVER, 'REQUEST_URI', FILTER_SANITIZE_URL ) ), + 'store_id' => get_option( \WC_Install::STORE_ID_OPTION, null ), ), ); + $blog_id = class_exists( 'Jetpack_Options' ) ? Jetpack_Options::get_option( 'id' ) : null; + + if ( ! empty( $blog_id ) && is_int( $blog_id ) ) { + $log_data['blog_id'] = $blog_id; + } + if ( isset( $context['backtrace'] ) ) { if ( is_array( $context['backtrace'] ) || is_string( $context['backtrace'] ) ) { $log_data['trace'] = $this->sanitize_trace( $context['backtrace'] ); @@ -88,25 +97,15 @@ class RemoteLogger extends \WC_Log_Handler { unset( $context['tags'] ); } - if ( class_exists( '\WC_Tracks' ) && function_exists( 'wp_get_current_user' ) ) { - $user = wp_get_current_user(); - $blog_details = \WC_Tracks::get_blog_details( $user->ID ); - - if ( is_numeric( $blog_details['blog_id'] ) && $blog_details['blog_id'] > 0 ) { - $log_data['blog_id'] = $blog_details['blog_id']; - } - - if ( ! empty( $blog_details['store_id'] ) ) { - $log_data['properties']['store_id'] = $blog_details['store_id']; - } - } - - if ( isset( $context['error'] ) && is_array( $context['error'] ) && ! empty( $context['error']['file'] ) ) { - $context['error']['file'] = $this->sanitize( $context['error']['file'] ); + if ( isset( $context['error']['file'] ) && is_string( $context['error']['file'] ) && '' !== $context['error']['file'] ) { + $log_data['file'] = $this->sanitize( $context['error']['file'] ); + unset( $context['error']['file'] ); } $extra_attrs = $context['extra'] ?? array(); unset( $context['extra'] ); + unset( $context['remote-logging'] ); + // Merge the extra attributes with the remaining context since we can't send arbitrary fields to Logstash. $log_data['extra'] = array_merge( $extra_attrs, $context ); @@ -166,9 +165,15 @@ class RemoteLogger extends \WC_Log_Handler { * @return bool True if the log should be handled. */ protected function should_handle( $level, $message, $context ) { + // Ignore logs that are not opted in for remote logging. + if ( ! isset( $context['remote-logging'] ) || false === $context['remote-logging'] ) { + return false; + } + if ( ! $this->is_remote_logging_allowed() ) { return false; } + // Ignore logs that are less severe than critical. This is temporary to prevent sending too many logs to the remote logging service. We can consider remove this if the remote logging service can handle more logs. if ( WC_Log_Levels::get_level_severity( $level ) < WC_Log_Levels::get_level_severity( WC_Log_Levels::CRITICAL ) ) { return false; @@ -178,6 +183,15 @@ class RemoteLogger extends \WC_Log_Handler { return false; } + try { + // Record fatal error stats. + $mc_stats = wc_get_container()->get( McStats::class ); + $mc_stats->add( 'error', 'critical-errors' ); + $mc_stats->do_server_side_stats(); + } catch ( \Throwable $e ) { + error_log( 'Warning: Failed to record fatal error stats: ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + } + if ( WC_Rate_Limiter::retried_too_soon( self::RATE_LIMIT_ID ) ) { error_log( 'Remote logging throttled.' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log return false; @@ -358,7 +372,7 @@ class RemoteLogger extends \WC_Log_Handler { * * The trace is sanitized by: * - * 1. Remove the absolute path to the WooCommerce plugin directory. + * 1. Remove the absolute path to the plugin directory based on WC_ABSPATH. This is more accurate than using WP_PLUGIN_DIR when the plugin is symlinked. * 2. Remove the absolute path to the WordPress root directory. * * For example, the trace: @@ -374,12 +388,12 @@ class RemoteLogger extends \WC_Log_Handler { return $message; } - $wc_path = StringUtil::normalize_local_path_slashes( WC_ABSPATH ); - $wp_path = StringUtil::normalize_local_path_slashes( ABSPATH ); + $plugin_path = StringUtil::normalize_local_path_slashes( trailingslashit( dirname( WC_ABSPATH ) ) ); + $wp_path = StringUtil::normalize_local_path_slashes( trailingslashit( ABSPATH ) ); $sanitized = str_replace( - array( $wc_path, $wp_path ), - array( '**/' . dirname( WC_PLUGIN_BASENAME ) . '/', '**/' ), + array( $plugin_path, $wp_path ), + array( './', './' ), $message ); diff --git a/plugins/woocommerce/src/Internal/McStats.php b/plugins/woocommerce/src/Internal/McStats.php index 89320e1ee6a..2f05d355451 100644 --- a/plugins/woocommerce/src/Internal/McStats.php +++ b/plugins/woocommerce/src/Internal/McStats.php @@ -60,4 +60,17 @@ class McStats extends A8c_Mc_Stats { return parent::do_server_side_stat( $url ); } + + /** + * Pings the stats server for the current stats and empty the stored stats from the object + * + * @return void + */ + public function do_server_side_stats() { + if ( ! \WC_Site_Tracking::is_tracking_enabled() ) { + return; + } + + parent::do_server_side_stats(); + } } diff --git a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php index 4bac73265b3..3b91fcbc721 100644 --- a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php +++ b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php @@ -31,6 +31,36 @@ class FilesystemUtil { return $wp_filesystem; } + /** + * Recursively creates a directory (if it doesn't exist) and adds an empty index.html and a .htaccess to prevent + * directory listing. + * + * @since 9.3.0 + * + * @param string $path Directory to create. + * @throws \Exception In case of error. + */ + public static function mkdir_p_not_indexable( string $path ): void { + $wp_fs = self::get_wp_filesystem(); + + if ( $wp_fs->is_dir( $path ) ) { + return; + } + + if ( ! wp_mkdir_p( $path ) ) { + throw new \Exception( esc_html( sprintf( 'Could not create directory: %s.', wp_basename( $path ) ) ) ); + } + + $files = array( + '.htaccess' => 'deny from all', + 'index.html' => '', + ); + + foreach ( $files as $name => $content ) { + $wp_fs->put_contents( trailingslashit( $path ) . $name, $content ); + } + } + /** * Wrapper to initialize the WP filesystem with defined credentials if they are available. * diff --git a/plugins/woocommerce/src/Packages.php b/plugins/woocommerce/src/Packages.php index b9255a4580c..eb251bbc908 100644 --- a/plugins/woocommerce/src/Packages.php +++ b/plugins/woocommerce/src/Packages.php @@ -37,23 +37,43 @@ class Packages { * initialization for the now-merged feature plugin. * * Once a package has been merged into WooCommerce Core it should have its slug added here. This will ensure - * that we deactivate the feature plugin automaticatlly to prevent any problems caused by conflicts between + * that we deactivate the feature plugin automatically to prevent any problems caused by conflicts between * the two versions caused by them both being active. * + * The packages included in this array cannot be deactivated and will always load with WooCommerce core. + * + * @var array Key is the package name/directory, value is the main package class which handles init. + */ + protected static $base_packages = array( + 'woocommerce-admin' => '\\Automattic\\WooCommerce\\Admin\\Composer\\Package', + 'woocommerce-gutenberg-products-block' => '\\Automattic\\WooCommerce\\Blocks\\Package', + ); + + /** + * Similar to $base_packages, but + * the packages included in this array can be deactivated via the 'woocommerce_merged_packages' filter. + * * @var array Key is the package name/directory, value is the main package class which handles init. */ protected static $merged_packages = array( - 'woocommerce-admin' => '\\Automattic\\WooCommerce\\Admin\\Composer\\Package', - 'woocommerce-gutenberg-products-block' => '\\Automattic\\WooCommerce\\Blocks\\Package', + 'woocommerce-brands' => '\\Automattic\\WooCommerce\\Internal\\Brands', ); + /** * Init the package loader. * * @since 3.7.0 */ public static function init() { - add_action( 'plugins_loaded', array( __CLASS__, 'on_init' ) ); + add_action( 'plugins_loaded', array( __CLASS__, 'on_init' ), 0 ); + + // Prevent plugins already merged into WooCommerce core from getting activated as standalone plugins. + add_action( 'activate_plugin', array( __CLASS__, 'deactivate_merged_plugins' ) ); + + // Display a notice in the Plugins tab next to plugins already merged into WooCommerce core. + add_filter( 'all_plugins', array( __CLASS__, 'mark_merged_plugins_as_pending_update' ), 10, 1 ); + add_action( 'after_plugin_row', array( __CLASS__, 'display_notice_for_merged_plugins' ), 10, 1 ); } /** @@ -74,6 +94,61 @@ class Packages { return file_exists( dirname( __DIR__ ) . '/packages/' . $package ); } + /** + * Checks a package exists by looking for it's directory. + * + * @param string $class_name Class name. + * @return boolean + */ + public static function should_load_class( $class_name ) { + + foreach ( self::$merged_packages as $merged_package_name => $merged_package_class ) { + if ( str_replace( 'woocommerce-', 'wc_', $merged_package_name ) === $class_name ) { + return true; + } + } + + return false; + } + + /** + * Gets all merged, enabled packages. + * + * @return array + */ + protected static function get_enabled_packages() { + $enabled_packages = array(); + + foreach ( self::$merged_packages as $merged_package_name => $package_class ) { + + // For gradual rollouts, ensure that a package is enabled for user's remote variant number. + $experimental_package_enabled = method_exists( $package_class, 'is_enabled' ) ? + call_user_func( array( $package_class, 'is_enabled' ) ) : + false; + + if ( ! $experimental_package_enabled ) { + continue; + } + + $option = 'wc_feature_' . str_replace( '-', '_', $merged_package_name ) . '_enabled'; + if ( 'yes' === get_option( $option, 'no' ) ) { + $enabled_packages[ $merged_package_name ] = $package_class; + } + } + + return array_merge( $enabled_packages, self::$base_packages ); + } + + /** + * Checks if a package is enabled. + * + * @param string $package Package name. + * @return boolean + */ + public static function is_package_enabled( $package ) { + return array_key_exists( $package, self::get_enabled_packages() ); + } + /** * Deactivates merged feature plugins. * @@ -93,7 +168,8 @@ class Packages { // Deactivate the plugin if possible so that there are no conflicts. foreach ( $active_plugins as $active_plugin_path ) { $plugin_file = basename( plugin_basename( $active_plugin_path ), '.php' ); - if ( ! isset( self::$merged_packages[ $plugin_file ] ) ) { + + if ( ! self::is_package_enabled( $plugin_file ) ) { continue; } @@ -107,7 +183,7 @@ class Packages { function() use ( $plugin_data ) { echo '

'; printf( - /* translators: %s: is referring to the plugin's name. */ + /* translators: %s: is referring to the plugin's name. */ esc_html__( 'The %1$s plugin has been deactivated as the latest improvements are now included with the %2$s plugin.', 'woocommerce' ), '' . esc_html( $plugin_data['Name'] ) . '', 'WooCommerce' @@ -118,13 +194,71 @@ class Packages { } } + /** + * Prevent plugins already merged into WooCommerce core from getting activated as standalone plugins. + * + * @param string $plugin Plugin name. + */ + public static function deactivate_merged_plugins( $plugin ) { + $plugin_dir = basename( dirname( $plugin ) ); + + if ( self::is_package_enabled( $plugin_dir ) ) { + $plugins_url = esc_url( admin_url( 'plugins.php' ) ); + wp_die( + esc_html__( 'This plugin cannot be activated because its functionality is now included in WooCommerce core.', 'woocommerce' ), + esc_html__( 'Plugin Activation Error', 'woocommerce' ), + array( + 'link_url' => esc_url( $plugins_url ), + 'link_text' => esc_html__( 'Return to the Plugins page', 'woocommerce' ), + ), + ); + } + } + + /** + * Mark merged plugins as pending update. + * This is required for correctly displaying maintenance notices. + * + * @param array $plugins Plugins list. + */ + public static function mark_merged_plugins_as_pending_update( $plugins ) { + foreach ( $plugins as $plugin_name => $plugin_data ) { + $plugin_dir = basename( dirname( $plugin_name ) ); + if ( self::is_package_enabled( $plugin_dir ) ) { + // Necessary to properly display notice within row. + $plugins[ $plugin_name ]['update'] = 1; + } + } + return $plugins; + } + + /** + * Displays a maintenance notice next to merged plugins, to inform users + * that the plugin functionality is now offered by WooCommerce core. + * + * Requires 'mark_merged_plugins_as_pending_update' to properly display this notice. + * + * @param string $plugin_file Plugin file. + */ + public static function display_notice_for_merged_plugins( $plugin_file ) { + global $wp_list_table; + + $plugin_dir = basename( dirname( $plugin_file ) ); + $columns_count = $wp_list_table->get_column_count(); + $notice = __( 'This plugin can no longer be activated because its functionality is now included in WooCommerce. It is recommended to delete it.', 'woocommerce' ); + + if ( self::is_package_enabled( $plugin_dir ) ) { + echo '

' . wp_kses_post( $notice ) . '

'; + } + } + /** * Loads packages after plugins_loaded hook. * * Each package should include an init file which loads the package so it can be used by core. */ protected static function initialize_packages() { - foreach ( self::$merged_packages as $package_name => $package_class ) { + foreach ( self::get_enabled_packages() as $package_name => $package_class ) { call_user_func( array( $package_class, 'init' ) ); } @@ -172,7 +306,7 @@ class Packages { } add_action( 'admin_notices', - function() use ( $package ) { + function () use ( $package ) { ?>

diff --git a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php index 25f915d2862..3fce3214ab0 100644 --- a/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php +++ b/plugins/woocommerce/src/StoreApi/Utilities/ProductQuery.php @@ -16,7 +16,7 @@ class ProductQuery { * @return array */ public function prepare_objects_query( $request ) { - $args = [ + $args = array( 'offset' => $request['offset'], 'order' => $request['order'], 'orderby' => $request['orderby'], @@ -31,17 +31,17 @@ class ProductQuery { 'fields' => 'ids', 'ignore_sticky_posts' => true, 'post_status' => 'publish', - 'date_query' => [], + 'date_query' => array(), 'post_type' => 'product', - ]; + ); // If searching for a specific SKU or slug, allow any post type. if ( ! empty( $request['sku'] ) || ! empty( $request['slug'] ) ) { - $args['post_type'] = [ 'product', 'product_variation' ]; + $args['post_type'] = array( 'product', 'product_variation' ); } // Taxonomy query to filter products by type, category, tag, shipping class, and attribute. - $tax_query = []; + $tax_query = array(); // Filter product type by slug. if ( ! empty( $request['type'] ) ) { @@ -49,11 +49,11 @@ class ProductQuery { $args['post_type'] = 'product_variation'; } else { $args['post_type'] = 'product'; - $tax_query[] = [ + $tax_query[] = array( 'taxonomy' => 'product_type', 'field' => 'slug', 'terms' => $request['type'], - ]; + ); } } @@ -77,12 +77,12 @@ class ProductQuery { } // Set custom args to handle later during clauses. - $custom_keys = [ + $custom_keys = array( 'sku', 'min_price', 'max_price', 'stock_status', - ]; + ); foreach ( $custom_keys as $key ) { if ( ! empty( $request[ $key ] ) ) { @@ -90,11 +90,11 @@ class ProductQuery { } } - $operator_mapping = [ + $operator_mapping = array( 'in' => 'IN', 'not_in' => 'NOT IN', 'and' => 'AND', - ]; + ); // Gets all registered product taxonomies and prefixes them with `tax_`. // This is needed to avoid situations where a user registers a new product taxonomy with the same name as default field. @@ -107,10 +107,10 @@ class ProductQuery { ); // Map between taxonomy name and arg key. - $default_taxonomies = [ + $default_taxonomies = array( 'product_cat' => 'category', 'product_tag' => 'tag', - ]; + ); $taxonomies = array_merge( $all_product_taxonomies, $default_taxonomies ); @@ -118,18 +118,18 @@ class ProductQuery { foreach ( $taxonomies as $taxonomy => $key ) { if ( ! empty( $request[ $key ] ) ) { $operator = $request->get_param( $key . '_operator' ) && isset( $operator_mapping[ $request->get_param( $key . '_operator' ) ] ) ? $operator_mapping[ $request->get_param( $key . '_operator' ) ] : 'IN'; - $tax_query[] = [ + $tax_query[] = array( 'taxonomy' => $taxonomy, 'field' => 'term_id', 'terms' => $request[ $key ], 'operator' => $operator, - ]; + ); } } // Filter by attributes. if ( ! empty( $request['attributes'] ) ) { - $att_queries = []; + $att_queries = array(); foreach ( $request['attributes'] as $attribute ) { if ( empty( $attribute['term_id'] ) && empty( $attribute['slug'] ) ) { @@ -137,22 +137,22 @@ class ProductQuery { } if ( in_array( $attribute['attribute'], wc_get_attribute_taxonomy_names(), true ) ) { $operator = isset( $attribute['operator'], $operator_mapping[ $attribute['operator'] ] ) ? $operator_mapping[ $attribute['operator'] ] : 'IN'; - $att_queries[] = [ + $att_queries[] = array( 'taxonomy' => $attribute['attribute'], 'field' => ! empty( $attribute['term_id'] ) ? 'term_id' : 'slug', 'terms' => ! empty( $attribute['term_id'] ) ? $attribute['term_id'] : $attribute['slug'], 'operator' => $operator, - ]; + ); } } if ( 1 < count( $att_queries ) ) { // Add relation arg when using multiple attributes. $relation = $request->get_param( 'attribute_relation' ) && isset( $operator_mapping[ $request->get_param( 'attribute_relation' ) ] ) ? $operator_mapping[ $request->get_param( 'attribute_relation' ) ] : 'IN'; - $tax_query[] = [ + $tax_query[] = array( 'relation' => $relation, $att_queries, - ]; + ); } else { $tax_query = array_merge( $tax_query, $att_queries ); } @@ -176,12 +176,12 @@ class ProductQuery { // Filter featured. if ( is_bool( $request['featured'] ) ) { - $args['tax_query'][] = [ + $args['tax_query'][] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => 'featured', 'operator' => true === $request['featured'] ? 'IN' : 'NOT IN', - ]; + ); } // Filter by on sale products. @@ -190,7 +190,7 @@ class ProductQuery { $on_sale_ids = wc_get_product_ids_on_sale(); // Use 0 when there's no on sale products to avoid return all products. - $on_sale_ids = empty( $on_sale_ids ) ? [ 0 ] : $on_sale_ids; + $on_sale_ids = empty( $on_sale_ids ) ? array( 0 ) : $on_sale_ids; $args[ $on_sale_key ] += $on_sale_ids; } @@ -203,25 +203,25 @@ class ProductQuery { $exclude_from_catalog = 'search' === $catalog_visibility ? '' : 'exclude-from-catalog'; $exclude_from_search = 'catalog' === $catalog_visibility ? '' : 'exclude-from-search'; - $args['tax_query'][] = [ + $args['tax_query'][] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', - 'terms' => [ $exclude_from_catalog, $exclude_from_search ], + 'terms' => array( $exclude_from_catalog, $exclude_from_search ), 'operator' => 'hidden' === $catalog_visibility ? 'AND' : 'NOT IN', 'rating_filter' => true, - ]; + ); } if ( $rating ) { - $rating_terms = []; + $rating_terms = array(); foreach ( $rating as $value ) { $rating_terms[] = 'rated-' . $value; } - $args['tax_query'][] = [ + $args['tax_query'][] = array( 'taxonomy' => 'product_visibility', 'field' => 'name', 'terms' => $rating_terms, - ]; + ); } $orderby = $request->get_param( 'orderby' ); @@ -283,7 +283,7 @@ class ProductQuery { public function get_results( $request ) { $query_args = $this->prepare_objects_query( $request ); - add_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10, 2 ); + add_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10, 2 ); $query = new \WP_Query(); $results = $query->query( $query_args ); @@ -297,13 +297,13 @@ class ProductQuery { $total_posts = $count_query->found_posts; } - remove_filter( 'posts_clauses', [ $this, 'add_query_clauses' ], 10 ); + remove_filter( 'posts_clauses', array( $this, 'add_query_clauses' ), 10 ); - return [ + return array( 'results' => $results, 'total' => (int) $total_posts, 'pages' => $query->query_vars['posts_per_page'] > 0 ? (int) ceil( $total_posts / (int) $query->query_vars['posts_per_page'] ) : 1, - ]; + ); } /** @@ -315,11 +315,11 @@ class ProductQuery { public function get_objects( $request ) { $results = $this->get_results( $request ); - return [ + return array( 'objects' => array_map( 'wc_get_product', $results['results'] ), 'total' => $results['total'], 'pages' => $results['pages'], - ]; + ); } /** @@ -442,7 +442,7 @@ class ProductQuery { return ''; } - $or_queries = []; + $or_queries = array(); // We need to adjust the filter for each possible tax class and combine the queries into one. foreach ( $product_tax_classes as $tax_class ) { diff --git a/plugins/woocommerce/templates/brands/brand-description.php b/plugins/woocommerce/templates/brands/brand-description.php new file mode 100644 index 00000000000..a72a251a3f6 --- /dev/null +++ b/plugins/woocommerce/templates/brands/brand-description.php @@ -0,0 +1,35 @@ + +

+ + + + Thumbnail + + + +
+ + + +
+ +
diff --git a/plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php b/plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php new file mode 100644 index 00000000000..ef2d9042a5f --- /dev/null +++ b/plugins/woocommerce/templates/brands/shortcodes/brands-a-z.php @@ -0,0 +1,63 @@ + +
+ + + + + +

+ +
    + %s', + esc_url( get_term_link( $brand->slug, 'product_brand' ) ), + esc_html( $brand->name ) + ); + } + ?> +
+ + + + + + + +
diff --git a/plugins/woocommerce/templates/brands/shortcodes/single-brand.php b/plugins/woocommerce/templates/brands/shortcodes/single-brand.php new file mode 100644 index 00000000000..556ae2055e9 --- /dev/null +++ b/plugins/woocommerce/templates/brands/shortcodes/single-brand.php @@ -0,0 +1,38 @@ + + + <?php echo esc_attr( $term->name ); ?> + diff --git a/plugins/woocommerce/templates/brands/taxonomy-product_brand.php b/plugins/woocommerce/templates/brands/taxonomy-product_brand.php new file mode 100644 index 00000000000..56898bf0cb3 --- /dev/null +++ b/plugins/woocommerce/templates/brands/taxonomy-product_brand.php @@ -0,0 +1,12 @@ + +
    + + $brand ) : + + /** + * Filter the brand's thumbnail size. + * + * @since 9.4.0 + * @param string $size Defaults to 'shop_catalog' + */ + $thumbnail = wc_get_brand_thumbnail_url( $brand->term_id, apply_filters( 'woocommerce_brand_thumbnail_size', 'shop_catalog' ) ); + + if ( ! $thumbnail ) { + $thumbnail = wc_placeholder_img_src(); + } + + $class = ''; + + if ( 0 === $index || 0 === $index % $columns ) { + $class = 'first'; + } elseif ( 0 === ( $index + 1 ) % $columns ) { + $class = 'last'; + } + + $width = floor( ( ( 100 - ( ( $columns - 1 ) * 2 ) ) / $columns ) * 100 ) / 100; + ?> +
  • + + <?php echo esc_attr( $brand->name ); ?> + +
    + description ) ) ); ?> +
    +
  • + + + +
diff --git a/plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php b/plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php new file mode 100644 index 00000000000..bbfdf43f236 --- /dev/null +++ b/plugins/woocommerce/templates/brands/widgets/brand-thumbnails.php @@ -0,0 +1,45 @@ + +
    + + $brand ) : + $class = ''; + if ( 0 === $index || 0 === $index % $columns ) { + $class = 'first'; + } elseif ( 0 === ( $index + 1 ) % $columns ) { + $class = 'last'; + } + ?> + +
  • + + + +
  • + + + +
diff --git a/plugins/woocommerce/templates/content-product.php b/plugins/woocommerce/templates/content-product.php index 7423164e81c..b3bc12ad92c 100644 --- a/plugins/woocommerce/templates/content-product.php +++ b/plugins/woocommerce/templates/content-product.php @@ -12,15 +12,15 @@ * * @see https://woocommerce.com/document/template-structure/ * @package WooCommerce\Templates - * @version 3.6.0 + * @version 9.4.0 */ defined( 'ABSPATH' ) || exit; global $product; -// Ensure visibility. -if ( empty( $product ) || ! $product->is_visible() ) { +// Check if the product is a valid WooCommerce product and ensure its visibility before proceeding. +if ( ! is_a( $product, WC_Product::class ) || ! $product->is_visible() ) { return; } ?> diff --git a/plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html b/plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html new file mode 100644 index 00000000000..aa23a7d2ccc --- /dev/null +++ b/plugins/woocommerce/templates/templates/blockified/taxonomy-product_brand.html @@ -0,0 +1,42 @@ + + + +
+ + + + + + + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + +
+ +
+ + + diff --git a/plugins/woocommerce/templates/templates/taxonomy-product_brand.html b/plugins/woocommerce/templates/templates/taxonomy-product_brand.html new file mode 100644 index 00000000000..4cf01077d40 --- /dev/null +++ b/plugins/woocommerce/templates/templates/taxonomy-product_brand.html @@ -0,0 +1,5 @@ + + +
+ + diff --git a/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js b/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js index 08977a04e71..27ec548c34a 100644 --- a/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js +++ b/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js @@ -9,8 +9,20 @@ config = { use: { ...devices[ 'Desktop Chrome' ] }, testMatch: [ '**/basic.spec.js', + '**/activate-and-setup/**/*.spec.js', + '**/admin-analytics/**/*.spec.js', + '**/admin-marketing/**/*.spec.js', + '**/admin-tasks/**/*.spec.js', '**/shopper/**/*.spec.js', '**/api-tests/**/*.test.js', + '**/merchant/products/add-variable-product/**/*.spec.js', + '**/merchant/command-palette.spec.js', + '**/merchant/create-cart-block.spec.js', + '**/merchant/create-checkout-block.spec.js', + '**/merchant/create-coupon.spec.js', + '**/merchant/create-order.spec.js', + '**/merchant/create-page.spec.js', + '**/merchant/create-post.spec.js', ], grepInvert: /@skip-on-default-wpcom/, }, diff --git a/plugins/woocommerce/tests/e2e-pw/tests/activate-and-setup/core-profiler.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/activate-and-setup/core-profiler.spec.js index f0cf8016e40..a4679791f03 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/activate-and-setup/core-profiler.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/activate-and-setup/core-profiler.spec.js @@ -3,7 +3,7 @@ const { setOption } = require( '../../utils/options' ); test.describe( 'Store owner can complete the core profiler', - { tag: '@skip-on-default-pressable' }, + { tag: [ '@skip-on-default-pressable', '@skip-on-default-wpcom' ] }, () => { test.use( { storageState: process.env.ADMINSTATE } ); @@ -450,7 +450,7 @@ test.describe( test.describe( 'Store owner can skip the core profiler', - { tag: '@skip-on-default-pressable' }, + { tag: [ '@skip-on-default-pressable', '@skip-on-default-wpcom' ] }, () => { test.use( { storageState: process.env.ADMINSTATE } ); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/admin-analytics/analytics-data.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/admin-analytics/analytics-data.spec.js index 6cfd1d35fd2..9d8ebd604a9 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/admin-analytics/analytics-data.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/admin-analytics/analytics-data.spec.js @@ -25,7 +25,14 @@ const test = baseTest.extend( { test.describe( 'Analytics-related tests', - { tag: [ '@payments', '@services', '@skip-on-default-pressable' ] }, + { + tag: [ + '@payments', + '@services', + '@skip-on-default-pressable', + '@skip-on-default-wpcom', + ], + }, () => { let categoryIds, productIds, orderIds, setupPage; diff --git a/plugins/woocommerce/tests/e2e-pw/tests/admin-marketing/overview.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/admin-marketing/overview.spec.js index 2ababaf97ec..773f160caf2 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/admin-marketing/overview.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/admin-marketing/overview.spec.js @@ -15,40 +15,44 @@ test.describe( 'Marketing page', () => { ).toBeVisible(); } ); - test( 'Marketing Overview page have relevant content', async ( { - page, - } ) => { - // Go to the Marketing page. - await page.goto( 'wp-admin/admin.php?page=wc-admin&path=%2Fmarketing' ); + test( + 'Marketing Overview page have relevant content', + { tag: '@skip-on-default-wpcom' }, + async ( { page } ) => { + // Go to the Marketing page. + await page.goto( + 'wp-admin/admin.php?page=wc-admin&path=%2Fmarketing' + ); - // Heading should be overview - await expect( - page.getByRole( 'heading', { name: 'Overview' } ) - ).toBeVisible(); + // Heading should be overview + await expect( + page.getByRole( 'heading', { name: 'Overview' } ) + ).toBeVisible(); - // Sections present - await expect( - page.getByText( 'Channels', { exact: true } ) - ).toBeVisible(); - await expect( - page.getByText( 'Discover more marketing tools' ) - ).toBeVisible(); - await expect( - page.getByRole( 'tab', { name: 'Email' } ) - ).toBeVisible(); - await expect( - page.getByRole( 'tab', { name: 'Automations' } ) - ).toBeVisible(); - await expect( - page.getByRole( 'tab', { name: 'Conversion' } ) - ).toBeVisible(); - await expect( - page.getByRole( 'tab', { name: 'CRM', exact: true } ) - ).toBeVisible(); - await expect( - page.getByText( 'Learn about marketing a store' ) - ).toBeVisible(); - } ); + // Sections present + await expect( + page.getByText( 'Channels', { exact: true } ) + ).toBeVisible(); + await expect( + page.getByText( 'Discover more marketing tools' ) + ).toBeVisible(); + await expect( + page.getByRole( 'tab', { name: 'Email' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'tab', { name: 'Automations' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'tab', { name: 'Conversion' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'tab', { name: 'CRM', exact: true } ) + ).toBeVisible(); + await expect( + page.getByText( 'Learn about marketing a store' ) + ).toBeVisible(); + } + ); test( 'Introduction can be dismissed', diff --git a/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js index eec70d721a8..035b63be270 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/admin-tasks/payment.spec.js @@ -73,7 +73,7 @@ test.describe( 'Payment setup task', () => { await page .locator( '//input[@placeholder="BIC / Swift"]' ) .fill( 'ABBA' ); - await page.locator( 'text=Save' ).click(); + await page.getByRole( 'button', { name: 'Save' } ).click(); // Check that bank transfers were set up. await expect( @@ -93,7 +93,7 @@ test.describe( 'Payment setup task', () => { page, } ) => { await page.goto( 'wp-admin/admin.php?page=wc-admin' ); - await page.locator( 'text=Get paid' ).click(); + await page.getByRole( 'button', { name: '3 Get paid' } ).click(); await expect( page.locator( '.woocommerce-layout__header-wrapper > h1' ) ).toHaveText( 'Get paid' ); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-checkout-block.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-checkout-block.spec.js index c5f0d7ec448..7279c31fbea 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-checkout-block.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-checkout-block.spec.js @@ -149,9 +149,11 @@ test.describe( .locator( 'legend' ) ).toBeVisible(); await expect( - page.locator( - '.wp-block-woocommerce-checkout-order-summary-block' - ) + page + .locator( + '.wp-block-woocommerce-checkout-order-summary-block' + ) + .first() ).toBeVisible(); await expect( page.locator( '.wc-block-components-address-form' ).first() diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-coupon.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-coupon.spec.js index 2630674a42b..1d38fd68d5c 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-coupon.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-coupon.spec.js @@ -39,102 +39,110 @@ const test = baseTest.extend( { }, } ); -test.describe( 'Coupon management', { tag: '@services' }, () => { - for ( const couponType of Object.keys( couponData ) ) { - test( `can create new ${ couponType } coupon`, async ( { - page, - coupon, - } ) => { - await test.step( 'add new coupon', async () => { - await page.goto( - 'wp-admin/post-new.php?post_type=shop_coupon' - ); - await page - .getByLabel( 'Coupon code' ) - .fill( couponData[ couponType ].code ); - await page - .getByPlaceholder( 'Description (optional)' ) - .fill( couponData[ couponType ].description ); - await page - .getByPlaceholder( '0' ) - .fill( couponData[ couponType ].amount ); +test.describe( + 'Coupon management', + { tag: [ '@services', '@skip-on-default-wpcom' ] }, + () => { + for ( const couponType of Object.keys( couponData ) ) { + test( `can create new ${ couponType } coupon`, async ( { + page, + coupon, + } ) => { + await test.step( 'add new coupon', async () => { + await page.goto( + 'wp-admin/post-new.php?post_type=shop_coupon' + ); + await page + .getByLabel( 'Coupon code' ) + .fill( couponData[ couponType ].code ); + await page + .getByPlaceholder( 'Description (optional)' ) + .fill( couponData[ couponType ].description ); + await page + .getByPlaceholder( '0' ) + .fill( couponData[ couponType ].amount ); - // set expiry date if it was provided + // set expiry date if it was provided + if ( couponData[ couponType ].expiryDate ) { + await page + .getByPlaceholder( 'yyyy-mm-dd' ) + .fill( couponData[ couponType ].expiryDate ); + } + + // be explicit about whether free shipping is allowed + if ( couponData[ couponType ].freeShipping ) { + await page.getByLabel( 'Allow free shipping' ).check(); + } else { + await page + .getByLabel( 'Allow free shipping' ) + .uncheck(); + } + } ); + + // publish the coupon and retrieve the id + await test.step( 'publish the coupon', async () => { + await expect( + page.getByRole( 'link', { name: 'Move to Trash' } ) + ).toBeVisible(); + await page + .getByRole( 'button', { name: 'Publish', exact: true } ) + .click(); + await expect( + page.getByText( 'Coupon updated.' ) + ).toBeVisible(); + coupon.id = page.url().match( /(?<=post=)\d+/ )[ 0 ]; + expect( coupon.id ).toBeDefined(); + } ); + + // verify the creation of the coupon and details + await test.step( 'verify coupon creation', async () => { + await page.goto( + 'wp-admin/edit.php?post_type=shop_coupon' + ); + await expect( + page.getByRole( 'cell', { + name: couponData[ couponType ].code, + } ) + ).toBeVisible(); + await expect( + page.getByRole( 'cell', { + name: couponData[ couponType ].description, + } ) + ).toBeVisible(); + await expect( + page.getByRole( 'cell', { + name: couponData[ couponType ].amount, + exact: true, + } ) + ).toBeVisible(); + } ); + + // check expiry date if it was set if ( couponData[ couponType ].expiryDate ) { - await page - .getByPlaceholder( 'yyyy-mm-dd' ) - .fill( couponData[ couponType ].expiryDate ); + await test.step( 'verify coupon expiry date', async () => { + await page + .getByText( couponData[ couponType ].code ) + .last() + .click(); + await expect( + page.getByPlaceholder( 'yyyy-mm-dd' ) + ).toHaveValue( couponData[ couponType ].expiryDate ); + } ); } - // be explicit about whether free shipping is allowed + // if it was a free shipping coupon check that if ( couponData[ couponType ].freeShipping ) { - await page.getByLabel( 'Allow free shipping' ).check(); - } else { - await page.getByLabel( 'Allow free shipping' ).uncheck(); + await test.step( 'verify free shipping', async () => { + await page + .getByText( couponData[ couponType ].code ) + .last() + .click(); + await expect( + page.getByLabel( 'Allow free shipping' ) + ).toBeChecked(); + } ); } } ); - - // publish the coupon and retrieve the id - await test.step( 'publish the coupon', async () => { - await expect( - page.getByRole( 'link', { name: 'Move to Trash' } ) - ).toBeVisible(); - await page - .getByRole( 'button', { name: 'Publish', exact: true } ) - .click(); - await expect( - page.getByText( 'Coupon updated.' ) - ).toBeVisible(); - coupon.id = page.url().match( /(?<=post=)\d+/ )[ 0 ]; - expect( coupon.id ).toBeDefined(); - } ); - - // verify the creation of the coupon and details - await test.step( 'verify coupon creation', async () => { - await page.goto( 'wp-admin/edit.php?post_type=shop_coupon' ); - await expect( - page.getByRole( 'cell', { - name: couponData[ couponType ].code, - } ) - ).toBeVisible(); - await expect( - page.getByRole( 'cell', { - name: couponData[ couponType ].description, - } ) - ).toBeVisible(); - await expect( - page.getByRole( 'cell', { - name: couponData[ couponType ].amount, - exact: true, - } ) - ).toBeVisible(); - } ); - - // check expiry date if it was set - if ( couponData[ couponType ].expiryDate ) { - await test.step( 'verify coupon expiry date', async () => { - await page - .getByText( couponData[ couponType ].code ) - .last() - .click(); - await expect( - page.getByPlaceholder( 'yyyy-mm-dd' ) - ).toHaveValue( couponData[ couponType ].expiryDate ); - } ); - } - - // if it was a free shipping coupon check that - if ( couponData[ couponType ].freeShipping ) { - await test.step( 'verify free shipping', async () => { - await page - .getByText( couponData[ couponType ].code ) - .last() - .click(); - await expect( - page.getByLabel( 'Allow free shipping' ) - ).toBeChecked(); - } ); - } - } ); + } } -} ); +); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js new file mode 100644 index 00000000000..2288819aa86 --- /dev/null +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-product-brand.spec.js @@ -0,0 +1,181 @@ +const { test, expect } = require( '@playwright/test' ); + +test.use( { storageState: process.env.ADMINSTATE } ); + +test.skip( 'Merchant can add brands', async ( { page } ) => { + /** + * Go to the Brands page. + * + * This will visit the Products page first, and then click on the Brands link. + * This is to workaround the hover menu for now. + */ + const goToBrandsPage = async () => { + await page.goto( + 'wp-admin/edit-tags.php?taxonomy=product_brand&post_type=product' + ); + + // Wait for the Brands page to load. + // This is needed so that checking for existing brands would work. + await page.waitForSelector( '.wp-list-table' ); + }; + + const createBrandIfNotExist = async ( + name, + slug, + parentBrand, + description, + thumbnailFileName + ) => { + // Create "WooCommerce" brand if it does not exist. + const cellVisible = await page + .locator( '#posts-filter' ) + .getByRole( 'cell', { name: slug, exact: true } ) + .isVisible(); + + if ( cellVisible ) { + return; + } + + await page.getByRole( 'textbox', { name: 'Name' } ).click(); + await page.getByRole( 'textbox', { name: 'Name' } ).fill( name ); + await page.getByRole( 'textbox', { name: 'Slug' } ).click(); + await page.getByRole( 'textbox', { name: 'Slug' } ).fill( slug ); + + await page + .getByRole( 'combobox', { name: 'Parent Brand' } ) + .selectOption( { label: parentBrand } ); + + await page.getByRole( 'textbox', { name: 'Description' } ).click(); + await page + .getByRole( 'textbox', { name: 'Description' } ) + .fill( description ); + await page.getByRole( 'button', { name: 'Upload/Add image' } ).click(); + await page.getByRole( 'tab', { name: 'Media Library' } ).click(); + await page.getByRole( 'checkbox', { name: thumbnailFileName } ).click(); + await page.getByRole( 'button', { name: 'Use image' } ).click(); + await page.getByRole( 'button', { name: 'Add New Brand' } ).click(); + + // We should see an "Item added." notice message at the top of the page. + await expect( + page.locator( '#ajax-response' ).getByText( 'Item added.' ) + ).toBeVisible(); + + // We should see the newly created brand in the Brands table. + await expect( + page + .locator( '#posts-filter' ) + .getByRole( 'cell', { name: slug, exact: true } ) + ).toHaveCount( 1 ); + }; + + /** + * Edit a brand. + * + * You must be in the Brands page before calling this function. + * To do so, call `goToBrandsPage()` first. + * + * After a brand is edited, you will be redirected to the Brands page. + */ + const editBrand = async ( + currentName, + { name, slug, parentBrand, description, thumbnailFileName } + ) => { + await page.getByLabel( `“${ currentName }” (Edit)` ).click(); + await page.getByLabel( 'Name' ).fill( name ); + await page.getByLabel( 'Slug' ).fill( slug ); + await page + .getByLabel( 'Parent Brand' ) + .selectOption( { label: parentBrand } ); + await page.getByLabel( 'Description' ).fill( description ); + + await page.getByRole( 'button', { name: 'Upload/Add image' } ).click(); + await page.getByRole( 'tab', { name: 'Media Library' } ).click(); + await page.getByLabel( thumbnailFileName ).click(); + await page.getByRole( 'button', { name: 'Use image' } ).click(); + + await page.getByRole( 'button', { name: 'Update' } ).click(); + + // We should see an "Item updated." notice message at the top of the page. + await expect( + page.locator( '#message' ).getByText( 'Item updated.' ) + ).toBeVisible(); + + // navigate back to Brands page. + await page.getByRole( 'link', { name: '← Go to Brands' } ).click(); + + // confirm that the brand has been updated. + await expect( + page + .locator( '#posts-filter' ) + .getByRole( 'cell', { name: slug, exact: true } ) + ).toHaveCount( 1 ); + }; + + /** + * Delete a brand. + * + * You must be in the Brands page before calling this function. + * To do so, call `goToBrandsPage()` first. + * + * After a brand is deleted, you will be redirected to the Brands page. + */ + const deleteBrand = async ( name ) => { + await page.getByLabel( `“${ name }” (Edit)` ).click(); + + // After clicking the "Delete" button, there will be a confirmation dialog. + page.once( 'dialog', ( dialog ) => { + // Click "OK" to confirm the deletion. + dialog.accept(); + } ); + + // Click on the "Delete" button. + await page.getByRole( 'link', { name: 'Delete' } ).click(); + + // We should now be in the Brands page. + // Confirm that the brand has been deleted and is no longer in the Brands table. + await expect( + page + .locator( '#posts-filter' ) + .getByRole( 'cell', { name, exact: true } ) + ).toHaveCount( 0 ); + }; + + await goToBrandsPage(); + await createBrandIfNotExist( + 'WooCommerce', + 'woocommerce', + 'None', + 'All things WooCommerce!', + 'image-01' + ); + + // Create child brand under the "WooCommerce" parent brand. + await createBrandIfNotExist( + 'WooCommerce Apparels', + 'woocommerce-apparels', + 'WooCommerce', + 'Cool WooCommerce clothings!', + 'image-02' + ); + + // Create a dummy child brand called "WooCommerce Dummy" under the "WooCommerce" parent brand. + await createBrandIfNotExist( + 'WooCommerce Dummy', + 'woocommerce-dummy', + 'WooCommerce', + 'Dummy WooCommerce brand!', + 'image-02' + ); + + // Edit the dummy child brand from "WooCommerce Dummy" to "WooCommerce Dummy Edited". + await editBrand( 'WooCommerce Dummy', { + name: 'WooCommerce Dummy Edited', + slug: 'woocommerce-dummy-edited', + parentBrand: 'WooCommerce', + description: 'Dummy WooCommerce brand edited!', + thumbnailFileName: 'image-03', + } ); + + // Delete the dummy child brand "WooCommerce Dummy Edited". + await deleteBrand( 'WooCommerce Dummy Edited' ); +} ); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js index 2f26691761b..3fd0b4c1717 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js @@ -37,6 +37,12 @@ const couponData = { amount: '60', excludeProductCategories: [ 'Uncategorized' ], }, + excludeProductBrands: { + code: `excludeProductBrands-${ new Date().getTime().toString() }`, + description: 'Exclude product brands coupon', + amount: '65', + excludeProductBrands: [ 'WooCommerce Apparels' ], + }, products: { code: `products-${ new Date().getTime().toString() }`, description: 'Products coupon', @@ -202,6 +208,26 @@ test.describe( 'Restricted coupon management', { tag: [ '@services' ] }, () => { .click(); } ); } + + // Skip Brands tests while behind a feature flag. + const skipBrandsTests = true; + + // set exclude product brands + if ( couponType === 'excludeProductBrands' && ! skipBrandsTests ) { + await test.step( 'set exclude product brands coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No brands' ) + .pressSequentially( 'WooCommerce Apparels' ); + await page + .getByRole( 'option', { name: 'WooCommerce Apparels' } ) + .click(); + } ); + } // set products if ( couponType === 'products' ) { await test.step( 'set products coupon', async () => { diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-create-simple.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-create-simple.spec.js index 77b8a6ebefa..46934a82a1d 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-create-simple.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-create-simple.spec.js @@ -112,7 +112,7 @@ for ( const productType of Object.keys( productData ) ) { .getByRole( 'link', { name: 'Attributes' } ) .click(); await page - .getByPlaceholder( 'f.e. size or color' ) + .getByPlaceholder( 'e.g. length or weight' ) .fill( attributeName ); await page .getByPlaceholder( 'Enter some descriptive text.' ) @@ -183,7 +183,7 @@ for ( const productType of Object.keys( productData ) ) { .getByPlaceholder( '0' ) .fill( productData[ productType ].shipping.weight ); await page - .getByPlaceholder( 'Length' ) + .getByPlaceholder( 'Length', { exact: true } ) .fill( productData[ productType ].shipping.length ); await page .getByPlaceholder( 'Width' ) diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js index 103d50fd8f6..507568812a0 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js @@ -89,8 +89,7 @@ const productCategories = [ ]; const productAttributes = [ 'Color', 'Size' ]; -const errorMessage = - 'Invalid file type. The importer supports CSV and TXT file formats.'; +const errorMessage = 'File is empty. Please upload something more substantial.'; test.describe.serial( 'Import Products from a CSV file', diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-product-attributes.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-product-attributes.spec.js index 77db0953f5c..9be4189e4b9 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-product-attributes.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-product-attributes.spec.js @@ -39,8 +39,9 @@ test.describe( 'Add product attributes', { tag: '@gutenberg' }, () => { } ); test( 'can add custom product attributes', async ( { page } ) => { - const textbox_attributeName = - page.getByPlaceholder( 'f.e. size or color' ); + const textbox_attributeName = page.getByPlaceholder( + 'e.g. length or weight' + ); const textbox_attributeValues = page.getByPlaceholder( 'Enter options for customers to choose from' ); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-variations.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-variations.spec.js index b1271496bf6..16f2965b3b4 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-variations.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/products/add-variable-product/create-variations.spec.js @@ -84,105 +84,117 @@ test.describe( 'Add variations', { tag: '@gutenberg' }, () => { } } ); - test( 'can manually add a variation', async ( { page } ) => { - await test.step( `Open "Edit product" page of product id ${ productId_addManually }`, async () => { - await page.goto( - `/wp-admin/post.php?post=${ productId_addManually }&action=edit` - ); - } ); - - // hook up the woocommerce_variations_added jQuery trigger so we can check if it's fired - await test.step( 'Hook up the woocommerce_variations_added jQuery trigger', async () => { - await page.evaluate( () => { - window.woocommerceVariationsAddedFunctionCalls = []; - - window - .jQuery( '#variable_product_options' ) - .on( 'woocommerce_variations_added', ( event, data ) => { - window.woocommerceVariationsAddedFunctionCalls.push( [ - event, - data, - ] ); - } ); + test( + 'can manually add a variation', + { tag: '@skip-on-default-wpcom' }, + async ( { page } ) => { + await test.step( `Open "Edit product" page of product id ${ productId_addManually }`, async () => { + await page.goto( + `/wp-admin/post.php?post=${ productId_addManually }&action=edit` + ); } ); - } ); - await test.step( 'Click on the "Variations" tab.', async () => { - await page.locator( '.variations_tab' ).click(); - } ); + // hook up the woocommerce_variations_added jQuery trigger so we can check if it's fired + await test.step( 'Hook up the woocommerce_variations_added jQuery trigger', async () => { + await page.evaluate( () => { + window.woocommerceVariationsAddedFunctionCalls = []; - await test.step( `Manually add ${ variationsToManuallyCreate.length } variations`, async () => { - const variationRows = page.locator( '.woocommerce_variation h3' ); - let variationRowsCount = await variationRows.count(); - const originalVariationRowsCount = variationRowsCount; - - for ( const variationToCreate of variationsToManuallyCreate ) { - await test.step( 'Click "Add manually"', async () => { - const addManuallyButton = page.getByRole( 'button', { - name: 'Add manually', - } ); - - await addManuallyButton.click(); - - await expect( variationRows ).toHaveCount( - ++variationRowsCount - ); - - // verify that the woocommerce_variations_added jQuery trigger was fired - const woocommerceVariationsAddedFunctionCalls = - await page.evaluate( - () => window.woocommerceVariationsAddedFunctionCalls + window + .jQuery( '#variable_product_options' ) + .on( + 'woocommerce_variations_added', + ( event, data ) => { + window.woocommerceVariationsAddedFunctionCalls.push( + [ event, data ] + ); + } ); - expect( - woocommerceVariationsAddedFunctionCalls.length - ).toEqual( - variationRowsCount - originalVariationRowsCount - ); } ); + } ); - for ( const attributeValue of variationToCreate ) { - const attributeName = productAttributes.find( - ( { options } ) => options.includes( attributeValue ) - ).name; - const addAttributeMenu = variationRows - .nth( 0 ) - .locator( 'select', { - has: page.locator( 'option', { - hasText: attributeValue, - } ), + await test.step( 'Click on the "Variations" tab.', async () => { + await page.locator( '.variations_tab' ).click(); + } ); + + await test.step( `Manually add ${ variationsToManuallyCreate.length } variations`, async () => { + const variationRows = page.locator( + '.woocommerce_variation h3' + ); + let variationRowsCount = await variationRows.count(); + const originalVariationRowsCount = variationRowsCount; + + for ( const variationToCreate of variationsToManuallyCreate ) { + await test.step( 'Click "Add manually"', async () => { + const addManuallyButton = page.getByRole( 'button', { + name: 'Add manually', } ); - await test.step( `Select "${ attributeValue }" from the "${ attributeName }" attribute menu`, async () => { - await addAttributeMenu.selectOption( attributeValue ); + await addManuallyButton.click(); + + await expect( variationRows ).toHaveCount( + ++variationRowsCount + ); + + // verify that the woocommerce_variations_added jQuery trigger was fired + const woocommerceVariationsAddedFunctionCalls = + await page.evaluate( + () => + window.woocommerceVariationsAddedFunctionCalls + ); + expect( + woocommerceVariationsAddedFunctionCalls.length + ).toEqual( + variationRowsCount - originalVariationRowsCount + ); } ); - } - - await test.step( 'Click "Save changes"', async () => { - await page - .getByRole( 'button', { - name: 'Save changes', - } ) - .click(); - } ); - - await test.step( `Expect the variation ${ variationToCreate.join( - ', ' - ) } to be successfully saved.`, async () => { - let newlyAddedVariationRow; for ( const attributeValue of variationToCreate ) { - newlyAddedVariationRow = ( - newlyAddedVariationRow || variationRows - ).filter( { - has: page.locator( 'option[selected]', { - hasText: attributeValue, - } ), + const attributeName = productAttributes.find( + ( { options } ) => + options.includes( attributeValue ) + ).name; + const addAttributeMenu = variationRows + .nth( 0 ) + .locator( 'select', { + has: page.locator( 'option', { + hasText: attributeValue, + } ), + } ); + + await test.step( `Select "${ attributeValue }" from the "${ attributeName }" attribute menu`, async () => { + await addAttributeMenu.selectOption( + attributeValue + ); } ); } - await expect( newlyAddedVariationRow ).toBeVisible(); - } ); - } - } ); - } ); + await test.step( 'Click "Save changes"', async () => { + await page + .getByRole( 'button', { + name: 'Save changes', + } ) + .click(); + } ); + + await test.step( `Expect the variation ${ variationToCreate.join( + ', ' + ) } to be successfully saved.`, async () => { + let newlyAddedVariationRow; + + for ( const attributeValue of variationToCreate ) { + newlyAddedVariationRow = ( + newlyAddedVariationRow || variationRows + ).filter( { + has: page.locator( 'option[selected]', { + hasText: attributeValue, + } ), + } ); + } + + await expect( newlyAddedVariationRow ).toBeVisible(); + } ); + } + } ); + } + ); } ); diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php new file mode 100644 index 00000000000..5ca5953daf5 --- /dev/null +++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-brands-test.php @@ -0,0 +1,116 @@ +factory()->term->create( + array( + 'taxonomy' => 'product_brand', + 'name' => 'Blah_A', + ) + ); + $term_b_id = $this->factory()->term->create( + array( + 'taxonomy' => 'product_brand', + 'name' => 'Foo_A', + ) + ); + $term_c_id = $this->factory()->term->create( + array( + 'taxonomy' => 'product_brand', + 'name' => 'Blah_B', + ) + ); + + wp_set_post_terms( $simple_product->get_id(), array( $term_a_id, $term_b_id, $term_c_id ), 'product_brand' ); + + add_filter( + 'woocommerce_product_brand_filter_threshold', + function () { + return 3; + } + ); + + $brands_admin = new WC_Brands_Admin(); + ob_start(); + $brands_admin->render_product_brand_filter(); + $output = ob_get_contents(); + ob_end_clean(); + + $this->assertStringContainsString( + ' 'woocommerce_core', 'severity' => 'error', - 'message' => 'Fatal error occurred at line 123 in **/wp-content/file.php', + 'message' => 'Fatal error occurred at line 123 in ./wp-content/file.php', 'tags' => array( 'woocommerce', 'php', 'tag1', 'tag2' ), ), ), @@ -236,7 +236,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { 'error', 'Test error message', array( 'backtrace' => ABSPATH . 'wp-content/plugins/woocommerce/file.php' ), - array( 'trace' => '**/woocommerce/file.php' ), + array( 'trace' => './woocommerce/file.php' ), ), 'log with extra attributes' => array( 'error', @@ -254,6 +254,14 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { ), ), ), + 'log with error file' => array( + 'error', + 'Test error message', + array( 'error' => array( 'file' => WC_ABSPATH . 'includes/class-wc-test.php' ) ), + array( + 'file' => './woocommerce/includes/class-wc-test.php', + ), + ), ); } @@ -348,7 +356,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { $setup( $this ); - $result = $this->invoke_private_method( $this->sut, 'should_handle', array( $level, 'Test message', array() ) ); + $result = $this->invoke_private_method( $this->sut, 'should_handle', array( $level, 'Test message', array( 'remote-logging' => true ) ) ); $this->assertEquals( $expected, $result ); } @@ -377,6 +385,14 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { ); } + /** + * @testdox Test should_handle returns false without remote-logging context + */ + public function test_should_handle_no_remote_logging_context() { + $result = $this->invoke_private_method( $this->sut, 'should_handle', array( 'error', 'Test message', array() ) ); + $this->assertFalse( $result, 'should_handle should return false without remote-logging context' ); + } + /** * @testdox handle method applies filter and doesn't send logs when filtered to null */ @@ -390,7 +406,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { add_filter( 'woocommerce_remote_logger_formatted_log_data', fn() => null, 10, 4 ); add_filter( 'pre_http_request', fn() => $this->fail( 'wp_safe_remote_post should not be called' ), 10, 3 ); - $this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) ); + $this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array( 'remote-logging' => true ) ) ); } /** @@ -404,7 +420,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { $this->sut->set_is_dev_or_local( true ); $this->sut->method( 'is_remote_logging_allowed' )->willReturn( true ); - $this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array() ) ); + $this->assertFalse( $this->sut->handle( time(), 'error', 'Test message', array( 'remote-logging' => true ) ) ); } /** @@ -435,7 +451,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { 3 ); - $this->assertTrue( $this->sut->handle( time(), 'critical', 'Test message', array() ) ); + $this->assertTrue( $this->sut->handle( time(), 'critical', 'Test message', array( 'remote-logging' => true ) ) ); $this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) ); } @@ -462,7 +478,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { 3 ); - $this->assertFalse( $this->sut->handle( time(), 'critical', 'Test message', array() ) ); + $this->assertFalse( $this->sut->handle( time(), 'critical', 'Test message', array( 'remote-logging' => true ) ) ); $this->assertTrue( WC_Rate_Limiter::retried_too_soon( RemoteLogger::RATE_LIMIT_ID ) ); } @@ -528,7 +544,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { */ public function test_sanitize() { $message = WC_ABSPATH . 'includes/class-wc-test.php on line 123'; - $expected = '**/woocommerce/includes/class-wc-test.php on line 123'; + $expected = './woocommerce/includes/class-wc-test.php on line 123'; $result = $this->invoke_private_method( $this->sut, 'sanitize', array( $message ) ); $this->assertEquals( $expected, $result ); } @@ -541,7 +557,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { WC_ABSPATH . 'includes/class-wc-test.php:123', ABSPATH . 'wp-includes/plugin.php:456', ); - $expected = "**/woocommerce/includes/class-wc-test.php:123\n**/wp-includes/plugin.php:456"; + $expected = "./woocommerce/includes/class-wc-test.php:123\n./wp-includes/plugin.php:456"; $result = $this->invoke_private_method( $this->sut, 'sanitize_trace', array( $trace ) ); $this->assertEquals( $expected, $result ); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a972c8f247..a2f8a2a6aab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -256,7 +256,7 @@ importers: version: 10.5.0(sass@1.69.5)(webpack@5.89.0(webpack-cli@3.3.12)) ts-jest: specifier: ~29.1.1 - version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) + version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) typescript: specifier: ^5.3.3 version: 5.3.3 @@ -292,7 +292,7 @@ importers: version: 2.3.2 debug: specifier: ^4.3.4 - version: 4.3.4(supports-color@9.4.0) + version: 4.3.4(supports-color@8.1.1) dompurify: specifier: ^2.4.7 version: 2.4.7 @@ -453,7 +453,7 @@ importers: version: 27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)) ts-jest: specifier: ~29.1.1 - version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) + version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) typescript: specifier: ^5.3.3 version: 5.3.3 @@ -2866,7 +2866,7 @@ importers: dependencies: debug: specifier: ^4.3.4 - version: 4.3.4(supports-color@9.4.0) + version: 4.3.4(supports-color@8.1.1) devDependencies: '@babel/core': specifier: ^7.23.5 @@ -2975,7 +2975,7 @@ importers: version: 1.2.5(@types/react@17.0.71)(react-dom@17.0.2(react@17.0.2))(react-with-direction@1.4.0(react-dom@17.0.2(react@17.0.2))(react@17.0.2))(react@17.0.2) debug: specifier: ^4.3.4 - version: 4.3.4(supports-color@9.4.0) + version: 4.3.4(supports-color@8.1.1) prop-types: specifier: ^15.8.1 version: 15.8.1 @@ -2991,10 +2991,10 @@ importers: devDependencies: '@babel/preset-react': specifier: 7.23.3 - version: 7.23.3(@babel/core@7.25.2) + version: 7.23.3(@babel/core@7.24.7) '@babel/preset-typescript': specifier: 7.23.2 - version: 7.23.2(@babel/core@7.25.2) + version: 7.23.2(@babel/core@7.24.7) '@svgr/webpack': specifier: ^8.1.0 version: 8.1.0(typescript@5.3.3) @@ -3045,10 +3045,10 @@ importers: version: 2.17.0(wp-prettier@2.8.5) '@wordpress/scripts': specifier: ^19.2.4 - version: 19.2.4(@babel/core@7.25.2)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4) + version: 19.2.4(@babel/core@7.24.7)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4) babel-jest: specifier: ~27.5.1 - version: 27.5.1(@babel/core@7.25.2) + version: 27.5.1(@babel/core@7.24.7) eslint: specifier: ^8.55.0 version: 8.55.0 @@ -3352,7 +3352,7 @@ importers: version: 3.34.0 debug: specifier: ^4.3.4 - version: 4.3.4(supports-color@9.4.0) + version: 4.3.4(supports-color@8.1.1) dompurify: specifier: ^2.4.7 version: 2.4.7 @@ -3906,7 +3906,7 @@ importers: version: 2.17.0(wp-prettier@2.8.5) '@wordpress/scripts': specifier: ^19.2.4 - version: 19.2.4(@babel/core@7.25.2)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4) + version: 19.2.4(@babel/core@7.25.2)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4) eslint: specifier: ^8.55.0 version: 8.55.0 @@ -3929,7 +3929,7 @@ importers: plugins/woocommerce-blocks: dependencies: '@ariakit/react': - specifier: ^0.4.4 + specifier: ^0.4.5 version: 0.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@dnd-kit/core': specifier: ^6.1.0 @@ -4025,8 +4025,8 @@ importers: specifier: 3.0.4 version: 3.0.4(react@18.3.1) postcode-validator: - specifier: 3.8.15 - version: 3.8.15 + specifier: 3.9.2 + version: 3.9.2 preact: specifier: ^10.19.3 version: 10.19.3 @@ -4642,7 +4642,7 @@ importers: version: 27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)) ts-jest: specifier: ~29.1.1 - version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) + version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) ts-node: specifier: ^10.9.2 version: 10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3) @@ -4839,7 +4839,7 @@ importers: version: 1.2.2 ts-jest: specifier: ~29.1.1 - version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) + version: 29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3) ts-loader: specifier: ^9.5.1 version: 9.5.1(typescript@5.3.3)(webpack@5.89.0(webpack-cli@3.3.12)) @@ -20623,8 +20623,8 @@ packages: resolution: {integrity: sha512-xTgYBc3fuo7Yt7JbiuFxSYGToMoz8fLoE6TC9Wx1P/u+LfeThMOAqmuyECnlBaaJb+u1m9hHiXUEtwW4OzfUJg==} engines: {node: '>=0.10.0'} - postcode-validator@3.8.15: - resolution: {integrity: sha512-B2oeZ4E9D7JBHk0GEo0lv9aqHGd6vv+VsWoTG6Jt0tMtc5RUzSXOSQixBZgAAn4A/Dbajp8GbwzMtUkkyYw2Ig==} + postcode-validator@3.9.2: + resolution: {integrity: sha512-C+oaXif+z+mAN1EWDZG/EM2dnrUxRQR0gpMq8VeLSZwHuT3Bqo4hR3+k7OaEevPl0VK/7W27JwEQb24CbdtzuQ==} postcss-calc@7.0.5: resolution: {integrity: sha512-1tKHutbGtLtEZF6PT4JSihCHfIVldU72mZ8SdZHIYriIZ9fh9k9aWSppaT8rHsyI3dX+KSR+W+Ix9BMY3AODrg==} @@ -21629,7 +21629,7 @@ packages: engines: {node: '>=18'} hasBin: true peerDependencies: - react: ^17.0.2 + react: 18.2.0 react-number-format@4.9.3: resolution: {integrity: sha512-am1A1xYAbENuKJ+zpM7V+B1oRTSeOHYltqVKExznIVFweBzhLmOBmyb1DfIKjHo90E0bo1p3nzVJ2NgS5xh+sQ==} @@ -21819,7 +21819,7 @@ packages: react-with-direction@1.4.0: resolution: {integrity: sha512-ybHNPiAmaJpoWwugwqry9Hd1Irl2hnNXlo/2SXQBwbLn/jGMauMS2y9jw+ydyX5V9ICryCqObNSthNt5R94xpg==} peerDependencies: - react: ^17.0.2 + react: ^0.14 || ^15 || ^16 react-dom: ^0.14 || ^15 || ^16 react-with-styles-interface-css@4.0.3: @@ -25603,7 +25603,7 @@ snapshots: '@wordpress/primitives': 3.55.0 '@wordpress/react-i18n': 3.55.0 classnames: 2.3.2 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) react: 17.0.2 react-dom: 17.0.2(react@17.0.2) react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -25628,7 +25628,7 @@ snapshots: '@wordpress/primitives': 3.55.0 '@wordpress/react-i18n': 3.55.0 classnames: 2.3.2 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) react: 17.0.2 react-dom: 17.0.2(react@17.0.2) react-popper: 2.3.0(@popperjs/core@2.11.8)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -25732,7 +25732,7 @@ snapshots: '@babel/traverse': 7.23.5 '@babel/types': 7.23.5 convert-source-map: 1.9.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 lodash: 4.17.21 @@ -25755,7 +25755,7 @@ snapshots: '@babel/traverse': 7.23.5 '@babel/types': 7.23.5 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -25775,7 +25775,7 @@ snapshots: '@babel/traverse': 7.23.5 '@babel/types': 7.23.5 convert-source-map: 2.0.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -25838,6 +25838,14 @@ snapshots: eslint-visitor-keys: 2.1.0 semver: 6.3.1 + '@babel/eslint-parser@7.23.3(@babel/core@7.24.7)(eslint@7.32.0)': + dependencies: + '@babel/core': 7.24.7 + '@nicolo-ribaudo/eslint-scope-5-internals': 5.1.1-v1 + eslint: 7.32.0 + eslint-visitor-keys: 2.1.0 + semver: 6.3.1 + '@babel/eslint-parser@7.23.3(@babel/core@7.25.2)(eslint@7.32.0)': dependencies: '@babel/core': 7.25.2 @@ -26002,6 +26010,19 @@ snapshots: '@babel/helper-split-export-declaration': 7.24.7 semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.23.6(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-environment-visitor': 7.24.7 + '@babel/helper-function-name': 7.24.7 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + '@babel/helper-replace-supers': 7.22.20(@babel/core@7.24.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/helper-split-export-declaration': 7.24.7 + semver: 6.3.1 + '@babel/helper-create-class-features-plugin@7.23.6(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -26993,7 +27014,6 @@ snapshots: dependencies: '@babel/core': 7.24.7 '@babel/helper-plugin-utils': 7.24.8 - optional: true '@babel/plugin-syntax-bigint@7.8.3(@babel/core@7.25.2)': dependencies: @@ -27230,6 +27250,11 @@ snapshots: '@babel/core': 7.23.2 '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-jsx@7.22.5(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-syntax-jsx@7.22.5(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -28841,6 +28866,11 @@ snapshots: '@babel/core': 7.23.5 '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-transform-react-display-name@7.24.7(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-transform-react-display-name@7.24.7(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -28860,6 +28890,13 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx-development@7.22.5(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/plugin-transform-react-jsx': 7.25.2(@babel/core@7.24.7) + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx-development@7.22.5(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -28951,6 +28988,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-module-imports': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-syntax-jsx': 7.24.7(@babel/core@7.24.7) + '@babel/types': 7.25.2 + transitivePeerDependencies: + - supports-color + '@babel/plugin-transform-react-jsx@7.25.2(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -28974,6 +29022,12 @@ snapshots: '@babel/helper-annotate-as-pure': 7.24.7 '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-transform-react-pure-annotations@7.23.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-annotate-as-pure': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/plugin-transform-react-pure-annotations@7.23.3(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -29286,13 +29340,13 @@ snapshots: '@babel/helper-plugin-utils': 7.24.8 '@babel/plugin-syntax-typescript': 7.23.3(@babel/core@7.23.2) - '@babel/plugin-transform-typescript@7.23.6(@babel/core@7.25.2)': + '@babel/plugin-transform-typescript@7.23.6(@babel/core@7.24.7)': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.24.7 '@babel/helper-annotate-as-pure': 7.24.7 - '@babel/helper-create-class-features-plugin': 7.23.6(@babel/core@7.25.2) + '@babel/helper-create-class-features-plugin': 7.23.6(@babel/core@7.24.7) '@babel/helper-plugin-utils': 7.24.8 - '@babel/plugin-syntax-typescript': 7.23.3(@babel/core@7.25.2) + '@babel/plugin-syntax-typescript': 7.23.3(@babel/core@7.24.7) '@babel/plugin-transform-typescript@7.25.2(@babel/core@7.12.9)': dependencies: @@ -30033,8 +30087,8 @@ snapshots: '@babel/preset-flow@7.23.3(@babel/core@7.23.2)': dependencies: '@babel/core': 7.23.2 - '@babel/helper-plugin-utils': 7.24.8 - '@babel/helper-validator-option': 7.24.8 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-validator-option': 7.23.5 '@babel/plugin-transform-flow-strip-types': 7.23.3(@babel/core@7.23.2) '@babel/preset-flow@7.23.3(@babel/core@7.23.5)': @@ -30119,6 +30173,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/preset-react@7.23.3(@babel/core@7.24.7)': + dependencies: + '@babel/core': 7.24.7 + '@babel/helper-plugin-utils': 7.24.8 + '@babel/helper-validator-option': 7.24.8 + '@babel/plugin-transform-react-display-name': 7.24.7(@babel/core@7.24.7) + '@babel/plugin-transform-react-jsx': 7.25.2(@babel/core@7.24.7) + '@babel/plugin-transform-react-jsx-development': 7.22.5(@babel/core@7.24.7) + '@babel/plugin-transform-react-pure-annotations': 7.23.3(@babel/core@7.24.7) + transitivePeerDependencies: + - supports-color + '@babel/preset-react@7.23.3(@babel/core@7.25.2)': dependencies: '@babel/core': 7.25.2 @@ -30142,14 +30208,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/preset-typescript@7.23.2(@babel/core@7.25.2)': + '@babel/preset-typescript@7.23.2(@babel/core@7.24.7)': dependencies: - '@babel/core': 7.25.2 + '@babel/core': 7.24.7 '@babel/helper-plugin-utils': 7.22.5 '@babel/helper-validator-option': 7.23.5 - '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.25.2) - '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.25.2) - '@babel/plugin-transform-typescript': 7.23.6(@babel/core@7.25.2) + '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.24.7) + '@babel/plugin-transform-modules-commonjs': 7.23.3(@babel/core@7.24.7) + '@babel/plugin-transform-typescript': 7.23.6(@babel/core@7.24.7) transitivePeerDependencies: - supports-color @@ -32107,7 +32173,7 @@ snapshots: '@oclif/color': 1.0.13 '@oclif/core': 2.15.0(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3) chalk: 4.1.2 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) fs-extra: 9.1.0 http-call: 5.3.0 load-json-file: 5.3.0 @@ -32596,7 +32662,7 @@ snapshots: '@puppeteer/browsers@1.4.6(typescript@5.3.2)': dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.3.0 @@ -32610,7 +32676,7 @@ snapshots: '@puppeteer/browsers@1.4.6(typescript@5.3.3)': dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.3.0 @@ -32624,7 +32690,7 @@ snapshots: '@puppeteer/browsers@1.9.0': dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.3.1 @@ -38349,7 +38415,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.56.0 '@typescript-eslint/type-utils': 5.56.0(eslint@8.55.0)(typescript@5.3.2) '@typescript-eslint/utils': 5.56.0(eslint@8.55.0)(typescript@5.3.2) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.55.0 grapheme-splitter: 1.0.4 ignore: 5.3.0 @@ -38368,7 +38434,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.55.0)(typescript@5.3.2) '@typescript-eslint/utils': 5.62.0(eslint@8.55.0)(typescript@5.3.2) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.55.0 graphemer: 1.4.0 ignore: 5.3.0 @@ -38387,7 +38453,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@8.55.0)(typescript@5.3.3) '@typescript-eslint/utils': 5.62.0(eslint@8.55.0)(typescript@5.3.3) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.55.0 graphemer: 1.4.0 ignore: 5.3.0 @@ -38467,7 +38533,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.56.0 '@typescript-eslint/types': 5.56.0 '@typescript-eslint/typescript-estree': 5.56.0(typescript@5.3.2) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) eslint: 8.55.0 optionalDependencies: typescript: 5.3.2 @@ -39018,7 +39084,7 @@ snapshots: webpack: 5.91.0(@swc/core@1.3.100)(esbuild@0.18.20)(webpack-cli@4.10.0) webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack-dev-server@4.15.1)(webpack@5.91.0) - '@webpack-cli/configtest@1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0))': + '@webpack-cli/configtest@1.2.0(webpack-cli@4.10.0(webpack@5.89.0))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0))': dependencies: webpack: 5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0) webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0) @@ -39038,7 +39104,7 @@ snapshots: envinfo: 7.13.0 webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack-dev-server@4.15.1)(webpack@5.91.0) - '@webpack-cli/info@1.5.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0))': + '@webpack-cli/info@1.5.0(webpack-cli@4.10.0(webpack@5.89.0))': dependencies: envinfo: 7.13.0 webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0) @@ -42113,6 +42179,33 @@ snapshots: - supports-color - typescript + '@wordpress/eslint-plugin@9.3.0(@babel/core@7.24.7)(eslint@7.32.0)(typescript@5.3.3)': + dependencies: + '@babel/eslint-parser': 7.23.3(@babel/core@7.24.7)(eslint@7.32.0) + '@typescript-eslint/eslint-plugin': 4.33.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.3.3))(eslint@7.32.0)(typescript@5.3.3) + '@typescript-eslint/parser': 4.33.0(eslint@7.32.0)(typescript@5.3.3) + '@wordpress/prettier-config': 1.4.0(wp-prettier@2.2.1-beta-1) + cosmiconfig: 7.1.0 + eslint: 7.32.0 + eslint-config-prettier: 7.2.0(eslint@7.32.0) + eslint-plugin-import: 2.29.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.3.3))(eslint@7.32.0) + eslint-plugin-jest: 24.7.0(@typescript-eslint/eslint-plugin@4.33.0(@typescript-eslint/parser@4.33.0(eslint@7.32.0)(typescript@5.3.3))(eslint@7.32.0)(typescript@5.3.3))(eslint@7.32.0)(typescript@5.3.3) + eslint-plugin-jsdoc: 36.1.1(eslint@7.32.0) + eslint-plugin-jsx-a11y: 6.8.0(eslint@7.32.0) + eslint-plugin-prettier: 3.4.1(eslint-config-prettier@7.2.0(eslint@7.32.0))(eslint@7.32.0)(wp-prettier@2.2.1-beta-1) + eslint-plugin-react: 7.33.2(eslint@7.32.0) + eslint-plugin-react-hooks: 4.6.0(eslint@7.32.0) + globals: 12.4.0 + prettier: wp-prettier@2.2.1-beta-1 + requireindex: 1.2.0 + optionalDependencies: + typescript: 5.3.3 + transitivePeerDependencies: + - '@babel/core' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + '@wordpress/eslint-plugin@9.3.0(@babel/core@7.25.2)(eslint@7.32.0)(typescript@5.3.3)': dependencies: '@babel/eslint-parser': 7.23.3(@babel/core@7.25.2)(eslint@7.32.0) @@ -42467,6 +42560,20 @@ snapshots: - react-dom - supports-color + '@wordpress/jest-preset-default@7.1.3(@babel/core@7.24.7)(jest@26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': + dependencies: + '@wojtekmaj/enzyme-adapter-react-17': 0.6.7(enzyme@3.11.0)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@wordpress/jest-console': 4.1.1(jest@26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))) + babel-jest: 26.6.3(@babel/core@7.24.7) + enzyme: 3.11.0 + enzyme-to-json: 3.6.2(enzyme@3.11.0) + jest: 26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)) + transitivePeerDependencies: + - '@babel/core' + - react + - react-dom + - supports-color + '@wordpress/jest-preset-default@7.1.3(@babel/core@7.25.2)(jest@26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)': dependencies: '@wojtekmaj/enzyme-adapter-react-17': 0.6.7(enzyme@3.11.0)(react-dom@17.0.2(react@17.0.2))(react@17.0.2) @@ -43216,7 +43323,86 @@ snapshots: - utf-8-validate - webpack-command - '@wordpress/scripts@19.2.4(@babel/core@7.25.2)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4)': + '@wordpress/scripts@19.2.4(@babel/core@7.24.7)(debug@4.3.4)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4)': + dependencies: + '@svgr/webpack': 5.5.0 + '@wordpress/babel-preset-default': 6.17.0 + '@wordpress/browserslist-config': 4.1.3 + '@wordpress/dependency-extraction-webpack-plugin': 3.7.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + '@wordpress/eslint-plugin': 9.3.0(@babel/core@7.24.7)(eslint@7.32.0)(typescript@5.3.3) + '@wordpress/jest-preset-default': 7.1.3(@babel/core@7.24.7)(jest@26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2) + '@wordpress/npm-package-json-lint-config': 4.32.0(npm-package-json-lint@5.4.2) + '@wordpress/postcss-plugins-preset': 3.6.1(postcss@8.4.32) + '@wordpress/prettier-config': 1.4.0(wp-prettier@2.2.1-beta-1) + '@wordpress/stylelint-config': 19.1.0(stylelint@13.13.1) + babel-jest: 26.6.3(@babel/core@7.24.7) + babel-loader: 8.3.0(@babel/core@7.24.7)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + browserslist: 4.19.3 + chalk: 4.1.2 + check-node-version: 4.2.1 + clean-webpack-plugin: 3.0.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + cross-spawn: 5.1.0 + css-loader: 6.8.1(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + cssnano: 5.1.12(postcss@8.4.32) + cwd: 0.10.0 + dir-glob: 3.0.1 + eslint: 7.32.0 + eslint-plugin-markdown: 2.2.1(eslint@7.32.0) + expect-puppeteer: 4.4.0 + filenamify: 4.3.0 + jest: 26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)) + jest-circus: 26.6.3(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)) + jest-dev-server: 5.0.3(debug@4.3.4) + jest-environment-node: 26.6.2 + markdownlint: 0.23.1 + markdownlint-cli: 0.27.1 + merge-deep: 3.0.3 + mini-css-extract-plugin: 2.7.6(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + minimist: 1.2.8 + npm-package-json-lint: 5.4.2 + postcss: 8.4.32 + postcss-loader: 6.2.1(postcss@8.4.32)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + prettier: wp-prettier@2.2.1-beta-1 + puppeteer-core: 10.4.0 + read-pkg-up: 1.0.1 + resolve-bin: 0.4.3 + sass: 1.69.5 + sass-loader: 12.6.0(sass@1.69.5)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + source-map-loader: 3.0.2(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + stylelint: 13.13.1 + terser-webpack-plugin: 5.3.6(uglify-js@3.17.4)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + url-loader: 4.1.1(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + webpack: 5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0) + webpack-bundle-analyzer: 4.7.0 + webpack-cli: 4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0) + webpack-livereload-plugin: 3.0.2(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + transitivePeerDependencies: + - '@babel/core' + - '@swc/core' + - '@webpack-cli/generators' + - '@webpack-cli/migrate' + - bufferutil + - canvas + - debug + - esbuild + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - fibers + - file-loader + - node-sass + - postcss-jsx + - postcss-markdown + - react + - react-dom + - sass-embedded + - supports-color + - ts-node + - typescript + - uglify-js + - utf-8-validate + - webpack-dev-server + + '@wordpress/scripts@19.2.4(@babel/core@7.25.2)(file-loader@6.2.0(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)))(react-dom@17.0.2(react@17.0.2))(react@17.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3))(typescript@5.3.3)(uglify-js@3.17.4)': dependencies: '@svgr/webpack': 5.5.0 '@wordpress/babel-preset-default': 6.17.0 @@ -44568,6 +44754,20 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@26.6.3(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + '@jest/transform': 26.6.2 + '@jest/types': 26.6.2 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 26.6.2(@babel/core@7.24.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-jest@26.6.3(@babel/core@7.25.2): dependencies: '@babel/core': 7.25.2 @@ -44596,6 +44796,20 @@ snapshots: transitivePeerDependencies: - supports-color + babel-jest@27.5.1(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + '@jest/transform': 27.5.1 + '@jest/types': 27.5.1 + '@types/babel__core': 7.20.5 + babel-plugin-istanbul: 6.1.1 + babel-preset-jest: 27.5.1(@babel/core@7.24.7) + chalk: 4.1.2 + graceful-fs: 4.2.11 + slash: 3.0.0 + transitivePeerDependencies: + - supports-color + babel-jest@27.5.1(@babel/core@7.25.2): dependencies: '@babel/core': 7.25.2 @@ -44700,6 +44914,15 @@ snapshots: schema-utils: 2.7.1 webpack: 5.91.0(@swc/core@1.3.100)(esbuild@0.18.20)(webpack-cli@4.10.0) + babel-loader@8.3.0(@babel/core@7.24.7)(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)): + dependencies: + '@babel/core': 7.24.7 + find-cache-dir: 3.3.2 + loader-utils: 2.0.4 + make-dir: 3.1.0 + schema-utils: 2.7.1 + webpack: 5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0) + babel-loader@8.3.0(@babel/core@7.25.2)(webpack@4.47.0(webpack-cli@3.3.12(webpack@5.89.0))): dependencies: '@babel/core': 7.25.2 @@ -45143,7 +45366,6 @@ snapshots: '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.24.7) '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.24.7) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.24.7) - optional: true babel-preset-current-node-syntax@1.0.1(@babel/core@7.25.2): dependencies: @@ -45191,6 +45413,12 @@ snapshots: babel-plugin-jest-hoist: 26.6.2 babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.5) + babel-preset-jest@26.6.2(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + babel-plugin-jest-hoist: 26.6.2 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.7) + babel-preset-jest@26.6.2(@babel/core@7.25.2): dependencies: '@babel/core': 7.25.2 @@ -45203,6 +45431,12 @@ snapshots: babel-plugin-jest-hoist: 27.5.1 babel-preset-current-node-syntax: 1.0.1(@babel/core@7.23.5) + babel-preset-jest@27.5.1(@babel/core@7.24.7): + dependencies: + '@babel/core': 7.24.7 + babel-plugin-jest-hoist: 27.5.1 + babel-preset-current-node-syntax: 1.0.1(@babel/core@7.24.7) + babel-preset-jest@27.5.1(@babel/core@7.25.2): dependencies: '@babel/core': 7.25.2 @@ -48173,7 +48407,7 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.56.0(eslint@8.55.0)(typescript@5.3.2))(eslint-import-resolver-webpack@0.13.2)(eslint-plugin-import@2.28.1)(eslint@8.55.0): dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) enhanced-resolve: 5.15.0 eslint: 8.55.0 eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.56.0(eslint@8.55.0)(typescript@5.3.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.56.0(eslint@8.55.0)(typescript@5.3.2))(eslint-import-resolver-webpack@0.13.2)(eslint-plugin-import@2.28.1)(eslint@8.55.0))(eslint-import-resolver-webpack@0.13.2(eslint-plugin-import@2.28.1)(webpack@5.91.0(@swc/core@1.3.100)(esbuild@0.18.20)(webpack-cli@5.1.4)))(eslint@8.55.0) @@ -48190,7 +48424,7 @@ snapshots: eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.55.0)(typescript@5.3.3))(eslint-import-resolver-webpack@0.13.8)(eslint-plugin-import@2.29.0)(eslint@8.55.0): dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) enhanced-resolve: 5.15.0 eslint: 8.55.0 eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.62.0(eslint@8.55.0)(typescript@5.3.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@5.62.0(eslint@8.55.0)(typescript@5.3.3))(eslint-import-resolver-webpack@0.13.8)(eslint-plugin-import@2.29.0)(eslint@8.55.0))(eslint-import-resolver-webpack@0.13.8(eslint-plugin-import@2.29.0)(webpack@5.89.0(webpack-cli@4.10.0)))(eslint@8.55.0) @@ -49598,7 +49832,7 @@ snapshots: follow-redirects@1.15.6(debug@4.3.4): optionalDependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) follow-redirects@1.5.10: dependencies: @@ -52791,7 +53025,9 @@ snapshots: pretty-format: 24.9.0 throat: 4.1.0 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate jest-jasmine2@25.5.4: dependencies: @@ -54463,7 +54699,7 @@ snapshots: chalk: 5.2.0 cli-truncate: 3.1.0 commander: 10.0.1 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) execa: 7.2.0 lilconfig: 2.1.0 listr2: 5.0.8(enquirer@2.4.1) @@ -55760,7 +55996,7 @@ snapshots: dependencies: carlo: 0.9.46 chokidar: 3.5.3 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) isbinaryfile: 3.0.3 mime: 2.6.0 opn: 5.5.0 @@ -56288,7 +56524,7 @@ snapshots: '@oclif/plugin-warn-if-update-available': 2.1.1(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3) aws-sdk: 2.1515.0 concurrently: 7.6.0 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) find-yarn-workspace-root: 2.0.0 fs-extra: 8.1.0 github-slugger: 1.5.0 @@ -56933,7 +57169,7 @@ snapshots: posix-character-classes@0.1.1: {} - postcode-validator@3.8.15: {} + postcode-validator@3.9.2: {} postcss-calc@7.0.5: dependencies: @@ -57798,7 +58034,7 @@ snapshots: puppeteer-core@13.7.0(encoding@0.1.13): dependencies: cross-fetch: 3.1.5(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.981744 extract-zip: 2.0.1 https-proxy-agent: 5.0.1 @@ -57837,7 +58073,7 @@ snapshots: '@puppeteer/browsers': 1.4.6(typescript@5.3.2) chromium-bidi: 0.4.16(devtools-protocol@0.0.1147663) cross-fetch: 4.0.0(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.1147663 ws: 8.13.0 optionalDependencies: @@ -57853,7 +58089,7 @@ snapshots: '@puppeteer/browsers': 1.4.6(typescript@5.3.3) chromium-bidi: 0.4.16(devtools-protocol@0.0.1147663) cross-fetch: 4.0.0(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.1147663 ws: 8.13.0 optionalDependencies: @@ -57869,7 +58105,7 @@ snapshots: '@puppeteer/browsers': 1.9.0 chromium-bidi: 0.5.1(devtools-protocol@0.0.1203626) cross-fetch: 4.0.0(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.1203626 ws: 8.14.2 transitivePeerDependencies: @@ -57905,7 +58141,7 @@ snapshots: puppeteer@17.1.3(encoding@0.1.13): dependencies: cross-fetch: 3.1.5(encoding@0.1.13) - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) devtools-protocol: 0.0.1036444 extract-zip: 2.0.1 https-proxy-agent: 5.0.1 @@ -58233,7 +58469,7 @@ snapshots: react-docgen-typescript-plugin@1.0.5(typescript@5.3.2)(webpack@5.91.0(@swc/core@1.3.100)(esbuild@0.18.20)(webpack-cli@5.1.4)): dependencies: - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) endent: 2.1.0 find-cache-dir: 3.3.2 flat-cache: 3.2.0 @@ -60110,7 +60346,7 @@ snapshots: dependencies: '@kwsites/file-exists': 1.1.1 '@kwsites/promise-deferred': 1.1.1 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -60859,7 +61095,7 @@ snapshots: colord: 2.9.3 cosmiconfig: 7.1.0 css-functions-list: 3.2.1 - debug: 4.3.4(supports-color@9.4.0) + debug: 4.3.4(supports-color@8.1.1) fast-glob: 3.3.2 fastest-levenshtein: 1.0.16 file-entry-cache: 6.0.1 @@ -61663,7 +61899,7 @@ snapshots: '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.24.7) - ts-jest@29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3): + ts-jest@29.1.1(@babel/core@7.25.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.25.2))(jest@27.5.1(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.3.100)(@types/node@16.18.68)(typescript@5.3.3)))(typescript@5.3.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -62769,8 +63005,8 @@ snapshots: webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0): dependencies: '@discoveryjs/json-ext': 0.5.7 - '@webpack-cli/configtest': 1.2.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) - '@webpack-cli/info': 1.5.0(webpack-cli@4.10.0(webpack-bundle-analyzer@4.7.0)(webpack@5.89.0)) + '@webpack-cli/configtest': 1.2.0(webpack-cli@4.10.0(webpack@5.89.0))(webpack@5.89.0(uglify-js@3.17.4)(webpack-cli@4.10.0)) + '@webpack-cli/info': 1.5.0(webpack-cli@4.10.0(webpack@5.89.0)) '@webpack-cli/serve': 1.7.0(webpack-cli@4.10.0(webpack@5.89.0)) colorette: 2.0.20 commander: 7.2.0