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/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..1485ab1707b --- /dev/null +++ b/.husky/post-checkout @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +. "$(dirname "$0")/_/husky.sh" + +# The hook documentation: https://git-scm.com/docs/githooks.html#_post_checkout +CHECKOUT_TYPE=$3 +HEAD_NEW=$2 +HEAD_PREVIOUS=$1 + +whiteColoured='\033[0m' +orangeColoured='\033[1;33m' + +# '1' is a branch checkout +if [ "$CHECKOUT_TYPE" = '1' ]; then + # Prompt about pnpm versions mismatch when switching between branches. + currentPnpmVersion=$( ( command -v pnpm > /dev/null && pnpm -v 2>/dev/null ) || echo 'n/a' ) + targetPnpmVersion=$( grep packageManager package.json | sed -nr 's/.+packageManager.+pnpm@([[:digit:].]+).+/\1/p' ) + if [ "$currentPnpmVersion" != "$targetPnpmVersion" ]; then + printf "${orangeColoured}pnpm versions mismatch: in use '$currentPnpmVersion', needed '$targetPnpmVersion'. If you are working on something in this branch, here are some hints on how to solve this:\n" + printf "${orangeColoured}* actualize environment: 'nvm use && pnpm -v' (the most common case)\n" + printf "${orangeColoured}* install: 'npm install -g pnpm@$targetPnpmVersion'\n" + fi + + # Auto-refresh dependencies when switching between branches. + changedManifests=$( ( git diff --name-only $HEAD_NEW $HEAD_PREVIOUS | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' ) + if [ -n "$changedManifests" ]; then + printf "${whiteColoured}The following file(s) in the new branch differs from the original one, dependencies might need to be refreshed:\n" + printf "${whiteColoured} %s\n" $changedManifests + printf "${orangeColoured}If you are working on something in this branch, ensure to refresh dependencies with 'pnpm install --frozen-lockfile'\n" + fi +fi diff --git a/.husky/post-merge b/.husky/post-merge index 7ff64bebced..bc022e8fede 100755 --- a/.husky/post-merge +++ b/.husky/post-merge @@ -1,6 +1,8 @@ #!/usr/bin/env bash . "$(dirname "$0")/_/husky.sh" +# The hook documentation: https://git-scm.com/docs/githooks.html#_post_merge + 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 "It was a change in the following file(s) - refreshing dependencies:\n" 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/changelog.txt b/changelog.txt index 2ba165e2bdc..592a7f919cf 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,12 @@ == Changelog == += 9.3.2 2024-09-18 = + +- Fix - Improve the product importer's handling of filepaths under Windows [#51456](https://github.com/woocommerce/woocommerce/pull/51456) +- Fix - Revert changes related to low stock product notifications [#51441](https://github.com/woocommerce/woocommerce/pull/51441) +- Fix - Resolve a bug where manually triggering `added_to_cart` event without a button element caused an Exception [#51449](https://github.com/woocommerce/woocommerce/pull/51449) + + = 9.3.1 2024-09-12 = * Tweak - Disable remote logging feature by default [#51312](https://github.com/woocommerce/woocommerce/pull/51312) diff --git a/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md b/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md index 3b7a53c9fe8..d5cbef24096 100644 --- a/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md +++ b/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md @@ -254,4 +254,4 @@ Displaying the variation in the front store works a bit differently for variable ## How to find hooks? -Everyone will have their own preferred way, but for me, the quickest way is to look in the WooCommere plugin code. The code for each data section can be found in `/woocommerce/includes/admin/meta-boxes/views`. To view how the inventory section is handled check the `html-product-data-inventory.php` file, and for variations take a look at `html-variation-admin.php`. +Everyone will have their own preferred way, but for me, the quickest way is to look in the WooCommerce plugin code. The code for each data section can be found in `/woocommerce/includes/admin/meta-boxes/views`. To view how the inventory section is handled check the `html-product-data-inventory.php` file, and for variations take a look at `html-variation-admin.php`. diff --git a/docs/cart-and-checkout-blocks/checkout-payment-methods/payment-method-integration.md b/docs/cart-and-checkout-blocks/checkout-payment-methods/payment-method-integration.md index d3457214654..664e64370ab 100644 --- a/docs/cart-and-checkout-blocks/checkout-payment-methods/payment-method-integration.md +++ b/docs/cart-and-checkout-blocks/checkout-payment-methods/payment-method-integration.md @@ -43,12 +43,16 @@ The options you feed the configuration instance should be an object in this shap ```js const options = { name: 'my_payment_method', - content: <div>A React node</div>, - edit: <div>A React node</div>, + title: 'My Mayment Method', + description: 'A setence or two about your payment method', + gatewayId: 'gateway-id', + content: <ReactNode />, + edit: <ReactNode />, canMakePayment: () => true, paymentMethodId: 'new_payment_method', supports: { features: [], + style: [], }, }; ``` @@ -59,6 +63,18 @@ Here's some more details on the configuration options: This should be a unique string (wise to try to pick something unique for your gateway that wouldn't be used by another implementation) that is used as the identifier for the gateway client side. If `paymentMethodId` is not provided, `name` is used for `paymentMethodId` as well. +#### `title` (optional) + +This should be a human readable string with the name of your payment method. It should be sentence capitalised. It is displayed to the merchant in the editor when viewing the Checkout block to indicate which express payment methods are active. If it is not provided, the `name` will be used as the title. + +#### `description` (optional) + +This is one or two sentences maximum describing your payment gateway. It should be sentence capitalised. It is displayed to the merchant in the editor when viewing the Checkout block to indicate which express payment methods are active. + +#### `gatewayId` (optional) + +This is the ID of the Payment Gateway that your plugin registers server side, and which registers the express payment method. It is used to link your express payment method on the clinet, to a payment gateway defined on the server. It is used to direct the merchant to the right settings page within the editor. If this is not provided, the merchant will be redirected to the general Woo payment settings page. + #### `content` (required) This should be a React node that will output in the express payment method area when the block is rendered in the frontend. It will be cloned in the rendering process. When cloned, this React node will receive props passed in from the checkout payment method interface that will allow your component to interact with checkout data (more on [these props later](#props-fed-to-payment-method-nodes)). @@ -97,7 +113,11 @@ This is the only optional configuration object. The value of this property is wh This is an array of payment features supported by the gateway. It is used to crosscheck if the payment method can be used for the content of the cart. By default payment methods should support at least `products` feature. If no value is provided then this assumes that `['products']` are supported. ---- +#### `supports:style` + +This is an array of style variations supported by the express payment method. These are styles that are applied across all the active express payment buttons and can be controlled from the express payment block in the editor. Supported values for these are one of `['height', 'borderRadius']`. + +![Express Checkout Uniform Styles](https://github.com/user-attachments/assets/f0f99f3f-dca7-42b0-8685-3b098a825020) ### Payment Methods - `registerPaymentMethod( options )` @@ -139,23 +159,24 @@ The options you feed the configuration instance are the same as those for expres A big part of the payment method integration is the interface that is exposed for payment methods to use via props when the node provided is cloned and rendered on block mount. While all the props are listed below, you can find more details about what the props reference, their types etc via the [typedefs described in this file](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce-blocks/assets/js/types/type-defs/payment-method-interface.ts). -| Property | Type | Description | Values | -| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `activePaymentMethod` | String | The slug of the current active payment method in the checkout. | - | -| `billing` | Object | Contains everything related to billing. | `billingAddress`, `cartTotal`, `currency`, `cartTotalItems`, `displayPricesIncludingTax`, `appliedCoupons`, `customerId` | -| `cartData` | Object | Data exposed from the cart including items, fees, and any registered extension data. Note that this data should be treated as immutable (should not be modified/mutated) or it will result in errors in your application. | `cartItems`, `cartFees`, `extensions` | -| `checkoutStatus` | Object | The current checkout status exposed as various boolean state. | `isCalculating`, `isComplete`, `isIdle`, `isProcessing` | +| Property | Type | Description | Values | +| ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `activePaymentMethod` | String | The slug of the current active payment method in the checkout. | - | +| `billing` | Object | Contains everything related to billing. | `billingAddress`, `cartTotal`, `currency`, `cartTotalItems`, `displayPricesIncludingTax`, `appliedCoupons`, `customerId` | +| `cartData` | Object | Data exposed from the cart including items, fees, and any registered extension data. Note that this data should be treated as immutable (should not be modified/mutated) or it will result in errors in your application. | `cartItems`, `cartFees`, `extensions` | +| `checkoutStatus` | Object | The current checkout status exposed as various boolean state. | `isCalculating`, `isComplete`, `isIdle`, `isProcessing` | | `components` | Object | It exposes React components that can be implemented by your payment method for various common interface elements used by payment methods. | | | `emitResponse` | Object | Contains some constants that can be helpful when using the event emitter. Read the _[Emitting Events](https://github.com/woocommerce/woocommerce-gutenberg-products-block/blob/e267cd96a4329a4eeef816b2ef627e113ebb72a5/docs/extensibility/checkout-flow-and-events.md#emitting-events)_ section for more details. | | -| `eventRegistration` | object | Contains all the checkout event emitter registration functions. These are functions the payment method can register observers on to interact with various points in the checkout flow (see [this doc](./checkout-flow-and-events.md) for more info). | `onCheckoutValidation`, `onCheckoutSuccess`, `onCheckoutFail`, `onPaymentSetup`, `onShippingRateSuccess`, `onShippingRateFail`, `onShippingRateSelectSuccess`, `onShippingRateSelectFail` | -| `onClick` | Function | **Provided to express payment methods** that should be triggered when the payment method button is clicked (which will signal to checkout the payment method has taken over payment processing) | - | -| `onClose` | Function | **Provided to express payment methods** that should be triggered when the express payment method modal closes and control is returned to checkout. | - | -| `onSubmit` | Function | Submits the checkout and begins processing | - | -| `paymentStatus` | Object | Various payment status helpers. Note, your payment method does not have to handle setting this status client side. Checkout will handle this via the responses your payment method gives from observers registered to [checkout event emitters](./checkout-flow-and-events.md). | `isPristine`, `isStarted`, `isProcessing`, `isFinished`, `hasError`, `hasFailed`, `isSuccessful` (see below for explanation) | -| `setExpressPaymentError` | Function | Receives a string and allows express payment methods to set an error notice for the express payment area on demand. This can be necessary because some express payment method processing might happen outside of checkout events. | - | -| `shippingData` | Object | Contains all shipping related data (outside of the shipping status). | `shippingRates`, `shippingRatesLoading`, `selectedRates`, `setSelectedRates`, `isSelectingRate`, `shippingAddress`, `setShippingAddress`, and `needsShipping` | +| `eventRegistration` | object | Contains all the checkout event emitter registration functions. These are functions the payment method can register observers on to interact with various points in the checkout flow (see [this doc](./checkout-flow-and-events.md) for more info). | `onCheckoutValidation`, `onCheckoutSuccess`, `onCheckoutFail`, `onPaymentSetup`, `onShippingRateSuccess`, `onShippingRateFail`, `onShippingRateSelectSuccess`, `onShippingRateSelectFail` | +| `onClick` | Function | **Provided to express payment methods** that should be triggered when the payment method button is clicked (which will signal to checkout the payment method has taken over payment processing) | - | +| `onClose` | Function | **Provided to express payment methods** that should be triggered when the express payment method modal closes and control is returned to checkout. | - | +| `onSubmit` | Function | Submits the checkout and begins processing | - | +| `buttonAttributes` | Object | Styles set by the merchant that should be respected by all express payment buttons | `height, borderRadius` | +| `paymentStatus` | Object | Various payment status helpers. Note, your payment method does not have to handle setting this status client side. Checkout will handle this via the responses your payment method gives from observers registered to [checkout event emitters](./checkout-flow-and-events.md). | `isPristine`, `isStarted`, `isProcessing`, `isFinished`, `hasError`, `hasFailed`, `isSuccessful`(see below for explanation) | +| `setExpressPaymentError` | Function | Receives a string and allows express payment methods to set an error notice for the express payment area on demand. This can be necessary because some express payment method processing might happen outside of checkout events. | - | +| `shippingData` | Object | Contains all shipping related data (outside of the shipping status). | `shippingRates`, `shippingRatesLoading`, `selectedRates`, `setSelectedRates`, `isSelectingRate`, `shippingAddress`, `setShippingAddress`, and `needsShipping` | | `shippingStatus` | Object | Various shipping status helpers. | | -| `shouldSavePayment` | Boolean | Indicates whether or not the shopper has selected to save their payment method details (for payment methods that support saved payments). True if selected, false otherwise. Defaults to false. | - | +| `shouldSavePayment` | Boolean | Indicates whether or not the shopper has selected to save their payment method details (for payment methods that support saved payments). True if selected, false otherwise. Defaults to false. | - | - `isPristine`: This is true when the current payment status is `PRISTINE`. - `isStarted`: This is true when the current payment status is `EXPRESS_STARTED`. @@ -167,6 +188,29 @@ A big part of the payment method integration is the interface that is exposed fo Any registered `savedTokenComponent` node will also receive a `token` prop which includes the id for the selected saved token in case your payment method needs to use it for some internal logic. However, keep in mind, this is just the id representing this token in the database (and the value of the radio input the shopper checked), not the actual customer payment token (since processing using that usually happens on the server for security). +### Button Attributes for Express Payment Methods + +This API provides a way to synchronise the look and feel of the express payment buttons for a coherent shopper experience. Express Payment Methods must prefer the values provided in the `buttonAttributes`, and use it's own configuration settings as backup when the buttons are rendered somewhere other than the Cart or Checkout block. + +For example, in your button component, you would do something like this: + +```js +// Get your extension specific settings and set defaults if not available +let { + borderRadius = '4', + height = '48', +} = getButtonSettingsFromConfig(); + +// In a cart & checkout block context, we receive `buttonAttributes` as a prop which overwrite the extension specific settings +if ( typeof buttonAttributes !== 'undefined' ) { + height = buttonAttributes.height; + borderRadius = buttonAttributes.borderRadius; +} +... + +return <button style={height: `${height}px`, borderRadius: `${borderRadius}px`} /> +``` + ## Server Side Integration ### Processing Payment diff --git a/docs/docs-manifest.json b/docs/docs-manifest.json index 45cec2a5a3f..0b71e764ab1 100644 --- a/docs/docs-manifest.json +++ b/docs/docs-manifest.json @@ -83,7 +83,7 @@ "menu_title": "Add Custom Fields to Products", "tags": "how-to", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md", - "hash": "fe8cf43940f5166bf69f102aa4643cbe32415b1167d6b6d8968d434a4d113879", + "hash": "df61c93febc234fe0dbb4826a20ae120b153ab6f6c92d8778177fcac8d6696fe", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/building-a-woo-store/adding-a-custom-field-to-variable-products.md", "id": "64b686dcd5fdd4842be2fc570108231d5a8bfc1b" } @@ -223,7 +223,7 @@ "menu_title": "Payment Method Integration", "tags": "reference", "edit_url": "https://github.com/woocommerce/woocommerce/edit/trunk/docs/cart-and-checkout-blocks/checkout-payment-methods/payment-method-integration.md", - "hash": "138ffbf27e79ec8b35d2c46e87e3663c203d91fc9ba3f76c43f3cbe76258e5bf", + "hash": "015aae25bb331364c224fe8eb2b7675e4cbed0a9e6bee0dde5f5311388161b0a", "url": "https://raw.githubusercontent.com/woocommerce/woocommerce/trunk/docs/cart-and-checkout-blocks/checkout-payment-methods/payment-method-integration.md", "id": "c9a763b6976ecf03aeb961577c17c31f1ac7c420", "links": { @@ -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": "c7122979df14f46646b3f1472ba071bc560b99e6462c5790a9aeaa3b4238ce15", "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": "dfe48a2a48d383c1f4e5b5bb4258d369dd4e80ac8582462b16bbfedd879cb0bf" + "hash": "a88d9ea54465c8bbd820042a92df79cbd48943e785b418fcaa04d0c0e66116c0" } \ No newline at end of file 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-no-permissions-api-error b/packages/js/data/changelog/fix-no-permissions-api-error new file mode 100644 index 00000000000..0695a289f9a --- /dev/null +++ b/packages/js/data/changelog/fix-no-permissions-api-error @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Added pre-API call permission checks for some API calls that were being called on non-admin accessible screens diff --git a/packages/js/data/src/notes/resolvers.ts b/packages/js/data/src/notes/resolvers.ts index 5a0dec47e6e..1da6d7d9845 100644 --- a/packages/js/data/src/notes/resolvers.ts +++ b/packages/js/data/src/notes/resolvers.ts @@ -10,11 +10,14 @@ import { apiFetch } from '@wordpress/data-controls'; import { NAMESPACE } from '../constants'; import { setNotes, setNotesQuery, setError } from './actions'; import { NoteQuery, Note } from './types'; +import { checkUserCapability } from '../utils'; export function* getNotes( query: NoteQuery = {} ) { const url = addQueryArgs( `${ NAMESPACE }/admin/notes`, query ); try { + yield checkUserCapability( 'manage_woocommerce' ); + const notes: Note[] = yield apiFetch( { path: url, } ); diff --git a/packages/js/data/src/onboarding/resolvers.ts b/packages/js/data/src/onboarding/resolvers.ts index a74e7be4760..814094413b7 100644 --- a/packages/js/data/src/onboarding/resolvers.ts +++ b/packages/js/data/src/onboarding/resolvers.ts @@ -31,6 +31,7 @@ import { TaskListType, } from './types'; import { Plugin } from '../plugins/types'; +import { checkUserCapability } from '../utils'; const resolveSelect = controls && controls.resolveSelect ? controls.resolveSelect : select; @@ -68,6 +69,8 @@ export function* getEmailPrefill() { export function* getTaskLists() { const deprecatedTasks = new DeprecatedTasks(); try { + yield checkUserCapability( 'manage_woocommerce' ); + const results: TaskListType[] = yield apiFetch( { path: WC_ADMIN_NAMESPACE + '/onboarding/tasks', method: deprecatedTasks.hasDeprecatedTasks() ? 'POST' : 'GET', diff --git a/packages/js/data/src/plugins/resolvers.ts b/packages/js/data/src/plugins/resolvers.ts index f656099ed95..c25fdfeeea5 100644 --- a/packages/js/data/src/plugins/resolvers.ts +++ b/packages/js/data/src/plugins/resolvers.ts @@ -27,6 +27,7 @@ import { RecommendedTypes, JetpackConnectionDataResponse, } from './types'; +import { checkUserCapability } from '../utils'; // Can be removed in WP 5.9, wp.data is supported in >5.7. const resolveSelect = @@ -61,6 +62,8 @@ type ConnectJetpackResponse = { export function* getActivePlugins() { yield setIsRequesting( 'getActivePlugins', true ); try { + yield checkUserCapability( 'manage_woocommerce' ); + const url = WC_ADMIN_NAMESPACE + '/plugins/active'; const results: PluginGetResponse = yield apiFetch( { path: url, @@ -77,6 +80,8 @@ export function* getInstalledPlugins() { yield setIsRequesting( 'getInstalledPlugins', true ); try { + yield checkUserCapability( 'manage_woocommerce' ); + const url = WC_ADMIN_NAMESPACE + '/plugins/installed'; const results: PluginGetResponse = yield apiFetch( { path: url, @@ -111,6 +116,8 @@ export function* getJetpackConnectionData() { yield setIsRequesting( 'getJetpackConnectionData', true ); try { + yield checkUserCapability( 'manage_woocommerce' ); + const url = JETPACK_NAMESPACE + '/connection/data'; const results: JetpackConnectionDataResponse = yield apiFetch( { diff --git a/packages/js/data/src/utils.ts b/packages/js/data/src/utils.ts index eff7bf1a483..1c0b80b7792 100644 --- a/packages/js/data/src/utils.ts +++ b/packages/js/data/src/utils.ts @@ -2,14 +2,15 @@ * External dependencies */ import { addQueryArgs } from '@wordpress/url'; -import { apiFetch } from '@wordpress/data-controls'; +import { apiFetch, select } from '@wordpress/data-controls'; /** * Internal dependencies */ import { BaseQueryParams } from './types/query-params'; import { fetchWithHeaders } from './controls'; - +import { USER_STORE_NAME } from './user'; +import { WCUser } from './user/types'; function replacer( _: string, value: unknown ) { if ( value ) { if ( Array.isArray( value ) ) { @@ -100,3 +101,20 @@ export function* request< Query extends BaseQueryParams, DataType >( return { items: response.data, totalCount }; } } + +/** + * Utility function to check if the current user has a specific capability. + * + * @param {string} capability - The capability to check (e.g. 'manage_woocommerce'). + * @throws {Error} If the user does not have the required capability. + */ +export function* checkUserCapability( capability: string ) { + const currentUser: WCUser< 'capabilities' > = yield select( + USER_STORE_NAME, + 'getCurrentUser' + ); + + if ( ! currentUser.capabilities[ capability ] ) { + throw new Error( `User does not have ${ capability } capability.` ); + } +} diff --git a/plugins/woocommerce-admin/client/activity-panel/activity-panel.js b/plugins/woocommerce-admin/client/activity-panel/activity-panel.js index 65fb6d08588..47373cf8e88 100644 --- a/plugins/woocommerce-admin/client/activity-panel/activity-panel.js +++ b/plugins/woocommerce-admin/client/activity-panel/activity-panel.js @@ -269,7 +269,8 @@ export const ActivityPanel = ( { isEmbedded, query } ) => { visible: ( isEmbedded || ! isHomescreen ) && ! isPerformingSetupTask() && - ! isProductScreen(), + ! isProductScreen() && + currentUserCan( 'manage_woocommerce' ), }; const feedback = { diff --git a/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx b/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx index c2f8c454009..93920c3f71f 100644 --- a/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx +++ b/plugins/woocommerce-admin/client/customize-store/assembler-hub/editor.tsx @@ -23,6 +23,7 @@ import { GlobalStylesRenderer } from '@wordpress/edit-site/build-module/componen /** * Internal dependencies */ +import { trackEvent } from '../tracking'; import { editorIsLoaded } from '../utils'; import { BlockEditorContainer } from './block-editor-container'; @@ -63,6 +64,7 @@ export const Editor = ( { isLoading }: { isLoading: boolean } ) => { useEffect( () => { if ( ! isLoading ) { editorIsLoaded(); + trackEvent( 'customize_your_store_assembler_hub_editor_loaded' ); } }, [ isLoading ] ); diff --git a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss index d713efc1a80..952780f05e2 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss @@ -1,13 +1,17 @@ @import "../../stylesheets/_variables.scss"; .woocommerce-marketplace__category-selector { + position: relative; display: flex; align-items: stretch; - margin: $grid-unit-20 0 0 0; + margin: 0; + overflow-x: auto; } .woocommerce-marketplace__category-item { cursor: pointer; + white-space: nowrap; + margin-bottom: 0; .components-dropdown { height: 100%; @@ -50,7 +54,6 @@ .woocommerce-marketplace__category-selector--full-width { display: none; - margin-top: $grid-unit-15; } @media screen and (max-width: $break-medium) { @@ -122,3 +125,22 @@ background-color: $gray-900; } } + +.woocommerce-marketplace__category-navigation-button { + border: none; + position: absolute; + top: 0; + bottom: 0; + height: 100%; + width: 50px; +} + +.woocommerce-marketplace__category-navigation-button--prev { + background: linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); + left: 0; +} + +.woocommerce-marketplace__category-navigation-button--next { + background: linear-gradient(to left, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); + right: 0; +} diff --git a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx index 07b298cde66..9625aef5862 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx @@ -1,20 +1,21 @@ /** * External dependencies */ -import { useState, useEffect } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { useState, useEffect, useRef } from '@wordpress/element'; import { useQuery } from '@woocommerce/navigation'; -import clsx from 'clsx'; +import { Icon } from '@wordpress/components'; +import { useDebounce } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ import CategoryLink from './category-link'; -import CategoryDropdown from './category-dropdown'; import { Category, CategoryAPIItem } from './types'; import { fetchCategories } from '../../utils/functions'; -import './category-selector.scss'; import { ProductType } from '../product-list/types'; +import CategoryDropdown from './category-dropdown'; +import './category-selector.scss'; const ALL_CATEGORIES_SLUGS = { [ ProductType.extension ]: '_all', @@ -29,32 +30,21 @@ interface CategorySelectorProps { export default function CategorySelector( props: CategorySelectorProps ): JSX.Element { - const [ visibleItems, setVisibleItems ] = useState< Category[] >( [] ); - const [ dropdownItems, setDropdownItems ] = useState< Category[] >( [] ); const [ selected, setSelected ] = useState< Category >(); const [ isLoading, setIsLoading ] = useState( false ); + const [ categoriesToShow, setCategoriesToShow ] = useState< Category[] >( + [] + ); + const [ isOverflowing, setIsOverflowing ] = useState( false ); + const [ scrollPosition, setScrollPosition ] = useState< + 'start' | 'middle' | 'end' + >( 'start' ); + + const categorySelectorRef = useRef< HTMLUListElement >( null ); + const selectedCategoryRef = useRef< HTMLLIElement >( null ); const query = useQuery(); - useEffect( () => { - // If no category is selected, show All as selected - let categoryToSearch = ALL_CATEGORIES_SLUGS[ props.type ]; - - if ( query.category ) { - categoryToSearch = query.category; - } - - const allCategories = visibleItems.concat( dropdownItems ); - - const selectedCategory = allCategories.find( - ( category ) => category.slug === categoryToSearch - ); - - if ( selectedCategory ) { - setSelected( selectedCategory ); - } - }, [ query.category, props.type, visibleItems, dropdownItems ] ); - useEffect( () => { setIsLoading( true ); @@ -72,21 +62,125 @@ export default function CategorySelector( return category.slug !== '_featured'; } ); - // Split array into two from 7th item - const visibleCategoryItems = categories.slice( 0, 7 ); - const dropdownCategoryItems = categories.slice( 7 ); - - setVisibleItems( visibleCategoryItems ); - setDropdownItems( dropdownCategoryItems ); + setCategoriesToShow( categories ); } ) .catch( () => { - setVisibleItems( [] ); - setDropdownItems( [] ); + setCategoriesToShow( [] ); } ) .finally( () => { setIsLoading( false ); } ); - }, [ props.type ] ); + }, [ props.type, setCategoriesToShow ] ); + + useEffect( () => { + // If no category is selected, show All as selected + let categoryToSearch = ALL_CATEGORIES_SLUGS[ props.type ]; + + if ( query.category ) { + categoryToSearch = query.category; + } + + const selectedCategory = categoriesToShow.find( + ( category ) => category.slug === categoryToSearch + ); + + if ( selectedCategory ) { + setSelected( selectedCategory ); + } + }, [ query.category, props.type, categoriesToShow ] ); + + useEffect( () => { + if ( selectedCategoryRef.current ) { + selectedCategoryRef.current.scrollIntoView( { + block: 'nearest', + inline: 'center', + } ); + } + }, [ selected ] ); + + function checkOverflow() { + if ( + categorySelectorRef.current && + categorySelectorRef.current.parentElement?.scrollWidth + ) { + const isContentOverflowing = + categorySelectorRef.current.scrollWidth > + categorySelectorRef.current.parentElement.scrollWidth; + + setIsOverflowing( isContentOverflowing ); + } + } + + function checkScrollPosition() { + const ulElement = categorySelectorRef.current; + + if ( ! ulElement ) { + return; + } + + const { scrollLeft, scrollWidth, clientWidth } = ulElement; + + if ( scrollLeft < 10 ) { + setScrollPosition( 'start' ); + + return; + } + + if ( scrollLeft + clientWidth < scrollWidth ) { + setScrollPosition( 'middle' ); + + return; + } + + if ( scrollLeft + clientWidth === scrollWidth ) { + setScrollPosition( 'end' ); + } + } + + const debouncedCheckOverflow = useDebounce( checkOverflow, 300 ); + const debouncedScrollPosition = useDebounce( checkScrollPosition, 100 ); + + function scrollCategories( scrollAmount: number ) { + if ( categorySelectorRef.current ) { + categorySelectorRef.current.scrollTo( { + left: categorySelectorRef.current.scrollLeft + scrollAmount, + behavior: 'smooth', + } ); + } + } + + function scrollToNextCategories() { + scrollCategories( 200 ); + } + + function scrollToPrevCategories() { + scrollCategories( -200 ); + } + + useEffect( () => { + window.addEventListener( 'resize', debouncedCheckOverflow ); + + const ulElement = categorySelectorRef.current; + + if ( ulElement ) { + ulElement.addEventListener( 'scroll', debouncedScrollPosition ); + } + + return () => { + window.removeEventListener( 'resize', debouncedCheckOverflow ); + + if ( ulElement ) { + ulElement.removeEventListener( + 'scroll', + debouncedScrollPosition + ); + } + }; + }, [ debouncedCheckOverflow, debouncedScrollPosition ] ); + + useEffect( () => { + checkOverflow(); + }, [ categoriesToShow ] ); function mobileCategoryDropdownLabel() { const allCategoriesText = __( 'All Categories', 'woocommerce' ); @@ -102,16 +196,6 @@ export default function CategorySelector( return selected.label; } - function isSelectedInDropdown() { - if ( ! selected ) { - return false; - } - - return dropdownItems.find( - ( category ) => category.slug === selected.slug - ); - } - if ( isLoading ) { return ( <> @@ -131,50 +215,62 @@ export default function CategorySelector( return ( <> - { ! isLoading && isLongList && ( - - { __( 'Show more…', 'woocommerce' ) } - + ) } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/index.tsx index d87769fa657..75205c4b208 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/index.tsx @@ -11,10 +11,12 @@ import { registerBlockType } from '@wordpress/blocks'; import metadata from './block.json'; import Edit from './edit'; import './style.scss'; +import Save from './save'; if ( isExperimentalBlocksEnabled() ) { registerBlockType( metadata, { edit: Edit, icon: productFilterOptions, + save: Save, } ); } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/save.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/save.tsx new file mode 100644 index 00000000000..e470a5c0f49 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/save.tsx @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import { useBlockProps } from '@wordpress/block-editor'; +import clsx from 'clsx'; + +/** + * Internal dependencies + */ +import { BlockAttributes } from './types'; +import { getColorClasses, getColorVars } from './utils'; + +const Save = ( { + attributes, + style, +}: { + attributes: BlockAttributes; + style: Record< string, string >; +} ) => { + const blockProps = useBlockProps.save( { + className: clsx( + 'wc-block-product-filter-checkbox-list', + attributes.className, + getColorClasses( attributes ) + ), + style: { + ...style, + ...getColorVars( attributes ), + }, + } ); + + return
; +}; + +export default Save; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss index ece2e52bc7f..8e0691d3af1 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/style.scss @@ -4,11 +4,6 @@ padding: 0; } -.wc-block-product-filter-checkbox-list__item.hidden { - display: none; -} - - :where(.wc-block-product-filter-checkbox-list__label) { align-items: center; display: flex; @@ -34,6 +29,7 @@ width: 1em; height: 1em; border-radius: 2px; + pointer-events: none; .has-option-element-color & { display: none; @@ -51,6 +47,7 @@ margin: 0; width: 1em; background: var(--wc-product-filter-checkbox-list-option-element, transparent); + cursor: pointer; } .wc-block-product-filter-checkbox-list__input:checked + .wc-block-product-filter-checkbox-list__mark { @@ -75,12 +72,15 @@ color: var(--wc-product-filter-checkbox-list-option-element-selected, currentColor); } +:where(.wc-block-product-filter-checkbox-list__text) { + font-size: 0.875em; +} + :where(.wc-block-product-filter-checkbox-list__show-more) { - cursor: pointer; text-decoration: underline; -} - -.wc-block-product-filter-checkbox-list__show-more.hidden { - display: none; + appearance: none; + background: transparent; + border: none; + padding: 0; } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/utils.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/utils.ts new file mode 100644 index 00000000000..df761d0aed6 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/checkbox-list/utils.ts @@ -0,0 +1,66 @@ +/** + * Internal dependencies + */ +import { BlockAttributes } from './types'; + +function getCSSVar( slug: string | undefined, value: string | undefined ) { + if ( slug ) { + return `var(--wp--preset--color--${ slug })`; + } + return value || ''; +} + +export function getColorVars( attributes: BlockAttributes ) { + const { + optionElement, + optionElementBorder, + optionElementSelected, + customOptionElement, + customOptionElementBorder, + customOptionElementSelected, + } = attributes; + + const vars: Record< string, string > = { + '--wc-product-filter-checkbox-list-option-element': getCSSVar( + optionElement, + customOptionElement + ), + '--wc-product-filter-checkbox-list-option-element-border': getCSSVar( + optionElementBorder, + customOptionElementBorder + ), + '--wc-product-filter-checkbox-list-option-element-selected': getCSSVar( + optionElementSelected, + customOptionElementSelected + ), + }; + + return Object.keys( vars ).reduce( + ( acc: Record< string, string >, key ) => { + if ( vars[ key ] ) { + acc[ key ] = vars[ key ]; + } + return acc; + }, + {} + ); +} + +export function getColorClasses( attributes: BlockAttributes ) { + const { + optionElement, + optionElementBorder, + optionElementSelected, + customOptionElement, + customOptionElementBorder, + customOptionElementSelected, + } = attributes; + + return { + 'has-option-element-color': optionElement || customOptionElement, + 'has-option-element-border-color': + optionElementBorder || customOptionElementBorder, + 'has-option-element-selected-color': + optionElementSelected || customOptionElementSelected, + }; +} diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/block.json b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/block.json index 44e26c25279..7ef9d364b96 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/block.json +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/block.json @@ -15,8 +15,44 @@ ], "supports": {}, "usesContext": [ - "filterData", - "isParentSelected" + "filterData" ], - "attributes": {} + "attributes": { + "chipText":{ + "type": "string" + }, + "customChipText":{ + "type": "string" + }, + "chipBackground":{ + "type": "string" + }, + "customChipBackground":{ + "type": "string" + }, + "chipBorder":{ + "type": "string" + }, + "customChipBorder":{ + "type": "string" + }, + "selectedChipText":{ + "type": "string" + }, + "customSelectedChipText":{ + "type": "string" + }, + "selectedChipBackground":{ + "type": "string" + }, + "customSelectedChipBackground":{ + "type": "string" + }, + "selectedChipBorder":{ + "type": "string" + }, + "customSelectedChipBorder":{ + "type": "string" + } + } } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx index fcefe04c0ab..b2782b3c323 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/edit.tsx @@ -1,15 +1,260 @@ /** * External dependencies */ -import { useBlockProps } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { useMemo } from '@wordpress/element'; +import clsx from 'clsx'; +import { + InspectorControls, + useBlockProps, + withColors, + // @ts-expect-error - no types. + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalColorGradientSettingsDropdown as ColorGradientSettingsDropdown, + // @ts-expect-error - no types. + // eslint-disable-next-line @wordpress/no-unsafe-wp-apis + __experimentalUseMultipleOriginColorsAndGradients as useMultipleOriginColorsAndGradients, +} from '@wordpress/block-editor'; /** * Internal dependencies */ -import './style.scss'; +import { EditProps } from './types'; +import './editor.scss'; +import { getColorClasses, getColorVars } from './utils'; -const Edit = () => { - return
These are chips.
; +const Edit = ( props: EditProps ): JSX.Element => { + const colorGradientSettings = useMultipleOriginColorsAndGradients(); + const { + context, + clientId, + attributes, + setAttributes, + chipText, + setChipText, + chipBackground, + setChipBackground, + chipBorder, + setChipBorder, + selectedChipText, + setSelectedChipText, + selectedChipBackground, + setSelectedChipBackground, + selectedChipBorder, + setSelectedChipBorder, + } = props; + const { + customChipText, + customChipBackground, + customChipBorder, + customSelectedChipText, + customSelectedChipBackground, + customSelectedChipBorder, + } = attributes; + const { filterData } = context; + const { isLoading, items } = filterData; + + const blockProps = useBlockProps( { + className: clsx( 'wc-block-product-filter-chips', { + 'is-loading': isLoading, + ...getColorClasses( attributes ), + } ), + style: getColorVars( attributes ), + } ); + + const loadingState = useMemo( () => { + return [ ...Array( 10 ) ].map( ( _, i ) => ( +
+   +
+ ) ); + }, [] ); + + if ( ! items ) { + return <>; + } + + const threshold = 15; + const isLongList = items.length > threshold; + + return ( + <> +
+
+ { isLoading && loadingState } + { ! isLoading && + ( isLongList + ? items.slice( 0, threshold ) + : items + ).map( ( item, index ) => ( +
+ + { item.label } + +
+ ) ) } +
+ { ! isLoading && isLongList && ( + + ) } +
+ + { colorGradientSettings.hasColorsOrGradients && ( + { + setChipText( colorValue ); + setAttributes( { + customChipText: colorValue, + } ); + }, + resetAllFilter: () => { + setChipText( '' ); + setAttributes( { + customChipText: '', + } ); + }, + }, + { + label: __( + 'Unselected Chip Border', + 'woocommerce' + ), + colorValue: + chipBorder.color || customChipBorder, + onColorChange: ( colorValue: string ) => { + setChipBorder( colorValue ); + setAttributes( { + customChipBorder: colorValue, + } ); + }, + resetAllFilter: () => { + setChipBorder( '' ); + setAttributes( { + customChipBorder: '', + } ); + }, + }, + { + label: __( + 'Unselected Chip Background', + 'woocommerce' + ), + colorValue: + chipBackground.color || + customChipBackground, + onColorChange: ( colorValue: string ) => { + setChipBackground( colorValue ); + setAttributes( { + customChipBackground: colorValue, + } ); + }, + resetAllFilter: () => { + setChipBackground( '' ); + setAttributes( { + customChipBackground: '', + } ); + }, + }, + { + label: __( + 'Selected Chip Text', + 'woocommerce' + ), + colorValue: + selectedChipText.color || + customSelectedChipText, + onColorChange: ( colorValue: string ) => { + setSelectedChipText( colorValue ); + setAttributes( { + customSelectedChipText: colorValue, + } ); + }, + resetAllFilter: () => { + setSelectedChipText( '' ); + setAttributes( { + customSelectedChipText: '', + } ); + }, + }, + { + label: __( + 'Selected Chip Border', + 'woocommerce' + ), + colorValue: + selectedChipBorder.color || + customSelectedChipBorder, + onColorChange: ( colorValue: string ) => { + setSelectedChipBorder( colorValue ); + setAttributes( { + customSelectedChipBorder: colorValue, + } ); + }, + resetAllFilter: () => { + setSelectedChipBorder( '' ); + setAttributes( { + customSelectedChipBorder: '', + } ); + }, + }, + { + label: __( + 'Selected Chip Background', + 'woocommerce' + ), + colorValue: + selectedChipBackground.color || + customSelectedChipBackground, + onColorChange: ( colorValue: string ) => { + setSelectedChipBackground( colorValue ); + setAttributes( { + customSelectedChipBackground: + colorValue, + } ); + }, + resetAllFilter: () => { + setSelectedChipBackground( '' ); + setAttributes( { + customSelectedChipBackground: '', + } ); + }, + }, + ] } + panelId={ clientId } + { ...colorGradientSettings } + /> + ) } + + + ); }; -export default Edit; +export default withColors( { + chipText: 'chip-text', + chipBorder: 'chip-border', + chipBackground: 'chip-background', + selectedChipText: 'selected-chip-text', + selectedChipBorder: 'selected-chip-border', + selectedChipBackground: 'selected-chip-background', +} )( Edit ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/editor.scss b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/editor.scss new file mode 100644 index 00000000000..ec741894be7 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/editor.scss @@ -0,0 +1,6 @@ +.wc-block-product-filter-chips.is-loading { + .wc-block-product-filter-chips__item { + @include placeholder(); + margin: 5px 0; + } +} diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts new file mode 100644 index 00000000000..45a5de9f317 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/frontend.ts @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import { getElement, getContext, store } from '@woocommerce/interactivity'; + +/** + * Internal dependencies + */ + +export type ChipsContext = { + items: { + id: string; + label: string; + value: string; + checked: boolean; + }[]; + showAll: boolean; +}; + +store( 'woocommerce/product-filter-chips', { + actions: { + showAllItems: () => { + const context = getContext< ChipsContext >(); + context.showAll = true; + }, + + selectItem: () => { + const { ref } = getElement(); + const value = ref.getAttribute( 'value' ); + + if ( ! value ) return; + + const context = getContext< ChipsContext >(); + + context.items = context.items.map( ( item ) => { + if ( item.value.toString() === value ) { + return { + ...item, + checked: ! item.checked, + }; + } + return item; + } ); + }, + }, +} ); diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/index.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/index.tsx index d87769fa657..df088f6f16f 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/index.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/index.tsx @@ -10,11 +10,13 @@ import { registerBlockType } from '@wordpress/blocks'; */ import metadata from './block.json'; import Edit from './edit'; +import Save from './save'; import './style.scss'; if ( isExperimentalBlocksEnabled() ) { registerBlockType( metadata, { edit: Edit, icon: productFilterOptions, + save: Save, } ); } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/save.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/save.tsx new file mode 100644 index 00000000000..5a5f9a3d110 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/save.tsx @@ -0,0 +1,35 @@ +/** + * External dependencies + */ +import { useBlockProps } from '@wordpress/block-editor'; +import clsx from 'clsx'; + +/** + * Internal dependencies + */ +import { BlockAttributes } from './types'; +import { getColorClasses, getColorVars } from './utils'; + +const Save = ( { + attributes, + style, +}: { + attributes: BlockAttributes; + style: Record< string, string >; +} ) => { + const blockProps = useBlockProps.save( { + className: clsx( + 'wc-block-product-filter-chips', + attributes.className, + getColorClasses( attributes ) + ), + style: { + ...style, + ...getColorVars( attributes ), + }, + } ); + + return
; +}; + +export default Save; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss index a8af7fda118..a26f333241b 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/style.scss @@ -1,3 +1,55 @@ -:where(.wc-block-product-filter-chips) { - // WIP +:where(.wc-block-product-filter-chips__items) { + display: flex; + flex-wrap: wrap; + gap: $gap-smallest; +} + +:where(.wc-block-product-filter-chips__item) { + border: 1px solid color-mix(in srgb, currentColor 20%, transparent); + padding: $gap-smallest $gap-smaller; + appearance: none; + background: transparent; + border-radius: 2px; + font-size: 0.875em; + cursor: pointer; + + .has-chip-text & { + color: var(--wc-product-filter-chips-text); + } + .has-chip-background & { + background: var(--wc-product-filter-chips-background); + } + .has-chip-border & { + border-color: var(--wc-product-filter-chips-border); + } +} + +:where(.wc-block-product-filter-chips__item[aria-checked="true"]) { + background: currentColor; + + .has-selected-chip-text & { + color: var(--wc-product-filter-chips-selected-text); + } + .has-selected-chip-background & { + background: var(--wc-product-filter-chips-selected-background); + } + .has-selected-chip-border & { + border-color: var(--wc-product-filter-chips-selected-border); + } +} + +:where( +.wc-block-product-filter-chips:not(.has-selected-chip-text) +.wc-block-product-filter-chips__item[aria-checked="true"] +> .wc-block-product-filter-chips__label +) { + filter: invert(100%); +} + +:where(.wc-block-product-filter-chips__show-more) { + text-decoration: underline; + appearance: none; + background: transparent; + border: none; + padding: 0; } diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts index 0a68e80edca..096019cb760 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/types.ts @@ -6,9 +6,44 @@ import { BlockEditProps } from '@wordpress/blocks'; /** * Internal dependencies */ +import { FilterBlockContext } from '../../types'; + +export type Color = { + slug?: string; + name?: string; + class?: string; + color: string; +}; export type BlockAttributes = { className: string; + chipText?: string; + customChipText?: string; + chipBackground?: string; + customChipBackground?: string; + chipBorder?: string; + customChipBorder?: string; + selectedChipText?: string; + customSelectedChipText?: string; + selectedChipBackground?: string; + customSelectedChipBackground?: string; + selectedChipBorder?: string; + customSelectedChipBorder?: string; }; -export type EditProps = BlockEditProps< BlockAttributes >; +export type EditProps = BlockEditProps< BlockAttributes > & { + style: Record< string, string >; + context: FilterBlockContext; + chipText: Color; + setChipText: ( value: string ) => void; + chipBackground: Color; + setChipBackground: ( value: string ) => void; + chipBorder: Color; + setChipBorder: ( value: string ) => void; + selectedChipText: Color; + setSelectedChipText: ( value: string ) => void; + selectedChipBackground: Color; + setSelectedChipBackground: ( value: string ) => void; + selectedChipBorder: Color; + setSelectedChipBorder: ( value: string ) => void; +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/utils.ts b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/utils.ts new file mode 100644 index 00000000000..0d9d462600e --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-filters/inner-blocks/chips/utils.ts @@ -0,0 +1,91 @@ +/** + * Internal dependencies + */ +import { BlockAttributes } from './types'; + +function getCSSVar( slug: string | undefined, value: string | undefined ) { + if ( slug ) { + return `var(--wp--preset--color--${ slug })`; + } + return value || ''; +} + +export function getColorVars( attributes: BlockAttributes ) { + const { + chipText, + chipBackground, + chipBorder, + selectedChipText, + selectedChipBackground, + selectedChipBorder, + customChipText, + customChipBackground, + customChipBorder, + customSelectedChipText, + customSelectedChipBackground, + customSelectedChipBorder, + } = attributes; + + const vars: Record< string, string > = { + '--wc-product-filter-chips-text': getCSSVar( chipText, customChipText ), + '--wc-product-filter-chips-background': getCSSVar( + chipBackground, + customChipBackground + ), + '--wc-product-filter-chips-border': getCSSVar( + chipBorder, + customChipBorder + ), + '--wc-product-filter-chips-selected-text': getCSSVar( + selectedChipText, + customSelectedChipText + ), + '--wc-product-filter-chips-selected-background': getCSSVar( + selectedChipBackground, + customSelectedChipBackground + ), + '--wc-product-filter-chips-selected-border': getCSSVar( + selectedChipBorder, + customSelectedChipBorder + ), + }; + + return Object.keys( vars ).reduce( + ( acc: Record< string, string >, key ) => { + if ( vars[ key ] ) { + acc[ key ] = vars[ key ]; + } + return acc; + }, + {} + ); +} + +export function getColorClasses( attributes: BlockAttributes ) { + const { + chipText, + chipBackground, + chipBorder, + selectedChipText, + selectedChipBackground, + selectedChipBorder, + customChipText, + customChipBackground, + customChipBorder, + customSelectedChipText, + customSelectedChipBackground, + customSelectedChipBorder, + } = attributes; + + return { + 'has-chip-text-color': chipText || customChipText, + 'has-chip-background-color': chipBackground || customChipBackground, + 'has-chip-border-color': chipBorder || customChipBorder, + 'has-selected-chip-text-color': + selectedChipText || customSelectedChipText, + 'has-selected-chip-background-color': + selectedChipBackground || customSelectedChipBackground, + 'has-selected-chip-border-color': + selectedChipBorder || customSelectedChipBorder, + }; +} diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/edit.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/edit.tsx index f6e3898701f..d220d0fdb67 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/edit.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/edit.tsx @@ -10,6 +10,10 @@ import { import { BlockEditProps, InnerBlockTemplate } from '@wordpress/blocks'; import { useEffect } from '@wordpress/element'; import { useSelect } from '@wordpress/data'; +import ErrorPlaceholder, { + ErrorObject, +} from '@woocommerce/editor-components/error-placeholder'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -132,14 +136,16 @@ export const Edit = ( { useEffect( () => { const mode = getMode( currentTemplateId, templateType ); + const newProductGalleryClientId = + attributes.productGalleryClientId || clientId; setAttributes( { ...attributes, mode, - productGalleryClientId: clientId, + productGalleryClientId: newProductGalleryClientId, } ); // Move the Thumbnails block to the correct above or below the Large Image based on the thumbnailsPosition attribute. - moveInnerBlocksToPosition( attributes, clientId ); + moveInnerBlocksToPosition( attributes, newProductGalleryClientId ); }, [ setAttributes, attributes, @@ -148,6 +154,18 @@ export const Edit = ( { templateType, ] ); + if ( attributes.productGalleryClientId !== clientId ) { + const error = { + message: __( + 'productGalleryClientId and clientId codes mismatch.', + 'woocommerce' + ), + type: 'general', + } as ErrorObject; + + return ; + } + return (
diff --git a/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/utils.tsx b/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/utils.tsx index eb2e15258b2..d5b97d3ea2e 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/utils.tsx +++ b/plugins/woocommerce-blocks/assets/js/blocks/product-gallery/utils.tsx @@ -136,10 +136,10 @@ export const moveInnerBlocksToPosition = ( ): void => { const { getBlock, getBlockRootClientId, getBlockIndex } = select( 'core/block-editor' ); - const { moveBlockToPosition } = dispatch( 'core/block-editor' ); const productGalleryBlock = getBlock( clientId ); - if ( productGalleryBlock ) { + if ( productGalleryBlock?.name === 'woocommerce/product-gallery' ) { + const { moveBlockToPosition } = dispatch( 'core/block-editor' ); const previousLayout = productGalleryBlock.innerBlocks.length ? productGalleryBlock.innerBlocks[ 0 ].attributes.layout : null; diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/action-types.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/action-types.ts index 327cd10bca1..3b1b82a5254 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/action-types.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/action-types.ts @@ -17,4 +17,6 @@ export const ACTION_TYPES = { SET_REDIRECT_URL: 'SET_REDIRECT_URL', SET_SHOULD_CREATE_ACCOUNT: 'SET_SHOULD_CREATE_ACCOUNT', SET_USE_SHIPPING_AS_BILLING: 'SET_USE_SHIPPING_AS_BILLING', + SET_EDITING_BILLING_ADDRESS: 'SET_EDITING_BILLING_ADDRESS', + SET_EDITING_SHIPPING_ADDRESS: 'SET_EDITING_SHIPPING_ADDRESS', } as const; diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/actions.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/actions.ts index 8cc1f724274..eee54051f65 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/actions.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/actions.ts @@ -118,6 +118,30 @@ export const __internalSetUseShippingAsBilling = ( useShippingAsBilling, } ); +/** + * Set whether the billing address is being edited + * + * @param isEditing True if the billing address is being edited, false otherwise + */ +export const setEditingBillingAddress = ( isEditing: boolean ) => { + return { + type: types.SET_EDITING_BILLING_ADDRESS, + isEditing, + }; +}; + +/** + * Set whether the shipping address is being edited + * + * @param isEditing True if the shipping address is being edited, false otherwise + */ +export const setEditingShippingAddress = ( isEditing: boolean ) => { + return { + type: types.SET_EDITING_SHIPPING_ADDRESS, + isEditing, + }; +}; + /** * Whether an account should be created for the user while checking out * @@ -182,6 +206,8 @@ export type CheckoutAction = | typeof __internalSetCustomerId | typeof __internalSetCustomerPassword | typeof __internalSetUseShippingAsBilling + | typeof setEditingBillingAddress + | typeof setEditingShippingAddress | typeof __internalSetShouldCreateAccount | typeof __internalSetOrderNotes | typeof setPrefersCollection diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/default-state.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/default-state.ts index 7891c255565..1a82d4d5056 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/default-state.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/default-state.ts @@ -23,8 +23,28 @@ export type CheckoutState = { shouldCreateAccount: boolean; // Should a user account be created? status: STATUS; // Status of the checkout useShippingAsBilling: boolean; // Should the billing form be hidden and inherit the shipping address? + editingBillingAddress: boolean; // Is the billing address being edited? + editingShippingAddress: boolean; // Is the shipping address being edited? }; +// Default editing state for CustomerAddress component comes from the current address and whether or not we're in the editor. +const hasBillingAddress = !! ( + checkoutData.billing_address.address_1 && + ( checkoutData.billing_address.first_name || + checkoutData.billing_address.last_name ) +); + +const hasShippingAddress = !! ( + checkoutData.shipping_address.address_1 && + ( checkoutData.shipping_address.first_name || + checkoutData.shipping_address.last_name ) +); + +const billingMatchesShipping = isSameAddress( + checkoutData.billing_address, + checkoutData.shipping_address +); + export const defaultState: CheckoutState = { additionalFields: checkoutData.additional_fields || {}, calculatingCount: 0, @@ -38,8 +58,7 @@ export const defaultState: CheckoutState = { redirectUrl: '', shouldCreateAccount: false, status: STATUS.IDLE, - useShippingAsBilling: isSameAddress( - checkoutData.billing_address, - checkoutData.shipping_address - ), + useShippingAsBilling: billingMatchesShipping, + editingBillingAddress: ! hasBillingAddress, + editingShippingAddress: ! hasShippingAddress, }; diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/reducers.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/reducers.ts index 1a01c5772df..01bf428d245 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/reducers.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/reducers.ts @@ -130,6 +130,20 @@ const reducer = ( state = defaultState, action: CheckoutAction ) => { } break; + case types.SET_EDITING_BILLING_ADDRESS: + newState = { + ...state, + editingBillingAddress: action.isEditing, + }; + break; + + case types.SET_EDITING_SHIPPING_ADDRESS: + newState = { + ...state, + editingShippingAddress: action.isEditing, + }; + break; + case types.SET_SHOULD_CREATE_ACCOUNT: if ( action.shouldCreateAccount !== undefined && diff --git a/plugins/woocommerce-blocks/assets/js/data/checkout/selectors.ts b/plugins/woocommerce-blocks/assets/js/data/checkout/selectors.ts index c472bc25bc8..759049db2ba 100644 --- a/plugins/woocommerce-blocks/assets/js/data/checkout/selectors.ts +++ b/plugins/woocommerce-blocks/assets/js/data/checkout/selectors.ts @@ -36,6 +36,14 @@ export const getUseShippingAsBilling = ( state: CheckoutState ) => { return state.useShippingAsBilling; }; +export const getEditingBillingAddress = ( state: CheckoutState ) => { + return state.editingBillingAddress; +}; + +export const getEditingShippingAddress = ( state: CheckoutState ) => { + return state.editingShippingAddress; +}; + export const getExtensionData = ( state: CheckoutState ) => { return state.extensionData; }; diff --git a/plugins/woocommerce-blocks/assets/js/data/payment/test/check-payment-methods.tsx b/plugins/woocommerce-blocks/assets/js/data/payment/test/check-payment-methods.tsx index 4669d09e0a1..fda47363036 100644 --- a/plugins/woocommerce-blocks/assets/js/data/payment/test/check-payment-methods.tsx +++ b/plugins/woocommerce-blocks/assets/js/data/payment/test/check-payment-methods.tsx @@ -123,6 +123,9 @@ const registerMockPaymentMethods = ( savedCards = true ) => { }; registerExpressPaymentMethod( { name, + title: 'Express Payment Method', + description: 'A test express payment method', + gatewayId: 'test-express-payment-method', content: , edit:
An express payment method
, canMakePayment: mockedExpressCanMakePayment, diff --git a/plugins/woocommerce-blocks/assets/js/data/payment/test/selectors.js b/plugins/woocommerce-blocks/assets/js/data/payment/test/selectors.js index cf0739e6a95..b7d30f83a1c 100644 --- a/plugins/woocommerce-blocks/assets/js/data/payment/test/selectors.js +++ b/plugins/woocommerce-blocks/assets/js/data/payment/test/selectors.js @@ -123,6 +123,9 @@ const registerMockPaymentMethods = ( savedCards = true ) => { }; registerExpressPaymentMethod( { name, + title: `${ name } express payment method`, + description: `${ name } express payment method description`, + gatewayId: 'woo', content: , edit:
An express payment method
, canMakePayment: () => true, diff --git a/plugins/woocommerce-blocks/assets/js/data/payment/utils/check-payment-methods.ts b/plugins/woocommerce-blocks/assets/js/data/payment/utils/check-payment-methods.ts index 594c04fd8c2..1391b00a074 100644 --- a/plugins/woocommerce-blocks/assets/js/data/payment/utils/check-payment-methods.ts +++ b/plugins/woocommerce-blocks/assets/js/data/payment/utils/check-payment-methods.ts @@ -163,11 +163,30 @@ export const checkPaymentMethodsCanPay = async ( express = false ) => { | PaymentMethodConfigInstance | ExpressPaymentMethodConfigInstance ) => { - const { name } = paymentMethod; - availablePaymentMethods = { - ...availablePaymentMethods, - [ paymentMethod.name ]: { name }, - }; + if ( express ) { + const { name, title, description, gatewayId, supports } = + paymentMethod as ExpressPaymentMethodConfigInstance; + + availablePaymentMethods = { + ...availablePaymentMethods, + [ paymentMethod.name ]: { + name, + title, + description, + gatewayId, + supportsStyle: supports?.style, + }, + }; + } else { + const { name } = paymentMethod as PaymentMethodConfigInstance; + + availablePaymentMethods = { + ...availablePaymentMethods, + [ paymentMethod.name ]: { + name, + }, + }; + } }; // Order payment methods. diff --git a/plugins/woocommerce-blocks/assets/js/types/type-defs/payments.ts b/plugins/woocommerce-blocks/assets/js/types/type-defs/payments.ts index c0c82626ecb..2a11bf7e7fb 100644 --- a/plugins/woocommerce-blocks/assets/js/types/type-defs/payments.ts +++ b/plugins/woocommerce-blocks/assets/js/types/type-defs/payments.ts @@ -31,6 +31,7 @@ export interface SupportsConfiguration { features?: string[]; // Deprecated, in favour of showSavedCards and showSaveOption savePaymentInfo?: boolean; + style?: string[]; } // we assign a value in the class for supports.features @@ -119,10 +120,28 @@ export interface PaymentMethodConfiguration { savedTokenComponent?: ReactNode | null; } -export type ExpressPaymentMethodConfiguration = Omit< - PaymentMethodConfiguration, - 'icons' | 'label' | 'ariaLabel' | 'placeOrderButtonLabel' ->; +export interface ExpressPaymentMethodConfiguration { + // A unique string to identify the payment method client side. + name: string; + // A human readable title for the payment method. + title?: string; + // A human readable description for the payment method. + description?: string; + // The gateway ID for the payment method. + gatewayId?: string; + // A react node for your payment method UI. + content: ReactNode; + // A react node to display a preview of your payment method in the editor. + edit: ReactNode; + // A callback to determine whether the payment method should be shown in the checkout. + canMakePayment: CanMakePaymentCallback; + // A unique string to represent the payment method server side. If not provided, defaults to name. + paymentMethodId?: string; + // Object that describes various features provided by the payment method. + supports: SupportsConfiguration; + // A React node that contains logic handling any processing your payment method has to do with saved payment methods if your payment method supports them + savedTokenComponent?: ReactNode | null; +} export type PaymentMethods = | Record< string, PaymentMethodConfigInstance > @@ -131,7 +150,16 @@ export type PaymentMethods = /** * Used to represent payment methods in a context where storing objects is not allowed, i.e. in data stores. */ -export type PlainPaymentMethods = Record< string, { name: string } >; +export type PlainPaymentMethods = Record< + string, + { + name: string; + title: string; + description: string; + gatewayId: string; + supportsStyle: string[]; + } +>; /** * Used to represent payment methods in a context where storing objects is not allowed, i.e. in data stores. @@ -159,6 +187,9 @@ export interface PaymentMethodConfigInstance { export interface ExpressPaymentMethodConfigInstance { name: string; + title: string; + description: string; + gatewayId: string; content: ReactNode; edit: ReactNode; paymentMethodId?: string; diff --git a/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/data-store/checkout.md b/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/data-store/checkout.md index 5badd9d879e..b4a99cc4ecb 100644 --- a/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/data-store/checkout.md +++ b/plugins/woocommerce-blocks/docs/third-party-developers/extensibility/data-store/checkout.md @@ -1,5 +1,7 @@ # Checkout Store (`wc/store/checkout`) + + > 💡 What's the difference between the Cart Store and the Checkout Store? > > The **Cart Store (`wc/store/cart`)** manages and retrieves data about the shopping cart, including items, customer data, and interactions like coupons. @@ -173,6 +175,36 @@ const store = select( CHECKOUT_STORE_KEY ); const useShippingAsBilling = store.getUseShippingAsBilling(); ``` +### getEditingBillingAddress + +Returns true if the billing address is being edited. + +#### _Returns_ + +- `boolean`: True if the billing address is being edited. + +#### _Example_ + +```js +const store = select( CHECKOUT_STORE_KEY ); +const editingBillingAddress = store.getEditingBillingAddress(); +``` + +### getEditingShippingAddress + +Returns true if the shipping address is being edited. + +#### _Returns_ + +- `boolean`: True if the shipping address is being edited. + +#### _Example_ + +```js +const store = select( CHECKOUT_STORE_KEY ); +const editingShippingAddress = store.getEditingShippingAddress(); +``` + ### hasError Returns true if an error occurred, and false otherwise. @@ -293,7 +325,6 @@ const store = select( CHECKOUT_STORE_KEY ); const isCalculating = store.isCalculating(); ``` - ### prefersCollection Returns true if the customer prefers to collect their order, and false otherwise. @@ -326,6 +357,36 @@ const store = dispatch( CHECKOUT_STORE_KEY ); store.setPrefersCollection( true ); ``` +### setEditingBillingAddress + +Set the billing address to editing state or collapsed state. Note that if the address has invalid fields, it will not be set to collapsed state. + +#### _Parameters_ + +- _isEditing_ `boolean`: True to set the billing address to editing state, false to set it to collapsed state. + +#### _Example_ + +```js +const store = dispatch( CHECKOUT_STORE_KEY ); +store.setEditingBillingAddress( true ); +``` + +### setEditingShippingAddress + +Set the shipping address to editing state or collapsed state. Note that if the address has invalid fields, it will not be set to collapsed state. + +#### _Parameters_ + +- _isEditing_ `boolean`: True to set the shipping address to editing state, false to set it to collapsed state. + +#### _Example_ + +```js +const store = dispatch( CHECKOUT_STORE_KEY ); +store.setEditingShippingAddress( true ); +``` + --- diff --git a/plugins/woocommerce-blocks/packages/components/panel/style.scss b/plugins/woocommerce-blocks/packages/components/panel/style.scss index 8df33917d6a..d4651f9b918 100644 --- a/plugins/woocommerce-blocks/packages/components/panel/style.scss +++ b/plugins/woocommerce-blocks/packages/components/panel/style.scss @@ -16,7 +16,6 @@ .wc-block-components-panel__button { box-sizing: border-box; height: auto; - line-height: inherit; margin-top: em(6px); padding-right: #{24px + $gap-smaller}; padding-top: em($gap-small - 6px); diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/local-pickup/local-pickup.merchant.block_theme.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/local-pickup/local-pickup.merchant.block_theme.spec.ts index 6bdf8015ca2..d48c78315c9 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/local-pickup/local-pickup.merchant.block_theme.spec.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/local-pickup/local-pickup.merchant.block_theme.spec.ts @@ -184,7 +184,7 @@ test.describe( 'Merchant → Local Pickup Settings', () => { ).toBeVisible(); } ); - test( 'updating the title in WC Settings updates the local pickup text in the block and vice/versa', async ( { + test.skip( 'updating the title in WC Settings updates the local pickup text in the block and vice/versa', async ( { page, localPickupUtils, admin, diff --git a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts index bb4f6bd8f75..bc984dbb627 100644 --- a/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts +++ b/plugins/woocommerce-blocks/tests/e2e/tests/product-collection/product-collection.block_theme.spec.ts @@ -87,7 +87,11 @@ test.describe( 'Product Collection', () => { await admin.createNewPost(); } ); - test( 'does not render', async ( { page, editor, pageObject } ) => { + test.skip( 'does not render', async ( { + page, + editor, + pageObject, + } ) => { await pageObject.insertProductCollection(); await pageObject.chooseCollectionInPost( 'featured' ); await pageObject.addFilter( 'Price Range' ); 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..d201ef01574 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 @@ -235,7 +235,7 @@ test.describe( 'Product Collection registration', () => { await expect( previewButtonLocator ).toBeHidden(); } ); - test( 'Should display properly in Product Catalog template', async ( { + test.skip( 'Should display properly in Product Catalog template', async ( { pageObject, editor, } ) => { @@ -356,4 +356,84 @@ test.describe( 'Product Collection registration', () => { await expect( previewButtonLocator ).toBeHidden(); } ); } ); + + test.skip( '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/47899-try-poc-express-checkout-button-styles b/plugins/woocommerce/changelog/47899-try-poc-express-checkout-button-styles new file mode 100644 index 00000000000..857d00e2bb8 --- /dev/null +++ b/plugins/woocommerce/changelog/47899-try-poc-express-checkout-button-styles @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adds unified styles for the express checkout block \ No newline at end of file diff --git a/plugins/woocommerce/changelog/50688-add-sync-cart-checkout b/plugins/woocommerce/changelog/50688-add-sync-cart-checkout new file mode 100644 index 00000000000..2358e0fc1ce --- /dev/null +++ b/plugins/woocommerce/changelog/50688-add-sync-cart-checkout @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Synchronise the express payment controls between the Cart & Checkout blocks \ No newline at end of file diff --git a/plugins/woocommerce/changelog/50791-feature-express-payment-improvements b/plugins/woocommerce/changelog/50791-feature-express-payment-improvements new file mode 100644 index 00000000000..84c3df51562 --- /dev/null +++ b/plugins/woocommerce/changelog/50791-feature-express-payment-improvements @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Improve the express checkout experience with several design tweak, uniform button styles and editor improvements \ No newline at end of file diff --git a/plugins/woocommerce/changelog/50983-add-express-payment-methods-to-sidebar b/plugins/woocommerce/changelog/50983-add-express-payment-methods-to-sidebar new file mode 100644 index 00000000000..640c491e1cc --- /dev/null +++ b/plugins/woocommerce/changelog/50983-add-express-payment-methods-to-sidebar @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Available express payment methods are visible in the editor when selecting the express payment block \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51109-remove-dark-mode b/plugins/woocommerce/changelog/51109-remove-dark-mode new file mode 100644 index 00000000000..b0cb81d045a --- /dev/null +++ b/plugins/woocommerce/changelog/51109-remove-dark-mode @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak +Comment: This is part of a bigger feature that will have its own changelog entry + 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/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/51296-try-supports-express-payment-controls b/plugins/woocommerce/changelog/51296-try-supports-express-payment-controls new file mode 100644 index 00000000000..ef5d03c75bb --- /dev/null +++ b/plugins/woocommerce/changelog/51296-try-supports-express-payment-controls @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak + +UX improvements to the express payment block in the editor \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector b/plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector new file mode 100644 index 00000000000..504e3d4d2d2 --- /dev/null +++ b/plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Update In-App Marketplace category selector \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component b/plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component new file mode 100644 index 00000000000..42a06d9bf86 --- /dev/null +++ b/plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Replace marketplace search component with SearchControl from @wordpress/components \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51342-fix-21596-search-loading-state b/plugins/woocommerce/changelog/51342-fix-21596-search-loading-state new file mode 100644 index 00000000000..a55cd0e6601 --- /dev/null +++ b/plugins/woocommerce/changelog/51342-fix-21596-search-loading-state @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix the loading state for the In-App Marketplace search \ 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/51422-e2e-external-expand-wpcom-suite-part3 b/plugins/woocommerce/changelog/51422-e2e-external-expand-wpcom-suite-part3 new file mode 100644 index 00000000000..4eeb743cc9d --- /dev/null +++ b/plugins/woocommerce/changelog/51422-e2e-external-expand-wpcom-suite-part3 @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Expand the e2e suite we're running on WPCOM part #3. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button b/plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button new file mode 100644 index 00000000000..50f6c905990 --- /dev/null +++ b/plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Added a Load More button to product lists on the Extensions page, to request additional search results from WooCommerce.com. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51510-e2e-skip-skip-flaky-e2e-tests-until-fixed b/plugins/woocommerce/changelog/51510-e2e-skip-skip-flaky-e2e-tests-until-fixed new file mode 100644 index 00000000000..b34a0554f9f --- /dev/null +++ b/plugins/woocommerce/changelog/51510-e2e-skip-skip-flaky-e2e-tests-until-fixed @@ -0,0 +1,4 @@ +Significance: patch +Type: tweak +Comment: This PR skips 4 e2e tests and makes no other changes + diff --git a/plugins/woocommerce/changelog/add-50832-loading-time b/plugins/woocommerce/changelog/add-50832-loading-time new file mode 100644 index 00000000000..3b1f7bef150 --- /dev/null +++ b/plugins/woocommerce/changelog/add-50832-loading-time @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Track customize_your_store_assembler_hub_editor_loaded event to measure CYS loading time diff --git a/plugins/woocommerce/changelog/add-chips-style-and-new-interactitity-implementation b/plugins/woocommerce/changelog/add-chips-style-and-new-interactitity-implementation new file mode 100644 index 00000000000..e9c85e6063a --- /dev/null +++ b/plugins/woocommerce/changelog/add-chips-style-and-new-interactitity-implementation @@ -0,0 +1,5 @@ +Significance: patch +Type: add +Comment: [Experimental] Product Filters Chips style and new interactivity API implementation + + 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-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-51472-fix-deprecation-notice b/plugins/woocommerce/changelog/fix-51472-fix-deprecation-notice new file mode 100644 index 00000000000..79ce818f52b --- /dev/null +++ b/plugins/woocommerce/changelog/fix-51472-fix-deprecation-notice @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Wrap parse_str under a check to resolve deprecation notice 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-no-permissions-api-error b/plugins/woocommerce/changelog/fix-no-permissions-api-error new file mode 100644 index 00000000000..0695a289f9a --- /dev/null +++ b/plugins/woocommerce/changelog/fix-no-permissions-api-error @@ -0,0 +1,4 @@ +Significance: minor +Type: fix + +Added pre-API call permission checks for some API calls that were being called on non-admin accessible screens 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-unit-test-trac-61739 b/plugins/woocommerce/changelog/fix-unit-test-trac-61739 new file mode 100644 index 00000000000..45cd4544e59 --- /dev/null +++ b/plugins/woocommerce/changelog/fix-unit-test-trac-61739 @@ -0,0 +1,5 @@ +Significance: patch +Type: dev +Comment: Update unit test to account for WordPress nightly change. See core trac ticket 61739 + + 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/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/refactor-wccom-21576-search-tab b/plugins/woocommerce/changelog/refactor-wccom-21576-search-tab new file mode 100644 index 00000000000..ba8046f3451 --- /dev/null +++ b/plugins/woocommerce/changelog/refactor-wccom-21576-search-tab @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Change the way search results are displayed in the in-app marketplace diff --git a/plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count b/plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count new file mode 100644 index 00000000000..b0edda219c3 --- /dev/null +++ b/plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add search result counts to the in-app marketplace header tabs (Extensions area) 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/changelog/update-rest-api-global-unique-id b/plugins/woocommerce/changelog/update-rest-api-global-unique-id new file mode 100644 index 00000000000..2a66b917137 --- /dev/null +++ b/plugins/woocommerce/changelog/update-rest-api-global-unique-id @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add global_unique_id parameter to products REST API 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/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php index cadf53a7bc5..0ce9d749a2b 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 @@ -125,6 +125,7 @@ class WC_Product_CSV_Importer_Controller { // Check that file is within an allowed location. if ( $is_valid_file ) { + $normalized_path = wp_normalize_path( $path ); $in_valid_location = false; $valid_locations = array(); $valid_locations[] = ABSPATH; @@ -135,7 +136,8 @@ class WC_Product_CSV_Importer_Controller { } foreach ( $valid_locations as $valid_location ) { - if ( 0 === stripos( $path, trailingslashit( realpath( $valid_location ) ) ) ) { + $normalized_location = wp_normalize_path( realpath( $valid_location ) ); + if ( 0 === stripos( $normalized_path, trailingslashit( $normalized_location ) ) ) { $in_valid_location = true; break; } 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/data-stores/class-wc-product-data-store-cpt.php b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php index b5a611d6f8a..b6da3a8e879 100644 --- a/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php +++ b/plugins/woocommerce/includes/data-stores/class-wc-product-data-store-cpt.php @@ -656,21 +656,21 @@ class WC_Product_Data_Store_CPT extends WC_Data_Store_WP implements WC_Object_Da // Fire actions to let 3rd parties know the stock is about to be changed. if ( $product->is_type( 'variation' ) ) { /** - * Action to signal that the value of 'stock_quantity' for a variation is about to change. - * - * @param WC_Product $product The variation whose stock is about to change. - * - * @since 4.9 - */ + * Action to signal that the value of 'stock_quantity' for a variation is about to change. + * + * @since 4.9 + * + * @param int $product The variation whose stock is about to change. + */ do_action( 'woocommerce_variation_before_set_stock', $product ); } else { /** - * Action to signal that the value of 'stock_quantity' for a product is about to change. - * - * @param WC_Product $product The product whose stock is about to change. - * - * @since 4.9 - */ + * Action to signal that the value of 'stock_quantity' for a product is about to change. + * + * @since 4.9 + * + * @param int $product The product whose stock is about to change. + */ do_action( 'woocommerce_product_before_set_stock', $product ); } break; 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 @@ +add_meta_query( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + $args, + array( + 'key' => '_global_unique_id', + 'value' => $global_unique_ids, + 'compare' => 'IN', + ) + ); + } + // Filter by tax class. if ( ! empty( $request['tax_class'] ) ) { $args['meta_query'] = $this->add_meta_query( // WPCS: slow query ok. diff --git a/plugins/woocommerce/includes/wc-brands-functions.php b/plugins/woocommerce/includes/wc-brands-functions.php new file mode 100644 index 00000000000..2c23ddca9f6 --- /dev/null +++ b/plugins/woocommerce/includes/wc-brands-functions.php @@ -0,0 +1,141 @@ +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/wc-stock-functions.php b/plugins/woocommerce/includes/wc-stock-functions.php index 1489a0e6630..e81b31e8c50 100644 --- a/plugins/woocommerce/includes/wc-stock-functions.php +++ b/plugins/woocommerce/includes/wc-stock-functions.php @@ -242,10 +242,31 @@ function wc_trigger_stock_change_notifications( $order, $changes ) { return; } - $order_notes = array(); + $order_notes = array(); + $no_stock_amount = absint( get_option( 'woocommerce_notify_no_stock_amount', 0 ) ); foreach ( $changes as $change ) { - $order_notes[] = $change['product']->get_formatted_name() . ' ' . $change['from'] . '→' . $change['to']; + $order_notes[] = $change['product']->get_formatted_name() . ' ' . $change['from'] . '→' . $change['to']; + $low_stock_amount = absint( wc_get_low_stock_amount( wc_get_product( $change['product']->get_id() ) ) ); + if ( $change['to'] <= $no_stock_amount ) { + /** + * Action to signal that the value of 'stock_quantity' for a variation is about to change. + * + * @since 4.9 + * + * @param int $product The variation whose stock is about to change. + */ + do_action( 'woocommerce_no_stock', wc_get_product( $change['product']->get_id() ) ); + } elseif ( $change['to'] <= $low_stock_amount ) { + /** + * Action to signal that the value of 'stock_quantity' for a product is about to change. + * + * @since 4.9 + * + * @param int $product The product whose stock is about to change. + */ + do_action( 'woocommerce_low_stock', wc_get_product( $change['product']->get_id() ) ); + } if ( $change['to'] < 0 ) { /** @@ -312,8 +333,6 @@ function wc_trigger_stock_change_actions( $product ) { do_action( 'woocommerce_low_stock', $product ); } } -add_action( 'woocommerce_variation_set_stock', 'wc_trigger_stock_change_actions' ); -add_action( 'woocommerce_product_set_stock', 'wc_trigger_stock_change_actions' ); /** * Increase stock levels for items within an order. @@ -485,11 +504,8 @@ function wc_get_low_stock_amount( WC_Product $product ) { $low_stock_amount = $product->get_low_stock_amount(); if ( '' === $low_stock_amount && $product->is_type( 'variation' ) ) { - $parent_product = wc_get_product( $product->get_parent_id() ); - - if ( $parent_product instanceof WC_Product ) { - $low_stock_amount = $parent_product->get_low_stock_amount(); - } + $product = wc_get_product( $product->get_parent_id() ); + $low_stock_amount = $product->get_low_stock_amount(); } if ( '' === $low_stock_amount ) { 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/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/Admin/WCAdminHelper.php b/plugins/woocommerce/src/Admin/WCAdminHelper.php index 9e234762eb8..768102e35fb 100644 --- a/plugins/woocommerce/src/Admin/WCAdminHelper.php +++ b/plugins/woocommerce/src/Admin/WCAdminHelper.php @@ -154,11 +154,13 @@ class WCAdminHelper { 'post_type' => 'product', ); - parse_str( wp_parse_url( $url, PHP_URL_QUERY ), $url_params ); - - foreach ( $params as $key => $param ) { - if ( isset( $url_params[ $key ] ) && $url_params[ $key ] === $param ) { - return true; + $query_string = wp_parse_url( $url, PHP_URL_QUERY ); + if ( $query_string ) { + parse_str( $query_string, $url_params ); + foreach ( $params as $key => $param ) { + if ( isset( $url_params[ $key ] ) && $url_params[ $key ] === $param ) { + return true; + } } } 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/CartExpressPaymentBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/CartExpressPaymentBlock.php index d491967c844..b8fd43c7b5b 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/CartExpressPaymentBlock.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/CartExpressPaymentBlock.php @@ -11,4 +11,18 @@ class CartExpressPaymentBlock extends AbstractInnerBlock { * @var string */ protected $block_name = 'cart-express-payment-block'; + + /** + * Uniform default_styles for the express payment buttons + * + * @var boolean + */ + protected $default_styles = null; + + /** + * Current styles for the express payment buttons + * + * @var boolean + */ + protected $current_styles = null; } diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/CheckoutExpressPaymentBlock.php b/plugins/woocommerce/src/Blocks/BlockTypes/CheckoutExpressPaymentBlock.php index 3efe4261e6a..a5864d7502c 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/CheckoutExpressPaymentBlock.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/CheckoutExpressPaymentBlock.php @@ -1,6 +1,9 @@ default_styles = array( + 'showButtonStyles' => false, + 'buttonHeight' => '48', + 'buttonBorderRadius' => '4', + ); + + add_action( 'save_post', array( $this, 'sync_express_payment_attrs' ), 10, 2 ); + } + + /** + * Synchorize the express payment attributes between the Cart and Checkout pages. + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + */ + public function sync_express_payment_attrs( $post_id, $post ) { + if ( wc_get_page_id( 'cart' ) === $post_id ) { + $cart_or_checkout = 'cart'; + } elseif ( wc_get_page_id( 'checkout' ) === $post_id ) { + $cart_or_checkout = 'checkout'; + } else { + return; + } + + // This is not a proper save action, maybe an autosave, so don't continue. + if ( empty( $post->post_status ) || 'inherit' === $post->post_status ) { + return; + } + + $block_name = 'woocommerce/' . $cart_or_checkout; + $page_id = 'woocommerce_' . $cart_or_checkout . '_page_id'; + $template_name = 'page-' . $cart_or_checkout; + + // Check if we are editing the cart/checkout page and that it contains a Cart/Checkout block. + // Cast to string for Cart/Checkout page ID comparison because get_option can return it as a string, so better to compare both values as strings. + if ( ! empty( $post->post_type ) && 'wp_template' !== $post->post_type && ( false === has_block( $block_name, $post ) || (string) get_option( $page_id ) !== (string) $post_id ) ) { + return; + } + + // Check if we are editing the Cart/Checkout template and that it contains a Cart/Checkout block. + if ( ( ! empty( $post->post_type ) && ! empty( $post->post_name ) && $template_name !== $post->post_name && 'wp_template' === $post->post_type ) || false === has_block( $block_name, $post ) ) { + return; + } + + if ( empty( $post->post_content ) ) { + return; + } + + try { + // Parse the post content to get the express payment attributes of the current page. + $blocks = parse_blocks( $post->post_content ); + $attrs = CartCheckoutUtils::find_express_checkout_attributes( $blocks, $cart_or_checkout ); + + if ( ! is_array( $attrs ) ) { + return; + } + $updated_attrs = array_merge( $this->default_styles, $attrs ); + + // We need to sync the attributes between the Cart and Checkout pages. + $other_page = 'cart' === $cart_or_checkout ? 'checkout' : 'cart'; + + $this->update_other_page_with_express_payment_attrs( $other_page, $updated_attrs ); + } catch ( Exception $e ) { + wc_get_logger()->log( 'error', 'Error updating express payment attributes: ' . $e->getMessage() ); + } + } + + /** + * Update the express payment attributes in the other page (Cart or Checkout). + * + * @param string $cart_or_checkout The page to update. + * @param array $updated_attrs The updated attributes. + */ + private function update_other_page_with_express_payment_attrs( $cart_or_checkout, $updated_attrs ) { + $page_id = 'cart' === $cart_or_checkout ? wc_get_page_id( 'cart' ) : wc_get_page_id( 'checkout' ); + + if ( -1 === $page_id ) { + return; + } + + $post = get_post( $page_id ); + + if ( empty( $post->post_content ) ) { + return; + } + + $blocks = parse_blocks( $post->post_content ); + CartCheckoutUtils::update_blocks_with_new_attrs( $blocks, $cart_or_checkout, $updated_attrs ); + + $updated_content = serialize_blocks( $blocks ); + remove_action( 'save_post', array( $this, 'sync_express_payment_attrs' ), 10, 2 ); + + wp_update_post( + array( + 'ID' => $page_id, + 'post_content' => $updated_content, + ), + false, + false + ); + + add_action( 'save_post', array( $this, 'sync_express_payment_attrs' ), 10, 2 ); + } } 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/ProductFilterActive.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php index 11469663107..e8d24554024 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterActive.php @@ -48,15 +48,9 @@ final class ProductFilterActive extends AbstractBlock { */ $active_filters = apply_filters( 'collection_active_filters_data', array(), $this->get_filter_query_params( $query_id ) ); - $context = array( - 'queryId' => $query_id, - 'params' => array_keys( $this->get_filter_query_params( $query_id ) ), - ); - $wrapper_attributes = get_block_wrapper_attributes( array( 'data-wc-interactive' => wp_json_encode( array( 'namespace' => $this->get_full_block_name() ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), - 'data-wc-context' => wp_json_encode( $context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), ) ); diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php index 0a5327f9ce0..0ef49f27988 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterAttribute.php @@ -130,10 +130,10 @@ final class ProductFilterAttribute extends AbstractBlock { return array( 'title' => $term_object->name, 'attributes' => array( - 'data-wc-on--click' => "$action_namespace::actions.removeFilter", + 'value' => $term, + 'data-wc-on--click' => "$action_namespace::actions.toggleFilter", 'data-wc-context' => "$action_namespace::" . wp_json_encode( array( - 'value' => $term, 'attributeSlug' => $product_attribute, 'queryType' => get_query_var( "query_type_{$product_attribute}" ), ), @@ -228,8 +228,8 @@ final class ProductFilterAttribute extends AbstractBlock { ); $filter_context = array( - 'on_change' => "{$this->get_full_block_name()}::actions.updateProducts", - 'items' => $filtered_options, + 'action' => "{$this->get_full_block_name()}::actions.toggleFilter", + 'items' => $filtered_options, ); foreach ( $block->parsed_block['innerBlocks'] as $inner_block ) { @@ -395,22 +395,25 @@ final class ProductFilterAttribute extends AbstractBlock { '
- -
- -

{{attribute_label}}

- + +
+ +

{{attribute_label}}

+ - - -
- -
- -
- + + +
+ +
+ +
+ + + +
+ -
', diff --git a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php index a4a299d9ff8..3c0fa38eddb 100644 --- a/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php +++ b/plugins/woocommerce/src/Blocks/BlockTypes/ProductFilterCheckboxList.php @@ -27,29 +27,16 @@ final class ProductFilterCheckboxList extends AbstractBlock { $context = $block->context['filterData']; $items = $context['items'] ?? array(); $checkbox_list_context = array( 'items' => $items ); - $on_change = $context['on_change'] ?? ''; + $action = $context['action'] ?? ''; $namespace = wp_json_encode( array( 'namespace' => 'woocommerce/product-filter-checkbox-list' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ); + $classes = ''; + $style = ''; - $classes = array( - 'has-option-element-border-color' => $this->get_color_attribute_value( 'optionElementBorder', $attributes ), - 'has-option-element-selected-color' => $this->get_color_attribute_value( 'optionElementSelected', $attributes ), - 'has-option-element-color' => $this->get_color_attribute_value( 'optionElement', $attributes ), - ); - $classes = array_filter( $classes ); - - $styles = array( - '--wc-product-filter-checkbox-list-option-element-border' => $this->get_color_attribute_value( 'optionElementBorder', $attributes ), - '--wc-product-filter-checkbox-list-option-element-selected' => $this->get_color_attribute_value( 'optionElementSelected', $attributes ), - '--wc-product-filter-checkbox-list-option-element' => $this->get_color_attribute_value( 'optionElement', $attributes ), - ); - $style = array_reduce( - array_keys( $styles ), - function ( $acc, $key ) use ( $styles ) { - if ( $styles[ $key ] ) { - return $acc . "{$key}: var( --wp--preset--color--{$styles[$key]} );"; - } - } - ); + $tags = new \WP_HTML_Tag_Processor( $content ); + if ( $tags->next_tag( array( 'class_name' => 'wc-block-product-filter-checkbox-list' ) ) ) { + $classes = $tags->get_attribute( 'class' ); + $style = $tags->get_attribute( 'style' ); + } $checked_items = array_filter( $items, @@ -64,7 +51,7 @@ final class ProductFilterCheckboxList extends AbstractBlock { $wrapper_attributes = array( 'data-wc-interactive' => esc_attr( $namespace ), 'data-wc-context' => wp_json_encode( $checkbox_list_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), - 'class' => implode( ' ', array_keys( $classes ) ), + 'class' => esc_attr( $classes ), 'style' => esc_attr( $style ), ); @@ -84,8 +71,9 @@ final class ProductFilterCheckboxList extends AbstractBlock { if ( ! $item['selected'] ) : if ( $count >= $remaining_initial_unchecked ) : ?> - class="wc-block-product-filter-checkbox-list__item hidden" - data-wc-class--hidden="!context.showAll" + class="wc-block-product-filter-checkbox-list__item" + data-wc-bind--hidden="!context.showAll" + hidden @@ -104,7 +92,7 @@ final class ProductFilterCheckboxList extends AbstractBlock { aria-invalid="false" aria-label="" data-wc-on--change--select-item="actions.selectCheckboxItem" - data-wc-on--change--parent-action="" + data-wc-on--change--parent-action="" value="" > @@ -120,36 +108,17 @@ final class ProductFilterCheckboxList extends AbstractBlock { $show_initially ) : ?> - + +
context['filterData']; + $items = $context['items'] ?? array(); + $checkbox_list_context = array( 'items' => $items ); + $action = $context['action'] ?? ''; + $namespace = wp_json_encode( array( 'namespace' => 'woocommerce/product-filter-chips' ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ); + + $tags = new \WP_HTML_Tag_Processor( $content ); + if ( $tags->next_tag( array( 'class_name' => 'wc-block-product-filter-chips' ) ) ) { + $classes = $tags->get_attribute( 'class' ); + $style = $tags->get_attribute( 'style' ); + } + + $checked_items = array_filter( + $items, + function ( $item ) { + return $item['selected']; + } + ); + $show_initially = $context['show_initially'] ?? 15; + $remaining_initial_unchecked = count( $checked_items ) > $show_initially ? count( $checked_items ) : $show_initially - count( $checked_items ); + $count = 0; + + $wrapper_attributes = array( + 'data-wc-interactive' => esc_attr( $namespace ), + 'data-wc-context' => wp_json_encode( $checkbox_list_context, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ), + 'class' => esc_attr( $classes ), + 'style' => esc_attr( $style ), + ); + + ob_start(); + ?> +
> +
+ + + + +
+ $show_initially ) : ?> + + +
+ false, 'hasPageWithWordPressAdminBar' => false, + 'params' => $this->get_filter_query_params( 0 ), + 'originalParams' => $this->get_filter_query_params( 0 ), ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); $tags->set_attribute( 'data-wc-navigation-id', $this->generate_navigation_id( $block ) ); + $tags->set_attribute( 'data-wc-watch', 'callbacks.maybeNavigate' ); if ( 'always' === $attributes['overlay'] || @@ -171,4 +174,45 @@ class ProductFilters extends AbstractBlock { md5( wp_json_encode( $block->parsed_block['innerBlocks'] ) ) ); } + + /** + * Parse the filter parameters from the URL. + * For now we only get the global query params from the URL. In the future, + * we should get the query params based on $query_id. + * + * @param int $query_id Query ID. + * @return array Parsed filter params. + */ + private function get_filter_query_params( $query_id ) { + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : ''; + + $parsed_url = wp_parse_url( esc_url_raw( $request_uri ) ); + + if ( empty( $parsed_url['query'] ) ) { + return array(); + } + + parse_str( $parsed_url['query'], $url_query_params ); + + /** + * Filters the active filter data provided by filter blocks. + * + * @since 11.7.0 + * + * @param array $filter_param_keys The active filters data + * @param array $url_param_keys The query param parsed from the URL. + * + * @return array Active filters params. + */ + $filter_param_keys = array_unique( apply_filters( 'collection_filter_query_param_keys', array(), array_keys( $url_query_params ) ) ); + + return array_filter( + $url_query_params, + function ( $key ) use ( $filter_param_keys ) { + return in_array( $key, $filter_param_keys, true ); + }, + ARRAY_FILTER_USE_KEY + ); + } } 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/BlockTemplateUtils.php b/plugins/woocommerce/src/Blocks/Utils/BlockTemplateUtils.php index 595ceca323e..8e6133155fa 100644 --- a/plugins/woocommerce/src/Blocks/Utils/BlockTemplateUtils.php +++ b/plugins/woocommerce/src/Blocks/Utils/BlockTemplateUtils.php @@ -481,7 +481,7 @@ class BlockTemplateUtils { * @return boolean */ public static function theme_has_template( $template_name ) { - return ! ! self::get_theme_template_path( $template_name, 'wp_template' ); + return (bool) self::get_theme_template_path( $template_name, 'wp_template' ); } /** @@ -491,7 +491,7 @@ class BlockTemplateUtils { * @return boolean */ public static function theme_has_template_part( $template_name ) { - return ! ! self::get_theme_template_path( $template_name, 'wp_template_part' ); + return (bool) self::get_theme_template_path( $template_name, 'wp_template_part' ); } /** diff --git a/plugins/woocommerce/src/Blocks/Utils/CartCheckoutUtils.php b/plugins/woocommerce/src/Blocks/Utils/CartCheckoutUtils.php index 53b4cde7f56..8b9e0e9098a 100644 --- a/plugins/woocommerce/src/Blocks/Utils/CartCheckoutUtils.php +++ b/plugins/woocommerce/src/Blocks/Utils/CartCheckoutUtils.php @@ -108,7 +108,7 @@ class CartCheckoutUtils { } $array_without_accents = array_map( - function( $value ) { + function ( $value ) { return is_array( $value ) ? self::deep_sort_with_accents( $value ) : remove_accents( wc_strtolower( html_entity_decode( $value ) ) ); @@ -129,7 +129,7 @@ class CartCheckoutUtils { $shipping_zones = \WC_Shipping_Zones::get_zones(); $formatted_shipping_zones = array_reduce( $shipping_zones, - function( $acc, $zone ) { + function ( $acc, $zone ) { $acc[] = [ 'id' => $zone['id'], 'title' => $zone['zone_name'], @@ -146,4 +146,47 @@ class CartCheckoutUtils { ]; return $formatted_shipping_zones; } + + /** + * Recursively search the checkout block to find the express checkout block and + * get the button style attributes + * + * @param array $blocks Blocks to search. + * @param string $cart_or_checkout The block type to check. + */ + public static function find_express_checkout_attributes( $blocks, $cart_or_checkout ) { + $express_block_name = 'woocommerce/' . $cart_or_checkout . '-express-payment-block'; + foreach ( $blocks as $block ) { + if ( ! empty( $block['blockName'] ) && $express_block_name === $block['blockName'] && ! empty( $block['attrs'] ) ) { + return $block['attrs']; + } + + if ( ! empty( $block['innerBlocks'] ) ) { + $answer = self::find_express_checkout_attributes( $block['innerBlocks'], $cart_or_checkout ); + if ( $answer ) { + return $answer; + } + } + } + } + + /** + * Given an array of blocks, find the express payment block and update its attributes. + * + * @param array $blocks Blocks to search. + * @param string $cart_or_checkout The block type to check. + * @param array $updated_attrs The new attributes to set. + */ + public static function update_blocks_with_new_attrs( &$blocks, $cart_or_checkout, $updated_attrs ) { + $express_block_name = 'woocommerce/' . $cart_or_checkout . '-express-payment-block'; + foreach ( $blocks as $key => &$block ) { + if ( ! empty( $block['blockName'] ) && $express_block_name === $block['blockName'] ) { + $blocks[ $key ]['attrs'] = $updated_attrs; + } + + if ( ! empty( $block['innerBlocks'] ) ) { + self::update_blocks_with_new_attrs( $block['innerBlocks'], $cart_or_checkout, $updated_attrs ); + } + } + } } 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/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 @@ + 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/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/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 276458fa570..a3e2135c0ad 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 @@ -15,6 +15,24 @@ config = { '**/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', + '**/merchant/create-restricted-coupons.spec.js', + '**/merchant/create-shipping-classes.spec.js', + '**/merchant/create-shipping-zones.spec.js', + '**/merchant/create-woocommerce-blocks.spec.js', + '**/merchant/create-woocommerce-patterns.spec.js', + '**/merchant/customer-list.spec.js', + '**/merchant/customer-payment-page.spec.js', + '**/merchant/launch-your-store.spec.js', + '**/merchant/lost-password.spec.js', + '**/merchant/order-bulk-edit.spec.js', ], grepInvert: /@skip-on-default-wpcom/, }, 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..2d6f69e7eb0 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', @@ -97,377 +103,414 @@ const test = baseTest.extend( { }, } ); -test.describe( 'Restricted coupon management', { tag: [ '@services' ] }, () => { - for ( const couponType of Object.keys( couponData ) ) { - test( `can create new ${ couponType } coupon`, async ( { - page, - coupon, - product, - } ) => { - // create basics for the 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 ); - await expect( page.getByText( 'Move to Trash' ) ).toBeVisible(); - } ); - - // set up the restrictions for each coupon type - // set minimum spend - if ( couponType === 'minimumSpend' ) { - await test.step( 'set minimum spend coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'No minimum' ) - .fill( couponData[ couponType ].minSpend ); - } ); - } - // set maximum spend - if ( couponType === 'maximumSpend' ) { - await test.step( 'set maximum spend coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'No maximum' ) - .fill( couponData[ couponType ].maxSpend ); - } ); - } - // set individual use - if ( couponType === 'individualUse' ) { - await test.step( 'set individual use coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page.getByLabel( 'Individual use only' ).check(); - } ); - } - // set exclude sale items - if ( couponType === 'excludeSaleItems' ) { - await test.step( 'set exclude sale items coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page.getByLabel( 'Exclude sale items' ).check(); - } ); - } - // set product categories - if ( couponType === 'productCategories' ) { - await test.step( 'set product categories coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'Any category' ) - .pressSequentially( 'Uncategorized' ); - await page - .getByRole( 'option', { name: 'Uncategorized' } ) - .click(); - } ); - } - // set exclude product categories - if ( couponType === 'excludeProductCategories' ) { - await test.step( 'set exclude product categories coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'No categories' ) - .pressSequentially( 'Uncategorized' ); - await page - .getByRole( 'option', { name: 'Uncategorized' } ) - .click(); - } ); - } - // set products - if ( couponType === 'products' ) { - await test.step( 'set products coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'Search for a product…' ) - .first() - .pressSequentially( product.name ); - await page - .getByRole( 'option', { name: product.name } ) - .click(); - } ); - } - // set exclude products - if ( couponType === 'excludeProducts' ) { - await test.step( 'set exclude products coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'Search for a product…' ) - .last() - .pressSequentially( product.name ); - await page - .getByRole( 'option', { name: product.name } ) - .click(); - } ); - } - // set allowed emails - if ( couponType === 'allowedEmails' ) { - await test.step( 'set allowed emails coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await page - .getByPlaceholder( 'No restrictions' ) - .fill( couponData[ couponType ].allowedEmails[ 0 ] ); - } ); - } - // set usage limit - if ( couponType === 'usageLimitPerCoupon' ) { - await test.step( 'set usage limit coupon', async () => { - await page - .getByRole( 'link', { name: 'Usage limits' } ) - .click(); - await page - .getByLabel( 'Usage limit per coupon' ) - .fill( couponData[ couponType ].usageLimit ); - } ); - } - // set usage limit per user - if ( couponType === 'usageLimitPerUser' ) { - await test.step( 'set usage limit per user coupon', async () => { - await page - .getByRole( 'link', { name: 'Usage limits' } ) - .click(); - await page - .getByLabel( 'Usage limit per user' ) - .fill( couponData[ couponType ].usageLimitPerUser ); - } ); - } - - // publish the coupon and retrieve the id - await test.step( 'publish the coupon', async () => { - 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 basic 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(); - - await page - .getByRole( 'link', { - name: couponData[ couponType ].code, - } ) - .first() - .click(); - } ); - - // verify the restrictions for each coupon type - // verify minimum spend - if ( couponType === 'minimumSpend' ) { - await test.step( 'verify minimum spend coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByPlaceholder( 'No minimum' ) - ).toHaveValue( couponData[ couponType ].minSpend ); - } ); - } - - // verify maximum spend - if ( couponType === 'maximumSpend' ) { - await test.step( 'verify maximum spend coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByPlaceholder( 'No maximum' ) - ).toHaveValue( couponData[ couponType ].maxSpend ); - } ); - } - - // verify individual use - if ( couponType === 'individualUse' ) { - await test.step( 'verify individual use coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByLabel( 'Individual use only' ) - ).toBeChecked(); - } ); - } - - // verify exclude sale items - if ( couponType === 'excludeSaleItems' ) { - await test.step( 'verify exclude sale items coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByLabel( 'Exclude sale items' ) - ).toBeChecked(); - } ); - } - - // verify product categories - if ( couponType === 'productCategories' ) { - await test.step( 'verify product categories coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByRole( 'listitem', { - name: 'Uncategorized', - } ) - ).toBeVisible(); - } ); - } - - // verify exclude product categories - if ( couponType === 'excludeProductCategories' ) { - await test.step( 'verify exclude product categories coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByRole( 'listitem', { - name: 'Uncategorized', - } ) - ).toBeVisible(); - } ); - } - - // verify products - if ( couponType === 'products' ) { - await test.step( 'verify products coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByRole( 'listitem', { name: product.name } ) - ).toBeVisible(); - } ); - } - - // verify exclude products - if ( couponType === 'excludeProducts' ) { - await test.step( 'verify exclude products coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByRole( 'listitem', { name: product.name } ) - ).toBeVisible(); - } ); - } - - // verify allowed emails - if ( couponType === 'allowedEmails' ) { - await test.step( 'verify allowed emails coupon', async () => { - await page - .getByRole( 'link', { - name: 'Usage restriction', - } ) - .click(); - await expect( - page.getByPlaceholder( 'No restrictions' ) - ).toHaveValue( - couponData[ couponType ].allowedEmails[ 0 ] +test.describe( + 'Restricted coupon management', + { tag: [ '@services', '@skip-on-default-wpcom' ] }, + () => { + for ( const couponType of Object.keys( couponData ) ) { + test( `can create new ${ couponType } coupon`, async ( { + page, + coupon, + product, + } ) => { + // create basics for the coupon + await test.step( 'add new coupon', async () => { + await page.goto( + 'wp-admin/post-new.php?post_type=shop_coupon' ); - } ); - } - - // verify usage limit - if ( couponType === 'usageLimitPerCoupon' ) { - await test.step( 'verify usage limit coupon', async () => { await page - .getByRole( 'link', { name: 'Usage limits' } ) + .getByLabel( 'Coupon code' ) + .fill( couponData[ couponType ].code ); + await page + .getByPlaceholder( 'Description (optional)' ) + .fill( couponData[ couponType ].description ); + await page + .getByPlaceholder( '0' ) + .fill( couponData[ couponType ].amount ); + await expect( + page.getByText( 'Move to Trash' ) + ).toBeVisible(); + } ); + + // set up the restrictions for each coupon type + // set minimum spend + if ( couponType === 'minimumSpend' ) { + await test.step( 'set minimum spend coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No minimum' ) + .fill( couponData[ couponType ].minSpend ); + } ); + } + // set maximum spend + if ( couponType === 'maximumSpend' ) { + await test.step( 'set maximum spend coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No maximum' ) + .fill( couponData[ couponType ].maxSpend ); + } ); + } + // set individual use + if ( couponType === 'individualUse' ) { + await test.step( 'set individual use coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page.getByLabel( 'Individual use only' ).check(); + } ); + } + // set exclude sale items + if ( couponType === 'excludeSaleItems' ) { + await test.step( 'set exclude sale items coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page.getByLabel( 'Exclude sale items' ).check(); + } ); + } + // set product categories + if ( couponType === 'productCategories' ) { + await test.step( 'set product categories coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'Any category' ) + .pressSequentially( 'Uncategorized' ); + await page + .getByRole( 'option', { name: 'Uncategorized' } ) + .click(); + } ); + } + // set exclude product categories + if ( couponType === 'excludeProductCategories' ) { + await test.step( 'set exclude product categories coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No categories' ) + .pressSequentially( 'Uncategorized' ); + await page + .getByRole( 'option', { name: 'Uncategorized' } ) + .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 () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'Search for a product…' ) + .first() + .pressSequentially( product.name ); + await page + .getByRole( 'option', { name: product.name } ) + .click(); + } ); + } + // set exclude products + if ( couponType === 'excludeProducts' ) { + await test.step( 'set exclude products coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'Search for a product…' ) + .last() + .pressSequentially( product.name ); + await page + .getByRole( 'option', { name: product.name } ) + .click(); + } ); + } + // set allowed emails + if ( couponType === 'allowedEmails' ) { + await test.step( 'set allowed emails coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await page + .getByPlaceholder( 'No restrictions' ) + .fill( + couponData[ couponType ].allowedEmails[ 0 ] + ); + } ); + } + // set usage limit + if ( couponType === 'usageLimitPerCoupon' ) { + await test.step( 'set usage limit coupon', async () => { + await page + .getByRole( 'link', { name: 'Usage limits' } ) + .click(); + await page + .getByLabel( 'Usage limit per coupon' ) + .fill( couponData[ couponType ].usageLimit ); + } ); + } + // set usage limit per user + if ( couponType === 'usageLimitPerUser' ) { + await test.step( 'set usage limit per user coupon', async () => { + await page + .getByRole( 'link', { name: 'Usage limits' } ) + .click(); + await page + .getByLabel( 'Usage limit per user' ) + .fill( couponData[ couponType ].usageLimitPerUser ); + } ); + } + + // publish the coupon and retrieve the id + await test.step( 'publish the coupon', async () => { + await page + .getByRole( 'button', { name: 'Publish', exact: true } ) .click(); await expect( - page.getByLabel( 'Usage limit per coupon' ) - ).toHaveValue( couponData[ couponType ].usageLimit ); + page.getByText( 'Coupon updated.' ) + ).toBeVisible(); + coupon.id = page.url().match( /(?<=post=)\d+/ )[ 0 ]; + expect( coupon.id ).toBeDefined(); } ); - } - // verify usage limit per user - if ( couponType === 'usageLimitPerUser' ) { - await test.step( 'verify usage limit per user coupon', async () => { - await page - .getByRole( 'link', { name: 'Usage limits' } ) - .click(); + // verify the creation of the coupon and basic details + await test.step( 'verify coupon creation', async () => { + await page.goto( + 'wp-admin/edit.php?post_type=shop_coupon' + ); await expect( - page.getByLabel( 'Usage limit per user' ) - ).toHaveValue( couponData[ couponType ].usageLimitPerUser ); + 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(); + + await page + .getByRole( 'link', { + name: couponData[ couponType ].code, + } ) + .first() + .click(); } ); - } - } ); + + // verify the restrictions for each coupon type + // verify minimum spend + if ( couponType === 'minimumSpend' ) { + await test.step( 'verify minimum spend coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByPlaceholder( 'No minimum' ) + ).toHaveValue( couponData[ couponType ].minSpend ); + } ); + } + + // verify maximum spend + if ( couponType === 'maximumSpend' ) { + await test.step( 'verify maximum spend coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByPlaceholder( 'No maximum' ) + ).toHaveValue( couponData[ couponType ].maxSpend ); + } ); + } + + // verify individual use + if ( couponType === 'individualUse' ) { + await test.step( 'verify individual use coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByLabel( 'Individual use only' ) + ).toBeChecked(); + } ); + } + + // verify exclude sale items + if ( couponType === 'excludeSaleItems' ) { + await test.step( 'verify exclude sale items coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByLabel( 'Exclude sale items' ) + ).toBeChecked(); + } ); + } + + // verify product categories + if ( couponType === 'productCategories' ) { + await test.step( 'verify product categories coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByRole( 'listitem', { + name: 'Uncategorized', + } ) + ).toBeVisible(); + } ); + } + + // verify exclude product categories + if ( couponType === 'excludeProductCategories' ) { + await test.step( 'verify exclude product categories coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByRole( 'listitem', { + name: 'Uncategorized', + } ) + ).toBeVisible(); + } ); + } + + // verify products + if ( couponType === 'products' ) { + await test.step( 'verify products coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByRole( 'listitem', { name: product.name } ) + ).toBeVisible(); + } ); + } + + // verify exclude products + if ( couponType === 'excludeProducts' ) { + await test.step( 'verify exclude products coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByRole( 'listitem', { name: product.name } ) + ).toBeVisible(); + } ); + } + + // verify allowed emails + if ( couponType === 'allowedEmails' ) { + await test.step( 'verify allowed emails coupon', async () => { + await page + .getByRole( 'link', { + name: 'Usage restriction', + } ) + .click(); + await expect( + page.getByPlaceholder( 'No restrictions' ) + ).toHaveValue( + couponData[ couponType ].allowedEmails[ 0 ] + ); + } ); + } + + // verify usage limit + if ( couponType === 'usageLimitPerCoupon' ) { + await test.step( 'verify usage limit coupon', async () => { + await page + .getByRole( 'link', { name: 'Usage limits' } ) + .click(); + await expect( + page.getByLabel( 'Usage limit per coupon' ) + ).toHaveValue( couponData[ couponType ].usageLimit ); + } ); + } + + // verify usage limit per user + if ( couponType === 'usageLimitPerUser' ) { + await test.step( 'verify usage limit per user coupon', async () => { + await page + .getByRole( 'link', { name: 'Usage limits' } ) + .click(); + await expect( + page.getByLabel( 'Usage limit per user' ) + ).toHaveValue( + couponData[ couponType ].usageLimitPerUser + ); + } ); + } + } ); + } } -} ); +); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-shipping-zones.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-shipping-zones.spec.js index 93bc2a1d605..37dbf85ec6d 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-shipping-zones.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-shipping-zones.spec.js @@ -60,8 +60,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { // this shipping zone already exists, don't create it } else { await page.goto( - 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new', - { waitUntil: 'networkidle' } + 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new' ); await page .getByPlaceholder( 'Zone name' ) @@ -92,10 +91,8 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .getByRole( 'button', { name: 'Continue' } ) .last() .click(); - await page.waitForLoadState( 'networkidle' ); await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); await expect( page @@ -132,8 +129,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { // this shipping zone already exists, don't create it } else { await page.goto( - 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new', - { waitUntil: 'networkidle' } + 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new' ); await page .getByPlaceholder( 'Zone name' ) @@ -159,10 +155,8 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .getByRole( 'button', { name: 'Continue' } ) .last() .click(); - await page.waitForLoadState( 'networkidle' ); await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); await expect( page @@ -196,8 +190,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { // this shipping zone already exists, don't create it } else { await page.goto( - 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new', - { waitUntil: 'networkidle' } + 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new' ); await page .getByPlaceholder( 'Zone name' ) @@ -209,7 +202,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { input.click(); input.fill( 'Canada' ); - await page.getByText( 'Canada' ).last().click(); + await page.getByLabel( 'Canada', { exact: true } ).click(); // Close dropdown await page.getByPlaceholder( 'Zone name' ).click(); @@ -222,10 +215,8 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .getByRole( 'button', { name: 'Continue' } ) .last() .click(); - await page.waitForLoadState( 'networkidle' ); await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); await expect( page @@ -240,7 +231,6 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .click(); await page.getByLabel( 'Cost', { exact: true } ).fill( '10' ); await page.getByRole( 'button', { name: 'Save' } ).last().click(); - await page.waitForLoadState( 'networkidle' ); await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=shipping' @@ -342,8 +332,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { // this shipping zone already exists, don't create it } else { await page.goto( - 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new', - { waitUntil: 'networkidle' } + 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new' ); await page.locator( '#zone_name' ).fill( shippingZoneNameFlatRate ); @@ -353,7 +342,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { input.click(); input.type( 'Canada' ); - await page.getByText( 'Canada' ).last().click(); + await page.getByLabel( 'Canada', { exact: true } ).click(); // Close dropdown await page.keyboard.press( 'Escape' ); @@ -366,10 +355,7 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .last() .click(); - await page.waitForLoadState( 'networkidle' ); - await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); await expect( page @@ -384,13 +370,17 @@ test.describe( 'WooCommerce Shipping Settings - Add new shipping zone', () => { .click(); await page.locator( '#woocommerce_flat_rate_cost' ).fill( '10' ); await page.locator( '#btn-ok' ).click(); - await page.waitForLoadState( 'networkidle' ); - await page.locator( 'text=Delete' ).waitFor(); + await expect( + page.getByRole( 'cell', { name: 'Edit | Delete', exact: true } ) + ).toBeVisible(); page.on( 'dialog', ( dialog ) => dialog.accept() ); - await page.locator( 'text=Delete' ).click(); + await page + .getByRole( 'cell', { name: 'Edit | Delete', exact: true } ) + .locator( 'text=Delete' ) + .click(); await expect( page.locator( '.wc-shipping-zone-method-blank-state' ) @@ -482,7 +472,6 @@ test.describe( 'Verifies shipping options from customer perspective', () => { await context.clearCookies(); await page.goto( `/shop/?add-to-cart=${ productId }` ); - await page.waitForLoadState( 'networkidle' ); } ); test.afterAll( async ( { baseURL } ) => { diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-blocks.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-blocks.spec.js index 7ade737367f..05b21525efa 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-blocks.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-blocks.spec.js @@ -52,7 +52,14 @@ const test = baseTest.extend( { test.describe( 'Add WooCommerce Blocks Into Page', - { tag: [ '@gutenberg', '@services', '@skip-on-default-pressable' ] }, + { + tag: [ + '@gutenberg', + '@services', + '@skip-on-default-pressable', + '@skip-on-default-wpcom', + ], + }, () => { test.beforeAll( async ( { api } ) => { // add product attribute diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-patterns.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-patterns.spec.js index ee6d56b0d9c..671788097b4 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-patterns.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-woocommerce-patterns.spec.js @@ -28,7 +28,14 @@ const test = baseTest.extend( { test.describe( 'Add WooCommerce Patterns Into Page', - { tag: [ '@gutenberg', '@services', '@skip-on-default-pressable' ] }, + { + tag: [ + '@gutenberg', + '@services', + '@skip-on-default-pressable', + '@skip-on-default-wpcom', + ], + }, () => { test( 'can insert WooCommerce patterns into page', async ( { page, @@ -86,7 +93,9 @@ test.describe( // check some elements from added patterns for ( let i = 1; i < wooPatterns.length; i++ ) { await expect( - page.getByText( `${ wooPatterns[ i ].button }` ) + page.getByRole( 'link', { + name: `${ wooPatterns[ i ].button }`, + } ) ).toBeVisible(); } } ); diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-list.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-list.spec.js index 1b8cb58846a..3a4501f9871 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-list.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-list.spec.js @@ -85,7 +85,7 @@ test.describe( 'Merchant > Customer List', { tag: '@services' }, () => { test( 'Merchant can view a list of all customers, filter and download', - { tag: '@skip-on-default-pressable' }, + { tag: [ '@skip-on-default-pressable', '@skip-on-default-wpcom' ] }, async ( { page, customers } ) => { await test.step( 'Go to the customers reports page', async () => { const responsePromise = page.waitForResponse( diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-payment-page.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-payment-page.spec.js index 652d819edd2..adc673add2b 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-payment-page.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/customer-payment-page.spec.js @@ -110,6 +110,17 @@ test.describe( await test.step( 'Select payment method and pay for the order', async () => { // explicitly select the payment method await page.getByText( 'Direct bank transfer' ).click(); + + // Handle notice if present + await page.addLocatorHandler( + page.getByRole( 'link', { name: 'Dismiss' } ), + async () => { + await page + .getByRole( 'link', { name: 'Dismiss' } ) + .click(); + } + ); + // pay for the order await page .getByRole( 'button', { name: 'Pay for order' } ) diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/launch-your-store.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/launch-your-store.spec.js index cfef43102a9..4d40109a591 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/launch-your-store.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/launch-your-store.spec.js @@ -4,7 +4,7 @@ const wcApi = require( '@woocommerce/woocommerce-rest-api' ).default; test.describe( 'Launch Your Store - logged in', - { tag: [ '@gutenberg', '@services' ] }, + { tag: [ '@gutenberg', '@services', '@skip-on-default-wpcom' ] }, () => { test.use( { storageState: process.env.ADMINSTATE } ); 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/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/legacy/unit-tests/rest-api/Tests/Version2/product-reviews.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/product-reviews.php index df53aeece57..a68ad775f87 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/product-reviews.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/product-reviews.php @@ -1,4 +1,7 @@ assertEquals( 200, $response->get_status() ); $this->assertEquals( 10, count( $product_reviews ) ); - $this->assertContains( - array( - 'id' => $review_id, - 'date_created' => $product_reviews[0]['date_created'], - 'date_created_gmt' => $product_reviews[0]['date_created_gmt'], - 'product_id' => $product->get_id(), - 'product_name' => $product->get_name(), - 'product_permalink' => $product->get_permalink(), - 'status' => 'approved', - 'reviewer' => 'admin', - 'reviewer_email' => 'woo@woo.local', - 'review' => "

