diff --git a/.husky/post-checkout b/.husky/post-checkout index cddb5753bc3..1485ab1707b 100755 --- a/.husky/post-checkout +++ b/.husky/post-checkout @@ -1,35 +1,30 @@ #!/usr/bin/env bash . "$(dirname "$0")/_/husky.sh" -# '1' is branch +# The hook documentation: https://git-scm.com/docs/githooks.html#_post_checkout CHECKOUT_TYPE=$3 -redColoured='\033[0;31m' +HEAD_NEW=$2 +HEAD_PREVIOUS=$1 + whiteColoured='\033[0m' +orangeColoured='\033[1;33m' +# '1' is a branch checkout if [ "$CHECKOUT_TYPE" = '1' ]; then - canUpdateDependencies='no' - # Prompt about pnpm versions mismatch when switching between branches. - currentPnpmVersion=$( ( command -v pnpm > /dev/null && pnpm -v ) || echo 'n/a' ) + 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 "${redColoured}pnpm versions mismatch: in use '$currentPnpmVersion', needed '$targetPnpmVersion'. Here some hints how to solve this:\n" - printf "${redColoured}* actualize environment: 'nvm use && pnpm -v' (the most common case)\n" - printf "${redColoured}* install: 'npm install -g pnpm@$targetPnpmVersion'\n" - else - canUpdateDependencies='yes' + 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 ORIG_HEAD | grep -E '(package.json|pnpm-lock.yaml|pnpm-workspace.yaml|composer.json|composer.lock)$' ) || echo '' ) + 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}It was a change in the following file(s) - refreshing dependencies:\n" + 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 - - if [ "$canUpdateDependencies" = 'yes' ]; then - pnpm install --frozen-lockfile - else - printf "${redColoured}Skipping dependencies refresh. Please actualize pnpm version and execute 'pnpm install --frozen-lockfile' manually.\n" - fi + 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/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 d18a4367869..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": { @@ -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": "34109195216ebcb5b23e741391b9f355ba861777a5533d4ef1e341472cb5209e", + "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": "47dc2a7e213e1e9d83e93a85dafdf8c7b539cc5474b4166eb6398b734d150ff3" + "hash": "a88d9ea54465c8bbd820042a92df79cbd48943e785b418fcaa04d0c0e66116c0" } \ No newline at end of file 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/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/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/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 6fd09e4050c..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, } ) => { @@ -357,7 +357,7 @@ test.describe( 'Product Collection registration', () => { } ); } ); - test( 'Product picker should be shown when selected product is deleted', async ( { + test.skip( 'Product picker should be shown when selected product is deleted', async ( { pageObject, admin, editor, 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/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/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/51449-dev-harden-added-to-cart b/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart deleted file mode 100644 index 99351de4130..00000000000 --- a/plugins/woocommerce/changelog/51449-dev-harden-added-to-cart +++ /dev/null @@ -1,4 +0,0 @@ -Significance: patch -Type: fix - -Fix bug where manually triggering `added_to_cart` event without a button element caused an Exception. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/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/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-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-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/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-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/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/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/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php index f1c472a38cd..4e981a84ff0 100644 --- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php +++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php @@ -259,6 +259,18 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller { } } + if ( ! empty( $request['global_unique_id'] ) ) { + $global_unique_ids = array_map( 'trim', explode( ',', $request['global_unique_id'] ) ); + $args['meta_query'] = $this->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-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/readme.txt b/plugins/woocommerce/readme.txt index d3730c2db5b..6b77c072437 100644 --- a/plugins/woocommerce/readme.txt +++ b/plugins/woocommerce/readme.txt @@ -4,7 +4,7 @@ Tags: online store, ecommerce, shop, shopping cart, sell online Requires at least: 6.5 Tested up to: 6.6 Requires PHP: 7.4 -Stable tag: 9.2.3 +Stable tag: 9.3.2 License: GPLv3 License URI: https://www.gnu.org/licenses/gpl-3.0.html 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/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/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/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/tests/e2e-pw/envs/default-wpcom/playwright.config.js b/plugins/woocommerce/tests/e2e-pw/envs/default-wpcom/playwright.config.js index 27ec548c34a..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 @@ -23,6 +23,16 @@ config = { '**/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-restricted-coupons.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/create-restricted-coupons.spec.js index 3fd0b4c1717..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 @@ -103,397 +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(); - } ); - } - - // 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.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/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/wc-stock-functions-tests.php b/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php index 90f312be98b..3e8bbb7bed8 100644 --- a/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php +++ b/plugins/woocommerce/tests/php/includes/wc-stock-functions-tests.php @@ -356,104 +356,4 @@ class WC_Stock_Functions_Tests extends \WC_Unit_Test_Case { $this->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 ); - } }