diff --git a/.editorconfig b/.editorconfig index 227bdbd85fa..e71bb8db345 100644 --- a/.editorconfig +++ b/.editorconfig @@ -22,6 +22,3 @@ trim_trailing_whitespace = false trim_trailing_whitespace = false indent_style = space indent_size = 2 - -[*.md] -indent_style = space diff --git a/assets/js/base/components/block-error-boundary/types.ts b/assets/js/base/components/block-error-boundary/types.ts index 6f383cceed1..b15f4c50d53 100644 --- a/assets/js/base/components/block-error-boundary/types.ts +++ b/assets/js/base/components/block-error-boundary/types.ts @@ -47,7 +47,7 @@ export interface BlockErrorBoundaryProps extends BlockErrorBase { * Override the default error with a function that takes the error message and returns a React component */ renderError?: ( props: RenderErrorProps ) => React.ReactNode; - showErrorMessage?: boolean; + showErrorMessage?: boolean | undefined; } export interface DerivedStateReturn { diff --git a/assets/js/base/components/country-input/country-input.tsx b/assets/js/base/components/country-input/country-input.tsx index 5a8d6a1a8dd..e9061a08da1 100644 --- a/assets/js/base/components/country-input/country-input.tsx +++ b/assets/js/base/components/country-input/country-input.tsx @@ -57,36 +57,6 @@ export const CountryInput = ( { required={ required } autoComplete={ autoComplete } /> - { autoComplete !== 'off' && ( - { - const textValue = - event.target.value.toLocaleUpperCase(); - const foundOption = options.find( - ( option ) => - ( textValue.length !== 2 && - option.label.toLocaleUpperCase() === - textValue ) || - ( textValue.length === 2 && - option.value.toLocaleUpperCase() === - textValue ) - ); - onChange( foundOption ? foundOption.value : '' ); - } } - style={ { - minHeight: '0', - height: '0', - border: '0', - padding: '0', - position: 'absolute', - } } - tabIndex={ -1 } - /> - ) } ); }; diff --git a/assets/js/base/components/sidebar-layout/style.scss b/assets/js/base/components/sidebar-layout/style.scss index 107915bd2bd..72a3babfa39 100644 --- a/assets/js/base/components/sidebar-layout/style.scss +++ b/assets/js/base/components/sidebar-layout/style.scss @@ -4,6 +4,10 @@ margin: 0 auto $gap; position: relative; + .wc-block-must-login-prompt { + display: block; + } + .wc-block-components-main { box-sizing: border-box; margin: 0; diff --git a/assets/js/base/components/state-input/state-input.tsx b/assets/js/base/components/state-input/state-input.tsx index ca9707269a3..15d8b6f61b9 100644 --- a/assets/js/base/components/state-input/state-input.tsx +++ b/assets/js/base/components/state-input/state-input.tsx @@ -88,44 +88,23 @@ const StateInput = ( { if ( options.length > 0 ) { return ( - <> - - { autoComplete !== 'off' && ( - - onChangeState( event.target.value ) - } - style={ { - minHeight: '0', - height: '0', - border: '0', - padding: '0', - position: 'absolute', - } } - tabIndex={ -1 } - /> + + id={ id } + label={ label } + onChange={ onChangeState } + options={ options } + value={ value } + errorMessage={ __( + 'Please select a state.', + 'woo-gutenberg-products-block' + ) } + required={ required } + autoComplete={ autoComplete } + /> ); } diff --git a/assets/js/base/context/event-emit/utils.ts b/assets/js/base/context/event-emit/utils.ts index 5f5a58c0b64..7cb4a5c036e 100644 --- a/assets/js/base/context/event-emit/utils.ts +++ b/assets/js/base/context/event-emit/utils.ts @@ -26,8 +26,15 @@ export enum responseTypes { } export enum noticeContexts { - PAYMENTS = 'wc/payment-area', - EXPRESS_PAYMENTS = 'wc/express-payment-area', + CART = 'wc/cart', + CHECKOUT = 'wc/checkout', + PAYMENTS = 'wc/checkout/payments', + EXPRESS_PAYMENTS = 'wc/checkout/express-payments', + CONTACT_INFORMATION = 'wc/checkout/contact-information', + SHIPPING_ADDRESS = 'wc/checkout/shipping-address', + BILLING_ADDRESS = 'wc/checkout/billing-address', + SHIPPING_METHODS = 'wc/checkout/shipping-methods', + CHECKOUT_ACTIONS = 'wc/checkout/checkout-actions', } export interface ResponseType extends Record< string, unknown > { diff --git a/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts b/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts index b3ff5960823..4ba4807d0da 100644 --- a/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts +++ b/assets/js/base/context/hooks/cart/use-store-cart-coupons.ts @@ -1,16 +1,12 @@ -/** @typedef { import('@woocommerce/type-defs/hooks').StoreCartCoupon } StoreCartCoupon */ - /** * External dependencies */ import { __, sprintf } from '@wordpress/i18n'; import { useDispatch, useSelect } from '@wordpress/data'; -import { - CART_STORE_KEY as storeKey, - VALIDATION_STORE_KEY, -} from '@woocommerce/block-data'; +import { CART_STORE_KEY, VALIDATION_STORE_KEY } from '@woocommerce/block-data'; import { decodeEntities } from '@wordpress/html-entities'; import type { StoreCartCoupon } from '@woocommerce/types'; +import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout'; /** * Internal dependencies @@ -21,9 +17,6 @@ import { useStoreCart } from './use-store-cart'; * This is a custom hook for loading the Store API /cart/coupons endpoint and an * action for adding a coupon _to_ the cart. * See also: https://github.com/woocommerce/woocommerce-gutenberg-products-block/tree/trunk/src/RestApi/StoreApi - * - * @return {StoreCartCoupon} An object exposing data and actions from/for the - * store api /cart/coupons endpoint. */ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => { const { cartCoupons, cartIsLoading } = useStoreCart(); @@ -32,37 +25,35 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => { const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY ); const { - applyCoupon, - removeCoupon, isApplyingCoupon, isRemovingCoupon, - }: Pick< - StoreCartCoupon, - | 'applyCoupon' - | 'removeCoupon' - | 'isApplyingCoupon' - | 'isRemovingCoupon' - | 'receiveApplyingCoupon' - > = useSelect( - ( select, { dispatch } ) => { - const store = select( storeKey ); - const actions = dispatch( storeKey ); + }: Pick< StoreCartCoupon, 'isApplyingCoupon' | 'isRemovingCoupon' > = + useSelect( + ( select ) => { + const store = select( CART_STORE_KEY ); + + return { + isApplyingCoupon: store.isApplyingCoupon(), + isRemovingCoupon: store.isRemovingCoupon(), + }; + }, + [ createErrorNotice, createNotice ] + ); - return { - applyCoupon: actions.applyCoupon, - removeCoupon: actions.removeCoupon, - isApplyingCoupon: store.isApplyingCoupon(), - isRemovingCoupon: store.isRemovingCoupon(), - receiveApplyingCoupon: actions.receiveApplyingCoupon, - }; - }, - [ createErrorNotice, createNotice ] - ); + const { applyCoupon, removeCoupon, receiveApplyingCoupon } = + useDispatch( CART_STORE_KEY ); const applyCouponWithNotices = ( couponCode: string ) => { applyCoupon( couponCode ) .then( ( result ) => { - if ( result === true ) { + if ( + result === true && + __experimentalApplyCheckoutFilter( { + filterName: 'showApplyCouponNotice', + defaultValue: true, + arg: { couponCode, context }, + } ) + ) { createNotice( 'info', sprintf( @@ -96,7 +87,14 @@ export const useStoreCartCoupons = ( context = '' ): StoreCartCoupon => { const removeCouponWithNotices = ( couponCode: string ) => { removeCoupon( couponCode ) .then( ( result ) => { - if ( result === true ) { + if ( + result === true && + __experimentalApplyCheckoutFilter( { + filterName: 'showRemoveCouponNotice', + defaultValue: true, + arg: { couponCode, context }, + } ) + ) { createNotice( 'info', sprintf( diff --git a/assets/js/base/context/hooks/index.js b/assets/js/base/context/hooks/index.js index b47f97613ff..7190de32465 100644 --- a/assets/js/base/context/hooks/index.js +++ b/assets/js/base/context/hooks/index.js @@ -8,7 +8,6 @@ export * from './use-store-products'; export * from './use-store-add-to-cart'; export * from './use-customer-data'; export * from './use-checkout-address'; -export * from './use-checkout-notices'; export * from './use-checkout-submit'; export * from './use-checkout-extension-data'; export * from './use-validation'; diff --git a/assets/js/base/context/hooks/use-checkout-notices.js b/assets/js/base/context/hooks/use-checkout-notices.js deleted file mode 100644 index c211bcabc14..00000000000 --- a/assets/js/base/context/hooks/use-checkout-notices.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * External dependencies - */ -import { useSelect } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { noticeContexts } from '../event-emit'; - -/** - * @typedef {import('@woocommerce/type-defs/contexts').StoreNoticeObject} StoreNoticeObject - * @typedef {import('@woocommerce/type-defs/hooks').CheckoutNotices} CheckoutNotices - */ - -/** - * A hook that returns all notices visible in the Checkout block. - * - * @return {CheckoutNotices} Notices from the checkout form or payment methods. - */ -export const useCheckoutNotices = () => { - /** - * @type {StoreNoticeObject[]} - */ - const checkoutNotices = useSelect( - ( select ) => select( 'core/notices' ).getNotices( 'wc/checkout' ), - [] - ); - - /** - * @type {StoreNoticeObject[]} - */ - const expressPaymentNotices = useSelect( - ( select ) => - select( 'core/notices' ).getNotices( - noticeContexts.EXPRESS_PAYMENTS - ), - [ noticeContexts.EXPRESS_PAYMENTS ] - ); - - /** - * @type {StoreNoticeObject[]} - */ - const paymentNotices = useSelect( - ( select ) => - select( 'core/notices' ).getNotices( noticeContexts.PAYMENTS ), - [ noticeContexts.PAYMENTS ] - ); - - return { - checkoutNotices, - expressPaymentNotices, - paymentNotices, - }; -}; diff --git a/assets/js/base/context/providers/cart-checkout/checkout-events/index.tsx b/assets/js/base/context/providers/cart-checkout/checkout-events/index.tsx index 0525be93980..16fc2484166 100644 --- a/assets/js/base/context/providers/cart-checkout/checkout-events/index.tsx +++ b/assets/js/base/context/providers/cart-checkout/checkout-events/index.tsx @@ -24,9 +24,8 @@ import { * Internal dependencies */ import { useEventEmitters, reducer as emitReducer } from './event-emit'; -import type { emitterCallback } from '../../../event-emit'; +import { emitterCallback, noticeContexts } from '../../../event-emit'; import { useStoreEvents } from '../../../hooks/use-store-events'; -import { useCheckoutNotices } from '../../../hooks/use-checkout-notices'; import { getExpressPaymentMethods, getPaymentMethods, @@ -134,11 +133,29 @@ export const CheckoutEventsProvider = ( { } const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY ); - const { createErrorNotice } = useDispatch( 'core/notices' ); - const { dispatchCheckoutEvent } = useStoreEvents(); const { checkoutNotices, paymentNotices, expressPaymentNotices } = - useCheckoutNotices(); + useSelect( ( select ) => { + const { getNotices } = select( 'core/notices' ); + const checkoutContexts = Object.values( noticeContexts ).filter( + ( context ) => + context !== noticeContexts.PAYMENTS && + context !== noticeContexts.EXPRESS_PAYMENTS + ); + const allCheckoutNotices = checkoutContexts.reduce( + ( acc, context ) => { + return [ ...acc, ...getNotices( context ) ]; + }, + [] + ); + return { + checkoutNotices: allCheckoutNotices, + paymentNotices: getNotices( noticeContexts.PAYMENTS ), + expressPaymentNotices: getNotices( + noticeContexts.EXPRESS_PAYMENTS + ), + }; + }, [] ); const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); const currentObservers = useRef( observers ); @@ -186,7 +203,6 @@ export const CheckoutEventsProvider = ( { }, [ isCheckoutBeforeProcessing, setValidationErrors, - createErrorNotice, __internalEmitValidateEvent, ] ); @@ -224,7 +240,6 @@ export const CheckoutEventsProvider = ( { isCheckoutBeforeProcessing, previousStatus, previousHasError, - createErrorNotice, checkoutNotices, expressPaymentNotices, paymentNotices, diff --git a/assets/js/base/context/providers/cart-checkout/checkout-processor.js b/assets/js/base/context/providers/cart-checkout/checkout-processor.ts similarity index 80% rename from assets/js/base/context/providers/cart-checkout/checkout-processor.js rename to assets/js/base/context/providers/cart-checkout/checkout-processor.ts index 01844818aac..0cc709c8fbf 100644 --- a/assets/js/base/context/providers/cart-checkout/checkout-processor.js +++ b/assets/js/base/context/providers/cart-checkout/checkout-processor.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import triggerFetch from '@wordpress/api-fetch'; import { useEffect, @@ -12,7 +12,7 @@ import { } from '@wordpress/element'; import { emptyHiddenAddressFields, - formatStoreApiErrorMessage, + removeAllNotices, } from '@woocommerce/base-utils'; import { useDispatch, useSelect } from '@wordpress/data'; import { @@ -20,11 +20,18 @@ import { PAYMENT_STORE_KEY, VALIDATION_STORE_KEY, CART_STORE_KEY, + processErrorResponse, } from '@woocommerce/block-data'; import { getPaymentMethods, getExpressPaymentMethods, } from '@woocommerce/blocks-registry'; +import { + ApiResponse, + CheckoutResponseSuccess, + CheckoutResponseError, + assertResponseIsValid, +} from '@woocommerce/types'; /** * Internal dependencies @@ -78,7 +85,6 @@ const CheckoutProcessor = () => { ); const { cartNeedsPayment, cartNeedsShipping, receiveCart } = useStoreCart(); - const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' ); const { activePaymentMethod, @@ -177,7 +183,7 @@ const CheckoutProcessor = () => { // Validate the checkout using the CHECKOUT_VALIDATION_BEFORE_PROCESSING event useEffect( () => { - let unsubscribeProcessing; + let unsubscribeProcessing: () => void; if ( ! isExpressPaymentMethodActive ) { unsubscribeProcessing = onCheckoutValidationBeforeProcessing( checkValidation, @@ -185,7 +191,10 @@ const CheckoutProcessor = () => { ); } return () => { - if ( ! isExpressPaymentMethodActive ) { + if ( + ! isExpressPaymentMethodActive && + typeof unsubscribeProcessing === 'function' + ) { unsubscribeProcessing(); } }; @@ -208,7 +217,7 @@ const CheckoutProcessor = () => { return; } setIsProcessingOrder( true ); - removeNotice( 'checkout' ); + removeAllNotices(); const paymentData = cartNeedsPayment ? { @@ -222,6 +231,9 @@ const CheckoutProcessor = () => { : {}; const data = { + shipping_address: cartNeedsShipping + ? emptyHiddenAddressFields( currentShippingAddress.current ) + : undefined, billing_address: emptyHiddenAddressFields( currentBillingAddress.current ), @@ -231,12 +243,6 @@ const CheckoutProcessor = () => { extensions: { ...extensionData }, }; - if ( cartNeedsShipping ) { - data.shipping_address = emptyHiddenAddressFields( - currentShippingAddress.current - ); - } - triggerFetch( { path: '/wc/store/v1/checkout', method: 'POST', @@ -244,74 +250,49 @@ const CheckoutProcessor = () => { cache: 'no-store', parse: false, } ) - .then( ( response ) => { + .then( ( response: unknown ) => { + assertResponseIsValid< CheckoutResponseSuccess >( response ); processCheckoutResponseHeaders( response.headers ); if ( ! response.ok ) { - throw new Error( response ); + throw response; } return response.json(); } ) - .then( ( responseJson ) => { + .then( ( responseJson: CheckoutResponseSuccess ) => { __internalProcessCheckoutResponse( responseJson ); setIsProcessingOrder( false ); } ) - .catch( ( errorResponse ) => { + .catch( ( errorResponse: ApiResponse< CheckoutResponseError > ) => { + processCheckoutResponseHeaders( errorResponse?.headers ); try { - if ( errorResponse?.headers ) { - processCheckoutResponseHeaders( errorResponse.headers ); - } // This attempts to parse a JSON error response where the status code was 4xx/5xx. - errorResponse.json().then( ( response ) => { - // If updated cart state was returned, update the store. - if ( response.data?.cart ) { - receiveCart( response.data.cart ); - } - createErrorNotice( - formatStoreApiErrorMessage( response ), - { - id: 'checkout', - context: 'wc/checkout', - __unstableHTML: true, + errorResponse + .json() + .then( + ( response ) => response as CheckoutResponseError + ) + .then( ( response: CheckoutResponseError ) => { + if ( response.data?.cart ) { + receiveCart( response.data.cart ); } - ); - response?.additional_errors?.forEach?.( - ( additionalError ) => { - createErrorNotice( additionalError.message, { - id: additionalError.error_code, - context: 'wc/checkout', - __unstableHTML: true, - } ); - } - ); - __internalProcessCheckoutResponse( response ); - } ); + processErrorResponse( response ); + __internalProcessCheckoutResponse( response ); + } ); } catch { - createErrorNotice( - sprintf( - // Translators: %s Error text. - __( - '%s Please try placing your order again.', - 'woo-gutenberg-products-block' - ), - errorResponse?.message ?? - __( - 'Something went wrong. Please contact us for assistance.', - 'woo-gutenberg-products-block' - ) + processErrorResponse( { + code: 'unknown_error', + message: __( + 'Something went wrong. Please try placing your order again.', + 'woo-gutenberg-products-block' ), - { - id: 'checkout', - context: 'wc/checkout', - __unstableHTML: true, - } - ); + data: null, + } ); } __internalSetHasError( true ); setIsProcessingOrder( false ); } ); }, [ isProcessingOrder, - removeNotice, cartNeedsPayment, paymentMethodId, paymentMethodData, @@ -321,7 +302,6 @@ const CheckoutProcessor = () => { shouldCreateAccount, extensionData, cartNeedsShipping, - createErrorNotice, receiveCart, __internalSetHasError, __internalProcessCheckoutResponse, diff --git a/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx b/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx index 085514f2855..10a38b2cea2 100644 --- a/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx +++ b/assets/js/base/context/providers/cart-checkout/payment-events/index.tsx @@ -19,7 +19,6 @@ import { * Internal dependencies */ import { useEventEmitters, reducer as emitReducer } from './event-emit'; -import { useCustomerData } from '../../../hooks/use-customer-data'; import { emitterCallback } from '../../../event-emit'; type PaymentEventsContextType = { @@ -73,7 +72,6 @@ export const PaymentEventsProvider = ( { }; } ); - const { createErrorNotice, removeNotice } = useDispatch( 'core/notices' ); const { setValidationErrors } = useDispatch( VALIDATION_STORE_KEY ); const [ observers, observerDispatch ] = useReducer( emitReducer, {} ); const { onPaymentProcessing } = useEventEmitters( observerDispatch ); @@ -87,10 +85,8 @@ export const PaymentEventsProvider = ( { const { __internalSetPaymentProcessing, __internalSetPaymentPristine, - __internalSetPaymentMethodData, __internalEmitPaymentProcessingEvent, } = useDispatch( PAYMENT_STORE_KEY ); - const { setBillingAddress, setShippingAddress } = useCustomerData(); // flip payment to processing if checkout processing is complete, there are no errors, and payment status is started. useEffect( () => { @@ -139,11 +135,6 @@ export const PaymentEventsProvider = ( { }, [ isPaymentProcessing, setValidationErrors, - removeNotice, - createErrorNotice, - setBillingAddress, - __internalSetPaymentMethodData, - setShippingAddress, __internalEmitPaymentProcessingEvent, ] ); diff --git a/assets/js/base/context/providers/cart-checkout/utils.ts b/assets/js/base/context/providers/cart-checkout/utils.ts index 5151a987824..da9044f111a 100644 --- a/assets/js/base/context/providers/cart-checkout/utils.ts +++ b/assets/js/base/context/providers/cart-checkout/utils.ts @@ -31,7 +31,12 @@ export const preparePaymentData = ( /** * Process headers from an API response an dispatch updates. */ -export const processCheckoutResponseHeaders = ( headers: Headers ): void => { +export const processCheckoutResponseHeaders = ( + headers: Headers | undefined +): void => { + if ( ! headers ) { + return; + } const { __internalSetCustomerId } = dispatch( CHECKOUT_STORE_KEY ); if ( // eslint-disable-next-line @typescript-eslint/ban-ts-comment diff --git a/assets/js/base/context/providers/index.js b/assets/js/base/context/providers/index.js index 73e64c7c2cc..d02577e0b64 100644 --- a/assets/js/base/context/providers/index.js +++ b/assets/js/base/context/providers/index.js @@ -1,7 +1,6 @@ export * from './editor-context'; export * from './add-to-cart-form'; export * from './cart-checkout'; -export * from './store-snackbar-notices'; export * from './container-width-context'; export * from './editor-context'; export * from './query-state-context'; diff --git a/assets/js/base/context/providers/store-snackbar-notices/components/snackbar-notices-container.js b/assets/js/base/context/providers/store-snackbar-notices/components/snackbar-notices-container.js deleted file mode 100644 index 87ee473c504..00000000000 --- a/assets/js/base/context/providers/store-snackbar-notices/components/snackbar-notices-container.js +++ /dev/null @@ -1,85 +0,0 @@ -/** - * External dependencies - */ -import PropTypes from 'prop-types'; -import { SnackbarList } from 'wordpress-components'; -import classnames from 'classnames'; -import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout'; -import { useDispatch, useSelect } from '@wordpress/data'; - -/** - * Internal dependencies - */ -import { useEditorContext } from '../../editor-context'; - -const EMPTY_SNACKBAR_NOTICES = {}; - -export const SnackbarNoticesContainer = ( { - className, - context = 'default', -} ) => { - const { isEditor } = useEditorContext(); - - const { notices } = useSelect( ( select ) => { - const store = select( 'core/notices' ); - return { - notices: store.getNotices( context ), - }; - } ); - const { removeNotice } = useDispatch( 'core/notices' ); - - if ( isEditor ) { - return null; - } - - const snackbarNotices = notices.filter( - ( notice ) => notice.type === 'snackbar' - ); - - const noticeVisibility = - snackbarNotices.length > 0 - ? snackbarNotices.reduce( ( acc, { content } ) => { - acc[ content ] = true; - return acc; - }, {} ) - : EMPTY_SNACKBAR_NOTICES; - - const filteredNotices = __experimentalApplyCheckoutFilter( { - filterName: 'snackbarNoticeVisibility', - defaultValue: noticeVisibility, - } ); - - const visibleNotices = snackbarNotices.filter( - ( notice ) => filteredNotices[ notice.content ] === true - ); - - const wrapperClass = classnames( - className, - 'wc-block-components-notices__snackbar' - ); - - return ( - { - visibleNotices.forEach( ( notice ) => - removeNotice( notice.id, context ) - ); - } } - /> - ); -}; - -SnackbarNoticesContainer.propTypes = { - className: PropTypes.string, - notices: PropTypes.arrayOf( - PropTypes.shape( { - content: PropTypes.string.isRequired, - id: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - isDismissible: PropTypes.bool, - type: PropTypes.oneOf( [ 'default', 'snackbar' ] ), - } ) - ), -}; diff --git a/assets/js/base/context/providers/store-snackbar-notices/components/style.scss b/assets/js/base/context/providers/store-snackbar-notices/components/style.scss deleted file mode 100644 index e5a419a1810..00000000000 --- a/assets/js/base/context/providers/store-snackbar-notices/components/style.scss +++ /dev/null @@ -1,20 +0,0 @@ -.wc-block-components-notices__snackbar { - position: fixed; - bottom: 20px; - left: 16px; - width: auto; - - @include breakpoint("<782px") { - position: fixed; - top: 10px; - left: 0; - bottom: auto; - } - - .components-snackbar-list__notice-container { - @include breakpoint("<782px") { - margin-left: 10px; - margin-right: 10px; - } - } -} diff --git a/assets/js/base/context/providers/store-snackbar-notices/index.ts b/assets/js/base/context/providers/store-snackbar-notices/index.ts deleted file mode 100644 index 05f01b11123..00000000000 --- a/assets/js/base/context/providers/store-snackbar-notices/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './components/snackbar-notices-container'; diff --git a/assets/js/base/utils/create-notice.ts b/assets/js/base/utils/create-notice.ts new file mode 100644 index 00000000000..40f03a7489f --- /dev/null +++ b/assets/js/base/utils/create-notice.ts @@ -0,0 +1,94 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import type { Options as NoticeOptions } from '@wordpress/notices'; +import { select, dispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { noticeContexts } from '../context/event-emit/utils'; + +export const DEFAULT_ERROR_MESSAGE = __( + 'Something went wrong. Please contact us to get assistance.', + 'woo-gutenberg-products-block' +); + +export const hasStoreNoticesContainer = ( container: string ): boolean => { + const containers = select( 'wc/store/store-notices' ).getContainers(); + return containers.includes( container ); +}; + +const findParentContainer = ( container: string ): string => { + if ( container.includes( noticeContexts.CHECKOUT + '/' ) ) { + return noticeContexts.CHECKOUT; + } + if ( container.includes( noticeContexts.CART + '/' ) ) { + return hasStoreNoticesContainer( noticeContexts.CART ) + ? noticeContexts.CART + : noticeContexts.CHECKOUT; + } + return container; +}; + +/** + * Wrapper for @wordpress/notices createNotice. + * + * This is used to create the correct type of notice based on the provided context, and to ensure the notice container + * exists first, otherwise it uses the default context instead. + */ +export const createNotice = ( + status: 'error' | 'warning' | 'info' | 'success', + message: string, + options: Partial< NoticeOptions > +) => { + const noticeContext = options?.context; + const suppressNotices = + select( 'wc/store/payment' ).isExpressPaymentMethodActive(); + + if ( suppressNotices || noticeContext === undefined ) { + return; + } + + const { createNotice: dispatchCreateNotice } = dispatch( 'core/notices' ); + + dispatchCreateNotice( status, message, { + isDismissible: true, + ...options, + context: hasStoreNoticesContainer( noticeContext ) + ? noticeContext + : findParentContainer( noticeContext ), + } ); +}; + +/** + * Creates a notice only if the Store Notice Container is visible. + */ +export const createNoticeIfVisible = ( + status: 'error' | 'warning' | 'info' | 'success', + message: string, + options: Partial< NoticeOptions > +) => { + if ( options?.context && hasStoreNoticesContainer( options.context ) ) { + createNotice( status, message, options ); + } +}; + +/** + * Remove notices from all contexts. + * + * @todo Remove this when supported in Gutenberg. + * @see https://github.com/WordPress/gutenberg/pull/44059 + */ +export const removeAllNotices = () => { + const containers = select( 'wc/store/store-notices' ).getContainers(); + const { removeNotice } = dispatch( 'core/notices' ); + const { getNotices } = select( 'core/notices' ); + + containers.forEach( ( container ) => { + getNotices( container ).forEach( ( notice ) => { + removeNotice( notice.id, container ); + } ); + } ); +}; diff --git a/assets/js/base/utils/errors.js b/assets/js/base/utils/errors.js index dd8c5020851..a2bbb56b926 100644 --- a/assets/js/base/utils/errors.js +++ b/assets/js/base/utils/errors.js @@ -1,9 +1,3 @@ -/** - * External dependencies - */ -import { __ } from '@wordpress/i18n'; -import { decodeEntities } from '@wordpress/html-entities'; - /** * Given a JS error or a fetch response error, parse and format it, so it can be displayed to the user. * @@ -34,27 +28,3 @@ export const formatError = async ( error ) => { type: error.type || 'general', }; }; - -/** - * Given an API response object, formats the error message into something more human-readable. - * - * @param {Object} response Response object. - * @return {string} Error message. - */ -export const formatStoreApiErrorMessage = ( response ) => { - if ( response.data && response.code === 'rest_invalid_param' ) { - const invalidParams = Object.values( response.data.params ); - if ( invalidParams[ 0 ] ) { - return invalidParams[ 0 ]; - } - } - - if ( ! response?.message ) { - return __( - 'Something went wrong. Please contact us to get assistance.', - 'woo-gutenberg-products-block' - ); - } - - return decodeEntities( response.message ); -}; diff --git a/assets/js/base/utils/index.js b/assets/js/base/utils/index.js index c81fa33c4b2..3439277d829 100644 --- a/assets/js/base/utils/index.js +++ b/assets/js/base/utils/index.js @@ -8,3 +8,4 @@ export * from './product-data'; export * from './derive-selected-shipping-rates'; export * from './get-icons-from-payment-methods'; export * from './parse-style'; +export * from './create-notice'; diff --git a/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-error-boundary.js b/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-error-boundary.js index ea2904e7f49..42f90ed26d3 100644 --- a/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-error-boundary.js +++ b/assets/js/blocks/cart-checkout-shared/payment-methods/payment-method-error-boundary.js @@ -6,7 +6,7 @@ import { Component } from 'react'; import PropTypes from 'prop-types'; import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings'; import { StoreNoticesContainer } from '@woocommerce/blocks-checkout'; -import { noticeContexts } from '@woocommerce/base-context/hooks'; +import { noticeContexts } from '@woocommerce/base-context'; class PaymentMethodErrorBoundary extends Component { state = { errorMessage: '', hasError: false }; diff --git a/assets/js/blocks/cart/block.js b/assets/js/blocks/cart/block.js index 42459b213a4..e2b2fc829e6 100644 --- a/assets/js/blocks/cart/block.js +++ b/assets/js/blocks/cart/block.js @@ -5,12 +5,11 @@ import { __ } from '@wordpress/i18n'; import { useStoreCart } from '@woocommerce/base-context/hooks'; import { useEffect } from '@wordpress/element'; import LoadingMask from '@woocommerce/base-components/loading-mask'; -import { SnackbarNoticesContainer } from '@woocommerce/base-context'; +import { CartProvider, noticeContexts } from '@woocommerce/base-context'; import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings'; import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary'; import { translateJQueryEventToNative } from '@woocommerce/base-utils'; import withScrollToTop from '@woocommerce/base-hocs/with-scroll-to-top'; -import { CartProvider } from '@woocommerce/base-context/providers'; import { SlotFillProvider, StoreNoticesContainer, @@ -83,8 +82,7 @@ const Block = ( { attributes, children, scrollToTop } ) => ( } showErrorMessage={ CURRENT_USER_IS_ADMIN } > - - + { children } diff --git a/assets/js/blocks/checkout/block.tsx b/assets/js/blocks/checkout/block.tsx index deebe6ec439..861c46ae05d 100644 --- a/assets/js/blocks/checkout/block.tsx +++ b/assets/js/blocks/checkout/block.tsx @@ -5,11 +5,7 @@ import { __ } from '@wordpress/i18n'; import classnames from 'classnames'; import { createInterpolateElement, useEffect } from '@wordpress/element'; import { useStoreCart } from '@woocommerce/base-context/hooks'; -import { - CheckoutProvider, - SnackbarNoticesContainer, -} from '@woocommerce/base-context'; - +import { CheckoutProvider, noticeContexts } from '@woocommerce/base-context'; import BlockErrorBoundary from '@woocommerce/base-components/block-error-boundary'; import { SidebarLayout } from '@woocommerce/base-components/sidebar-layout'; import { CURRENT_USER_IS_ADMIN, getSetting } from '@woocommerce/settings'; @@ -33,22 +29,21 @@ import CheckoutOrderError from './checkout-order-error'; import { LOGIN_TO_CHECKOUT_URL, isLoginRequired, reloadPage } from './utils'; import type { Attributes } from './types'; import { CheckoutBlockContext } from './context'; -import { hasNoticesOfType } from '../../utils/notices'; -const LoginPrompt = () => { +const MustLoginPrompt = () => { return ( - <> +
{ __( - 'You must be logged in to checkout. ', + 'You must be logged in to checkout.', 'woo-gutenberg-products-block' - ) } + ) }{ ' ' } { __( 'Click here to log in.', 'woo-gutenberg-products-block' ) } - +
); }; @@ -84,22 +79,29 @@ const Checkout = ( { return ; } + /** + * If checkout requires an account (guest checkout is turned off), render + * a notice and prevent access to the checkout, unless we explicitly allow + * account creation during the checkout flow. + */ if ( isLoginRequired( customerId ) && - getSetting( 'checkoutAllowsSignup', false ) + ! getSetting( 'checkoutAllowsSignup', false ) ) { - ; + return ; } return ( { children } @@ -129,9 +131,7 @@ const ScrollOnError = ( { const { showAllValidationErrors } = useDispatch( VALIDATION_STORE_KEY ); const hasErrorsToDisplay = - checkoutIsIdle && - checkoutHasError && - ( hasValidationErrors || hasNoticesOfType( 'wc/checkout', 'default' ) ); + checkoutIsIdle && checkoutHasError && hasValidationErrors; useEffect( () => { let scrollToTopTimeout: number; @@ -183,8 +183,7 @@ const Block = ( { ) } showErrorMessage={ CURRENT_USER_IS_ADMIN } > - - + { /* SlotFillProvider need to be defined before CheckoutProvider so fills have the SlotFill context ready when they mount. */ } diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/block.tsx index e09f0832252..4ad6356032a 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/block.tsx @@ -8,7 +8,11 @@ import { ReturnToCartButton, } from '@woocommerce/base-components/cart-checkout'; import { useCheckoutSubmit } from '@woocommerce/base-context/hooks'; -import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout'; +import { noticeContexts } from '@woocommerce/base-context'; +import { + StoreNoticesContainer, + __experimentalApplyCheckoutFilter, +} from '@woocommerce/blocks-checkout'; /** * Internal dependencies @@ -40,12 +44,17 @@ const Block = ( {
- { showReturnToCart && ( - - ) } - + +
+ { showReturnToCart && ( + + ) } + +
); }; diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/style.scss b/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/style.scss index 62e1f8931ca..15f3697cad1 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/style.scss +++ b/assets/js/blocks/checkout/inner-blocks/checkout-actions-block/style.scss @@ -1,19 +1,21 @@ .wc-block-checkout__actions { - display: flex; - justify-content: space-between; - align-items: center; + &_row { + display: flex; + justify-content: space-between; + align-items: center; - .wc-block-components-checkout-place-order-button { - width: 50%; - padding: 1em; - height: auto; + .wc-block-components-checkout-place-order-button { + width: 50%; + padding: 1em; + height: auto; - .wc-block-components-button__text { - line-height: 24px; + .wc-block-components-button__text { + line-height: 24px; - > svg { - fill: $white; - vertical-align: top; + > svg { + fill: $white; + vertical-align: top; + } } } } diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx index 1fcb04e3410..0dca4751f1c 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-billing-address-block/block.tsx @@ -6,6 +6,7 @@ import { useCheckoutAddress, useStoreEvents, useEditorContext, + noticeContexts, } from '@woocommerce/base-context'; import { AddressForm } from '@woocommerce/base-components/cart-checkout'; import Noninteractive from '@woocommerce/base-components/noninteractive'; @@ -14,6 +15,7 @@ import type { AddressField, AddressFields, } from '@woocommerce/settings'; +import { StoreNoticesContainer } from '@woocommerce/blocks-checkout'; /** * Internal dependencies @@ -39,7 +41,6 @@ const Block = ( { setBillingAddress, setShippingAddress, setBillingPhone, - setShippingPhone, forcedBillingAddress, } = useCheckoutAddress(); const { dispatchCheckoutEvent } = useStoreEvents(); @@ -89,6 +90,7 @@ const Block = ( { return ( + ) } diff --git a/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx b/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx index f5554ba9576..58a9e32bc13 100644 --- a/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx +++ b/assets/js/blocks/checkout/inner-blocks/checkout-contact-information-block/block.tsx @@ -2,11 +2,16 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { useCheckoutAddress, useStoreEvents } from '@woocommerce/base-context'; +import { + useCheckoutAddress, + useStoreEvents, + noticeContexts, +} from '@woocommerce/base-context'; import { getSetting } from '@woocommerce/settings'; import { CheckboxControl, ValidatedTextInput, + StoreNoticesContainer, } from '@woocommerce/blocks-checkout'; import { useDispatch, useSelect } from '@wordpress/data'; import { CHECKOUT_STORE_KEY } from '@woocommerce/block-data'; @@ -53,6 +58,9 @@ const Block = (): JSX.Element => { return ( <> + + { return ( <> + { isEditor && ! shippingRatesPackageCount ? ( ) : ( diff --git a/assets/js/data/cart/action-types.ts b/assets/js/data/cart/action-types.ts index 2a8796ca92b..46bd8e78b2e 100644 --- a/assets/js/data/cart/action-types.ts +++ b/assets/js/data/cart/action-types.ts @@ -1,5 +1,5 @@ export const ACTION_TYPES = { - RECEIVE_CART: 'RECEIVE_CART', + SET_CART_DATA: 'SET_CART_DATA', RECEIVE_ERROR: 'RECEIVE_ERROR', REPLACE_ERRORS: 'REPLACE_ERRORS', APPLYING_COUPON: 'APPLYING_COUPON', diff --git a/assets/js/data/cart/actions.ts b/assets/js/data/cart/actions.ts index 8a1fdb037ce..69029356a29 100644 --- a/assets/js/data/cart/actions.ts +++ b/assets/js/data/cart/actions.ts @@ -7,37 +7,34 @@ import type { CartResponseItem, ExtensionCartUpdateArgs, BillingAddressShippingAddress, + ApiErrorResponse, } from '@woocommerce/types'; import { camelCase, mapKeys } from 'lodash'; -import type { AddToCartEventDetail } from '@woocommerce/type-defs/events'; import { BillingAddress, ShippingAddress } from '@woocommerce/settings'; -import { controls } from '@wordpress/data'; +import { + triggerAddedToCartEvent, + triggerAddingToCartEvent, +} from '@woocommerce/base-utils'; /** * Internal dependencies */ import { ACTION_TYPES as types } from './action-types'; -import { STORE_KEY as CART_STORE_KEY } from './constants'; import { apiFetchWithHeaders } from '../shared-controls'; -import type { ResponseError } from '../types'; import { ReturnOrGeneratorYieldUnion } from '../mapped-types'; +import { CartDispatchFromMap, CartResolveSelectFromMap } from './index'; + +// Thunks are functions that can be dispatched, similar to actions creators +export * from './thunks'; /** - * Returns an action object used in updating the store with the provided items - * retrieved from a request using the given querystring. - * - * This is a generic response action. + * An action creator that dispatches the plain action responsible for setting the cart data in the store. * - * @param {CartResponse} response + * @param cart the parsed cart object. (Parsed into camelCase). */ -export const receiveCart = ( - response: CartResponse -): { type: string; response: Cart } => { - const cart = mapKeys( response, ( _, key ) => - camelCase( key ) - ) as unknown as Cart; +export const setCartData = ( cart: Cart ): { type: string; response: Cart } => { return { - type: types.RECEIVE_CART, + type: types.SET_CART_DATA, response: cart, }; }; @@ -60,21 +57,16 @@ export const receiveCartContents = ( ) as unknown as Cart; const { shippingAddress, billingAddress, ...cartWithoutAddress } = cart; return { - type: types.RECEIVE_CART, + type: types.SET_CART_DATA, response: cartWithoutAddress, }; }; /** * Returns an action object used for receiving customer facing errors from the API. - * - * @param {ResponseError|null} [error=null] An error object containing the error - * message and response code. - * @param {boolean} [replace=true] Should existing errors be replaced, - * or should the error be appended. */ export const receiveError = ( - error: ResponseError | null = null, + error: ApiErrorResponse | null = null, replace = true ) => ( { @@ -184,53 +176,34 @@ export const shippingRatesBeingSelected = ( isResolving: boolean ) => isResolving, } as const ); -/** - * Triggers an adding to cart event so other blocks can update accordingly. - */ -export const triggerAddingToCartEvent = () => - ( { - type: types.TRIGGER_ADDING_TO_CART_EVENT, - } as const ); - -/** - * Triggers an added to cart event so other blocks can update accordingly. - */ -export const triggerAddedToCartEvent = ( { - preserveCartData, -}: AddToCartEventDetail ) => - ( { - type: types.TRIGGER_ADDED_TO_CART_EVENT, - preserveCartData, - } as const ); - /** * POSTs to the /cart/extensions endpoint with the data supplied by the extension. * * @param {Object} args The data to be posted to the endpoint */ -export function* applyExtensionCartUpdate( - args: ExtensionCartUpdateArgs -): Generator< unknown, CartResponse, { response: CartResponse } > { - try { - const { response } = yield apiFetchWithHeaders( { - path: '/wc/store/v1/cart/extensions', - method: 'POST', - data: { namespace: args.namespace, data: args.data }, - cache: 'no-store', - } ); - yield receiveCart( response ); - return response; - } catch ( error ) { - yield receiveError( error ); - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const applyExtensionCartUpdate = + ( args: ExtensionCartUpdateArgs ) => + async ( { dispatch } ) => { + try { + const { response } = await apiFetchWithHeaders( { + path: '/wc/store/v1/cart/extensions', + method: 'POST', + data: { namespace: args.namespace, data: args.data }, + cache: 'no-store', + } ); + dispatch.receiveCart( response ); + return response; + } catch ( error ) { + dispatch.receiveError( error ); + // If updated cart state was returned, also update that. + if ( error.data?.cart ) { + dispatch.receiveCart( error.data.cart ); + } + + // Re-throw the error. + throw error; } - - // Re-throw the error. - throw error; - } -} + }; /** * Applies a coupon code and either invalidates caches, or receives an error if @@ -239,38 +212,37 @@ export function* applyExtensionCartUpdate( * @param {string} couponCode The coupon code to apply to the cart. * @throws Will throw an error if there is an API problem. */ -export function* applyCoupon( - couponCode: string -): Generator< unknown, boolean, { response: CartResponse } > { - yield receiveApplyingCoupon( couponCode ); - - try { - const { response } = yield apiFetchWithHeaders( { - path: '/wc/store/v1/cart/apply-coupon', - method: 'POST', - data: { - code: couponCode, - }, - cache: 'no-store', - } ); - - yield receiveCart( response ); - yield receiveApplyingCoupon( '' ); - } catch ( error ) { - yield receiveError( error ); - yield receiveApplyingCoupon( '' ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const applyCoupon = + ( couponCode: string ) => + async ( { dispatch } ) => { + dispatch.receiveApplyingCoupon( couponCode ); + try { + const { response } = await apiFetchWithHeaders( { + path: '/wc/store/v1/cart/apply-coupon', + method: 'POST', + data: { + code: couponCode, + }, + cache: 'no-store', + } ); + + dispatch.receiveCart( response ); + } catch ( error ) { + dispatch.receiveError( error ); + + // If updated cart state was returned, also update that. + if ( error.data?.cart ) { + dispatch.receiveCart( error.data.cart ); + } + + // Re-throw the error. + throw error; + } finally { + dispatch.receiveApplyingCoupon( '' ); } - // Re-throw the error. - throw error; - } - - return true; -} + return true; + }; /** * Removes a coupon code and either invalidates caches, or receives an error if @@ -279,38 +251,38 @@ export function* applyCoupon( * @param {string} couponCode The coupon code to remove from the cart. * @throws Will throw an error if there is an API problem. */ -export function* removeCoupon( - couponCode: string -): Generator< unknown, boolean, { response: CartResponse } > { - yield receiveRemovingCoupon( couponCode ); - - try { - const { response } = yield apiFetchWithHeaders( { - path: '/wc/store/v1/cart/remove-coupon', - method: 'POST', - data: { - code: couponCode, - }, - cache: 'no-store', - } ); - - yield receiveCart( response ); - yield receiveRemovingCoupon( '' ); - } catch ( error ) { - yield receiveError( error ); - yield receiveRemovingCoupon( '' ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const removeCoupon = + ( couponCode: string ) => + async ( { dispatch } ) => { + dispatch.receiveRemovingCoupon( couponCode ); + + try { + const { response } = await apiFetchWithHeaders( { + path: '/wc/store/v1/cart/remove-coupon', + method: 'POST', + data: { + code: couponCode, + }, + cache: 'no-store', + } ); + + dispatch.receiveCart( response ); + } catch ( error ) { + dispatch.receiveError( error ); + + // If updated cart state was returned, also update that. + if ( error.data?.cart ) { + dispatch.receiveCart( error.data.cart ); + } + + // Re-throw the error. + throw error; + } finally { + dispatch.receiveRemovingCoupon( '' ); } - // Re-throw the error. - throw error; - } - - return true; -} + return true; + }; /** * Adds an item to the cart: @@ -322,36 +294,35 @@ export function* removeCoupon( * @param {number} [quantity=1] Number of product ID being added to cart. * @throws Will throw an error if there is an API problem. */ -export function* addItemToCart( - productId: number, - quantity = 1 -): Generator< unknown, void, { response: CartResponse } > { - try { - yield triggerAddingToCartEvent(); - const { response } = yield apiFetchWithHeaders( { - path: `/wc/store/v1/cart/add-item`, - method: 'POST', - data: { - id: productId, - quantity, - }, - cache: 'no-store', - } ); - - yield receiveCart( response ); - yield triggerAddedToCartEvent( { preserveCartData: true } ); - } catch ( error ) { - yield receiveError( error ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const addItemToCart = + ( productId: number, quantity = 1 ) => + async ( { dispatch } ) => { + try { + triggerAddingToCartEvent(); + const { response } = await apiFetchWithHeaders( { + path: `/wc/store/v1/cart/add-item`, + method: 'POST', + data: { + id: productId, + quantity, + }, + cache: 'no-store', + } ); + + dispatch.receiveCart( response ); + triggerAddedToCartEvent( { preserveCartData: true } ); + } catch ( error ) { + dispatch.receiveError( error ); + + // If updated cart state was returned, also update that. + if ( error.data?.cart ) { + dispatch.receiveCart( error.data.cart ); + } + + // Re-throw the error. + throw error; } - - // Re-throw the error. - throw error; - } -} + }; /** * Removes specified item from the cart: @@ -362,32 +333,33 @@ export function* addItemToCart( * * @param {string} cartItemKey Cart item being updated. */ -export function* removeItemFromCart( - cartItemKey: string -): Generator< unknown, void, { response: CartResponse } > { - yield itemIsPendingDelete( cartItemKey ); - - try { - const { response } = yield apiFetchWithHeaders( { - path: `/wc/store/v1/cart/remove-item`, - data: { - key: cartItemKey, - }, - method: 'POST', - cache: 'no-store', - } ); - - yield receiveCart( response ); - } catch ( error ) { - yield receiveError( error ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const removeItemFromCart = + ( cartItemKey: string ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + dispatch.itemIsPendingDelete( cartItemKey ); + + try { + const { response } = await apiFetchWithHeaders( { + path: `/wc/store/v1/cart/remove-item`, + data: { + key: cartItemKey, + }, + method: 'POST', + cache: 'no-store', + } ); + + dispatch.receiveCart( response ); + } catch ( error ) { + dispatch.receiveError( error ); + + // If updated cart state was returned, also update that. + if ( error.data?.cart ) { + dispatch.receiveCart( error.data.cart ); + } + } finally { + dispatch.itemIsPendingDelete( cartItemKey, false ); } - } - yield itemIsPendingDelete( cartItemKey, false ); -} + }; /** * Persists a quantity change the for specified cart item: @@ -398,42 +370,47 @@ export function* removeItemFromCart( * @param {string} cartItemKey Cart item being updated. * @param {number} quantity Specified (new) quantity. */ -export function* changeCartItemQuantity( - cartItemKey: string, - quantity: number - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- unclear how to represent multiple different yields as type -): Generator< unknown, void, any > { - const cartItem = yield controls.resolveSelect( - CART_STORE_KEY, - 'getCartItem', - cartItemKey - ); - if ( cartItem?.quantity === quantity ) { - return; - } - yield itemIsPendingQuantity( cartItemKey ); - try { - const { response } = yield apiFetchWithHeaders( { - path: '/wc/store/v1/cart/update-item', - method: 'POST', - data: { - key: cartItemKey, - quantity, - }, - cache: 'no-store', - } ); - - yield receiveCart( response ); - } catch ( error ) { - yield receiveError( error ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const changeCartItemQuantity = + ( + cartItemKey: string, + quantity: number + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- unclear how to represent multiple different yields as type + ) => + async ( { + dispatch, + resolveSelect, + }: { + dispatch: CartDispatchFromMap; + resolveSelect: CartResolveSelectFromMap; + } ) => { + const cartItem = await resolveSelect.getCartItem( cartItemKey ); + if ( cartItem?.quantity === quantity ) { + return; + } + dispatch.itemIsPendingQuantity( cartItemKey ); + try { + const { response } = await apiFetchWithHeaders( { + path: '/wc/store/v1/cart/update-item', + method: 'POST', + data: { + key: cartItemKey, + quantity, + }, + cache: 'no-store', + } ); + + dispatch.receiveCart( response ); + } catch ( error ) { + dispatch.receiveError( error ); + + // If updated cart state was returned, also update that. + if ( error.data?.cart ) { + dispatch.receiveCart( error.data.cart ); + } + } finally { + dispatch.itemIsPendingQuantity( cartItemKey, false ); } - } - yield itemIsPendingQuantity( cartItemKey, false ); -} + }; /** * Selects a shipping rate. @@ -442,38 +419,37 @@ export function* changeCartItemQuantity( * @param {number | string} [packageId] The key of the packages that we will * select within. */ -export function* selectShippingRate( - rateId: string, - packageId = 0 -): Generator< unknown, boolean, { response: CartResponse } > { - try { - yield shippingRatesBeingSelected( true ); - const { response } = yield apiFetchWithHeaders( { - path: `/wc/store/v1/cart/select-shipping-rate`, - method: 'POST', - data: { - package_id: packageId, - rate_id: rateId, - }, - cache: 'no-store', - } ); - - yield receiveCart( response ); - } catch ( error ) { - yield receiveError( error ); - yield shippingRatesBeingSelected( false ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const selectShippingRate = + ( rateId: string, packageId = 0 ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + try { + dispatch.shippingRatesBeingSelected( true ); + const { response } = await apiFetchWithHeaders( { + path: `/wc/store/v1/cart/select-shipping-rate`, + method: 'POST', + data: { + package_id: packageId, + rate_id: rateId, + }, + cache: 'no-store', + } ); + + dispatch.receiveCart( response ); + } catch ( error ) { + dispatch.receiveError( error ); + + // If updated cart state was returned, also update that. + if ( error.data?.cart ) { + dispatch.receiveCart( error.data.cart ); + } + + // Re-throw the error. + throw error; + } finally { + dispatch.shippingRatesBeingSelected( false ); } - - // Re-throw the error. - throw error; - } - yield shippingRatesBeingSelected( false ); - return true; -} + return true; + }; /** * Sets billing address locally, as opposed to updateCustomerData which sends it to the server. @@ -496,39 +472,37 @@ export const setShippingAddress = ( * @param {BillingAddressShippingAddress} customerData Address data to be updated; can contain both * billing_address and shipping_address. */ -export function* updateCustomerData( - customerData: Partial< BillingAddressShippingAddress > -): Generator< unknown, boolean, { response: CartResponse } > { - yield updatingCustomerData( true ); - - try { - const { response } = yield apiFetchWithHeaders( { - path: '/wc/store/v1/cart/update-customer', - method: 'POST', - data: customerData, - cache: 'no-store', - } ); - - yield receiveCartContents( response ); - } catch ( error ) { - yield receiveError( error ); - yield updatingCustomerData( false ); - - // If updated cart state was returned, also update that. - if ( error.data?.cart ) { - yield receiveCart( error.data.cart ); +export const updateCustomerData = + ( customerData: Partial< BillingAddressShippingAddress > ) => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + dispatch.updatingCustomerData( true ); + + try { + const { response } = await apiFetchWithHeaders( { + path: '/wc/store/v1/cart/update-customer', + method: 'POST', + data: customerData, + cache: 'no-store', + } ); + + dispatch.receiveCartContents( response ); + } catch ( error ) { + dispatch.receiveError( error ); + + // If updated cart state was returned, also update that. + if ( error.data?.cart ) { + dispatch.receiveCart( error.data.cart ); + } + + // rethrow error. + throw error; + } finally { + dispatch.updatingCustomerData( false ); } - - // rethrow error. - throw error; - } - - yield updatingCustomerData( false ); - return true; -} + return true; + }; export type CartAction = ReturnOrGeneratorYieldUnion< - | typeof receiveCart | typeof receiveCartContents | typeof setBillingAddress | typeof setShippingAddress @@ -545,4 +519,8 @@ export type CartAction = ReturnOrGeneratorYieldUnion< | typeof removeItemFromCart | typeof changeCartItemQuantity | typeof addItemToCart + | typeof setCartData + | typeof applyCoupon + | typeof removeCoupon + | typeof selectShippingRate >; diff --git a/assets/js/data/cart/controls.js b/assets/js/data/cart/controls.js deleted file mode 100644 index 22eaac09f01..00000000000 --- a/assets/js/data/cart/controls.js +++ /dev/null @@ -1,21 +0,0 @@ -/** - * External dependencies - */ -import { - triggerAddedToCartEvent, - triggerAddingToCartEvent, -} from '@woocommerce/base-utils'; - -/** - * Default export for registering the controls with the store. - * - * @return {Object} An object with the controls to register with the store on the controls property of the registration object. - */ -export const controls = { - TRIGGER_ADDING_TO_CART_EVENT() { - triggerAddingToCartEvent(); - }, - TRIGGER_ADDED_TO_CART_EVENT( preserveCartData ) { - triggerAddedToCartEvent( preserveCartData ); - }, -}; diff --git a/assets/js/data/cart/default-state.ts b/assets/js/data/cart/default-state.ts index 066a1a2e889..f7f1d65fdd2 100644 --- a/assets/js/data/cart/default-state.ts +++ b/assets/js/data/cart/default-state.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { Cart, CartMeta } from '@woocommerce/types'; +import type { Cart, CartMeta, ApiErrorResponse } from '@woocommerce/types'; /** * Internal dependencies @@ -18,17 +18,16 @@ import { EMPTY_PAYMENT_REQUIREMENTS, EMPTY_EXTENSIONS, } from '../constants'; -import type { ResponseError } from '../types'; const EMPTY_PENDING_QUANTITY: [] = []; const EMPTY_PENDING_DELETE: [] = []; export interface CartState { - cartItemsPendingQuantity: Array< string >; - cartItemsPendingDelete: Array< string >; + cartItemsPendingQuantity: string[]; + cartItemsPendingDelete: string[]; cartData: Cart; metaData: CartMeta; - errors: Array< ResponseError >; + errors: ApiErrorResponse[]; } export const defaultCartState: CartState = { cartItemsPendingQuantity: EMPTY_PENDING_QUANTITY, diff --git a/assets/js/data/cart/index.ts b/assets/js/data/cart/index.ts index f4615f64f13..47ba38793ae 100644 --- a/assets/js/data/cart/index.ts +++ b/assets/js/data/cart/index.ts @@ -12,14 +12,13 @@ import * as selectors from './selectors'; import * as actions from './actions'; import * as resolvers from './resolvers'; import reducer, { State } from './reducers'; -import { controls as sharedControls } from '../shared-controls'; -import { controls } from './controls'; import type { SelectFromMap, DispatchFromMap } from '../mapped-types'; import { pushChanges } from './push-changes'; import { updatePaymentMethods, debouncedUpdatePaymentMethods, } from './update-payment-methods'; +import { ResolveSelectFromMap } from '../mapped-types'; // Please update from deprecated "registerStore" to "createReduxStore" when this PR is merged: // https://github.com/WordPress/gutenberg/pull/45513 @@ -27,7 +26,7 @@ const registeredStore = registerStore< State >( STORE_KEY, { reducer, actions, // eslint-disable-next-line @typescript-eslint/no-explicit-any - controls: { ...dataControls, ...sharedControls, ...controls } as any, + controls: dataControls, selectors, resolvers, } ); @@ -60,3 +59,21 @@ declare module '@wordpress/data' { hasFinishedResolution: ( selector: string ) => boolean; }; } + +/** + * CartDispatchFromMap is a type that maps the cart store's action creators to the dispatch function passed to thunks. + */ +export type CartDispatchFromMap = DispatchFromMap< typeof actions >; + +/** + * CartResolveSelectFromMap is a type that maps the cart store's resolvers and selectors to the resolveSelect function + * passed to thunks. + */ +export type CartResolveSelectFromMap = ResolveSelectFromMap< + typeof resolvers & typeof selectors +>; + +/** + * CartSelectFromMap is a type that maps the cart store's selectors to the select function passed to thunks. + */ +export type CartSelectFromMap = SelectFromMap< typeof selectors >; diff --git a/assets/js/data/cart/notify-quantity-changes.ts b/assets/js/data/cart/notify-quantity-changes.ts new file mode 100644 index 00000000000..66fb9a44d28 --- /dev/null +++ b/assets/js/data/cart/notify-quantity-changes.ts @@ -0,0 +1,222 @@ +/** + * External dependencies + */ +import { Cart, CartItem } from '@woocommerce/types'; +import { dispatch } from '@wordpress/data'; +import { __, sprintf } from '@wordpress/i18n'; + +interface NotifyQuantityChangesArgs { + oldCart: Cart; + newCart: Cart; + cartItemsPendingQuantity?: string[] | undefined; + cartItemsPendingDelete?: string[] | undefined; +} + +const isWithinQuantityLimits = ( cartItem: CartItem ) => { + return ( + cartItem.quantity >= cartItem.quantity_limits.minimum && + cartItem.quantity <= cartItem.quantity_limits.maximum && + cartItem.quantity % cartItem.quantity_limits.multiple_of === 0 + ); +}; + +const notifyIfQuantityLimitsChanged = ( oldCart: Cart, newCart: Cart ) => { + newCart.items.forEach( ( cartItem ) => { + const oldCartItem = oldCart.items.find( ( item ) => { + return item && item.key === cartItem.key; + } ); + + // If getCartData has not finished resolving, then this is the first load. + const isFirstLoad = oldCart.items.length === 0; + + // Item has been removed, we don't need to do any more checks. + if ( ! oldCartItem && ! isFirstLoad ) { + return; + } + + if ( isWithinQuantityLimits( cartItem ) ) { + return; + } + + const quantityAboveMax = + cartItem.quantity > cartItem.quantity_limits.maximum; + const quantityBelowMin = + cartItem.quantity < cartItem.quantity_limits.minimum; + const quantityOutOfStep = + cartItem.quantity % cartItem.quantity_limits.multiple_of !== 0; + + // If the quantity is still within the constraints, then we don't need to show any notice, this is because + // QuantitySelector will not automatically update the value. + if ( ! quantityAboveMax && ! quantityBelowMin && ! quantityOutOfStep ) { + return; + } + + if ( quantityOutOfStep ) { + dispatch( 'core/notices' ).createInfoNotice( + sprintf( + /* translators: %1$s is the name of the item, %2$d is the quantity of the item. %3$d is a number that the quantity must be a multiple of. */ + __( + 'The quantity of "%1$s" was changed to %2$d. You must purchase this product in groups of %3$d.', + 'woo-gutenberg-products-block' + ), + cartItem.name, + // We round down to the nearest step value here. We need to do it this way because at this point we + // don't know the next quantity. That only gets set once the HTML Input field applies its min/max + // constraints. + Math.floor( + cartItem.quantity / cartItem.quantity_limits.multiple_of + ) * cartItem.quantity_limits.multiple_of, + cartItem.quantity_limits.multiple_of + ), + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: `${ cartItem.key }-quantity-update`, + } + ); + return; + } + + if ( quantityBelowMin ) { + dispatch( 'core/notices' ).createInfoNotice( + sprintf( + /* translators: %1$s is the name of the item, %2$d is the quantity of the item. */ + __( + 'The quantity of "%1$s" was increased to %2$d. This is the minimum required quantity.', + 'woo-gutenberg-products-block' + ), + cartItem.name, + cartItem.quantity_limits.minimum + ), + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: `${ cartItem.key }-quantity-update`, + } + ); + return; + } + + // Quantity is above max, so has been reduced. + dispatch( 'core/notices' ).createInfoNotice( + sprintf( + /* translators: %1$s is the name of the item, %2$d is the quantity of the item. */ + __( + 'The quantity of "%1$s" was decreased to %2$d. This is the maximum allowed quantity.', + 'woo-gutenberg-products-block' + ), + cartItem.name, + cartItem.quantity_limits.maximum + ), + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: `${ cartItem.key }-quantity-update`, + } + ); + } ); +}; + +const notifyIfQuantityChanged = ( + oldCart: Cart, + newCart: Cart, + cartItemsPendingQuantity: string[] +) => { + newCart.items.forEach( ( cartItem ) => { + if ( cartItemsPendingQuantity.includes( cartItem.key ) ) { + return; + } + const oldCartItem = oldCart.items.find( ( item ) => { + return item && item.key === cartItem.key; + } ); + if ( ! oldCartItem ) { + return; + } + + if ( cartItem.key === oldCartItem.key ) { + if ( + cartItem.quantity !== oldCartItem.quantity && + isWithinQuantityLimits( cartItem ) + ) { + dispatch( 'core/notices' ).createInfoNotice( + sprintf( + /* translators: %1$s is the name of the item, %2$d is the quantity of the item. */ + __( + 'The quantity of "%1$s" was changed to %2$d.', + 'woo-gutenberg-products-block' + ), + cartItem.name, + cartItem.quantity + ), + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: `${ cartItem.key }-quantity-update`, + } + ); + } + return cartItem; + } + } ); +}; + +/** + * Checks whether the old cart contains an item that the new cart doesn't, and that the item was not slated for removal. + * + * @param oldCart The old cart. + * @param newCart The new cart. + * @param cartItemsPendingDelete The cart items that are pending deletion. + */ +const notifyIfRemoved = ( + oldCart: Cart, + newCart: Cart, + cartItemsPendingDelete: string[] +) => { + oldCart.items.forEach( ( oldCartItem ) => { + if ( cartItemsPendingDelete.includes( oldCartItem.key ) ) { + return; + } + + const newCartItem = newCart.items.find( ( item: CartItem ) => { + return item && item.key === oldCartItem.key; + } ); + + if ( ! newCartItem ) { + dispatch( 'core/notices' ).createInfoNotice( + sprintf( + /* translators: %s is the name of the item. */ + __( + '"%s" was removed from your cart.', + 'woo-gutenberg-products-block' + ), + oldCartItem.name + ), + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: `${ oldCartItem.key }-removed`, + } + ); + } + } ); +}; + +/** + * This function is used to notify the user when the quantity of an item in the cart has changed. It checks both the + * item's quantity and quantity limits. + */ +export const notifyQuantityChanges = ( { + oldCart, + newCart, + cartItemsPendingQuantity = [], + cartItemsPendingDelete = [], +}: NotifyQuantityChangesArgs ) => { + notifyIfRemoved( oldCart, newCart, cartItemsPendingDelete ); + notifyIfQuantityLimitsChanged( oldCart, newCart ); + notifyIfQuantityChanged( oldCart, newCart, cartItemsPendingQuantity ); +}; diff --git a/assets/js/data/cart/push-changes.ts b/assets/js/data/cart/push-changes.ts index 58b7781188b..71c9715ca26 100644 --- a/assets/js/data/cart/push-changes.ts +++ b/assets/js/data/cart/push-changes.ts @@ -4,9 +4,9 @@ import { debounce } from 'lodash'; import { select, dispatch } from '@wordpress/data'; import { - formatStoreApiErrorMessage, pluckAddress, pluckEmail, + removeAllNotices, } from '@woocommerce/base-utils'; import { CartResponseBillingAddress, @@ -20,6 +20,7 @@ import { BillingAddressShippingAddress } from '@woocommerce/type-defs/cart'; */ import { STORE_KEY } from './constants'; import { VALIDATION_STORE_KEY } from '../validation'; +import { processErrorResponse } from '../utils'; declare type CustomerData = { billingAddress: CartResponseBillingAddress; @@ -103,20 +104,10 @@ const updateCustomerData = debounce( (): void => { dispatch( STORE_KEY ) .updateCustomerData( customerDataToUpdate ) .then( () => { - dispatch( 'core/notices' ).removeNotice( - 'checkout', - 'wc/checkout' - ); + removeAllNotices(); } ) .catch( ( response ) => { - dispatch( 'core/notices' ).createNotice( - 'error', - formatStoreApiErrorMessage( response ), - { - id: 'checkout', - context: 'wc/checkout', - } - ); + processErrorResponse( response ); } ); } }, 1000 ); diff --git a/assets/js/data/cart/reducers.ts b/assets/js/data/cart/reducers.ts index 2fcb25fccab..7fba51c7c74 100644 --- a/assets/js/data/cart/reducers.ts +++ b/assets/js/data/cart/reducers.ts @@ -64,7 +64,7 @@ const reducer: Reducer< CartState > = ( }; } break; - case types.RECEIVE_CART: + case types.SET_CART_DATA: if ( action.response ) { state = { ...state, diff --git a/assets/js/data/cart/resolvers.ts b/assets/js/data/cart/resolvers.ts index 67d250bc862..a2dbcbe5b65 100644 --- a/assets/js/data/cart/resolvers.ts +++ b/assets/js/data/cart/resolvers.ts @@ -1,37 +1,44 @@ /** * External dependencies */ -import { apiFetch } from '@wordpress/data-controls'; -import { controls } from '@wordpress/data'; -import { CartResponse, Cart } from '@woocommerce/types'; +import apiFetch from '@wordpress/api-fetch'; +import { CartResponse } from '@woocommerce/type-defs/cart-response'; /** * Internal dependencies */ -import { receiveCart, receiveError } from './actions'; -import { STORE_KEY, CART_API_ERROR } from './constants'; +import { CART_API_ERROR } from './constants'; +import type { CartDispatchFromMap, CartResolveSelectFromMap } from './index'; /** * Resolver for retrieving all cart data. */ -export function* getCartData(): Generator< unknown, void, CartResponse > { - const cartData = yield apiFetch( { - path: '/wc/store/v1/cart', - method: 'GET', - cache: 'no-store', - } ); +export const getCartData = + () => + async ( { dispatch }: { dispatch: CartDispatchFromMap } ) => { + const cartData = await apiFetch< CartResponse >( { + path: '/wc/store/v1/cart', + method: 'GET', + cache: 'no-store', + } ); - if ( ! cartData ) { - yield receiveError( CART_API_ERROR ); - return; - } - - yield receiveCart( cartData ); -} + const { receiveCart, receiveError } = dispatch; + if ( ! cartData ) { + receiveError( CART_API_ERROR ); + return; + } + receiveCart( cartData ); + }; /** * Resolver for retrieving cart totals. */ -export function* getCartTotals(): Generator< unknown, void, Cart > { - yield controls.resolveSelect( STORE_KEY, 'getCartData' ); -} +export const getCartTotals = + () => + async ( { + resolveSelect, + }: { + resolveSelect: CartResolveSelectFromMap; + } ) => { + await resolveSelect.getCartData(); + }; diff --git a/assets/js/data/cart/selectors.ts b/assets/js/data/cart/selectors.ts index 73b88c13d22..f4644a4b2d2 100644 --- a/assets/js/data/cart/selectors.ts +++ b/assets/js/data/cart/selectors.ts @@ -7,6 +7,7 @@ import type { CartMeta, CartItem, CartShippingRate, + ApiErrorResponse, } from '@woocommerce/types'; import { BillingAddress, ShippingAddress } from '@woocommerce/settings'; @@ -14,7 +15,6 @@ import { BillingAddress, ShippingAddress } from '@woocommerce/settings'; * Internal dependencies */ import { CartState, defaultCartState } from './default-state'; -import type { ResponseError } from '../types'; /** * Retrieves cart data from state. @@ -90,11 +90,8 @@ export const getCartMeta = ( state: CartState ): CartMeta => { /** * Retrieves cart errors from state. - * - * @param {CartState} state The current state. - * @return {Array} Array of errors. */ -export const getCartErrors = ( state: CartState ): Array< ResponseError > => { +export const getCartErrors = ( state: CartState ): ApiErrorResponse[] => { return state.errors; }; @@ -212,3 +209,16 @@ export const isCustomerDataUpdating = ( state: CartState ): boolean => { export const isShippingRateBeingSelected = ( state: CartState ): boolean => { return !! state.metaData.updatingSelectedRate; }; + +/** + * Retrieves the item keys for items whose quantity is currently being updated. + */ +export const getItemsPendingQuantityUpdate = ( state: CartState ): string[] => { + return state.cartItemsPendingQuantity; +}; +/** + * Retrieves the item keys for items that are currently being deleted. + */ +export const getItemsPendingDelete = ( state: CartState ): string[] => { + return state.cartItemsPendingDelete; +}; diff --git a/assets/js/data/cart/test/notify-quantity-changes.ts b/assets/js/data/cart/test/notify-quantity-changes.ts new file mode 100644 index 00000000000..0393f42fda9 --- /dev/null +++ b/assets/js/data/cart/test/notify-quantity-changes.ts @@ -0,0 +1,182 @@ +/** + * External dependencies + */ +import { dispatch } from '@wordpress/data'; +import { previewCart } from '@woocommerce/resource-previews'; +import { camelCase, cloneDeep, mapKeys } from 'lodash'; +import { Cart } from '@woocommerce/type-defs/cart'; +import { CartResponse } from '@woocommerce/types'; + +/** + * Internal dependencies + */ +import { notifyQuantityChanges } from '../notify-quantity-changes'; + +jest.mock( '@wordpress/data' ); + +const mockedCreateInfoNotice = jest.fn(); +dispatch.mockImplementation( ( store ) => { + if ( store === 'core/notices' ) { + return { + createInfoNotice: mockedCreateInfoNotice, + }; + } +} ); + +/** + * Clones the preview cart and turns it into a `Cart`. + */ +const getFreshCarts = (): { oldCart: Cart; newCart: Cart } => { + const oldCart = mapKeys( + cloneDeep< CartResponse >( previewCart ), + ( _, key ) => camelCase( key ) + ) as unknown as Cart; + const newCart = mapKeys( + cloneDeep< CartResponse >( previewCart ), + ( _, key ) => camelCase( key ) + ) as unknown as Cart; + return { oldCart, newCart }; +}; + +describe( 'notifyQuantityChanges', () => { + afterEach( () => { + jest.clearAllMocks(); + } ); + it( 'shows notices when the quantity limits of an item change', () => { + const { oldCart, newCart } = getFreshCarts(); + newCart.items[ 0 ].quantity_limits.minimum = 50; + notifyQuantityChanges( { + oldCart, + newCart, + cartItemsPendingQuantity: [], + } ); + expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith( + 'The quantity of "Beanie" was increased to 50. This is the minimum required quantity.', + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: '1-quantity-update', + } + ); + + newCart.items[ 0 ].quantity_limits.minimum = 1; + newCart.items[ 0 ].quantity_limits.maximum = 10; + // Quantity needs to be outside the limits for the notice to show. + newCart.items[ 0 ].quantity = 11; + notifyQuantityChanges( { + oldCart, + newCart, + cartItemsPendingQuantity: [], + } ); + expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith( + 'The quantity of "Beanie" was decreased to 10. This is the maximum allowed quantity.', + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: '1-quantity-update', + } + ); + newCart.items[ 0 ].quantity = 10; + oldCart.items[ 0 ].quantity = 10; + newCart.items[ 0 ].quantity_limits.multiple_of = 6; + notifyQuantityChanges( { + oldCart, + newCart, + cartItemsPendingQuantity: [], + } ); + expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith( + 'The quantity of "Beanie" was changed to 6. You must purchase this product in groups of 6.', + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: '1-quantity-update', + } + ); + } ); + it( 'does not show notices if the quantity limit changes, and the quantity is within limits', () => { + const { oldCart, newCart } = getFreshCarts(); + newCart.items[ 0 ].quantity = 5; + oldCart.items[ 0 ].quantity = 5; + newCart.items[ 0 ].quantity_limits.maximum = 10; + notifyQuantityChanges( { + oldCart, + newCart, + cartItemsPendingQuantity: [], + } ); + expect( mockedCreateInfoNotice ).not.toHaveBeenCalled(); + + newCart.items[ 0 ].quantity_limits.minimum = 4; + notifyQuantityChanges( { + oldCart, + newCart, + cartItemsPendingQuantity: [], + } ); + expect( mockedCreateInfoNotice ).not.toHaveBeenCalled(); + } ); + it( 'shows notices when the quantity of an item changes', () => { + const { oldCart, newCart } = getFreshCarts(); + newCart.items[ 0 ].quantity = 50; + notifyQuantityChanges( { + oldCart, + newCart, + cartItemsPendingQuantity: [], + } ); + expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith( + 'The quantity of "Beanie" was changed to 50.', + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: '1-quantity-update', + } + ); + } ); + it( 'does not show notices when the the item is the one being updated', () => { + const { oldCart, newCart } = getFreshCarts(); + newCart.items[ 0 ].quantity = 5; + newCart.items[ 0 ].quantity_limits.maximum = 10; + notifyQuantityChanges( { + oldCart, + newCart, + cartItemsPendingQuantity: [ '1' ], + } ); + expect( mockedCreateInfoNotice ).not.toHaveBeenCalled(); + } ); + it( 'does not show notices when a deleted item is the one being removed', () => { + const { oldCart, newCart } = getFreshCarts(); + + // Remove both items from the new cart. + delete newCart.items[ 0 ]; + delete newCart.items[ 1 ]; + notifyQuantityChanges( { + oldCart, + newCart, + // This means the user is only actively removing item with key '1'. The second item is "unexpected" so we + // expect exactly one notification to be shown. + cartItemsPendingDelete: [ '1' ], + } ); + // Check it was called for item 2, but not item 1. + expect( mockedCreateInfoNotice ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'shows a notice when an item is unexpectedly removed', () => { + const { oldCart, newCart } = getFreshCarts(); + delete newCart.items[ 0 ]; + notifyQuantityChanges( { + oldCart, + newCart, + } ); + expect( mockedCreateInfoNotice ).toHaveBeenLastCalledWith( + '"Beanie" was removed from your cart.', + { + context: 'wc/cart', + speak: true, + type: 'snackbar', + id: '1-removed', + } + ); + } ); +} ); diff --git a/assets/js/data/cart/test/reducers.js b/assets/js/data/cart/test/reducers.js index c463e64d2e6..9f2d39d9922 100644 --- a/assets/js/data/cart/test/reducers.js +++ b/assets/js/data/cart/test/reducers.js @@ -31,7 +31,7 @@ describe( 'cartReducer', () => { } ); it( 'sets expected state when a cart is received', () => { const testAction = { - type: types.RECEIVE_CART, + type: types.SET_CART_DATA, response: { coupons: [], items: [], diff --git a/assets/js/data/cart/test/resolvers.js b/assets/js/data/cart/test/resolvers.js deleted file mode 100644 index 735136055d7..00000000000 --- a/assets/js/data/cart/test/resolvers.js +++ /dev/null @@ -1,61 +0,0 @@ -/** - * Internal dependencies - */ -import { getCartData } from '../resolvers'; -import { receiveCart, receiveError } from '../actions'; -import { CART_API_ERROR } from '../constants'; - -jest.mock( '@wordpress/data-controls' ); - -describe( 'getCartData', () => { - describe( 'yields with expected responses', () => { - let fulfillment; - const rewind = () => ( fulfillment = getCartData() ); - test( - 'when apiFetch returns a valid response, yields expected ' + - 'action', - () => { - rewind(); - fulfillment.next( 'https://example.org' ); - const { value } = fulfillment.next( { - coupons: [], - items: [], - fees: [], - itemsCount: 0, - itemsWeight: 0, - needsShipping: true, - totals: {}, - } ); - expect( value ).toEqual( - receiveCart( { - coupons: [], - items: [], - fees: [], - itemsCount: 0, - itemsWeight: 0, - needsShipping: true, - totals: {}, - } ) - ); - const { done } = fulfillment.next(); - expect( done ).toBe( true ); - } - ); - } ); - describe( 'yields with expected response when there is an error', () => { - let fulfillment; - const rewind = () => ( fulfillment = getCartData() ); - test( - 'when apiFetch returns a valid response, yields expected ' + - 'action', - () => { - rewind(); - fulfillment.next( 'https://example.org' ); - const { value } = fulfillment.next( undefined ); - expect( value ).toEqual( receiveError( CART_API_ERROR ) ); - const { done } = fulfillment.next(); - expect( done ).toBe( true ); - } - ); - } ); -} ); diff --git a/assets/js/data/cart/test/resolvers.ts b/assets/js/data/cart/test/resolvers.ts new file mode 100644 index 00000000000..cf5f66b57b4 --- /dev/null +++ b/assets/js/data/cart/test/resolvers.ts @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import { dispatch } from '@wordpress/data'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { getCartData } from '../resolvers'; +import { CART_STORE_KEY } from '..'; + +jest.mock( '@wordpress/data-controls' ); +jest.mock( '@wordpress/api-fetch' ); +describe( 'getCartData', () => { + it( 'when apiFetch returns a valid response, receives the cart correctly', async () => { + const mockDispatch = { + ...dispatch( CART_STORE_KEY ), + receiveCart: jest.fn(), + receiveError: jest.fn(), + }; + apiFetch.mockReturnValue( { + coupons: [], + items: [], + fees: [], + itemsCount: 0, + itemsWeight: 0, + needsShipping: true, + totals: {}, + } ); + await getCartData()( { dispatch: mockDispatch } ); + expect( mockDispatch.receiveCart ).toHaveBeenCalledWith( { + coupons: [], + items: [], + fees: [], + itemsCount: 0, + itemsWeight: 0, + needsShipping: true, + totals: {}, + } ); + expect( mockDispatch.receiveError ).not.toHaveBeenCalled(); + } ); + it( 'when apiFetch returns an invalid response, dispatches the correct error action', async () => { + const mockDispatch = { + ...dispatch( CART_STORE_KEY ), + receiveCart: jest.fn(), + receiveError: jest.fn(), + }; + apiFetch.mockReturnValue( undefined ); + await getCartData()( { dispatch: mockDispatch } ); + expect( mockDispatch.receiveCart ).not.toHaveBeenCalled(); + expect( mockDispatch.receiveError ).toHaveBeenCalled(); + } ); +} ); diff --git a/assets/js/data/cart/thunks.ts b/assets/js/data/cart/thunks.ts new file mode 100644 index 00000000000..288e5945f31 --- /dev/null +++ b/assets/js/data/cart/thunks.ts @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { CartResponse } from '@woocommerce/type-defs/cart-response'; +import { camelCase, mapKeys } from 'lodash'; +import { Cart } from '@woocommerce/type-defs/cart'; + +/** + * Internal dependencies + */ +import { notifyQuantityChanges } from './notify-quantity-changes'; +import { CartDispatchFromMap, CartSelectFromMap } from './index'; + +/** + * A thunk used in updating the store with the cart items retrieved from a request. This also notifies the shopper + * of any unexpected quantity changes occurred. + * + * @param {CartResponse} response + */ +export const receiveCart = + ( response: CartResponse ) => + ( { + dispatch, + select, + }: { + dispatch: CartDispatchFromMap; + select: CartSelectFromMap; + } ) => { + const cart = mapKeys( response, ( _, key ) => + camelCase( key ) + ) as unknown as Cart; + notifyQuantityChanges( { + oldCart: select.getCartData(), + newCart: cart, + cartItemsPendingQuantity: select.getItemsPendingQuantityUpdate(), + cartItemsPendingDelete: select.getItemsPendingDelete(), + } ); + dispatch.setCartData( cart ); + }; diff --git a/assets/js/data/cart/utils.ts b/assets/js/data/cart/utils.ts new file mode 100644 index 00000000000..9b4a308822a --- /dev/null +++ b/assets/js/data/cart/utils.ts @@ -0,0 +1,12 @@ +/** + * External dependencies + */ +import { camelCase, mapKeys } from 'lodash'; +import { Cart } from '@woocommerce/type-defs/cart'; +import { CartResponse } from '@woocommerce/type-defs/cart-response'; + +export const mapCartResponseToCart = ( responseCart: CartResponse ): Cart => { + return mapKeys( responseCart, ( _, key ) => + camelCase( key ) + ) as unknown as Cart; +}; diff --git a/assets/js/data/collections/resolvers.js b/assets/js/data/collections/resolvers.js index 5a1a37f6689..823e83635c2 100644 --- a/assets/js/data/collections/resolvers.js +++ b/assets/js/data/collections/resolvers.js @@ -10,7 +10,7 @@ import { addQueryArgs } from '@wordpress/url'; import { receiveCollection, receiveCollectionError } from './actions'; import { STORE_KEY as SCHEMA_STORE_KEY } from '../schema/constants'; import { STORE_KEY, DEFAULT_EMPTY_ARRAY } from './constants'; -import { apiFetchWithHeaders } from '../shared-controls'; +import { apiFetchWithHeadersControl } from '../shared-controls'; /** * Check if the store needs invalidating due to a change in last modified headers. @@ -55,7 +55,7 @@ export function* getCollection( namespace, resourceName, query, ids ) { try { const { response = DEFAULT_EMPTY_ARRAY, headers } = - yield apiFetchWithHeaders( { path: route + queryString } ); + yield apiFetchWithHeadersControl( { path: route + queryString } ); if ( headers && headers.get && headers.has( 'last-modified' ) ) { // Do any invalidation before the collection is received to prevent diff --git a/assets/js/data/collections/test/resolvers.js b/assets/js/data/collections/test/resolvers.js index b827a5cda58..b481e659805 100644 --- a/assets/js/data/collections/test/resolvers.js +++ b/assets/js/data/collections/test/resolvers.js @@ -10,7 +10,7 @@ import { getCollection, getCollectionHeader } from '../resolvers'; import { receiveCollection } from '../actions'; import { STORE_KEY as SCHEMA_STORE_KEY } from '../../schema/constants'; import { STORE_KEY } from '../constants'; -import { apiFetchWithHeaders } from '../../shared-controls'; +import { apiFetchWithHeadersControl } from '../../shared-controls'; jest.mock( '@wordpress/data' ); @@ -73,7 +73,7 @@ describe( 'getCollection', () => { fulfillment.next(); const { value } = fulfillment.next( 'https://example.org' ); expect( value ).toEqual( - apiFetchWithHeaders( { + apiFetchWithHeadersControl( { path: 'https://example.org?foo=bar', } ) ); diff --git a/assets/js/data/index.ts b/assets/js/data/index.ts index ce459998a6e..5367e06bb06 100644 --- a/assets/js/data/index.ts +++ b/assets/js/data/index.ts @@ -13,5 +13,7 @@ export { CHECKOUT_STORE_KEY } from './checkout'; export { PAYMENT_STORE_KEY } from './payment'; export { VALIDATION_STORE_KEY } from './validation'; export { QUERY_STATE_STORE_KEY } from './query-state'; +export { STORE_NOTICES_STORE_KEY } from './store-notices'; export * from './constants'; export * from './types'; +export * from './utils'; diff --git a/assets/js/data/mapped-types.ts b/assets/js/data/mapped-types.ts index 01c711c8653..ace786d4269 100644 --- a/assets/js/data/mapped-types.ts +++ b/assets/js/data/mapped-types.ts @@ -30,6 +30,21 @@ export type SelectFromMap< S extends object > = { ) => ReturnType< S[ selector ] >; }; +/** + * Maps a "raw" resolver object to the resolvers available on a @wordpress/data store. + * + * @template R Resolver map, usually from `import * as resolvers from './my-store/resolvers';` + */ +export type ResolveSelectFromMap< R extends object > = { + [ resolver in FunctionKeys< R > ]: ( + ...args: ReturnType< R[ resolver ] > extends Promise< any > + ? Parameters< R[ resolver ] > + : TailParameters< R[ resolver ] > + ) => ReturnType< R[ resolver ] > extends Promise< any > + ? Promise< ReturnType< R[ resolver ] > > + : void; +}; + /** * Maps a "raw" actionCreators object to the actions available when registered on the @wordpress/data store. * @@ -40,11 +55,25 @@ export type DispatchFromMap< > = { [ actionCreator in keyof A ]: ( ...args: Parameters< A[ actionCreator ] > - ) => A[ actionCreator ] extends ( ...args: any[] ) => Generator + ) => // If the action creator is a function that returns a generator return GeneratorReturnType, if not, then check + // if it's a function that returns a Promise, in other words: a thunk. https://developer.wordpress.org/block-editor/how-to-guides/thunks/ + // If it is, then return the return type of the thunk (which in most cases will be void, but sometimes it won't be). + A[ actionCreator ] extends ( ...args: any[] ) => Generator ? Promise< GeneratorReturnType< A[ actionCreator ] > > + : A[ actionCreator ] extends Thunk + ? ThunkReturnType< A[ actionCreator ] > : void; }; +/** + * A thunk is a function (action creator) that returns a function. + */ +type Thunk = ( ...args: any[] ) => ( ...args: any[] ) => any; +/** + * The function returned by a thunk action creator can return a value, too. + */ +type ThunkReturnType< A extends Thunk > = ReturnType< ReturnType< A > >; + /** * Parameters type of a function, excluding the first parameter. * diff --git a/assets/js/data/shared-controls.ts b/assets/js/data/shared-controls.ts index 6631f86b9d5..c928d28bd68 100644 --- a/assets/js/data/shared-controls.ts +++ b/assets/js/data/shared-controls.ts @@ -4,28 +4,11 @@ import { __ } from '@wordpress/i18n'; import triggerFetch, { APIFetchOptions } from '@wordpress/api-fetch'; import DataLoader from 'dataloader'; -import { isWpVersion } from '@woocommerce/settings'; - -/** - * Internal dependencies - */ import { + ApiResponse, assertBatchResponseIsValid, assertResponseIsValid, - ApiResponse, -} from './types'; - -/** - * Dispatched a control action for triggering an api fetch call with no parsing. - * Typically this would be used in scenarios where headers are needed. - * - * @param {APIFetchOptions} options The options for the API request. - */ -export const apiFetchWithHeaders = ( options: APIFetchOptions ) => - ( { - type: 'API_FETCH_WITH_HEADERS', - options, - } as const ); +} from '@woocommerce/types'; const EMPTY_OBJECT = {}; @@ -114,84 +97,111 @@ const batchFetch = async ( request: APIFetchOptions ) => { }; /** - * Default export for registering the controls with the store. + * Dispatched a control action for triggering an api fetch call with no parsing. + * Typically this would be used in scenarios where headers are needed. * - * @return {Object} An object with the controls to register with the store on - * the controls property of the registration object. + * @param {APIFetchOptions} options The options for the API request. */ -export const controls = { - API_FETCH_WITH_HEADERS: ( { +export const apiFetchWithHeadersControl = ( options: APIFetchOptions ) => + ( { + type: 'API_FETCH_WITH_HEADERS', options, - }: ReturnType< typeof apiFetchWithHeaders > ): Promise< unknown > => { - return new Promise( ( resolve, reject ) => { - // GET Requests cannot be batched. - if ( - ! options.method || - options.method === 'GET' || - isWpVersion( '5.6', '<' ) - ) { - // Parse is disabled here to avoid returning just the body--we also need headers. - triggerFetch( { - ...options, - parse: false, + } as const ); + +/** + * The underlying function that actually does the fetch. This is used by both the generator (control) version of + * apiFetchWithHeadersControl and the async function apiFetchWithHeaders. + */ +const doApiFetchWithHeaders = ( options: APIFetchOptions ) => + new Promise( ( resolve, reject ) => { + // GET Requests cannot be batched. + if ( ! options.method || options.method === 'GET' ) { + // Parse is disabled here to avoid returning just the body--we also need headers. + triggerFetch( { + ...options, + parse: false, + } ) + .then( ( fetchResponse ) => { + fetchResponse + .json() + .then( ( response ) => { + resolve( { + response, + headers: fetchResponse.headers, + } ); + setNonceOnFetch( fetchResponse.headers ); + } ) + .catch( () => { + reject( invalidJsonError ); + } ); } ) - .then( ( fetchResponse ) => { - fetchResponse + .catch( ( errorResponse ) => { + setNonceOnFetch( errorResponse.headers ); + if ( typeof errorResponse.json === 'function' ) { + // Parse error response before rejecting it. + errorResponse .json() - .then( ( response ) => { - resolve( { - response, - headers: fetchResponse.headers, - } ); - setNonceOnFetch( fetchResponse.headers ); + .then( ( error: unknown ) => { + reject( error ); } ) .catch( () => { reject( invalidJsonError ); } ); - } ) - .catch( ( errorResponse ) => { + } else { + reject( errorResponse.message ); + } + } ); + } else { + batchFetch( options ) + .then( ( response: ApiResponse ) => { + assertResponseIsValid( response ); + + if ( response.status >= 200 && response.status < 300 ) { + resolve( { + response: response.body, + headers: response.headers, + } ); + setNonceOnFetch( response.headers ); + } + + // Status code indicates error. + throw response; + } ) + .catch( ( errorResponse: ApiResponse ) => { + if ( errorResponse.headers ) { setNonceOnFetch( errorResponse.headers ); - if ( typeof errorResponse.json === 'function' ) { - // Parse error response before rejecting it. - errorResponse - .json() - .then( ( error: unknown ) => { - reject( error ); - } ) - .catch( () => { - reject( invalidJsonError ); - } ); - } else { - reject( errorResponse.message ); - } - } ); - } else { - batchFetch( options ) - .then( ( response: ApiResponse ) => { - assertResponseIsValid( response ); + } + if ( errorResponse.body ) { + reject( errorResponse.body ); + } else { + reject( errorResponse ); + } + } ); + } + } ); - if ( response.status >= 200 && response.status < 300 ) { - resolve( { - response: response.body, - headers: response.headers, - } ); - setNonceOnFetch( response.headers ); - } +/** + * Triggers an api fetch call with no parsing. + * Typically this would be used in scenarios where headers are needed. + * + * @param {APIFetchOptions} options The options for the API request. + */ +export const apiFetchWithHeaders = ( options: APIFetchOptions ) => { + return doApiFetchWithHeaders( options ); +}; - // Status code indicates error. - throw response; - } ) - .catch( ( errorResponse: ApiResponse ) => { - if ( errorResponse.headers ) { - setNonceOnFetch( errorResponse.headers ); - } - if ( errorResponse.body ) { - reject( errorResponse.body ); - } else { - reject( errorResponse ); - } - } ); - } - } ); +/** + * Default export for registering the controls with the store. + * + * @return {Object} An object with the controls to register with the store on + * the controls property of the registration object. + */ +export const controls = { + API_FETCH_WITH_HEADERS: ( { + options, + }: ReturnType< + typeof apiFetchWithHeadersControl + > ): Promise< unknown > => { + return doApiFetchWithHeaders( options ); }, }; diff --git a/assets/js/data/store-notices/action-types.ts b/assets/js/data/store-notices/action-types.ts new file mode 100644 index 00000000000..1b8b1dd9b52 --- /dev/null +++ b/assets/js/data/store-notices/action-types.ts @@ -0,0 +1,4 @@ +export enum ACTION_TYPES { + REGISTER_CONTAINER = 'REGISTER_CONTAINER', + UNREGISTER_CONTAINER = 'UNREGISTER_CONTAINER', +} diff --git a/assets/js/data/store-notices/actions.ts b/assets/js/data/store-notices/actions.ts new file mode 100644 index 00000000000..1ffcc465866 --- /dev/null +++ b/assets/js/data/store-notices/actions.ts @@ -0,0 +1,18 @@ +/** + * Internal dependencies + */ +import { ACTION_TYPES } from './action-types'; + +export const registerContainer = ( containerContext: string ) => { + return { + type: ACTION_TYPES.REGISTER_CONTAINER, + containerContext, + }; +}; + +export const unregisterContainer = ( containerContext: string ) => { + return { + type: ACTION_TYPES.UNREGISTER_CONTAINER, + containerContext, + }; +}; diff --git a/assets/js/data/store-notices/default-state.ts b/assets/js/data/store-notices/default-state.ts new file mode 100644 index 00000000000..90174b2ed3f --- /dev/null +++ b/assets/js/data/store-notices/default-state.ts @@ -0,0 +1,7 @@ +export interface StoreNoticesState { + containers: string[]; +} + +export const defaultStoreNoticesState: StoreNoticesState = { + containers: [], +}; diff --git a/assets/js/data/store-notices/index.ts b/assets/js/data/store-notices/index.ts new file mode 100644 index 00000000000..1d7f498a9f0 --- /dev/null +++ b/assets/js/data/store-notices/index.ts @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { createReduxStore, register } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import * as actions from './actions'; +import * as selectors from './selectors'; +import reducer from './reducers'; +import { DispatchFromMap, SelectFromMap } from '../mapped-types'; + +const STORE_KEY = 'wc/store/store-notices'; +const config = { + reducer, + actions, + selectors, +}; +const store = createReduxStore( STORE_KEY, config ); +register( store ); + +export const STORE_NOTICES_STORE_KEY = STORE_KEY; + +declare module '@wordpress/data' { + function dispatch( + key: typeof STORE_KEY + ): DispatchFromMap< typeof actions >; + function select( key: typeof STORE_KEY ): SelectFromMap< + typeof selectors + > & { + hasFinishedResolution: ( selector: string ) => boolean; + }; +} diff --git a/assets/js/data/store-notices/reducers.ts b/assets/js/data/store-notices/reducers.ts new file mode 100644 index 00000000000..a7b0643da36 --- /dev/null +++ b/assets/js/data/store-notices/reducers.ts @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import type { Reducer } from 'redux'; + +/** + * Internal dependencies + */ +import { defaultStoreNoticesState, StoreNoticesState } from './default-state'; +import { ACTION_TYPES } from './action-types'; + +const reducer: Reducer< StoreNoticesState > = ( + state = defaultStoreNoticesState, + action +) => { + switch ( action.type ) { + case ACTION_TYPES.REGISTER_CONTAINER: + return { + ...state, + containers: [ ...state.containers, action.containerContext ], + }; + case ACTION_TYPES.UNREGISTER_CONTAINER: + const newContainers = state.containers.filter( + ( container ) => container !== action.containerContext + ); + return { + ...state, + containers: newContainers, + }; + } + return state; +}; + +export default reducer; diff --git a/assets/js/data/store-notices/selectors.ts b/assets/js/data/store-notices/selectors.ts new file mode 100644 index 00000000000..6008d68ce45 --- /dev/null +++ b/assets/js/data/store-notices/selectors.ts @@ -0,0 +1,8 @@ +/** + * Internal dependencies + */ +import { StoreNoticesState } from './default-state'; + +export const getContainers = ( + state: StoreNoticesState +): StoreNoticesState[ 'containers' ] => state.containers; diff --git a/assets/js/data/types.ts b/assets/js/data/types.ts index 491ff5ed4ed..e69de29bb2d 100644 --- a/assets/js/data/types.ts +++ b/assets/js/data/types.ts @@ -1,49 +0,0 @@ -export interface ResponseError { - code: string; - message: string; - data: { - status: number; - [ key: string ]: unknown; - }; -} - -export interface ApiResponse { - body: Record< string, unknown >; - headers: Headers; - status: number; -} - -export function assertBatchResponseIsValid( - response: unknown -): asserts response is { - responses: ApiResponse[]; - headers: Headers; -} { - if ( - typeof response === 'object' && - response !== null && - response.hasOwnProperty( 'responses' ) - ) { - return; - } - throw new Error( 'Response not valid' ); -} - -export function assertResponseIsValid( - response: unknown -): asserts response is ApiResponse { - if ( - typeof response === 'object' && - response !== null && - response.hasOwnProperty( 'body' ) && - response.hasOwnProperty( 'headers' ) - ) { - return; - } - throw new Error( 'Response not valid' ); -} - -export interface FieldValidationStatus { - message: string; - hidden: boolean; -} diff --git a/assets/js/data/utils/index.js b/assets/js/data/utils/index.js index c8d47bd6f98..19a8b886967 100644 --- a/assets/js/data/utils/index.js +++ b/assets/js/data/utils/index.js @@ -1,2 +1,3 @@ export { default as hasInState } from './has-in-state'; export { default as updateState } from './update-state'; +export { default as processErrorResponse } from './process-error-response'; diff --git a/assets/js/data/utils/process-error-response.ts b/assets/js/data/utils/process-error-response.ts new file mode 100644 index 00000000000..3412cea81b1 --- /dev/null +++ b/assets/js/data/utils/process-error-response.ts @@ -0,0 +1,148 @@ +/** + * External dependencies + */ +import { + createNotice, + createNoticeIfVisible, + DEFAULT_ERROR_MESSAGE, +} from '@woocommerce/base-utils'; +import { decodeEntities } from '@wordpress/html-entities'; +import { isObject, objectHasProp, ApiErrorResponse } from '@woocommerce/types'; +import { noticeContexts } from '@woocommerce/base-context/event-emit/utils'; + +type ApiParamError = { + param: string; + id: string; + code: string; + message: string; +}; + +const isApiResponse = ( response: unknown ): response is ApiErrorResponse => { + return ( + isObject( response ) && + objectHasProp( response, 'code' ) && + objectHasProp( response, 'message' ) && + objectHasProp( response, 'data' ) + ); +}; + +/** + * Flattens error details which are returned from the API when multiple params are not valid. + * + * - Codes will be prefixed with the param. For example, `invalid_email` becomes `billing_address_invalid_email`. + * - Additional error messages will be flattened alongside the main error message. + * - Supports 1 level of nesting. + * - Decodes HTML entities in error messages. + */ +const getErrorDetails = ( response: ApiErrorResponse ): ApiParamError[] => { + const errorDetails = objectHasProp( response.data, 'details' ) + ? Object.entries( response.data.details ) + : null; + + if ( ! errorDetails ) { + return []; + } + + return errorDetails.reduce( + ( + acc, + [ + param, + { code, message, additional_errors: additionalErrors = [] }, + ] + ) => { + return [ + ...acc, + { + param, + id: `${ param }_${ code }`, + code, + message: decodeEntities( message ), + }, + ...( Array.isArray( additionalErrors ) + ? additionalErrors.flatMap( ( additionalError ) => { + if ( + ! objectHasProp( additionalError, 'code' ) || + ! objectHasProp( additionalError, 'message' ) + ) { + return []; + } + return [ + { + param, + id: `${ param }_${ additionalError.code }`, + code: additionalError.code, + message: decodeEntities( + additionalError.message + ), + }, + ]; + } ) + : [] ), + ]; + }, + [] as ApiParamError[] + ); +}; + +/** + * Processes the response for an invalid param error, with response code rest_invalid_param. + */ +const processInvalidParamResponse = ( response: ApiErrorResponse ) => { + const errorDetails = getErrorDetails( response ); + + errorDetails.forEach( ( { code, message, id, param } ) => { + switch ( code ) { + case 'invalid_email': + createNotice( 'error', message, { + id, + context: noticeContexts.CONTACT_INFORMATION, + } ); + return; + } + switch ( param ) { + case 'billing_address': + createNoticeIfVisible( 'error', message, { + id, + context: noticeContexts.BILLING_ADDRESS, + } ); + break; + case 'shipping_address': + createNoticeIfVisible( 'error', message, { + id, + context: noticeContexts.SHIPPING_ADDRESS, + } ); + break; + } + } ); +}; + +/** + * Takes an API response object and creates error notices to display to the customer. + * + * This is where we can handle specific error codes and display notices in specific contexts. + */ +const processErrorResponse = ( response: ApiErrorResponse ) => { + if ( ! isApiResponse( response ) ) { + return; + } + switch ( response.code ) { + case 'woocommerce_rest_missing_email_address': + case 'woocommerce_rest_invalid_email_address': + createNotice( 'error', response.message, { + id: response.code, + context: noticeContexts.CONTACT_INFORMATION, + } ); + break; + case 'rest_invalid_param': + processInvalidParamResponse( response ); + break; + default: + createNotice( 'error', response.message || DEFAULT_ERROR_MESSAGE, { + id: response.code, + context: noticeContexts.CHECKOUT, + } ); + } +}; + +export default processErrorResponse; diff --git a/assets/js/legacy/README.md b/assets/js/legacy/README.md index aaa8da48dbc..0c3689c14b0 100644 --- a/assets/js/legacy/README.md +++ b/assets/js/legacy/README.md @@ -53,12 +53,6 @@ Example: import { Label } from '@woocommerce/base-components/label'; ``` -### Webpack Plugin - -The second part of the webpack magic is a custom plugin. Located in `bin/fallback-module-directory-webpack-plugin.js`, this custom plugin is used instead of the default Alias plugin. It handles trying a fallback if the original path aliased to does not exist. The fallback is a variation of the aliased path using the provided `search` and `replace` strings when instantiating the plugin. You can see it setup in the `LegacyBlocksConfig.resolve.plugins` property of the `webpack.config.js` file. - - - --- [We're hiring!](https://woocommerce.com/careers/) Come work with us! diff --git a/assets/js/previews/cart.ts b/assets/js/previews/cart.ts index 6852a35ee9b..1c5d5a7a823 100644 --- a/assets/js/previews/cart.ts +++ b/assets/js/previews/cart.ts @@ -51,6 +51,12 @@ export const previewCart: CartResponse = { backorders_allowed: false, show_backorder_badge: false, sold_individually: false, + quantity_limits: { + minimum: 1, + maximum: 99, + multiple_of: 1, + editable: true, + }, images: [ { id: 10, @@ -121,6 +127,12 @@ export const previewCart: CartResponse = { backorders_allowed: false, show_backorder_badge: false, sold_individually: false, + quantity_limits: { + minimum: 1, + maximum: 99, + multiple_of: 1, + editable: true, + }, images: [ { id: 11, diff --git a/assets/js/settings/shared/default-constants.ts b/assets/js/settings/shared/default-constants.ts index 8e1158f2090..b6b8fff87a7 100644 --- a/assets/js/settings/shared/default-constants.ts +++ b/assets/js/settings/shared/default-constants.ts @@ -9,7 +9,7 @@ import { allSettings } from './settings-init'; export const ADMIN_URL = allSettings.adminUrl; export const COUNTRIES = allSettings.countries; export const CURRENCY = allSettings.currency; -export const CURRENT_USER_IS_ADMIN = allSettings.currentUserIsAdmin; +export const CURRENT_USER_IS_ADMIN = allSettings.currentUserIsAdmin as boolean; export const HOME_URL = allSettings.homeUrl; export const LOCALE = allSettings.locale; export const ORDER_STATUSES = allSettings.orderStatuses; diff --git a/assets/js/types/type-defs/api-error-response.ts b/assets/js/types/type-defs/api-error-response.ts new file mode 100644 index 00000000000..e7b64dc5afa --- /dev/null +++ b/assets/js/types/type-defs/api-error-response.ts @@ -0,0 +1,28 @@ +/** + * Internal dependencies + */ +import type { CartResponse } from './cart-response'; + +// This is the standard API response data when an error is returned. +export type ApiErrorResponse = { + code: string; + message: string; + data: ApiErrorResponseData; +}; + +// API errors contain data with the status, and more in-depth error details. This may be null. +export type ApiErrorResponseData = { + status: number; + params: Record< string, string >; + details: Record< string, ApiErrorResponseDataDetails >; + // Some endpoints return cart data to update the client. + cart?: CartResponse | undefined; +} | null; + +// The details object lists individual errors for each field. +export type ApiErrorResponseDataDetails = { + code: string; + message: string; + data: ApiErrorResponseData; + additional_errors: ApiErrorResponse[]; +}; diff --git a/assets/js/types/type-defs/api-response.ts b/assets/js/types/type-defs/api-response.ts new file mode 100644 index 00000000000..4ae440b62f7 --- /dev/null +++ b/assets/js/types/type-defs/api-response.ts @@ -0,0 +1,37 @@ +export interface ApiResponse< T > { + body: Record< string, unknown >; + headers: Headers; + status: number; + ok: boolean; + json: () => Promise< T >; +} + +export function assertBatchResponseIsValid( + response: unknown +): asserts response is { + responses: ApiResponse< unknown >[]; + headers: Headers; +} { + if ( + typeof response === 'object' && + response !== null && + response.hasOwnProperty( 'responses' ) + ) { + return; + } + throw new Error( 'Response not valid' ); +} + +export function assertResponseIsValid< T >( + response: unknown +): asserts response is ApiResponse< T > { + if ( + typeof response === 'object' && + response !== null && + 'body' in response && + 'headers' in response + ) { + return; + } + throw new Error( 'Response not valid' ); +} diff --git a/assets/js/types/type-defs/checkout.ts b/assets/js/types/type-defs/checkout.ts index 45c038dba8d..41a49eef0c3 100644 --- a/assets/js/types/type-defs/checkout.ts +++ b/assets/js/types/type-defs/checkout.ts @@ -3,6 +3,11 @@ */ import { ShippingAddress, BillingAddress } from '@woocommerce/settings'; +/** + * Internal dependencies + */ +import type { ApiErrorResponse } from './api-error-response'; + export interface CheckoutResponseSuccess { billing_address: BillingAddress; customer_id: number; @@ -20,12 +25,6 @@ export interface CheckoutResponseSuccess { status: string; } -export interface CheckoutResponseError { - code: string; - message: string; - data: { - status: number; - }; -} +export type CheckoutResponseError = ApiErrorResponse; export type CheckoutResponse = CheckoutResponseSuccess | CheckoutResponseError; diff --git a/assets/js/types/type-defs/hooks.ts b/assets/js/types/type-defs/hooks.ts index 09cead7e6c8..998847424b9 100644 --- a/assets/js/types/type-defs/hooks.ts +++ b/assets/js/types/type-defs/hooks.ts @@ -18,17 +18,18 @@ import type { CartResponse, CartResponseCoupons, } from './cart-response'; -import type { ResponseError } from '../../data/types'; +import type { ApiErrorResponse } from './api-error-response'; export interface StoreCartItemQuantity { isPendingDelete: boolean; quantity: number; setItemQuantity: React.Dispatch< React.SetStateAction< number > >; removeItem: () => Promise< boolean >; - cartItemQuantityErrors: Array< CartResponseErrorItem >; + cartItemQuantityErrors: CartResponseErrorItem[]; } +// An object exposing data and actions from/for the store api /cart/coupons endpoint. export interface StoreCartCoupon { - appliedCoupons: Array< CartResponseCouponItem >; + appliedCoupons: CartResponseCouponItem[]; isLoading: boolean; applyCoupon: ( coupon: string ) => void; removeCoupon: ( coupon: string ) => void; @@ -38,24 +39,24 @@ export interface StoreCartCoupon { export interface StoreCart { cartCoupons: CartResponseCoupons; - cartItems: Array< CartResponseItem >; - crossSellsProducts: Array< ProductResponseItem >; - cartFees: Array< CartResponseFeeItem >; + cartItems: CartResponseItem[]; + crossSellsProducts: ProductResponseItem[]; + cartFees: CartResponseFeeItem[]; cartItemsCount: number; cartItemsWeight: number; cartNeedsPayment: boolean; cartNeedsShipping: boolean; - cartItemErrors: Array< CartResponseErrorItem >; + cartItemErrors: CartResponseErrorItem[]; cartTotals: CartResponseTotals; cartIsLoading: boolean; - cartErrors: Array< ResponseError >; + cartErrors: ApiErrorResponse[]; billingAddress: CartResponseBillingAddress; shippingAddress: CartResponseShippingAddress; - shippingRates: Array< CartResponseShippingRate >; + shippingRates: CartResponseShippingRate[]; extensions: Record< string, unknown >; isLoadingRates: boolean; cartHasCalculatedShipping: boolean; - paymentRequirements: Array< string >; + paymentRequirements: string[]; receiveCart: ( cart: CartResponse ) => void; } diff --git a/assets/js/types/type-defs/index.ts b/assets/js/types/type-defs/index.ts index 4b2fe4bc849..3f8d79018bf 100644 --- a/assets/js/types/type-defs/index.ts +++ b/assets/js/types/type-defs/index.ts @@ -1,3 +1,5 @@ +export * from './api-response'; +export * from './api-error-response'; export * from './blocks'; export * from './cart'; export * from './cart-response'; diff --git a/assets/js/utils/notices.ts b/assets/js/utils/notices.ts index d52780c14e6..026692ec49b 100644 --- a/assets/js/utils/notices.ts +++ b/assets/js/utils/notices.ts @@ -5,15 +5,19 @@ import { dispatch, select } from '@wordpress/data'; import type { Notice } from '@wordpress/notices'; export const hasNoticesOfType = ( - context = '', - type: 'default' | 'snackbar' + type: 'default' | 'snackbar', + context?: string | undefined ): boolean => { const notices: Notice[] = select( 'core/notices' ).getNotices( context ); return notices.some( ( notice: Notice ) => notice.type === type ); }; -export const removeNoticesByStatus = ( status: string, context = '' ): void => { - const notices = select( 'core/notices' ).getNotices(); +// Note, if context is blank, the default context is used. +export const removeNoticesByStatus = ( + status: string, + context?: string | undefined +): void => { + const notices = select( 'core/notices' ).getNotices( context ); const { removeNotice } = dispatch( 'core/notices' ); const noticesOfType = notices.filter( ( notice ) => notice.status === status diff --git a/assets/js/utils/test/notices.js b/assets/js/utils/test/notices.js index 6949eb047aa..b2480a78418 100644 --- a/assets/js/utils/test/notices.js +++ b/assets/js/utils/test/notices.js @@ -34,10 +34,10 @@ describe( 'Notice utils', () => { ] ), } ); const hasSnackbarNotices = hasNoticesOfType( - 'wc/cart', - 'snackbar' + 'snackbar', + 'wc/cart' ); - const hasDefaultNotices = hasNoticesOfType( 'wc/cart', 'default' ); + const hasDefaultNotices = hasNoticesOfType( 'default', 'wc/cart' ); expect( hasDefaultNotices ).toBe( true ); expect( hasSnackbarNotices ).toBe( false ); } ); @@ -46,7 +46,7 @@ describe( 'Notice utils', () => { select.mockReturnValue( { getNotices: jest.fn().mockReturnValue( [] ), } ); - const hasDefaultNotices = hasNoticesOfType( 'wc/cart', 'default' ); + const hasDefaultNotices = hasNoticesOfType( 'default', 'wc/cart' ); expect( hasDefaultNotices ).toBe( false ); } ); } ); @@ -98,12 +98,12 @@ describe( 'Notice utils', () => { expect( dispatch().removeNotice ).toHaveBeenNthCalledWith( 1, 'coupon-form', - '' + undefined ); expect( dispatch().removeNotice ).toHaveBeenNthCalledWith( 2, 'address-form', - '' + undefined ); } ); diff --git a/bin/fallback-module-directory-webpack-plugin.js b/bin/fallback-module-directory-webpack-plugin.js deleted file mode 100644 index ddfd5478ad0..00000000000 --- a/bin/fallback-module-directory-webpack-plugin.js +++ /dev/null @@ -1,128 +0,0 @@ -/*eslint-env node*/ - -/** - * External dependencies - */ -const fs = require( 'fs' ); -const path = require( 'path' ); - -// Note, this has some inspiration from the AliasPlugin and its implementation -// @see https://github.com/webpack/enhanced-resolve/blob/v4.1.0/lib/AliasPlugin.js - -module.exports = class FallbackModuleDirectoryWebpackPlugin { - constructor( search, replacement, alias ) { - this.search = search; - this.replacement = replacement; - this.alias = this.parseAlias( alias ); - this.hooks = [ - [ 'described-resolve', 'resolve' ], - [ 'file', 'resolve' ], - ]; - this.applyFallback = this.applyFallback.bind( this ); - this.doApply = this.doApply.bind( this ); - } - - parseAlias( alias ) { - if ( typeof alias === 'object' && ! Array.isArray( alias ) ) { - alias = Object.keys( alias ).map( ( key ) => { - let onlyModule = false; - let obj = alias[ key ]; - if ( /\$$/.test( key ) ) { - onlyModule = true; - key = key.substr( 0, key.length - 1 ); - } - if ( typeof obj === 'string' ) { - obj = { - alias: obj, - }; - } - obj = Object.assign( - { - name: key, - onlyModule, - }, - obj - ); - return obj; - } ); - } - return alias; - } - - getPathWithExtension( pathString ) { - if ( ! Boolean( path.extname( pathString ) ) ) { - return pathString + '.js'; - } - return pathString; - } - - applyFallback( pathString ) { - if ( - pathString.includes( this.search ) && - ! fs.existsSync( pathString ) && - ! fs.existsSync( this.getPathWithExtension( pathString ) ) - ) { - return pathString.replace( this.search, this.replacement ); - } - return pathString; - } - - doApply( resolver, source, target, alias ) { - resolver - .getHook( source ) - .tapAsync( - 'FallbackModuleDirectoryWebpackPlugin', - ( request, resolveContext, callback ) => { - const innerRequest = request.request || request.path; - - if ( ! innerRequest ) return callback(); - - for ( const item of alias ) { - if ( - innerRequest === item.name || - ( ! item.onlyModule && - innerRequest.startsWith( item.name + '/' ) ) - ) { - if ( - innerRequest !== item.alias && - ! innerRequest.startsWith( item.alias + '/' ) - ) { - const newRequestStr = this.applyFallback( - item.alias + - innerRequest.substr( item.name.length ) - ); - const obj = { - ...request, - request: newRequestStr, - }; - return resolver.doResolve( - target, - obj, - `aliased with mapping '${ item.name }' to '${ newRequestStr }'`, - resolveContext, - ( err, result ) => { - if ( err ) return callback( err ); - - // Don't allow other aliasing or raw request - if ( result === undefined ) { - return callback( null, null ); - } - callback( null, result ); - } - ); - } - } - } - return callback(); - } - ); - } - - apply( resolver ) { - const alias = this.alias; - this.hooks.forEach( ( [ source, target ] ) => { - target = resolver.ensureHook( target ); - this.doApply( resolver, source, target, alias ); - } ); - } -}; diff --git a/docs/README.md b/docs/README.md index 9adcafc8545..1fc571e7fd1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,9 +7,9 @@ - [Third-party developers](#third-party-developers) - [Designers](#designers) - [Developer Resources](#developer-resources) - - [Tools](#tools) - - [Articles](#articles) - - [Tutorials](#tutorials) + - [Tools](#tools) + - [Articles](#articles) + - [Tutorials](#tutorials) The WooCommerce Blocks Handbook provides documentation for designers and developers on how to extend or contribute to blocks, and how internal developers should handle new releases. @@ -66,7 +66,7 @@ The WooCommerce Blocks Handbook provides documentation for designers and develop - [Coupons](internal-developers/testing/cart-checkout/coupons.md) - [Compatibility](internal-developers/testing/cart-checkout/compatibility.md) - [Releases](internal-developers/testing/releases/README.md) -- [Translations](internal-developers/translations/README.md) +- [Translations](internal-developers/translations/README.md) - [Translation basics](internal-developers/translations/translation-basics.md) - [Translations in PHP files](internal-developers/translations/translations-in-PHP-files.md) - [Translations in JS/TS files](internal-developers/translations/translations-in-JS-TS-files.md) @@ -140,7 +140,7 @@ The following tutorials from [developer.woocommerce.com](https://developer.wooco - [Hiding Shipping and Payment Options in the Cart and Checkout Blocks](https://developer.woocommerce.com/2022/05/20/hiding-shipping-and-payment-options-in-the-cart-and-checkout-blocks/) - [Integrating your Payment Method with Cart and Checkout Blocks](https://developer.woocommerce.com/2021/03/15/integrating-your-payment-method-with-cart-and-checkout-blocks/) -- [Exposing Payment Options in the Checkout Block](https://developer.woocommerce.com/2022/07/07/exposing-payment-options-in-the-checkout-block/) +- [Exposing Payment Options in the Checkout Block](https://developer.woocommerce.com/2022/07/07/exposing-payment-options-in-the-checkout-block/) @@ -151,4 +151,3 @@ The following tutorials from [developer.woocommerce.com](https://developer.wooco 🐞 Found a mistake, or have a suggestion? [Leave feedback about this document here.](https://github.com/woocommerce/woocommerce-blocks/issues/new?assignees=&labels=type%3A+documentation&template=--doc-feedback.md&title=Feedback%20on%20./docs/README.md) - diff --git a/docs/third-party-developers/extensibility/checkout-block/available-filters.md b/docs/third-party-developers/extensibility/checkout-block/available-filters.md index d9262204fe2..275ec0ae1ad 100644 --- a/docs/third-party-developers/extensibility/checkout-block/available-filters.md +++ b/docs/third-party-developers/extensibility/checkout-block/available-filters.md @@ -6,13 +6,12 @@ - [Order Summary Items](#order-summary-items) - [Totals footer item (in Mini Cart, Cart and Checkout)](#totals-footer-item-in-mini-cart-cart-and-checkout) - [Coupons](#coupons) -- [Snackbar notices](#snackbar-notices) - [Place Order Button Label](#place-order-button-label) - [Examples](#examples) - [Changing the wording of the Totals label in the Mini Cart, Cart and Checkout](#changing-the-wording-of-the-totals-label-in-the-mini-cart-cart-and-checkout) - [Changing the format of the item's single price](#changing-the-format-of-the-items-single-price) - [Change the name of a coupon](#change-the-name-of-a-coupon) - - [Hide a snackbar notice containing a certain string](#hide-a-snackbar-notice-containing-a-certain-string) + - [Hide the "Remove item" link on a cart item](#hide-the-remove-item-link-on-a-cart-item) - [Change the label of the Place Order button](#change-the-label-of-the-place-order-button) - [Troubleshooting](#troubleshooting) @@ -27,7 +26,7 @@ Line items refer to each item listed in the cart or checkout. For instance, the The following filters are available for line items: | Filter name | Description | Return type | -| ---------------------- |----------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------| +| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | | `itemName` | Used to change the name of the item before it is rendered onto the page | `string` | | `cartItemPrice` | This is the price of the item, multiplied by the number of items in the cart. | `string` and **must** contain the substring `` where the price should appear. | | `cartItemClass` | This is the className of the item cell. | `string` | @@ -91,31 +90,6 @@ CartCoupon { } ``` -## Snackbar notices - -There is a snackbar at the bottom of the page used to display notices to the customer, it looks like this: - -![Snackbar notices](https://user-images.githubusercontent.com/5656702/120882329-d573c100-c5ce-11eb-901b-d7f206f74a66.png) - -It may be desirable to hide this if there's a notice you don't want the shopper to see. - -| Filter name | Description | Return type | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `snackbarNoticeVisibility` | An object keyed by the content of the notices slated to be displayed. The value of each member of this object will initially be true. | `object` | - -The filter passes an object whose keys are the `content` of each notice. - -If there are two notices slated to be displayed ('Coupon code "10off" has been applied to your basket.', and 'Coupon code "50off" has been removed from your basket.'), the value passed to the filter would look like so: - -```js -{ - 'Coupon code "10off" has been applied to your basket.': true, - 'Coupon code "50off" has been removed from your basket.': true -} -``` - -To reiterate, the _value_ here will determine whether this notice gets displayed or not. It will display if true. - ## Place Order Button Label The Checkout block contains a button which is labelled 'Place Order' by default, but can be changed using the following filter. @@ -212,21 +186,22 @@ __experimentalRegisterCheckoutFilters( 'automatic-coupon-extension', { | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | | ![image](https://user-images.githubusercontent.com/5656702/123768988-bc55eb80-d8c0-11eb-9262-5d629837706d.png) | ![image](https://user-images.githubusercontent.com/5656702/124126048-2c57a380-da72-11eb-9b45-b2cae0cffc37.png) | -### Hide a snackbar notice containing a certain string +### Prevent a snackbar notice from appearing for coupons + +If you want to prevent a coupon apply notice from appearing, you can use the `showApplyCouponNotice` filter. If it returns `false` then the notice will not be created. -Let's say we want to hide all notices that contain the string `auto-generated-coupon`. We would do this by setting the value of the `snackbarNoticeVisibility` to false for the notices we would like to hide. +The same can be done with the `showRemoveCouponNotice` filter to prevent a notice when a coupon is removed from the cart. ```ts import { __experimentalRegisterCheckoutFilters } from '@woocommerce/blocks-checkout'; -__experimentalRegisterCheckoutFilters( 'automatic-coupon-extension', { - snackbarNoticeVisibility: ( value ) => { - // Copy the value so we don't mutate what is being passed by the filter. - const valueCopy = Object.assign( {}, value ); - Object.keys( value ).forEach( ( key ) => { - valueCopy[ key ] = key.indexOf( 'auto-generated-coupon' ) === -1; - } ); - return valueCopy; +__experimentalRegisterCheckoutFilters( 'example-extension', { + showApplyCouponNotice: ( value, extensions, { couponCode } ) => { + // Prevent a couponCode called '10off' from creating a notice. + return couponCode === '10off' ? false : value; + }, + showRemoveCouponNotice: ( value, _, { couponCode } ) => { + return couponCode === '10off' ? false : value; }, } ); ``` @@ -234,7 +209,7 @@ __experimentalRegisterCheckoutFilters( 'automatic-coupon-extension', { ### Hide the "Remove item" link on a cart item If you want to stop customers from being able to remove a specific item from their cart **on the front end**, you can do -this by using the `showRemoveItemLink` filter. If it returns `false` for that line item the link will not show. +this by using the `showRemoveItemLink` filter. If it returns `false` for that line item the link will not show. An important caveat to note is this does _not_ prevent the item from being removed from the cart using StoreAPI or by removing it in the Mini Cart, or traditional shortcode cart. @@ -291,4 +266,3 @@ The error will also be shown in your console. 🐞 Found a mistake, or have a suggestion? [Leave feedback about this document here.](https://github.com/woocommerce/woocommerce-blocks/issues/new?assignees=&labels=type%3A+documentation&template=--doc-feedback.md&title=Feedback%20on%20./docs/third-party-developers/extensibility/checkout-block/available-filters.md) - diff --git a/packages/checkout/components/store-notices-container/index.tsx b/packages/checkout/components/store-notices-container/index.tsx index 12faf4790cd..efb6098bdac 100644 --- a/packages/checkout/components/store-notices-container/index.tsx +++ b/packages/checkout/components/store-notices-container/index.tsx @@ -1,95 +1,67 @@ /** * External dependencies */ -import classnames from 'classnames'; -import { Notice } from 'wordpress-components'; -import { sanitizeHTML } from '@woocommerce/utils'; -import { useDispatch, useSelect } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { PAYMENT_STORE_KEY } from '@woocommerce/block-data'; -import type { Notice as NoticeType } from '@wordpress/notices'; /** * Internal dependencies */ import './style.scss'; +import StoreNotices from './store-notices'; +import SnackbarNotices from './snackbar-notices'; +import type { StoreNoticesContainerProps, StoreNotice } from './types'; -const getWooClassName = ( { status = 'default' } ) => { - switch ( status ) { - case 'error': - return 'woocommerce-error'; - case 'success': - return 'woocommerce-message'; - case 'info': - case 'warning': - return 'woocommerce-info'; - } - return ''; +const formatNotices = ( + notices: StoreNotice[], + context: string +): StoreNotice[] => { + return notices.map( ( notice ) => ( { + ...notice, + context, + } ) ); }; -interface StoreNoticesContainerProps { - className?: string; - context?: string; - additionalNotices?: NoticeType[]; -} - -/** - * Component that displays notices from the core/notices data store. See - * https://developer.wordpress.org/block-editor/reference-guides/data/data-core-notices/ for more information on this - * data store. - * - * @param props - * @param props.className Class name to add to the container. - * @param props.context Context to show notices from. - * @param props.additionalNotices Additional notices to display. - * @function Object() { [native code] } - */ -export const StoreNoticesContainer = ( { - className, - context = 'default', +const StoreNoticesContainer = ( { + className = '', + context, additionalNotices = [], }: StoreNoticesContainerProps ): JSX.Element | null => { - const isExpressPaymentMethodActive = useSelect( ( select ) => + const suppressNotices = useSelect( ( select ) => select( PAYMENT_STORE_KEY ).isExpressPaymentMethodActive() ); - const { notices } = useSelect( ( select ) => { - const store = select( 'core/notices' ); - return { - notices: store.getNotices( context ), - }; + const notices = useSelect< StoreNotice[] >( ( select ) => { + const { getNotices } = select( 'core/notices' ); + + return formatNotices( + ( getNotices( context ) as StoreNotice[] ).concat( + additionalNotices + ), + context + ).filter( Boolean ) as StoreNotice[]; } ); - const { removeNotice } = useDispatch( 'core/notices' ); - const regularNotices = notices - .filter( ( notice ) => notice.type !== 'snackbar' ) - .concat( additionalNotices ); - if ( ! regularNotices.length ) { + if ( suppressNotices ) { return null; } - const wrapperClass = classnames( className, 'wc-block-components-notices' ); - - // We suppress the notices when the express payment method is active - return isExpressPaymentMethodActive ? null : ( -
- { regularNotices.map( ( props ) => ( - { - if ( props.isDismissible ) { - removeNotice( props.id, context ); - } - } } - > - { sanitizeHTML( props.content ) } - - ) ) } -
+ return ( + <> + notice.type === 'default' + ) } + /> + notice.type === 'snackbar' + ) } + /> + ); }; diff --git a/packages/checkout/components/store-notices-container/snackbar-notices.tsx b/packages/checkout/components/store-notices-container/snackbar-notices.tsx new file mode 100644 index 00000000000..f5b591cbb75 --- /dev/null +++ b/packages/checkout/components/store-notices-container/snackbar-notices.tsx @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { SnackbarList } from 'wordpress-components'; +import { useDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import type { StoreNotice } from './types'; + +const SnackbarNotices = ( { + className, + notices, +}: { + className: string; + notices: StoreNotice[]; +} ): JSX.Element | null => { + const { removeNotice } = useDispatch( 'core/notices' ); + + if ( ! notices.length ) { + return null; + } + + return ( + { + return { + ...notice, + className: 'components-snackbar--status-' + notice.status, + }; + } ) } + onRemove={ ( noticeId: string ) => { + notices.forEach( ( notice ) => { + if ( notice.explicitDismiss && notice.id === noticeId ) { + removeNotice( notice.id, notice.context ); + } else if ( ! notice.explicitDismiss ) { + removeNotice( notice.id, notice.context ); + } + } ); + } } + /> + ); +}; + +export default SnackbarNotices; diff --git a/packages/checkout/components/store-notices-container/store-notices.tsx b/packages/checkout/components/store-notices-container/store-notices.tsx new file mode 100644 index 00000000000..471c99ffa0a --- /dev/null +++ b/packages/checkout/components/store-notices-container/store-notices.tsx @@ -0,0 +1,133 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; +import { useRef, useEffect } from '@wordpress/element'; +import { Notice } from 'wordpress-components'; +import { sanitizeHTML } from '@woocommerce/utils'; +import { useDispatch } from '@wordpress/data'; +import { usePrevious } from '@woocommerce/base-hooks'; +import { decodeEntities } from '@wordpress/html-entities'; +import { STORE_NOTICES_STORE_KEY } from '@woocommerce/block-data'; + +/** + * Internal dependencies + */ +import { getClassNameFromStatus } from './utils'; +import type { StoreNotice } from './types'; + +const StoreNotices = ( { + context, + className, + notices, +}: { + context: string; + className: string; + notices: StoreNotice[]; +} ): JSX.Element => { + const ref = useRef< HTMLDivElement >( null ); + const { removeNotice } = useDispatch( 'core/notices' ); + const { registerContainer, unregisterContainer } = useDispatch( + STORE_NOTICES_STORE_KEY + ); + const noticeIds = notices.map( ( notice ) => notice.id ); + const previousNoticeIds = usePrevious( noticeIds ); + + useEffect( () => { + // Scroll to container when an error is added here. + const containerRef = ref.current; + + if ( ! containerRef ) { + return; + } + + // Do not scroll if input has focus. + const activeElement = containerRef.ownerDocument.activeElement; + const inputs = [ 'input', 'select', 'button', 'textarea' ]; + + if ( + activeElement && + inputs.indexOf( activeElement.tagName.toLowerCase() ) !== -1 + ) { + return; + } + + const newNoticeIds = noticeIds.filter( + ( value ) => + ! previousNoticeIds || ! previousNoticeIds.includes( value ) + ); + + if ( newNoticeIds.length && containerRef?.scrollIntoView ) { + containerRef.scrollIntoView( { + behavior: 'smooth', + } ); + } + }, [ noticeIds, previousNoticeIds, ref ] ); + + // Register the container context with the parent. + useEffect( () => { + registerContainer( context ); + return () => { + unregisterContainer( context ); + }; + }, [ context, registerContainer, unregisterContainer ] ); + + // Group notices by status. Do not group notices that are not dismissable. + const noticesByStatus = { + error: notices.filter( ( { status } ) => status === 'error' ), + success: notices.filter( ( { status } ) => status === 'success' ), + warning: notices.filter( ( { status } ) => status === 'warning' ), + info: notices.filter( ( { status } ) => status === 'info' ), + }; + + return ( +
+ { Object.entries( noticesByStatus ).map( + ( [ status, noticeGroup ] ) => { + if ( ! noticeGroup.length ) { + return null; + } + return ( + { + noticeGroup.forEach( ( notice ) => { + removeNotice( notice.id, notice.context ); + } ); + } } + > + { noticeGroup.length === 1 ? ( + <> + { sanitizeHTML( + decodeEntities( + noticeGroup[ 0 ].content + ) + ) } + + ) : ( +
    + { noticeGroup.map( ( notice ) => ( +
  • + { sanitizeHTML( + decodeEntities( notice.content ) + ) } +
  • + ) ) } +
+ ) } +
+ ); + } + ) } +
+ ); +}; + +export default StoreNotices; diff --git a/packages/checkout/components/store-notices-container/style.scss b/packages/checkout/components/store-notices-container/style.scss index d4b58526489..4c7812f6de2 100644 --- a/packages/checkout/components/store-notices-container/style.scss +++ b/packages/checkout/components/store-notices-container/style.scss @@ -1,6 +1,12 @@ .wc-block-components-notices { display: block; - margin-bottom: 2em; + margin: 1.5em 0; + &:first-child { + margin-top: 0; + } + &:empty { + margin: 0; + } .wc-block-components-notices__notice { margin: 0; display: flex; @@ -28,6 +34,16 @@ margin-bottom: 0; } } + .components-notice__content { + ul { + margin: 0; + padding: 0; + list-style: none; + } + li + li { + margin: 0.25em 0 0 0; + } + } } .wc-block-components-notices__notice + .wc-block-components-notices__notice { margin-top: 1em; @@ -41,3 +57,24 @@ padding: 1.5rem 3rem; } } + +.wc-block-components-notices__snackbar { + position: fixed; + bottom: 20px; + left: 16px; + width: auto; + + @include breakpoint("<782px") { + position: fixed; + top: 10px; + left: 0; + bottom: auto; + } + + .components-snackbar-list__notice-container { + @include breakpoint("<782px") { + margin-left: 10px; + margin-right: 10px; + } + } +} diff --git a/packages/checkout/components/store-notices-container/types.ts b/packages/checkout/components/store-notices-container/types.ts new file mode 100644 index 00000000000..332ea079f5d --- /dev/null +++ b/packages/checkout/components/store-notices-container/types.ts @@ -0,0 +1,16 @@ +/** + * External dependencies + */ +import type { + Notice as NoticeType, + Options as NoticeOptions, +} from '@wordpress/notices'; + +export interface StoreNoticesContainerProps { + className?: string | undefined; + context: string; + // List of additional notices that were added inline and not stored in the `core/notices` store. + additionalNotices?: ( NoticeType & NoticeOptions )[]; +} + +export type StoreNotice = NoticeType & NoticeOptions; diff --git a/packages/checkout/components/store-notices-container/utils.ts b/packages/checkout/components/store-notices-container/utils.ts new file mode 100644 index 00000000000..8565c4bb626 --- /dev/null +++ b/packages/checkout/components/store-notices-container/utils.ts @@ -0,0 +1,12 @@ +export const getClassNameFromStatus = ( status = 'default' ): string => { + switch ( status ) { + case 'error': + return 'woocommerce-error'; + case 'success': + return 'woocommerce-message'; + case 'info': + case 'warning': + return 'woocommerce-info'; + } + return ''; +}; diff --git a/packages/checkout/filter-registry/index.ts b/packages/checkout/filter-registry/index.ts index 312b5fd420a..91efcbb66a8 100644 --- a/packages/checkout/filter-registry/index.ts +++ b/packages/checkout/filter-registry/index.ts @@ -1,7 +1,6 @@ /** * External dependencies */ -import { useMemo } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { CURRENT_USER_IS_ADMIN } from '@woocommerce/settings'; import deprecated from '@wordpress/deprecated'; @@ -41,19 +40,6 @@ export const __experimentalRegisterCheckoutFilters = ( namespace: string, filters: Record< string, CheckoutFilterFunction > ): void => { - /** - * Let developers know snackbarNotices is no longer available as a filter. - * - * See: https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4417 - */ - if ( Object.keys( filters ).includes( 'couponName' ) ) { - deprecated( 'snackbarNotices', { - alternative: 'snackbarNoticeVisibility', - plugin: 'WooCommerce Blocks', - link: 'https://github.com/woocommerce/woocommerce-gutenberg-products-block/pull/4417', - } ); - } - /** * Let the user know couponName is no longer available as a filter. * @@ -211,42 +197,40 @@ export const __experimentalApplyCheckoutFilter = < T >( { /** Function that needs to return true when the filtered value is passed in order for the filter to be applied. */ validation?: ( value: T ) => true | Error; } ): T => { - return useMemo( () => { - if ( - ! shouldReRunFilters( filterName, arg, extensions, defaultValue ) && - cachedValues[ filterName ] !== undefined - ) { - return cachedValues[ filterName ]; - } - const filters = getCheckoutFilters( filterName ); - let value = defaultValue; - filters.forEach( ( filter ) => { - try { - const newValue = filter( value, extensions || {}, arg ); - if ( typeof newValue !== typeof value ) { - throw new Error( - sprintf( - /* translators: %1$s is the type of the variable passed to the filter function, %2$s is the type of the value returned by the filter function. */ - __( - 'The type returned by checkout filters must be the same as the type they receive. The function received %1$s but returned %2$s.', - 'woo-gutenberg-products-block' - ), - typeof value, - typeof newValue - ) - ); - } - value = validation( newValue ) ? newValue : value; - } catch ( e ) { - if ( CURRENT_USER_IS_ADMIN ) { - throw e; - } else { - // eslint-disable-next-line no-console - console.error( e ); - } + if ( + ! shouldReRunFilters( filterName, arg, extensions, defaultValue ) && + cachedValues[ filterName ] !== undefined + ) { + return cachedValues[ filterName ]; + } + const filters = getCheckoutFilters( filterName ); + let value = defaultValue; + filters.forEach( ( filter ) => { + try { + const newValue = filter( value, extensions || {}, arg ); + if ( typeof newValue !== typeof value ) { + throw new Error( + sprintf( + /* translators: %1$s is the type of the variable passed to the filter function, %2$s is the type of the value returned by the filter function. */ + __( + 'The type returned by checkout filters must be the same as the type they receive. The function received %1$s but returned %2$s.', + 'woo-gutenberg-products-block' + ), + typeof value, + typeof newValue + ) + ); } - } ); - cachedValues[ filterName ] = value; - return value; - }, [ arg, defaultValue, extensions, filterName, validation ] ); + value = validation( newValue ) ? newValue : value; + } catch ( e ) { + if ( CURRENT_USER_IS_ADMIN ) { + throw e; + } else { + // eslint-disable-next-line no-console + console.error( e ); + } + } + } ); + cachedValues[ filterName ] = value; + return value; }; diff --git a/src/BlockTemplatesController.php b/src/BlockTemplatesController.php index 461ba164dcc..06a339b779f 100644 --- a/src/BlockTemplatesController.php +++ b/src/BlockTemplatesController.php @@ -93,12 +93,11 @@ public function get_block_template_fallback( $template, $id, $template_type ) { } $wp_query_args = array( - 'post_name__in' => array( 'archive-product' ), - 'post_type' => $template_type, - 'post_status' => array( 'auto-draft', 'draft', 'publish', 'trash' ), - 'posts_per_page' => 1, - 'no_found_rows' => true, - 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query + 'post_name__in' => array( 'archive-product', $slug ), + 'post_type' => $template_type, + 'post_status' => array( 'auto-draft', 'draft', 'publish', 'trash' ), + 'no_found_rows' => true, + 'tax_query' => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query array( 'taxonomy' => 'wp_theme', 'field' => 'name', @@ -109,6 +108,12 @@ public function get_block_template_fallback( $template, $id, $template_type ) { $template_query = new \WP_Query( $wp_query_args ); $posts = $template_query->posts; + // If we have more than one result from the query, it means that the current template is present in the db (has + // been customized by the user) and we should not return the `archive-product` template. + if ( count( $posts ) > 1 ) { + return null; + } + if ( count( $posts ) > 0 ) { $template = _build_block_template_result_from_post( $posts[0] ); @@ -117,6 +122,7 @@ public function get_block_template_fallback( $template, $id, $template_type ) { $template->slug = $slug; $template->title = BlockTemplateUtils::get_block_template_title( $slug ); $template->description = BlockTemplateUtils::get_block_template_description( $slug ); + unset( $template->source ); return $template; } diff --git a/src/BlockTypes/ProductQuery.php b/src/BlockTypes/ProductQuery.php index 27ff5d8b67b..6b63c16d489 100644 --- a/src/BlockTypes/ProductQuery.php +++ b/src/BlockTypes/ProductQuery.php @@ -127,8 +127,9 @@ public function update_rest_query( $args, $request ) { $orderby_query = isset( $orderby ) ? $this->get_custom_orderby_query( $orderby ) : array(); $attributes_query = is_array( $woo_attributes ) ? $this->get_product_attributes_query( $woo_attributes ) : array(); $stock_query = is_array( $woo_stock_status ) ? $this->get_stock_status_query( $woo_stock_status ) : array(); + $visibility_query = $this->get_product_visibility_query( $stock_query ); - return array_merge( $args, $on_sale_query, $orderby_query, $attributes_query, $stock_query ); + return array_merge( $args, $on_sale_query, $orderby_query, $attributes_query, $stock_query, $visibility_query ); } /** @@ -325,6 +326,34 @@ function ( $carry, $item ) { * @return array */ private function get_stock_status_query( $stock_statii ) { + if ( ! is_array( $stock_statii ) ) { + return array(); + } + + $stock_status_options = array_keys( wc_get_product_stock_status_options() ); + + /** + * If all available stock status are selected, we don't need to add the + * meta query for stock status. + */ + if ( + count( $stock_statii ) === count( $stock_status_options ) && + array_diff( $stock_statii, $stock_status_options ) === array_diff( $stock_status_options, $stock_statii ) + ) { + return array(); + } + + /** + * If all stock statuses are selected except 'outofstock', we use the + * product visibility query to filter out out of stock products. + * + * @see get_product_visibility_query() + */ + $diff = array_diff( $stock_status_options, $stock_statii ); + if ( count( $diff ) === 1 && in_array( 'outofstock', $diff, true ) ) { + return array(); + } + return array( 'meta_query' => array( array( @@ -336,6 +365,34 @@ private function get_stock_status_query( $stock_statii ) { ); } + /** + * Return a query for product visibility depending on their stock status. + * + * @param array $stock_query Stock status query. + * + * @return array Tax query for product visibility. + */ + private function get_product_visibility_query( $stock_query ) { + $product_visibility_terms = wc_get_product_visibility_term_ids(); + $product_visibility_not_in = array( is_search() ? $product_visibility_terms['exclude-from-search'] : $product_visibility_terms['exclude-from-catalog'] ); + + // Hide out of stock products. + if ( empty( $stock_query ) && 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) { + $product_visibility_not_in[] = $product_visibility_terms['outofstock']; + } + + return array( + 'tax_query' => array( + array( + 'taxonomy' => 'product_visibility', + 'field' => 'term_taxonomy_id', + 'terms' => $product_visibility_not_in, + 'operator' => 'NOT IN', + ), + ), + ); + } + /** * Set the query vars that are used by filter blocks. * @@ -439,11 +496,13 @@ private function get_queries_by_attributes( $parsed_block ) { $on_sale_enabled = isset( $query['__woocommerceOnSale'] ) && true === $query['__woocommerceOnSale']; $attributes_query = isset( $query['__woocommerceAttributes'] ) ? $this->get_product_attributes_query( $query['__woocommerceAttributes'] ) : array(); $stock_query = isset( $query['__woocommerceStockStatus'] ) ? $this->get_stock_status_query( $query['__woocommerceStockStatus'] ) : array(); + $visibility_query = $this->get_product_visibility_query( $stock_query ); return array( 'on_sale' => ( $on_sale_enabled ? $this->get_on_sale_products_query() : array() ), 'attributes' => $attributes_query, 'stock_status' => $stock_query, + 'visibility' => $visibility_query, ); } diff --git a/src/StoreApi/Routes/V1/CartItemsByKey.php b/src/StoreApi/Routes/V1/CartItemsByKey.php index d53ebfd6850..19339acbd6f 100644 --- a/src/StoreApi/Routes/V1/CartItemsByKey.php +++ b/src/StoreApi/Routes/V1/CartItemsByKey.php @@ -78,7 +78,7 @@ protected function get_route_response( \WP_REST_Request $request ) { $cart_item = $this->cart_controller->get_cart_item( $request['key'] ); if ( empty( $cart_item ) ) { - throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 404 ); + throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 409 ); } $data = $this->prepare_item_for_response( $cart_item, $request ); @@ -116,7 +116,7 @@ protected function get_route_delete_response( \WP_REST_Request $request ) { $cart_item = $this->cart_controller->get_cart_item( $request['key'] ); if ( empty( $cart_item ) ) { - throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 404 ); + throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 409 ); } $cart->remove_cart_item( $request['key'] ); diff --git a/src/StoreApi/Utilities/CartController.php b/src/StoreApi/Utilities/CartController.php index c97698dfd43..2186ba7c6d5 100644 --- a/src/StoreApi/Utilities/CartController.php +++ b/src/StoreApi/Utilities/CartController.php @@ -182,7 +182,7 @@ public function set_cart_item_quantity( $item_id, $quantity = 1 ) { $cart_item = $this->get_cart_item( $item_id ); if ( empty( $cart_item ) ) { - throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 404 ); + throw new RouteException( 'woocommerce_rest_cart_invalid_key', __( 'Cart item does not exist.', 'woo-gutenberg-products-block' ), 409 ); } $product = $cart_item['data']; diff --git a/src/StoreApi/Utilities/QuantityLimits.php b/src/StoreApi/Utilities/QuantityLimits.php index 543007e434b..1a1dc0a82f1 100644 --- a/src/StoreApi/Utilities/QuantityLimits.php +++ b/src/StoreApi/Utilities/QuantityLimits.php @@ -24,7 +24,7 @@ public function get_cart_item_quantity_limits( $cart_item ) { if ( ! $product instanceof \WC_Product ) { return [ 'minimum' => 1, - 'maximum' => null, + 'maximum' => 9999, 'multiple_of' => 1, 'editable' => true, ]; diff --git a/src/StoreApi/docs/cart.md b/src/StoreApi/docs/cart.md index 1a740deff81..20196360968 100644 --- a/src/StoreApi/docs/cart.md +++ b/src/StoreApi/docs/cart.md @@ -2,17 +2,22 @@ ## Table of Contents -- [Responses](#responses) - - [Cart Response](#cart-response) - - [Error Response](#error-response) -- [Get Cart](#get-cart) -- [Add Item](#add-item) -- [Remove Item](#remove-item) -- [Update Item](#update-item) -- [Apply Coupon](#apply-coupon) -- [Remove Coupon](#remove-coupon) -- [Update Customer](#update-customer) -- [Select Shipping Rate](#select-shipping-rate) +- [Responses](#responses) + - [Cart Response](#cart-response) + - [Error Response](#error-response) +- [Get Cart](#get-cart) +- [Add Item](#add-item) +- [Remove Item](#remove-item) +- [Update Item](#update-item) +- [Apply Coupon](#apply-coupon) +- [Remove Coupon](#remove-coupon) +- [Update Customer](#update-customer) +- [Select Shipping Rate](#select-shipping-rate) + +Test: + +- Hello +- Hello 2 The cart API returns the current state of the cart for the current session or logged in user. @@ -460,10 +465,10 @@ This endpoint will return an error unless a valid [Nonce Token](nonce-tokens.md) POST /cart/select-shipping-rate ``` -| Attribute | Type | Required | Description | -| :----------- | :------ | :------: | :---------------------------------- | -| `package_id` | integer | string | yes | The ID of the shipping package within the cart. | -| `rate_id` | string | yes | The chosen rate ID for the package. | +| Attribute | Type | Required | Description | +| :----------- | :------ | :------: | :---------------------------------------------- | +| `package_id` | integer | yes | The ID of the shipping package within the cart. | +| `rate_id` | string | yes | The chosen rate ID for the package. | ```sh curl --header "Nonce: 12345" --request POST /cart/select-shipping-rate?package_id=1&rate_id=flat_rate:1 @@ -480,4 +485,3 @@ Returns the full [Cart Response](#cart-response) on success, or an [Error Respon 🐞 Found a mistake, or have a suggestion? [Leave feedback about this document here.](https://github.com/woocommerce/woocommerce-blocks/issues/new?assignees=&labels=type%3A+documentation&template=--doc-feedback.md&title=Feedback%20on%20./src/StoreApi/docs/cart.md) - diff --git a/tests/e2e/specs/product-query/templates.test.ts b/tests/e2e/specs/product-query/templates.test.ts index 22bd9c78f76..8a7465307f0 100644 --- a/tests/e2e/specs/product-query/templates.test.ts +++ b/tests/e2e/specs/product-query/templates.test.ts @@ -62,8 +62,8 @@ describe( `${ block.name } Block`, () => { SELECTORS.advanceFilterOptionsButton ); - expect( popularFilterEl ).toBeNull(); - expect( advanceFilterOptionsButton ).toBeNull(); + await expect( popularFilterEl ).toBeNull(); + await expect( advanceFilterOptionsButton ).toBeNull(); } ); } ); diff --git a/tests/e2e/specs/product-query/utils.ts b/tests/e2e/specs/product-query/utils.ts index f82da0c8cd4..868899e54b7 100644 --- a/tests/e2e/specs/product-query/utils.ts +++ b/tests/e2e/specs/product-query/utils.ts @@ -101,9 +101,8 @@ export const addProductQueryBlock = async () => { }; export const toggleInheritQueryFromTemplateSetting = async () => { - await setCheckbox( - getToggleIdByLabel( SELECTORS.inheritQueryFromTemplateSetting ) - ); + const [ el ] = await page.$x( SELECTORS.inheritQueryFromTemplateSetting ); + await setCheckbox( el ); }; export const configurateProductQueryBlock = async () => { diff --git a/tests/php/BlockTypes/ProductQuery.php b/tests/php/BlockTypes/ProductQuery.php index 38e42e2e03e..f4806646dc4 100644 --- a/tests/php/BlockTypes/ProductQuery.php +++ b/tests/php/BlockTypes/ProductQuery.php @@ -89,7 +89,10 @@ public function test_merging_on_sale_queries() { foreach ( $on_sale_product_ids as $id ) { $this->assertContainsEquals( $id, $merged_query['post__in'] ); } + $this->assertCount( 4, $merged_query['post__in'] ); + + delete_transient( 'wc_products_onsale' ); } /** @@ -112,24 +115,34 @@ public function test_merging_stock_status_queries() { ), $merged_query['meta_query'] ); + } + /** + * Test merging default stock queries that should use product visibility + * queries instead of meta query for stock status. + */ + public function test_merging_default_stock_queries() { $parsed_block = $this->get_base_parsed_block(); $parsed_block['attrs']['query']['__woocommerceStockStatus'] = array( 'instock', + 'outofstock', 'onbackorder', ); - $this->block_instance->set_parsed_block( $parsed_block ); - $merged_query = $this->block_instance->build_query( $parsed_block['attrs']['query'] ); + $merged_query = $this->initialize_merged_query( $parsed_block ); - $this->assertContainsEquals( - array( - 'compare' => 'IN', - 'key' => '_stock_status', - 'value' => array( 'instock', 'onbackorder' ), - ), - $merged_query['meta_query'] + $this->assertEmpty( $merged_query['meta_query'] ); + + // Test with hide out of stock items option enabled. + $parsed_block = $this->get_base_parsed_block(); + $parsed_block['attrs']['query']['__woocommerceStockStatus'] = array( + 'instock', + 'onbackorder', ); + + $merged_query = $this->initialize_merged_query( $parsed_block ); + + $this->assertEmpty( $merged_query['meta_query'] ); } /** @@ -201,6 +214,57 @@ public function test_merging_order_by_popularity_queries() { $this->assertEquals( 'total_sales', $merged_query['meta_key'] ); } + /** + * Test product visibility query exist in merged query. + */ + public function test_product_visibility_query_exist_in_merged_query() { + $product_visibility_terms = wc_get_product_visibility_term_ids(); + $product_visibility_not_in = array( is_search() ? $product_visibility_terms['exclude-from-search'] : $product_visibility_terms['exclude-from-catalog'] ); + + $parsed_block = $this->get_base_parsed_block(); + + $merged_query = $this->initialize_merged_query( $parsed_block ); + + $this->assertContainsEquals( + array( + 'taxonomy' => 'product_visibility', + 'field' => 'term_taxonomy_id', + 'terms' => $product_visibility_not_in, + 'operator' => 'NOT IN', + ), + $merged_query['tax_query'] + ); + + $fn = function() { + return 'yes'; + }; + + // Test with hide out of stock items option enabled. + add_filter( + 'pre_option_woocommerce_hide_out_of_stock_items', + $fn + ); + $product_visibility_not_in[] = $product_visibility_terms['outofstock']; + + $parsed_block = $this->get_base_parsed_block(); + + $merged_query = $this->initialize_merged_query( $parsed_block ); + + $this->assertContainsEquals( + array( + 'taxonomy' => 'product_visibility', + 'field' => 'term_taxonomy_id', + 'terms' => $product_visibility_not_in, + 'operator' => 'NOT IN', + ), + $merged_query['tax_query'] + ); + remove_filter( + 'pre_option_woocommerce_hide_out_of_stock_items', + $fn + ); + } + /** * Test merging multiple queries. */ @@ -209,7 +273,7 @@ public function test_merging_multiple_queries() { $parsed_block['attrs']['query']['orderBy'] = 'rating'; $parsed_block['attrs']['query']['__woocommerceStockStatus'] = array( 'instock', - 'onbackorder', + 'outofstock', ); $parsed_block['attrs']['query']['__woocommerceAttributes'] = array( array( @@ -230,7 +294,7 @@ public function test_merging_multiple_queries() { array( 'compare' => 'IN', 'key' => '_stock_status', - 'value' => array( 'instock', 'onbackorder' ), + 'value' => array( 'instock', 'outofstock' ), ), $merged_query['meta_query'] ); @@ -266,6 +330,7 @@ public function test_merging_filter_by_max_price_queries() { ), $merged_query['meta_query'] ); + set_query_var( 'max_price', '' ); } /** @@ -289,6 +354,7 @@ public function test_merging_filter_by_min_price_queries() { ), $merged_query['meta_query'] ); + set_query_var( 'min_price', '' ); } /** @@ -318,6 +384,9 @@ public function test_merging_filter_by_min_and_max_price_queries() { ), $merged_query['meta_query'] ); + + set_query_var( 'max_price', '' ); + set_query_var( 'min_price', '' ); } /** @@ -336,6 +405,8 @@ public function test_merging_filter_by_stock_status_queries() { ), $merged_query['meta_query'] ); + + set_query_var( 'filter_stock_status', '' ); } /** @@ -363,8 +434,16 @@ public function test_merging_filter_by_attribute_queries() { $merged_query = $this->initialize_merged_query(); - $attribute_tax_query = $merged_query['tax_query'][0]; + $attribute_tax_query = array(); + + foreach ( $merged_query['tax_query'] as $tax_query ) { + if ( isset( $tax_query['relation'] ) ) { + $attribute_tax_query = $tax_query; + } + } + $attribute_tax_query_queries = $attribute_tax_query[0]; + $this->assertEquals( 'AND', $attribute_tax_query['relation'] ); $this->assertContainsEquals( @@ -385,6 +464,11 @@ public function test_merging_filter_by_attribute_queries() { ), $attribute_tax_query_queries ); + + set_query_var( 'filter_color', '' ); + set_query_var( 'query_type_color', '' ); + set_query_var( 'filter_size', '' ); + set_query_var( 'query_type_size', '' ); } /** @@ -424,6 +508,10 @@ public function test_merging_multiple_filter_queries() { ), $merged_query['meta_query'] ); + + set_query_var( 'max_price', '' ); + set_query_var( 'min_price', '' ); + set_query_var( 'filter_stock_status', '' ); } } diff --git a/tests/php/StoreApi/Routes/CartItems.php b/tests/php/StoreApi/Routes/CartItems.php index df15b15be9e..ca7ba6ace93 100644 --- a/tests/php/StoreApi/Routes/CartItems.php +++ b/tests/php/StoreApi/Routes/CartItems.php @@ -221,7 +221,7 @@ public function test_delete_item() { $request->set_header( 'Nonce', wp_create_nonce( 'wc_store_api' ) ); $this->assertAPIResponse( $request, - 404 + 409 ); } @@ -286,11 +286,13 @@ public function test_get_item_schema() { // Simple product. $response = $controller->prepare_item_for_response( current( $cart ), new \WP_REST_Request() ); $diff = $validate->get_diff_from_object( $response->get_data() ); + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r $this->assertEmpty( $diff, print_r( $diff, true ) ); // Variable product. $response = $controller->prepare_item_for_response( end( $cart ), new \WP_REST_Request() ); $diff = $validate->get_diff_from_object( $response->get_data() ); + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_print_r $this->assertEmpty( $diff, print_r( $diff, true ) ); } } diff --git a/webpack.config.js b/webpack.config.js index 52dec484157..4f48de6403d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,7 +1,6 @@ /** * Internal dependencies */ -const FallbackModuleDirectoryPlugin = require( './bin/fallback-module-directory-webpack-plugin' ); const { NODE_ENV, getAlias } = require( './bin/webpack-helpers.js' ); const { getCoreConfig, @@ -77,91 +76,6 @@ const StylingConfig = { ...getStylingConfig( { alias: getAlias() } ), }; -/** - * Legacy Configs are for builds targeting < WP5.3 and handle backwards compatibility and disabling - * unsupported features. - * - * Now that WordPress 5.5 is released, as of WooCommerce Blocks 3.2.0 we don't support WP <5.3, - * so these legacy builds are not used. Keeping the config so we can conveniently reinstate - * these builds as needed (hence eslint-disable). - */ -const LegacyMainConfig = { - ...sharedConfig, - ...getMainConfig( { - fileSuffix: 'legacy', - resolvePlugins: [ - new FallbackModuleDirectoryPlugin( - '/legacy/', - '/', - getAlias( { pathPart: 'legacy' } ) - ), - ], - exclude: [ - 'all-products', - 'price-filter', - 'attribute-filter', - 'stock-filter', - 'active-filters', - 'checkout', - 'cart', - 'single-product', - ], - } ), -}; - -const LegacyFrontendConfig = { - ...sharedConfig, - ...getFrontConfig( { - fileSuffix: 'legacy', - resolvePlugins: [ - new FallbackModuleDirectoryPlugin( - '/legacy/', - '/', - getAlias( { pathPart: 'legacy' } ) - ), - ], - exclude: [ - 'all-products', - 'price-filter', - 'attribute-filter', - 'stock-filter', - 'active-filters', - 'checkout', - 'cart', - 'single-product', - ], - } ), -}; - -const LegacyStylingConfig = { - ...sharedConfig, - ...getStylingConfig( { - fileSuffix: 'legacy', - exclude: [ - 'all-products', - 'price-filter', - 'attribute-filter', - 'stock-filter', - 'active-filters', - 'checkout', - 'cart', - 'single-product', - ], - } ), -}; - -/** - * Now that WordPress 5.5 is released, as of WooCommerce Blocks 3.2.0 we don't support WP <5.3, - * so the legacy builds are not used. Keeping the config so we can conveniently reinstate - * these builds as needed (hence eslint-disable). - */ -// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars -const legacyConfigs = [ - LegacyMainConfig, - LegacyFrontendConfig, - LegacyStylingConfig, -]; - module.exports = [ CoreConfig, MainConfig,