Review content here

\n", - 'rating' => 0, - 'verified' => false, - 'reviewer_avatar_urls' => $product_reviews[0]['reviewer_avatar_urls'], - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/products/reviews/' . $review_id ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $review_id, + 'date_created' => $product_reviews[0]['date_created'], + 'date_created_gmt' => $product_reviews[0]['date_created_gmt'], + 'product_id' => $product->get_id(), + 'product_name' => $product->get_name(), + 'product_permalink' => $product->get_permalink(), + 'status' => 'approved', + 'reviewer' => 'admin', + 'reviewer_email' => 'woo@woo.local', + 'review' => "

Review content here

\n", + 'rating' => 0, + 'verified' => false, + 'reviewer_avatar_urls' => $product_reviews[0]['reviewer_avatar_urls'], + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/products/reviews/' . $review_id ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/products/reviews' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/products/reviews' ), + ), ), - ), - 'up' => array( - array( - 'href' => rest_url( '/wc/v3/products/' . $product->get_id() ), + 'up' => array( + array( + 'href' => rest_url( '/wc/v3/products/' . $product->get_id() ), + ), ), ), ), - ), - $product_reviews + $product_reviews[0] + ) ); } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/settings.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/settings.php index 32a96fb95dc..f185a811097 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/settings.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/settings.php @@ -6,6 +6,7 @@ * @since 3.0.0 */ +use Automattic\WooCommerce\Utilities\ArrayUtil; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; /** @@ -482,29 +483,39 @@ class Settings_V2 extends WC_REST_Unit_Test_Case { $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v2/settings/products' ) ); $data = $response->get_data(); $this->assertTrue( is_array( $data ) ); - $this->assertContains( - array( - 'id' => 'woocommerce_downloads_require_login', - 'label' => 'Access restriction', - 'description' => 'Downloads require login', - 'type' => 'checkbox', - 'default' => 'no', - 'tip' => 'This setting does not apply to guest purchases.', - 'value' => 'no', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/settings/products/woocommerce_downloads_require_login' ), + $data_download_required_login = null; + foreach ( $data as $setting ) { + if ( 'woocommerce_downloads_require_login' === $setting['id'] ) { + $data_download_required_login = $setting; + break; + } + } + $this->assertNotEmpty( $data_download_required_login ); + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'woocommerce_downloads_require_login', + 'label' => 'Access restriction', + 'description' => 'Downloads require login', + 'type' => 'checkbox', + 'default' => 'no', + 'tip' => 'This setting does not apply to guest purchases.', + 'value' => 'no', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/settings/products/woocommerce_downloads_require_login' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/settings/products' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/settings/products' ), + ), ), ), ), - ), - $data + $data_download_required_login + ) ); // test get single. @@ -540,29 +551,41 @@ class Settings_V2 extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); - $this->assertContains( - array( - 'id' => 'recipient', - 'label' => 'Recipient(s)', - 'description' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', - 'type' => 'text', - 'default' => '', - 'tip' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', - 'value' => '', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/settings/email_new_order/recipient' ), + $recipient_setting = null; + foreach ( $settings as $setting ) { + if ( 'recipient' === $setting['id'] ) { + $recipient_setting = $setting; + break; + } + } + + $this->assertNotEmpty( $recipient_setting ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'recipient', + 'label' => 'Recipient(s)', + 'description' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', + 'type' => 'text', + 'default' => '', + 'tip' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', + 'value' => '', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/settings/email_new_order/recipient' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/settings/email_new_order' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/settings/email_new_order' ), + ), ), ), ), - ), - $settings + $recipient_setting + ) ); // test get single. diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-methods.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-methods.php index 3f13ecb2ee1..d27efd5b67b 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-methods.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-methods.php @@ -1,4 +1,7 @@ get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertContains( - array( - 'id' => 'free_shipping', - 'title' => 'Free shipping', - 'description' => 'Free shipping is a special method which can be triggered with coupons and minimum spends.', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping_methods/free_shipping' ), + + $free_shipping_method = null; + foreach ( $methods as $method ) { + if ( 'free_shipping' === $method['id'] ) { + $free_shipping_method = $method; + break; + } + } + $this->assertNotEmpty( $free_shipping_method ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'free_shipping', + 'title' => 'Free shipping', + 'description' => 'Free shipping is a special method which can be triggered with coupons and minimum spends.', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping_methods/free_shipping' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping_methods' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping_methods' ), + ), ), ), ), - ), - $methods + $free_shipping_method + ) ); } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-zones.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-zones.php index bc020791d02..1e15bb91e02 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-zones.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version2/shipping-zones.php @@ -1,5 +1,7 @@ assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 1 ); - $this->assertContains( - array( - 'id' => $data[0]['id'], - 'name' => 'Locations not covered by your other zones', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[0]['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data[0]['id'], + 'name' => 'Locations not covered by your other zones', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[0]['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[0]['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[0]['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data[0] + ) ); // Create a zone and make sure it's in the response @@ -108,30 +112,32 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 2 ); - $this->assertContains( - array( - 'id' => $data[1]['id'], - 'name' => 'Zone 1', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[1]['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data[1]['id'], + 'name' => 'Zone 1', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[1]['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[1]['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data[1]['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data[1] + ) ); } @@ -195,30 +201,32 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 201, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $data['id'], - 'name' => 'Test Zone', - 'order' => 1, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data['id'], + 'name' => 'Test Zone', + 'order' => 1, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $data['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $data['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -260,30 +268,32 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $zone->get_id(), - 'name' => 'Zone Test', - 'order' => 2, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $zone->get_id(), + 'name' => 'Zone Test', + 'order' => 2, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -359,30 +369,32 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $zone->get_id(), - 'name' => 'Test Zone', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $zone->get_id(), + 'name' => 'Test Zone', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v2/shipping/zones/' . $zone->get_id() . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -624,13 +636,13 @@ class WC_Tests_API_Shipping_Zones_V2 extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 1 ); - $this->assertContains( $expected, $data ); + $this->assertEmpty( ArrayUtil::deep_assoc_array_diff( $expected, $data[0] ) ); $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v2/shipping/zones/' . $zone->get_id() . '/methods/' . $instance_id ) ); $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( $expected, $data ); + $this->assertEmpty( ArrayUtil::deep_assoc_array_diff( $expected, $data ) ); } /** diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-reviews.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-reviews.php index 11aa94c16b7..b655ffb538a 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-reviews.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/product-reviews.php @@ -1,4 +1,7 @@ assertEquals( 200, $response->get_status() ); $this->assertEquals( 10, count( $product_reviews ) ); - $this->assertContains( - array( - 'id' => $review_id, - 'date_created' => $product_reviews[0]['date_created'], - 'date_created_gmt' => $product_reviews[0]['date_created_gmt'], - 'product_id' => $product->get_id(), - 'product_name' => $product->get_name(), - 'product_permalink' => $product->get_permalink(), - 'status' => 'approved', - 'reviewer' => 'admin', - 'reviewer_email' => 'woo@woo.local', - 'review' => "

