From d3a9dc3d6bd52f61ac9cd9d62d2d4291df2ea264 Mon Sep 17 00:00:00 2001 From: Mike Jolley Date: Tue, 3 Mar 2020 10:26:02 +0000 Subject: [PATCH] Create Context Provider for Notices w/ Notices API (https://github.com/woocommerce/woocommerce-blocks/pull/1843) * Working on store provider * Working on store provider * Reducer implementation * Implement core/notices * Add notices to store coupon hook with context * Improve store notice text and styling * Improve JS side API for notices * Wrap functions with context additon * Update test to [] * Implement props feedback and useInstanceId * Update assets/js/base/context/store-notices-context.js Co-Authored-By: Darren Ethier * Update assets/js/base/context/store-notices-context.js Co-Authored-By: Darren Ethier * remove instance id Co-authored-by: Darren Ethier --- .../store-notices-container/index.js | 59 ++++++++++++ .../store-notices-container/style.scss | 26 ++++++ .../js/base/context/store-notices-context.js | 91 +++++++++++++++++++ .../assets/js/base/hooks/index.js | 1 + .../js/base/hooks/use-store-cart-coupons.js | 86 +++++++++++++++--- .../assets/js/base/hooks/use-store-notices.js | 48 ++++++++++ .../js/blocks/cart-checkout/cart/frontend.js | 16 +--- .../assets/js/data/cart/actions.js | 22 ++++- .../assets/js/data/collections/resolvers.js | 8 +- .../js/data/collections/test/resolvers.js | 2 +- .../assets/js/data/index.js | 8 ++ plugins/woocommerce-blocks/package-lock.json | 55 ++++++++++- plugins/woocommerce-blocks/package.json | 4 +- .../StoreApi/Utilities/CartController.php | 12 ++- 14 files changed, 397 insertions(+), 41 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/store-notices-container/index.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/components/store-notices-container/style.scss create mode 100644 plugins/woocommerce-blocks/assets/js/base/context/store-notices-context.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/hooks/use-store-notices.js diff --git a/plugins/woocommerce-blocks/assets/js/base/components/store-notices-container/index.js b/plugins/woocommerce-blocks/assets/js/base/components/store-notices-container/index.js new file mode 100644 index 00000000000..0e2a6182dad --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/store-notices-container/index.js @@ -0,0 +1,59 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { Notice } from 'wordpress-components'; +import { useStoreNoticesContext } from '@woocommerce/base-context/store-notices-context'; + +/** + * Internal dependencies + */ +import './style.scss'; + +const getWooClassName = ( { status = 'default' } ) => { + switch ( status ) { + case 'error': + return 'woocommerce-message woocommerce-error'; + case 'success': + return 'woocommerce-message woocommerce-success'; + case 'info': + case 'warning': + return 'woocommerce-message woocommerce-info'; + } + return ''; +}; + +const StoreNoticesContainer = ( { className, notices } ) => { + const { removeNotice } = useStoreNoticesContext(); + const wrapperClass = classnames( className, 'wc-block-components-notices' ); + + return ( +
+ { notices.map( ( props ) => ( + { + if ( props.isDismissible ) { + removeNotice( props.id ); + } + } } + > + { props.content } + + ) ) } +
+ ); +}; + +StoreNoticesContainer.propTypes = { + className: PropTypes.string, + notices: PropTypes.array, +}; + +export default StoreNoticesContainer; diff --git a/plugins/woocommerce-blocks/assets/js/base/components/store-notices-container/style.scss b/plugins/woocommerce-blocks/assets/js/base/components/store-notices-container/style.scss new file mode 100644 index 00000000000..31de746887c --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/components/store-notices-container/style.scss @@ -0,0 +1,26 @@ +.wc-block-components-notices { + display: block; + margin-bottom: 2em; + .wc-block-components-notices__notice { + margin: 0; + .components-notice__content { + display: inline-block; + } + .components-notice__dismiss { + background: transparent none; + padding: 0; + margin: 0; + border: 0; + outline: 0; + color: #fff; + float: right; + svg { + fill: #fff; + vertical-align: text-top; + } + } + } + .wc-block-components-notices__notice + .wc-block-components-notices__notice { + margin-top: 1em; + } +} diff --git a/plugins/woocommerce-blocks/assets/js/base/context/store-notices-context.js b/plugins/woocommerce-blocks/assets/js/base/context/store-notices-context.js new file mode 100644 index 00000000000..860d3ee4306 --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/context/store-notices-context.js @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { createContext, useContext, useCallback } from '@wordpress/element'; +import { useSelect, useDispatch } from '@wordpress/data'; +import StoreNoticesContainer from '@woocommerce/base-components/store-notices-container'; + +const StoreNoticesContext = createContext( { + notices: [], + createNotice: () => void null, + removeNotice: () => void null, + context: 'wc/core', +} ); + +export const useStoreNoticesContext = () => { + return useContext( StoreNoticesContext ); +}; + +/** + * Provides an interface for blocks to add notices to the frontend UI. + * + * Statuses map to https://github.com/WordPress/gutenberg/tree/master/packages/components/src/notice + * - Default (no status) + * - Error + * - Warning + * - Info + * - Success + */ +const StoreNoticesProvider = ( { + children, + className = '', + createNoticeContainer = true, + context = 'wc/core', +} ) => { + const { createNotice, removeNotice } = useDispatch( 'core/notices' ); + + const createNoticeWithContext = useCallback( + ( status = 'default', content = '', options = {} ) => { + createNotice( status, content, { + ...options, + context: options.context || context, + } ); + }, + [ createNotice, context ] + ); + + const removeNoticeWithContext = useCallback( + ( id ) => { + removeNotice( id, context ); + }, + [ createNotice, context ] + ); + + const { notices } = useSelect( + ( select ) => { + return { + notices: select( 'core/notices' ).getNotices( context ), + }; + }, + [ context ] + ); + + const contextValue = { + notices, + createNotice: createNoticeWithContext, + removeNotice: removeNoticeWithContext, + context, + }; + + return ( + + { createNoticeContainer && ( + + ) } + { children } + + ); +}; + +StoreNoticesProvider.propTypes = { + className: PropTypes.string, + createNoticeContainer: PropTypes.bool, + children: PropTypes.node, + context: PropTypes.string, +}; + +export default StoreNoticesProvider; diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/index.js b/plugins/woocommerce-blocks/assets/js/base/hooks/index.js index 63c2e42b535..eb2820eabac 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/index.js @@ -4,6 +4,7 @@ export * from './use-store-cart'; export * from './use-store-cart-coupons'; export * from './use-store-cart-item'; export * from './use-store-products'; +export * from './use-store-notices'; export * from './use-collection'; export * from './use-collection-header'; export * from './use-collection-data'; diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-coupons.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-coupons.js index 80b6b6a53c6..e44fd499b3e 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-coupons.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-cart-coupons.js @@ -3,8 +3,10 @@ /** * External dependencies */ +import { __, sprintf } from '@wordpress/i18n'; import { useSelect } from '@wordpress/data'; import { CART_STORE_KEY as storeKey } from '@woocommerce/block-data'; +import { useStoreNotices } from '@woocommerce/base-hooks'; /** * Internal dependencies @@ -21,20 +23,80 @@ import { useStoreCart } from './use-store-cart'; */ export const useStoreCartCoupons = () => { const { cartCoupons, cartIsLoading } = useStoreCart(); + const { + addErrorNotice, + addSuccessNotice, + addInfoNotice, + } = useStoreNotices(); - const results = useSelect( ( select, { dispatch } ) => { - const store = select( storeKey ); - const isApplyingCoupon = store.isApplyingCoupon(); - const isRemovingCoupon = store.isRemovingCoupon(); - const { applyCoupon, removeCoupon } = dispatch( storeKey ); + const results = useSelect( + ( select, { dispatch } ) => { + const store = select( storeKey ); + const isApplyingCoupon = store.isApplyingCoupon(); + const isRemovingCoupon = store.isRemovingCoupon(); + const { applyCoupon, removeCoupon } = dispatch( storeKey ); - return { - applyCoupon, - removeCoupon, - isApplyingCoupon, - isRemovingCoupon, - }; - }, [] ); + const applyCouponWithNotices = ( couponCode ) => { + applyCoupon( couponCode ) + .then( ( result ) => { + if ( result === true ) { + addSuccessNotice( + sprintf( + // translators: %s coupon code. + __( + 'Coupon code "%s" has been applied to your cart', + 'woo-gutenberg-products-block' + ), + couponCode + ), + { + id: 'coupon-form', + } + ); + } + } ) + .catch( ( error ) => { + addErrorNotice( error.message, { + id: 'coupon-form', + } ); + } ); + }; + + const removeCouponWithNotices = ( couponCode ) => { + removeCoupon( couponCode ) + .then( ( result ) => { + if ( result === true ) { + addInfoNotice( + sprintf( + // translators: %s coupon code. + __( + 'Coupon code "%s" has been removed from your cart', + 'woo-gutenberg-products-block' + ), + couponCode + ), + { + id: 'coupon-form', + } + ); + } + } ) + .catch( ( error ) => { + addErrorNotice( error.message, { + id: 'coupon-form', + } ); + } ); + }; + + return { + applyCoupon: applyCouponWithNotices, + removeCoupon: removeCouponWithNotices, + isApplyingCoupon, + isRemovingCoupon, + }; + }, + [ addErrorNotice, addSuccessNotice, addInfoNotice ] + ); return { appliedCoupons: cartCoupons, diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-notices.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-notices.js new file mode 100644 index 00000000000..922fd7d1dba --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-notices.js @@ -0,0 +1,48 @@ +/** + * External dependencies + */ +import { useStoreNoticesContext } from '@woocommerce/base-context/store-notices-context'; +import { useMemo } from '@wordpress/element'; + +export const useStoreNotices = () => { + const { notices, createNotice, removeNotice } = useStoreNoticesContext(); + + const noticesApi = useMemo( + () => ( { + addDefaultNotice: ( text, noticeProps = {} ) => + void createNotice( 'default', text, { + ...noticeProps, + } ), + addErrorNotice: ( text, noticeProps = {} ) => + void createNotice( 'error', text, { + ...noticeProps, + } ), + addWarningNotice: ( text, noticeProps = {} ) => + void createNotice( 'warning', text, { + ...noticeProps, + } ), + addInfoNotice: ( text, noticeProps = {} ) => + void createNotice( 'info', text, { + ...noticeProps, + } ), + addSuccessNotice: ( text, noticeProps = {} ) => + void createNotice( 'success', text, { + ...noticeProps, + } ), + removeNotices: ( type = null ) => { + notices.map( ( notice ) => { + if ( type === null || notice.status === type ) { + removeNotice( notice.id ); + } + return true; + } ); + }, + } ), + [ createNotice ] + ); + + return { + notices, + ...noticesApi, + }; +}; diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/frontend.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/frontend.js index 769888851b2..712a3ddfd21 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/frontend.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/frontend.js @@ -5,6 +5,8 @@ import { withRestApiHydration } from '@woocommerce/block-hocs'; import { useStoreCart } from '@woocommerce/base-hooks'; import { RawHTML } from '@wordpress/element'; import LoadingMask from '@woocommerce/base-components/loading-mask'; +import StoreNoticesProvider from '@woocommerce/base-context/store-notices-context'; + /** * Internal dependencies */ @@ -23,21 +25,11 @@ const CartFrontend = ( { cartItems, cartTotals, cartIsLoading, - cartErrors, cartCoupons, } = useStoreCart(); return ( - <> -
- { // @todo This is a placeholder for error messages - this needs refactoring. - cartErrors && - cartErrors.map( ( error = {}, i ) => ( -
- { error.message } -
- ) ) } -
+ { ! cartIsLoading && ! cartItems.length ? ( { emptyCart } ) : ( @@ -54,7 +46,7 @@ const CartFrontend = ( { /> ) } - + ); }; diff --git a/plugins/woocommerce-blocks/assets/js/data/cart/actions.js b/plugins/woocommerce-blocks/assets/js/data/cart/actions.js index 9b183a01b05..f0dd8d09227 100644 --- a/plugins/woocommerce-blocks/assets/js/data/cart/actions.js +++ b/plugins/woocommerce-blocks/assets/js/data/cart/actions.js @@ -72,6 +72,7 @@ export function receiveRemovingCoupon( couponCode ) { * Applies a coupon code and either invalidates caches, or receives an error if * the coupon cannot be applied. * + * @throws Will throw an error if there is an API problem. * @param {string} couponCode The coupon code to apply to the cart. */ export function* applyCoupon( couponCode ) { @@ -90,17 +91,26 @@ export function* applyCoupon( couponCode ) { if ( result ) { yield receiveCart( result ); } + + // Finished handling the coupon. + yield receiveApplyingCoupon( '' ); } catch ( error ) { + // Store the error message in state. yield receiveError( error ); + // Finished handling the coupon. + yield receiveApplyingCoupon( '' ); + // Re-throw the error. + throw error; } - yield receiveApplyingCoupon( '' ); + return true; } /** * Removes a coupon code and either invalidates caches, or receives an error if * the coupon cannot be removed. * + * @throws Will throw an error if there is an API problem. * @param {string} couponCode The coupon code to remove from the cart. */ export function* removeCoupon( couponCode ) { @@ -119,11 +129,19 @@ export function* removeCoupon( couponCode ) { if ( result ) { yield receiveCart( result ); } + + // Finished handling the coupon. + yield receiveRemovingCoupon( '' ); } catch ( error ) { + // Store the error message in state. yield receiveError( error ); + // Finished handling the coupon. + yield receiveRemovingCoupon( '' ); + // Re-throw the error. + throw error; } - yield receiveRemovingCoupon( '' ); + return true; } /** diff --git a/plugins/woocommerce-blocks/assets/js/data/collections/resolvers.js b/plugins/woocommerce-blocks/assets/js/data/collections/resolvers.js index 9b2bc08e0d3..fa49835d064 100644 --- a/plugins/woocommerce-blocks/assets/js/data/collections/resolvers.js +++ b/plugins/woocommerce-blocks/assets/js/data/collections/resolvers.js @@ -7,13 +7,9 @@ import { addQueryArgs } from '@wordpress/url'; /** * Internal dependencies */ -import { - receiveCollection, - receiveCollectionError, - DEFAULT_EMPTY_ARRAY, -} from './actions'; +import { receiveCollection, receiveCollectionError } from './actions'; import { STORE_KEY as SCHEMA_STORE_KEY } from '../schema/constants'; -import { STORE_KEY } from './constants'; +import { STORE_KEY, DEFAULT_EMPTY_ARRAY } from './constants'; import { apiFetchWithHeaders } from './controls'; /** diff --git a/plugins/woocommerce-blocks/assets/js/data/collections/test/resolvers.js b/plugins/woocommerce-blocks/assets/js/data/collections/test/resolvers.js index c0e8e7e96c4..f1375571474 100644 --- a/plugins/woocommerce-blocks/assets/js/data/collections/test/resolvers.js +++ b/plugins/woocommerce-blocks/assets/js/data/collections/test/resolvers.js @@ -88,7 +88,7 @@ describe( 'getCollection', () => { 'products', '?foo=bar', [ 20, 30 ], - { items: undefined, headers: undefined } + { items: [], headers: undefined } ) ); } diff --git a/plugins/woocommerce-blocks/assets/js/data/index.js b/plugins/woocommerce-blocks/assets/js/data/index.js index a31c17f1463..4c2a85ee85f 100644 --- a/plugins/woocommerce-blocks/assets/js/data/index.js +++ b/plugins/woocommerce-blocks/assets/js/data/index.js @@ -1,3 +1,11 @@ +/** + * External dependencies + */ +import '@wordpress/notices'; + +/** + * Internal dependencies + */ export { SCHEMA_STORE_KEY } from './schema'; export { COLLECTIONS_STORE_KEY } from './collections'; export { CART_STORE_KEY } from './cart'; diff --git a/plugins/woocommerce-blocks/package-lock.json b/plugins/woocommerce-blocks/package-lock.json index 9bdbe39ec7e..1a44ffe928c 100644 --- a/plugins/woocommerce-blocks/package-lock.json +++ b/plugins/woocommerce-blocks/package-lock.json @@ -5100,6 +5100,18 @@ } } }, + "@wordpress/notices": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-1.12.0.tgz", + "integrity": "sha512-TSX9ih2LfInO+/v0lb1k1PBOHYveIKINkLAmD+BJtAgFVjbJG1465rinv+efAYiqcnmQhrHHrpn4wGUP/7c0jg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.8.3", + "@wordpress/a11y": "^2.7.0", + "@wordpress/data": "^4.13.0", + "lodash": "^4.17.15" + } + }, "@wordpress/viewport": { "version": "2.13.0", "resolved": "https://registry.npmjs.org/@wordpress/viewport/-/viewport-2.13.0.tgz", @@ -5409,14 +5421,13 @@ } }, "@wordpress/notices": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-1.12.0.tgz", - "integrity": "sha512-TSX9ih2LfInO+/v0lb1k1PBOHYveIKINkLAmD+BJtAgFVjbJG1465rinv+efAYiqcnmQhrHHrpn4wGUP/7c0jg==", - "dev": true, + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@wordpress/notices/-/notices-2.0.0.tgz", + "integrity": "sha512-NOkI8r2YLRxRrx+z7wDzpifyJAMnKezVjTnsy6EiU84Kai7FM2Ce+I51asw3c6UfLPpDY8DjLrzUWVZkhQF/og==", "requires": { "@babel/runtime": "^7.8.3", "@wordpress/a11y": "^2.7.0", - "@wordpress/data": "^4.13.0", + "@wordpress/data": "^4.14.0", "lodash": "^4.17.15" } }, @@ -29654,6 +29665,40 @@ } } }, + "wordpress-compose": { + "version": "npm:@wordpress/compose@3.11.0", + "resolved": "https://registry.npmjs.org/@wordpress/compose/-/compose-3.11.0.tgz", + "integrity": "sha512-CNbLn9NtG2A0X71wjEux126uEHpWp3v546FtSgMoWlq73z3LEEBDoEeS2glIPAbIK6e1X2UibsKrn5Tn651tlg==", + "requires": { + "@babel/runtime": "^7.8.3", + "@wordpress/element": "^2.11.0", + "@wordpress/is-shallow-equal": "^1.8.0", + "lodash": "^4.17.15", + "mousetrap": "^1.6.2" + }, + "dependencies": { + "@wordpress/element": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-2.11.0.tgz", + "integrity": "sha512-56ZO8a+E7QEsYwiqS+3BQPSHrCPsOAIEz5smXzntb2f6BjvOKeA64pup40mdn1pNGexe06LBA8cjoZVdLBHB1w==", + "requires": { + "@babel/runtime": "^7.8.3", + "@wordpress/escape-html": "^1.7.0", + "lodash": "^4.17.15", + "react": "^16.9.0", + "react-dom": "^16.9.0" + } + }, + "@wordpress/is-shallow-equal": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@wordpress/is-shallow-equal/-/is-shallow-equal-1.8.0.tgz", + "integrity": "sha512-OV3qJqP9LhjuOzt85TsyBwv+//CvC8Byf/81D3NmjPKlstLaD/bBCC5nBhH6dKAv4bShYtQ2Hmut+V4dZnOM1A==", + "requires": { + "@babel/runtime": "^7.8.3" + } + } + } + }, "wordpress-element": { "version": "npm:@wordpress/element@2.11.0", "resolved": "https://registry.npmjs.org/@wordpress/element/-/element-2.11.0.tgz", diff --git a/plugins/woocommerce-blocks/package.json b/plugins/woocommerce-blocks/package.json index 1fdfe18c82c..710ce90d601 100644 --- a/plugins/woocommerce-blocks/package.json +++ b/plugins/woocommerce-blocks/package.json @@ -141,6 +141,7 @@ }, "dependencies": { "@woocommerce/components": "4.0.0", + "@wordpress/notices": "^2.0.0", "classnames": "2.2.6", "compare-versions": "3.6.0", "config": "3.2.6", @@ -149,7 +150,8 @@ "trim-html": "0.1.9", "use-debounce": "3.3.0", "wordpress-components": "npm:@wordpress/components@8.5.0", - "wordpress-element": "npm:@wordpress/element@2.11.0" + "wordpress-element": "npm:@wordpress/element@2.11.0", + "wordpress-compose": "npm:@wordpress/compose@3.11.0" }, "husky": { "hooks": { diff --git a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php index cce572dc105..12be4d38dfb 100644 --- a/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php +++ b/plugins/woocommerce-blocks/src/RestApi/StoreApi/Utilities/CartController.php @@ -228,7 +228,11 @@ class CartController { if ( $coupon->get_code() !== $coupon_code ) { throw new RestException( 'woocommerce_rest_cart_coupon_error', - __( 'Invalid coupon code.', 'woo-gutenberg-products-block' ), + sprintf( + /* Translators: %s coupon code */ + __( '"%s" is an invalid coupon code.', 'woo-gutenberg-products-block' ), + esc_html( $coupon_code ) + ), 403 ); } @@ -236,7 +240,11 @@ class CartController { if ( $this->has_coupon( $coupon_code ) ) { throw new RestException( 'woocommerce_rest_cart_coupon_error', - __( 'Coupon has already been applied.', 'woo-gutenberg-products-block' ), + sprintf( + /* Translators: %s coupon code */ + __( 'Coupon code "%s" has already been applied.', 'woo-gutenberg-products-block' ), + esc_html( $coupon_code ) + ), 403 ); }