diff --git a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/order-summary/order-summary-item.js b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/order-summary/order-summary-item.js index 7ce9a95535f..ee9c1062798 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/order-summary/order-summary-item.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/order-summary/order-summary-item.js @@ -6,10 +6,15 @@ import Label from '@woocommerce/base-components/label'; import ProductPrice from '@woocommerce/base-components/product-price'; import ProductName from '@woocommerce/base-components/product-name'; import { getCurrency } from '@woocommerce/price-format'; -import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout'; +import { + __experimentalApplyCheckoutFilter, + mustBeString, + mustContain, +} from '@woocommerce/blocks-checkout'; import PropTypes from 'prop-types'; import Dinero from 'dinero.js'; import { DISPLAY_CART_PRICES_INCLUDING_TAX } from '@woocommerce/block-settings'; +import { useCallback, useMemo } from '@wordpress/element'; /** * Internal dependencies @@ -36,16 +41,27 @@ const OrderSummaryItem = ( { cartItem } ) => { extensions, } = cartItem; + const productPriceValidation = useCallback( + ( value ) => mustBeString( value ) && mustContain( value, '' ), + [] + ); + + const arg = useMemo( + () => ( { + context: 'summary', + cartItem, + } ), + [ cartItem ] + ); + const priceCurrency = getCurrency( prices ); const name = __experimentalApplyCheckoutFilter( { filterName: 'itemName', defaultValue: initialName, - arg: { - extensions, - context: 'summary', - }, - validation: ( value ) => typeof value === 'string', + extensions, + arg, + validation: mustBeString, } ); const regularPriceSingle = Dinero( { @@ -74,24 +90,18 @@ const OrderSummaryItem = ( { cartItem } ) => { const subtotalPriceFormat = __experimentalApplyCheckoutFilter( { filterName: 'subtotalPriceFormat', defaultValue: '', - arg: { - lineItem: cartItem, - }, - // Only accept strings. - validation: ( value ) => - typeof value === 'string' && value.includes( '' ), + extensions, + arg, + validation: productPriceValidation, } ); // Allow extensions to filter how the price is displayed. Ie: prepending or appending some values. const productPriceFormat = __experimentalApplyCheckoutFilter( { filterName: 'cartItemPrice', defaultValue: '', - arg: { - cartItem, - block: 'checkout', - }, - validation: ( value ) => - typeof value === 'string' && value.includes( '' ), + extensions, + arg, + validation: productPriceValidation, } ); return ( diff --git a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/totals/footer-item/index.js b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/totals/footer-item/index.js index b86297838c1..6ad6407f7fa 100644 --- a/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/totals/footer-item/index.js +++ b/plugins/woocommerce-blocks/assets/js/base/components/cart-checkout/totals/footer-item/index.js @@ -11,6 +11,7 @@ import FormattedMonetaryAmount from '@woocommerce/base-components/formatted-mone import PropTypes from 'prop-types'; import { __experimentalApplyCheckoutFilter, + mustBeString, TotalsItem, } from '@woocommerce/blocks-checkout'; import { useStoreCart } from '@woocommerce/base-hooks'; @@ -28,11 +29,9 @@ const TotalsFooterItem = ( { currency, values } ) => { const label = __experimentalApplyCheckoutFilter( { filterName: 'totalLabel', defaultValue: __( 'Total', 'woo-gutenberg-products-block' ), - arg: { - extensions, - }, + extensions, // Only accept strings. - validation: ( value ) => typeof value === 'string', + validation: mustBeString, } ); return ( diff --git a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/cart-line-item-row.js b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/cart-line-item-row.js index 66e29f58a50..4e4693e1026 100644 --- a/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/cart-line-item-row.js +++ b/plugins/woocommerce-blocks/assets/js/blocks/cart-checkout/cart/full-cart/cart-line-item-row.js @@ -16,9 +16,14 @@ import { ProductSaleBadge, } from '@woocommerce/base-components/cart-checkout'; import { getCurrency } from '@woocommerce/price-format'; -import { __experimentalApplyCheckoutFilter } from '@woocommerce/blocks-checkout'; +import { + __experimentalApplyCheckoutFilter, + mustBeString, + mustContain, +} from '@woocommerce/blocks-checkout'; import Dinero from 'dinero.js'; import { DISPLAY_CART_PRICES_INCLUDING_TAX } from '@woocommerce/block-settings'; +import { useCallback, useMemo } from '@wordpress/element'; /** * @typedef {import('@woocommerce/type-defs/cart').CartItem} CartItem @@ -94,16 +99,24 @@ const CartLineItemRow = ( { lineItem = {} } ) => { isPendingDelete, } = useStoreCartItemQuantity( lineItem ); + const productPriceValidation = useCallback( + ( value ) => mustBeString( value ) && mustContain( value, '' ), + [] + ); + const arg = useMemo( + () => ( { + context: 'cart', + cartItem: lineItem, + } ), + [ lineItem ] + ); const priceCurrency = getCurrency( prices ); - const name = __experimentalApplyCheckoutFilter( { filterName: 'itemName', defaultValue: initialName, - arg: { - extensions, - context: 'cart', - }, - validation: ( value ) => typeof value === 'string', + extensions, + arg, + validation: mustBeString, } ); const regularAmountSingle = Dinero( { @@ -132,37 +145,29 @@ const CartLineItemRow = ( { lineItem = {} } ) => { catalogVisibility === 'hidden' || catalogVisibility === 'search'; // Allow extensions to filter how the price is displayed. Ie: prepending or appending some values. + const productPriceFormat = __experimentalApplyCheckoutFilter( { filterName: 'cartItemPrice', defaultValue: '', - arg: { - cartItem: lineItem, - block: 'cart', - }, - validation: ( value ) => - typeof value === 'string' && value.includes( '' ), + extensions, + arg, + validation: productPriceValidation, } ); const subtotalPriceFormat = __experimentalApplyCheckoutFilter( { filterName: 'subtotalPriceFormat', defaultValue: '', - arg: { - lineItem, - }, - // Only accept strings. - validation: ( value ) => - typeof value === 'string' && value.includes( '' ), + extensions, + arg, + validation: productPriceValidation, } ); const saleBadgePriceFormat = __experimentalApplyCheckoutFilter( { filterName: 'saleBadgePriceFormat', defaultValue: '', - arg: { - lineItem, - }, - // Only accept strings. - validation: ( value ) => - typeof value === 'string' && value.includes( '' ), + extensions, + arg, + validation: productPriceValidation, } ); return ( diff --git a/plugins/woocommerce-blocks/package-lock.json b/plugins/woocommerce-blocks/package-lock.json index 0c426219865..c67dcb0699a 100644 --- a/plugins/woocommerce-blocks/package-lock.json +++ b/plugins/woocommerce-blocks/package-lock.json @@ -3545,6 +3545,20 @@ "@testing-library/dom": "^7.28.1" } }, + "@testing-library/react-hooks": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-5.0.3.tgz", + "integrity": "sha512-UrnnRc5II7LMH14xsYNm/WRch/67cBafmrSQcyFh0v+UUmSf1uzfB7zn5jQXSettGwOSxJwdQUN7PgkT0w22Lg==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5", + "@types/react": ">=16.9.0", + "@types/react-dom": ">=16.9.0", + "@types/react-test-renderer": ">=16.9.0", + "filter-console": "^0.1.1", + "react-error-boundary": "^3.1.0" + } + }, "@testing-library/user-event": { "version": "12.6.3", "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.6.3.tgz", @@ -3948,6 +3962,15 @@ "@types/react": "*" } }, + "@types/react-test-renderer": { + "version": "17.0.1", + "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz", + "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==", + "dev": true, + "requires": { + "@types/react": "*" + } + }, "@types/reactcss": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.3.tgz", @@ -15160,6 +15183,12 @@ } } }, + "filter-console": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/filter-console/-/filter-console-0.1.1.tgz", + "integrity": "sha512-zrXoV1Uaz52DqPs+qEwNJWJFAWZpYJ47UNmpN9q4j+/EYsz85uV0DC9k8tRND5kYmoVzL0W+Y75q4Rg8sRJCdg==", + "dev": true + }, "finalhandler": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz", @@ -27306,6 +27335,15 @@ } } }, + "react-error-boundary": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.0.tgz", + "integrity": "sha512-lmPrdi5SLRJR+AeJkqdkGlW/CRkAUvZnETahK58J4xb5wpbfDngasEGu+w0T1iXEhVrYBJZeW+c4V1hILCnMWQ==", + "dev": true, + "requires": { + "@babel/runtime": "^7.12.5" + } + }, "react-error-overlay": { "version": "6.0.9", "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.9.tgz", diff --git a/plugins/woocommerce-blocks/package.json b/plugins/woocommerce-blocks/package.json index 26d59947d76..f64f4ff6916 100644 --- a/plugins/woocommerce-blocks/package.json +++ b/plugins/woocommerce-blocks/package.json @@ -83,6 +83,7 @@ "@storybook/react": "6.0.28", "@testing-library/jest-dom": "5.11.9", "@testing-library/react": "11.2.5", + "@testing-library/react-hooks": "^5.0.3", "@testing-library/user-event": "12.6.3", "@types/jest": "26.0.20", "@types/react": "16.14.3", @@ -103,7 +104,7 @@ "@wordpress/env": "3.0.0", "@wordpress/html-entities": "2.8.0", "@wordpress/i18n": "3.15.0", - "@wordpress/is-shallow-equal": "1.8.0", + "@wordpress/is-shallow-equal": "^1.8.0", "@wordpress/scripts": "13.0.1", "autoprefixer": "10.2.3", "axios": "0.21.1", @@ -139,7 +140,7 @@ "progress-bar-webpack-plugin": "2.1.0", "promptly": "3.2.0", "puppeteer": "npm:puppeteer-core@5.5.0", - "react-test-renderer": "17.0.1", + "react-test-renderer": "^17.0.1", "request-promise": "4.2.6", "rimraf": "3.0.2", "sass-loader": "10.1.0", diff --git a/plugins/woocommerce-blocks/packages/checkout/index.js b/plugins/woocommerce-blocks/packages/checkout/index.js index 6025a694b8a..0fb73fe6933 100644 --- a/plugins/woocommerce-blocks/packages/checkout/index.js +++ b/plugins/woocommerce-blocks/packages/checkout/index.js @@ -1,5 +1,6 @@ export * from './totals'; export * from './shipping'; +export * from './utils'; export * from './slot'; export * from './registry'; export { default as ExperimentalOrderMeta } from './order-meta'; diff --git a/plugins/woocommerce-blocks/packages/checkout/registry/index.js b/plugins/woocommerce-blocks/packages/checkout/registry/index.js index edd1115aeb4..f33f72ace9e 100644 --- a/plugins/woocommerce-blocks/packages/checkout/registry/index.js +++ b/plugins/woocommerce-blocks/packages/checkout/registry/index.js @@ -1,3 +1,14 @@ +/** + * External dependencies + */ +import { useMemo } from '@wordpress/element'; +import { CURRENT_USER_IS_ADMIN } from '@woocommerce/block-settings'; + +/** + * Internal dependencies + */ +import { returnTrue } from '../'; + let checkoutFilters = {}; /** @@ -32,33 +43,42 @@ const getCheckoutFilters = ( filterName ) => { /** * Apply a filter. * - * @param {Object} o Object of arguments. - * @param {string} o.filterName Name of the filter to apply. - * @param {any} o.defaultValue Default value to filter. - * @param {any} [o.arg] Argument to pass to registered functions. If - * several arguments need to be passed, use an - * object. - * @param {Function} [o.validation] Function that needs to return true when the - * filtered value is passed in order for the - * filter to be applied. + * @param {Object} o Object of arguments. + * @param {string} o.filterName Name of the filter to apply. + * @param {any} o.defaultValue Default value to filter. + * @param {Object} [o.extensions] Values extend to REST API response. + * @param {any} [o.arg] Argument to pass to registered functions. + * If several arguments need to be passed, use + * an object. + * @param {Function} [o.validation] Function that needs to return true when + * the filtered value is passed in order for + * the filter to be applied. * @return {any} Filtered value. */ export const __experimentalApplyCheckoutFilter = ( { filterName, defaultValue, + extensions, arg = null, - validation = () => true, + validation = returnTrue, } ) => { - const filters = getCheckoutFilters( filterName ); - let value = defaultValue; - filters.forEach( ( filter ) => { - try { - const newValue = filter( value, arg ); - value = validation( newValue ) ? newValue : value; - } catch ( e ) { - // eslint-disable-next-line no-console - console.log( e ); - } - } ); - return value; + return useMemo( () => { + const filters = getCheckoutFilters( filterName ); + + let value = defaultValue; + filters.forEach( ( filter ) => { + try { + const newValue = filter( value, extensions, arg ); + value = validation( newValue ) ? newValue : value; + } catch ( e ) { + if ( CURRENT_USER_IS_ADMIN ) { + throw e; + } else { + // eslint-disable-next-line no-console + console.error( e ); + } + } + } ); + return value; + }, [ filterName, defaultValue, extensions, arg, validation ] ); }; diff --git a/plugins/woocommerce-blocks/packages/checkout/registry/test/admin.js b/plugins/woocommerce-blocks/packages/checkout/registry/test/admin.js new file mode 100644 index 00000000000..3344118793f --- /dev/null +++ b/plugins/woocommerce-blocks/packages/checkout/registry/test/admin.js @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-hooks'; +/** + * Internal dependencies + */ +import { + __experimentalRegisterCheckoutFilters, + __experimentalApplyCheckoutFilter, +} from '../'; + +jest.mock( '@woocommerce/block-settings', () => { + const originalModule = jest.requireActual( '@woocommerce/settings' ); + return { + // @ts-ignore We know @woocommerce/settings is an object. + ...originalModule, + CURRENT_USER_IS_ADMIN: true, + }; +} ); + +describe( 'Checkout registry (as admin user)', () => { + test( 'should throw if the filter throws and user is an admin', () => { + const filterName = 'ErrorTestFilter'; + const value = 'Hello World'; + __experimentalRegisterCheckoutFilters( filterName, { + [ filterName ]: () => { + throw new Error( 'test error' ); + }, + } ); + + const { result } = renderHook( () => + __experimentalApplyCheckoutFilter( { + filterName, + defaultValue: value, + } ) + ); + expect( result.error ).toEqual( Error( 'test error' ) ); + } ); + + test( 'should throw if validation throws and user is an admin', () => { + const filterName = 'ValidationTestFilter'; + const value = 'Hello World'; + __experimentalRegisterCheckoutFilters( filterName, { + [ filterName ]: ( val ) => val, + } ); + const { result } = renderHook( () => + __experimentalApplyCheckoutFilter( { + filterName, + defaultValue: value, + validation: () => { + throw Error( 'validation error' ); + }, + } ) + ); + expect( result.error ).toEqual( Error( 'validation error' ) ); + } ); +} ); diff --git a/plugins/woocommerce-blocks/packages/checkout/registry/test/index.js b/plugins/woocommerce-blocks/packages/checkout/registry/test/index.js index 3f9c2919dff..097b455a5a7 100644 --- a/plugins/woocommerce-blocks/packages/checkout/registry/test/index.js +++ b/plugins/woocommerce-blocks/packages/checkout/registry/test/index.js @@ -1,3 +1,7 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react-hooks'; /** * Internal dependencies */ @@ -11,29 +15,32 @@ describe( 'Checkout registry', () => { test( 'should return default value if there are no filters', () => { const value = 'Hello World'; - const newValue = __experimentalApplyCheckoutFilter( { - filterName, - defaultValue: value, - } ); - - expect( newValue ).toBe( value ); + const { result: newValue } = renderHook( () => + __experimentalApplyCheckoutFilter( { + filterName, + defaultValue: value, + } ) + ); + expect( newValue.current ).toBe( value ); } ); test( 'should return filtered value when a filter is registered', () => { const value = 'Hello World'; __experimentalRegisterCheckoutFilters( filterName, { - [ filterName ]: ( val, args ) => + [ filterName ]: ( val, extensions, args ) => val.toUpperCase() + args.punctuationSign, } ); - const newValue = __experimentalApplyCheckoutFilter( { - filterName, - defaultValue: value, - arg: { - punctuationSign: '!', - }, - } ); + const { result: newValue } = renderHook( () => + __experimentalApplyCheckoutFilter( { + filterName, + defaultValue: value, + arg: { + punctuationSign: '!', + }, + } ) + ); - expect( newValue ).toBe( 'HELLO WORLD!' ); + expect( newValue.current ).toBe( 'HELLO WORLD!' ); } ); test( 'should not return filtered value if validation failed', () => { @@ -41,12 +48,39 @@ describe( 'Checkout registry', () => { __experimentalRegisterCheckoutFilters( filterName, { [ filterName ]: ( val ) => val.toUpperCase(), } ); - const newValue = __experimentalApplyCheckoutFilter( { - filterName, - defaultValue: value, - validation: ( val ) => ! val.includes( 'HELLO' ), - } ); + const { result: newValue } = renderHook( () => + __experimentalApplyCheckoutFilter( { + filterName, + defaultValue: value, + validation: ( val ) => ! val.includes( 'HELLO' ), + } ) + ); - expect( newValue ).toBe( value ); + expect( newValue.current ).toBe( value ); + } ); + + test( 'should catch filter errors if user is not an admin', () => { + const spy = {}; + spy.console = jest + .spyOn( console, 'error' ) + .mockImplementation( () => {} ); + + const error = new Error( 'test error' ); + const value = 'Hello World'; + __experimentalRegisterCheckoutFilters( filterName, { + [ filterName ]: () => { + throw error; + }, + } ); + const { result: newValue } = renderHook( () => + __experimentalApplyCheckoutFilter( { + filterName, + defaultValue: value, + } ) + ); + + expect( spy.console ).toHaveBeenCalledWith( error ); + expect( newValue.current ).toBe( value ); + spy.console.mockRestore(); } ); } ); diff --git a/plugins/woocommerce-blocks/packages/checkout/utils/index.js b/plugins/woocommerce-blocks/packages/checkout/utils/index.js new file mode 100644 index 00000000000..4d5ffa36ab3 --- /dev/null +++ b/plugins/woocommerce-blocks/packages/checkout/utils/index.js @@ -0,0 +1 @@ +export * from './validation'; diff --git a/plugins/woocommerce-blocks/packages/checkout/utils/validation/index.js b/plugins/woocommerce-blocks/packages/checkout/utils/validation/index.js new file mode 100644 index 00000000000..7f8d9af1301 --- /dev/null +++ b/plugins/woocommerce-blocks/packages/checkout/utils/validation/index.js @@ -0,0 +1,62 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Checks if value passed is a string, throws an error if not. + * + * @param {string} value Value to be validated. + * + * @return {Error|true} Error if value is not string, true otherwise. + */ +export const mustBeString = ( value ) => { + if ( typeof value !== 'string' ) { + throw Error( + sprintf( + // translators: %s is type of value passed + __( + 'Returned value must be a string, you passed "%s"', + 'woo-gutenberg-products-block' + ), + typeof value + ) + ); + } + return true; +}; + +/** + * Checks if value passed contain passed label + * + * @param {string} value Value to be validated. + * @param {string} label Label to be searched for. + * + * @return {Error|true} Error if value contains label, true otherwise. + */ +export const mustContain = ( value, label ) => { + if ( ! value.includes( label ) ) { + throw Error( + sprintf( + // translators: %1$s value passed to filter, %2$s : value that must be included. + __( + 'Returned value must include %1$s, you passed "%2$s"', + 'woo-gutenberg-products-block' + ), + value, + label + ) + ); + } + return true; +}; + +/** + * A function that always return true. + * We need to have a single instance of this function so it doesn't + * invalidate our memo comparison. + * + * + * @return {true} Returns true. + */ +export const returnTrue = () => true; diff --git a/plugins/woocommerce-blocks/tests/js/setup-globals.js b/plugins/woocommerce-blocks/tests/js/setup-globals.js index ecc104abe82..ef2162df28f 100644 --- a/plugins/woocommerce-blocks/tests/js/setup-globals.js +++ b/plugins/woocommerce-blocks/tests/js/setup-globals.js @@ -11,6 +11,7 @@ global.wcSettings = { precision: 2, symbol: '$', }, + currentUserIsAdmin: false, date: { dow: 0, },