Review content here

\n", - 'rating' => 0, - 'verified' => false, - 'reviewer_avatar_urls' => $product_reviews[0]['reviewer_avatar_urls'], - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/products/reviews/' . $review_id ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $review_id, + 'date_created' => $product_reviews[0]['date_created'], + 'date_created_gmt' => $product_reviews[0]['date_created_gmt'], + 'product_id' => $product->get_id(), + 'product_name' => $product->get_name(), + 'product_permalink' => $product->get_permalink(), + 'status' => 'approved', + 'reviewer' => 'admin', + 'reviewer_email' => 'woo@woo.local', + 'review' => "

Review content here

\n", + 'rating' => 0, + 'verified' => false, + 'reviewer_avatar_urls' => $product_reviews[0]['reviewer_avatar_urls'], + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/products/reviews/' . $review_id ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/products/reviews' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/products/reviews' ), + ), ), - ), - 'up' => array( - array( - 'href' => rest_url( '/wc/v3/products/' . $product->get_id() ), + 'up' => array( + array( + 'href' => rest_url( '/wc/v3/products/' . $product->get_id() ), + ), ), ), ), - ), - $product_reviews + $product_reviews[0] + ) ); } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/settings.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/settings.php index d8890569eff..d10062ec2ee 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/settings.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/settings.php @@ -6,6 +6,7 @@ * @since 3.5.0 */ +use Automattic\WooCommerce\Utilities\ArrayUtil; use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts; /** @@ -481,29 +482,42 @@ class Settings extends WC_REST_Unit_Test_Case { $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/settings/products' ) ); $data = $response->get_data(); $this->assertTrue( is_array( $data ) ); - $this->assertContains( - array( - 'id' => 'woocommerce_downloads_require_login', - 'label' => 'Access restriction', - 'description' => 'Downloads require login', - 'type' => 'checkbox', - 'default' => 'no', - 'tip' => 'This setting does not apply to guest purchases.', - 'value' => 'no', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/settings/products/woocommerce_downloads_require_login' ), + + $setting_downloads_required = null; + foreach ( $data as $setting ) { + if ( 'woocommerce_downloads_require_login' === $setting['id'] ) { + $setting_downloads_required = $setting; + break; + } + } + + $this->assertNotEmpty( $setting_downloads_required ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'woocommerce_downloads_require_login', + 'label' => 'Access restriction', + 'description' => 'Downloads require login', + 'type' => 'checkbox', + 'default' => 'no', + 'tip' => 'This setting does not apply to guest purchases.', + 'value' => 'no', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/settings/products/woocommerce_downloads_require_login' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/settings/products' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/settings/products' ), + ), ), ), ), - ), - $data + $setting_downloads_required + ) ); // test get single. @@ -539,29 +553,41 @@ class Settings extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); - $this->assertContains( - array( - 'id' => 'recipient', - 'label' => 'Recipient(s)', - 'description' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', - 'type' => 'text', - 'default' => '', - 'tip' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', - 'value' => '', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/settings/email_new_order/recipient' ), + $recipient_setting = null; + foreach ( $settings as $setting ) { + if ( 'recipient' === $setting['id'] ) { + $recipient_setting = $setting; + break; + } + } + + $this->assertNotEmpty( $recipient_setting ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'recipient', + 'label' => 'Recipient(s)', + 'description' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', + 'type' => 'text', + 'default' => '', + 'tip' => 'Enter recipients (comma separated) for this email. Defaults to admin@example.org.', + 'value' => '', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/settings/email_new_order/recipient' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/settings/email_new_order' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/settings/email_new_order' ), + ), ), ), ), - ), - $settings + $recipient_setting + ) ); // test get single. diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php index 31dc36c1b14..05d38ad0517 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-methods.php @@ -1,4 +1,7 @@ get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertContains( - array( - 'id' => 'free_shipping', - 'title' => 'Free shipping', - 'description' => 'Free shipping is a special method which can be triggered with coupons and minimum spends.', - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping_methods/free_shipping' ), + + $free_shipping = null; + foreach ( $methods as $method ) { + if ( 'free_shipping' === $method['id'] ) { + $free_shipping = $method; + break; + } + } + $this->assertNotEmpty( $free_shipping ); + + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => 'free_shipping', + 'title' => 'Free shipping', + 'description' => 'Free shipping is a special method which can be triggered with coupons and minimum spends.', + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping_methods/free_shipping' ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping_methods' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping_methods' ), + ), ), ), ), - ), - $methods + $free_shipping + ) ); } diff --git a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php index 1dd58034653..3c49902d989 100644 --- a/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php +++ b/plugins/woocommerce/tests/legacy/unit-tests/rest-api/Tests/Version3/shipping-zones.php @@ -1,5 +1,7 @@ assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 1 ); - $this->assertContains( - array( - 'id' => $data[0]['id'], - 'name' => 'Locations not covered by your other zones', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[0]['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data[0]['id'], + 'name' => 'Locations not covered by your other zones', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[0]['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[0]['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[0]['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data[0] + ) ); // Create a zone and make sure it's in the response @@ -111,30 +115,32 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 2 ); - $this->assertContains( - array( - 'id' => $data[1]['id'], - 'name' => 'Zone 1', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[1]['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data[1]['id'], + 'name' => 'Zone 1', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[1]['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[1]['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data[1]['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data[1] + ) ); } @@ -202,30 +208,32 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 201, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $data['id'], - 'name' => 'Test Zone', - 'order' => 1, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data['id'] ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $data['id'], + 'name' => 'Test Zone', + 'order' => 1, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data['id'] ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $data['id'] . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $data['id'] . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -269,30 +277,32 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $zone->get_id(), - 'name' => 'Zone Test', - 'order' => 2, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $zone->get_id(), + 'name' => 'Zone Test', + 'order' => 2, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -373,30 +383,32 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( - array( - 'id' => $zone->get_id(), - 'name' => 'Test Zone', - 'order' => 0, - '_links' => array( - 'self' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() ), + $this->assertEmpty( + ArrayUtil::deep_assoc_array_diff( + array( + 'id' => $zone->get_id(), + 'name' => 'Test Zone', + 'order' => 0, + '_links' => array( + 'self' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() ), + ), ), - ), - 'collection' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones' ), + 'collection' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones' ), + ), ), - ), - 'describedby' => array( - array( - 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() . '/locations' ), + 'describedby' => array( + array( + 'href' => rest_url( '/wc/v3/shipping/zones/' . $zone->get_id() . '/locations' ), + ), ), ), ), - ), - $data + $data + ) ); } @@ -644,13 +656,12 @@ class WC_Tests_API_Shipping_Zones extends WC_REST_Unit_Test_Case { $this->assertEquals( 200, $response->get_status() ); $this->assertEquals( count( $data ), 1 ); - $this->assertContains( $expected, $data ); - + $this->assertEmpty( ArrayUtil::deep_assoc_array_diff( $expected, $data[0] ) ); $response = $this->server->dispatch( new WP_REST_Request( 'GET', '/wc/v3/shipping/zones/' . $zone->get_id() . '/methods/' . $instance_id ) ); $data = $response->get_data(); $this->assertEquals( 200, $response->get_status() ); - $this->assertEquals( $expected, $data ); + $this->assertEmpty( ArrayUtil::deep_assoc_array_diff( $expected, $data ) ); } /** 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( + 'assertIsIntAndEquals( $site_wide_low_stock_amount, wc_get_low_stock_amount( $var1 ) ); } - - /** - * @testdox Test that the `woocommerce_low_stock` action fires when a product stock hits the low stock threshold. - */ - public function test_wc_update_product_stock_low_stock_action() { - $product = WC_Helper_Product::create_simple_product(); - $product->set_manage_stock( true ); - $product->save(); - - $low_stock_amount = wc_get_low_stock_amount( $product ); - $initial_stock = $low_stock_amount + 2; - - wc_update_product_stock( $product->get_id(), $initial_stock ); - - $action_fired = false; - $callback = function () use ( &$action_fired ) { - $action_fired = true; - }; - add_action( 'woocommerce_low_stock', $callback ); - - // Test with `wc_update_product_stock`. - wc_update_product_stock( $product->get_id(), 1, 'decrease' ); - $this->assertFalse( $action_fired ); - wc_update_product_stock( $product->get_id(), 1, 'decrease' ); - $this->assertTrue( $action_fired ); - - $action_fired = false; - - // Test with the data store. - $product->set_stock_quantity( $initial_stock ); - $product->save(); - $this->assertFalse( $action_fired ); - $product->set_stock_quantity( $low_stock_amount ); - $product->save(); - $this->assertTrue( $action_fired ); - - remove_action( 'woocommerce_low_stock', $callback ); - } - - /** - * @testdox Test that the `woocommerce_no_stock` action fires when a product stock hits the no stock threshold. - */ - public function test_wc_update_product_stock_no_stock_action() { - $product = WC_Helper_Product::create_simple_product(); - $product->set_manage_stock( true ); - $product->save(); - - $no_stock_amount = get_option( 'woocommerce_notify_no_stock_amount', 0 ); - $initial_stock = $no_stock_amount + 2; - - wc_update_product_stock( $product->get_id(), $initial_stock ); - - $action_fired = false; - $callback = function () use ( &$action_fired ) { - $action_fired = true; - }; - add_action( 'woocommerce_no_stock', $callback ); - - // Test with `wc_update_product_stock`. - wc_update_product_stock( $product->get_id(), 1, 'decrease' ); - $this->assertFalse( $action_fired ); - wc_update_product_stock( $product->get_id(), 1, 'decrease' ); - $this->assertTrue( $action_fired ); - - $action_fired = false; - - // Test with the data store. - $product->set_stock_quantity( $initial_stock ); - $product->save(); - $this->assertFalse( $action_fired ); - $product->set_stock_quantity( $no_stock_amount ); - $product->save(); - $this->assertTrue( $action_fired ); - - remove_action( 'woocommerce_no_stock', $callback ); - } - - /** - * @testdox The wc_trigger_stock_change_actions function should only trigger actions if the product is set - * to manage stock. - */ - public function test_wc_trigger_stock_change_actions_bails_early_for_unmanaged_stock() { - $action_fired = false; - $callback = function () use ( &$action_fired ) { - $action_fired = true; - }; - add_action( 'woocommerce_no_stock', $callback ); - - $product = WC_Helper_Product::create_simple_product(); - - $this->assertFalse( $action_fired ); - - $product->set_manage_stock( true ); - $product->set_stock_quantity( 0 ); - $product->save(); - - $this->assertTrue( $action_fired ); - - remove_action( 'woocommerce_no_stock', $callback ); - } } diff --git a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php index 1133880bad2..88218f1da75 100644 --- a/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php +++ b/plugins/woocommerce/tests/php/src/Internal/Logging/RemoteLoggerTest.php @@ -228,7 +228,7 @@ namespace Automattic\WooCommerce\Tests\Internal\Logging { array( 'feature' => '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 ); }