From 7e3f5e8ab97191b50d85a61d748239f7b03bc272 Mon Sep 17 00:00:00 2001 From: Darren Ethier Date: Tue, 29 Oct 2019 08:01:13 -0400 Subject: [PATCH] Add useCollection and useCollectionHeader hooks (closes: woocommerce/woocommerce-blocks#1096) (https://github.com/woocommerce/woocommerce-blocks/pull/1099) * add use-collection hook and tests * Add use-collection-header hook * update use-store-products hook to implement useCollection and useCollection header under the hood --- .../js/base/hooks/test/use-collection.js | 236 ++++++++++++++++++ .../js/base/hooks/test/use-store-products.js | 100 +------- .../js/base/hooks/use-collection-header.js | 85 +++++++ .../assets/js/base/hooks/use-collection.js | 70 ++++++ .../js/base/hooks/use-store-products.js | 73 ++---- 5 files changed, 415 insertions(+), 149 deletions(-) create mode 100644 plugins/woocommerce-blocks/assets/js/base/hooks/test/use-collection.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/hooks/use-collection-header.js create mode 100644 plugins/woocommerce-blocks/assets/js/base/hooks/use-collection.js diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/test/use-collection.js b/plugins/woocommerce-blocks/assets/js/base/hooks/test/use-collection.js new file mode 100644 index 00000000000..de998c0775b --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/test/use-collection.js @@ -0,0 +1,236 @@ +/** + * External dependencies + */ +import TestRenderer, { act } from 'react-test-renderer'; +import { createRegistry, RegistryProvider } from '@wordpress/data'; +import { Component as ReactComponent } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { useCollection } from '../use-collection'; +import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data'; + +jest.mock( '@woocommerce/block-data', () => ( { + __esModule: true, + COLLECTIONS_STORE_KEY: 'test/store', +} ) ); + +class TestErrorBoundary extends ReactComponent { + constructor( props ) { + super( props ); + this.state = { hasError: false, error: {} }; + } + static getDerivedStateFromError( error ) { + // Update state so the next render will show the fallback UI. + return { hasError: true, error }; + } + + render() { + if ( this.state.hasError ) { + return
; + } + + return this.props.children; + } +} + +describe( 'useStoreProducts', () => { + let registry, mocks, renderer; + const getProps = ( testRenderer ) => { + const { results, isLoading } = testRenderer.root.findByType( + 'div' + ).props; + return { + results, + isLoading, + }; + }; + + const getWrappedComponents = ( Component, props ) => ( + + + + + + ); + + const getTestComponent = () => ( { options } ) => { + const items = useCollection( options ); + return
; + }; + + const setUpMocks = () => { + mocks = { + selectors: { + getCollection: jest + .fn() + .mockImplementation( () => ( { foo: 'bar' } ) ), + hasFinishedResolution: jest.fn().mockReturnValue( true ), + }, + }; + registry.registerStore( storeKey, { + reducer: () => ( {} ), + selectors: mocks.selectors, + } ); + }; + + beforeEach( () => { + registry = createRegistry(); + mocks = {}; + renderer = null; + setUpMocks(); + } ); + it( + 'should throw an error if an options object is provided without ' + + 'a namespace property', + () => { + const TestComponent = getTestComponent(); + act( () => { + renderer = TestRenderer.create( + getWrappedComponents( TestComponent, { + options: { + resourceName: 'products', + query: { bar: 'foo' }, + }, + } ) + ); + } ); + const props = renderer.root.findByType( 'div' ).props; + expect( props.error.message ).toMatch( /options object/ ); + expect( console ).toHaveErrored( /your React components:/ ); + renderer.unmount(); + } + ); + it( + 'should throw an error if an options object is provided without ' + + 'a resourceName property', + () => { + const TestComponent = getTestComponent(); + act( () => { + renderer = TestRenderer.create( + getWrappedComponents( TestComponent, { + options: { + namespace: 'test/store', + query: { bar: 'foo' }, + }, + } ) + ); + } ); + const props = renderer.root.findByType( 'div' ).props; + expect( props.error.message ).toMatch( /options object/ ); + expect( console ).toHaveErrored( /your React components:/ ); + renderer.unmount(); + } + ); + it( + 'should return expected behaviour for equivalent query on props ' + + 'across renders', + () => { + const TestComponent = getTestComponent(); + act( () => { + renderer = TestRenderer.create( + getWrappedComponents( TestComponent, { + options: { + namespace: 'test/store', + resourceName: 'products', + query: { bar: 'foo' }, + }, + } ) + ); + } ); + const { results } = getProps( renderer ); + // rerender + act( () => { + renderer.update( + getWrappedComponents( TestComponent, { + options: { + namespace: 'test/store', + resourceName: 'products', + query: { bar: 'foo' }, + }, + } ) + ); + } ); + // re-render should result in same products object because although + // query-state is a different instance, it's still equivalent. + const { results: newResults } = getProps( renderer ); + expect( newResults ).toBe( results ); + // now let's change the query passed through to verify new object + // is created. + // remember this won't actually change the results because the mock + // selector is returning an equivalent object when it is called, + // however it SHOULD be a new object instance. + act( () => { + renderer.update( + getWrappedComponents( TestComponent, { + options: { + namespace: 'test/store', + resourceName: 'products', + query: { foo: 'bar' }, + }, + } ) + ); + } ); + const { results: resultsVerification } = getProps( renderer ); + expect( resultsVerification ).not.toBe( results ); + expect( resultsVerification ).toEqual( results ); + renderer.unmount(); + } + ); + it( + 'should return expected behaviour for equivalent resourceValues on' + + ' props across renders', + () => { + const TestComponent = getTestComponent(); + act( () => { + renderer = TestRenderer.create( + getWrappedComponents( TestComponent, { + options: { + namespace: 'test/store', + resourceName: 'products', + resourceValues: [ 10, 20 ], + }, + } ) + ); + } ); + const { results } = getProps( renderer ); + // rerender + act( () => { + renderer.update( + getWrappedComponents( TestComponent, { + options: { + namespace: 'test/store', + resourceName: 'products', + resourceValues: [ 10, 20 ], + }, + } ) + ); + } ); + // re-render should result in same products object because although + // query-state is a different instance, it's still equivalent. + const { results: newResults } = getProps( renderer ); + expect( newResults ).toBe( results ); + // now let's change the query passed through to verify new object + // is created. + // remember this won't actually change the results because the mock + // selector is returning an equivalent object when it is called, + // however it SHOULD be a new object instance. + act( () => { + renderer.update( + getWrappedComponents( TestComponent, { + options: { + namespace: 'test/store', + resourceName: 'products', + resourceValues: [ 20, 10 ], + }, + } ) + ); + } ); + const { results: resultsVerification } = getProps( renderer ); + expect( resultsVerification ).not.toBe( results ); + expect( resultsVerification ).toEqual( results ); + renderer.unmount(); + } + ); +} ); diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/test/use-store-products.js b/plugins/woocommerce-blocks/assets/js/base/hooks/test/use-store-products.js index aca9f34ac11..0a8117f4673 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/test/use-store-products.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/test/use-store-products.js @@ -3,7 +3,6 @@ */ import TestRenderer, { act } from 'react-test-renderer'; import { createRegistry, RegistryProvider } from '@wordpress/data'; -import { Component as ReactComponent } from '@wordpress/element'; /** * Internal dependencies @@ -16,25 +15,6 @@ jest.mock( '@woocommerce/block-data', () => ( { COLLECTIONS_STORE_KEY: 'test/store', } ) ); -class TestErrorBoundary extends ReactComponent { - constructor( props ) { - super( props ); - this.state = { hasError: false, error: {} }; - } - static getDerivedStateFromError( error ) { - // Update state so the next render will show the fallback UI. - return { hasError: true, error }; - } - - render() { - if ( this.state.hasError ) { - return
; - } - - return this.props.children; - } -} - describe( 'useStoreProducts', () => { let registry, mocks, renderer; const getProps = ( testRenderer ) => { @@ -52,14 +32,12 @@ describe( 'useStoreProducts', () => { const getWrappedComponents = ( Component, props ) => ( - - - + ); - const getTestComponent = ( options ) => ( { query } ) => { - const items = useStoreProducts( query, options ); + const getTestComponent = () => ( { query } ) => { + const items = useStoreProducts( query ); return
; }; @@ -85,78 +63,6 @@ describe( 'useStoreProducts', () => { renderer = null; setUpMocks(); } ); - it( - 'should throw an error if an options object is provided without ' + - 'a namespace property', - () => { - const TestComponent = getTestComponent( { modelName: 'products' } ); - act( () => { - renderer = TestRenderer.create( - getWrappedComponents( TestComponent, { - query: { bar: 'foo' }, - } ) - ); - } ); - const props = renderer.root.findByType( 'div' ).props; - expect( props.error.message ).toMatch( /options object/ ); - expect( console ).toHaveErrored( /your React components:/ ); - renderer.unmount(); - } - ); - it( - 'should throw an error if an options object is provided without ' + - 'a modelName property', - () => { - const TestComponent = getTestComponent( { - namespace: '/wc/blocks', - } ); - act( () => { - renderer = TestRenderer.create( - getWrappedComponents( TestComponent, { - query: { bar: 'foo' }, - } ) - ); - } ); - const props = renderer.root.findByType( 'div' ).props; - expect( props.error.message ).toMatch( /options object/ ); - expect( console ).toHaveErrored( /your React components:/ ); - renderer.unmount(); - } - ); - it( 'should use the default options if options not provided', () => { - const TestComponent = getTestComponent(); - const { - getCollection, - getCollectionHeader, - hasFinishedResolution, - } = mocks.selectors; - act( () => { - renderer = TestRenderer.create( - getWrappedComponents( TestComponent, { - query: { bar: 'foo' }, - } ) - ); - } ); - expect( getCollection ).toHaveBeenCalledWith( - {}, - '/wc/blocks', - 'products', - { bar: 'foo' } - ); - expect( getCollectionHeader ).toHaveBeenCalledWith( - {}, - 'x-wp-total', - '/wc/blocks', - 'products', - { bar: 'foo' } - ); - expect( hasFinishedResolution ).toHaveBeenCalledWith( - {}, - 'getCollection', - [ '/wc/blocks', 'products', { bar: 'foo' } ] - ); - renderer.unmount(); - } ); it( 'should return expected behaviour for equivalent query on props ' + 'across renders', diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-collection-header.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-collection-header.js new file mode 100644 index 00000000000..3854336129c --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-collection-header.js @@ -0,0 +1,85 @@ +/** + * External dependencies + */ +import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { useShallowEqual } from './use-shallow-equal'; + +/** + * This is a custom hook that is wired up to the `wc/store/collections` data + * store. Given a header key and a collections option object, this will ensure a + * component is kept up to date with the collection header value matching that + * query in the store state. + * + * @param {string} headerKey Used to indicate which header value to + * return for the given collection query. + * Example: `'x-wp-total'` + * @param {Object} options An object declaring the various + * collection arguments. + * @param {string} options.namespace The namespace for the collection. + * Example: `'/wc/blocks'` + * @param {string} options.resourceName The name of the resource for the + * collection. Example: + * `'products/attributes'` + * @param {array} options.resourceValues An array of values (in correct order) + * that are substituted in the route + * placeholders for the collection route. + * Example: `[10, 20]` + * @param {Object} options.query An object of key value pairs for the + * query to execute on the collection + * (optional). Example: + * `{ order: 'ASC', order_by: 'price' }` + * + * @return {Object} This hook will return an object with two properties: + * - value Whatever value is attached to the specified + * header. + * - isLoading A boolean indicating whether the header is + * loading (true) or not. + */ +export const useCollectionHeader = ( headerKey, options ) => { + const { namespace, resourceName, resourceValues, query } = options; + if ( ! namespace || ! resourceName ) { + throw new Error( + 'The options object must have valid values for the namespace and ' + + 'the modelName properties.' + ); + } + // ensure we feed the previous reference if it's equivalent + const currentQuery = useShallowEqual( query ); + const currentResourceValues = useShallowEqual( resourceValues ); + const { value, isLoading = true } = useSelect( + ( select ) => { + const store = select( storeKey ); + // filter out query if it is undefined. + const args = [ + headerKey, + namespace, + resourceName, + currentQuery, + currentResourceValues, + ].filter( ( item ) => typeof item !== 'undefined' ); + return { + value: store.getCollectionHeader( ...args ), + isLoading: store.hasFinishedResolution( + 'getCollectionHeader', + args + ), + }; + }, + [ + headerKey, + namespace, + resourceName, + currentResourceValues, + currentQuery, + ] + ); + return { + value, + isLoading, + }; +}; diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-collection.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-collection.js new file mode 100644 index 00000000000..7cba413ae9c --- /dev/null +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-collection.js @@ -0,0 +1,70 @@ +/** + * External dependencies + */ +import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data'; +import { useSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import { useShallowEqual } from './use-shallow-equal'; + +/** + * This is a custom hook that is wired up to the `wc/store/collections` data + * store. Given a collections option object, this will ensure a component is + * kept up to date with the collection matching that query in the store state. + * + * @param {Object} options An object declaring the various + * collection arguments. + * @param {string} options.namespace The namespace for the collection. + * Example: `'/wc/blocks'` + * @param {string} options.resourceName The name of the resource for the + * collection. Example: + * `'products/attributes'` + * @param {array} options.resourceValues An array of values (in correct order) + * that are substituted in the route + * placeholders for the collection route. + * Example: `[10, 20]` + * @param {Object} options.query An object of key value pairs for the + * query to execute on the collection + * (optional). Example: + * `{ order: 'ASC', order_by: 'price' }` + * + * @return {Object} This hook will return an object with two properties: + * - results An array of collection items returned. + * - isLoading A boolean indicating whether the collection is + * loading (true) or not. + */ +export const useCollection = ( options ) => { + const { namespace, resourceName, resourceValues, query } = options; + if ( ! namespace || ! resourceName ) { + throw new Error( + 'The options object must have valid values for the namespace and ' + + 'the modelName properties.' + ); + } + // ensure we feed the previous reference if it's equivalent + const currentQuery = useShallowEqual( query ); + const currentResourceValues = useShallowEqual( resourceValues ); + const { results = [], isLoading = true } = useSelect( + ( select ) => { + const store = select( storeKey ); + // filter out query if it is undefined. + const args = [ + namespace, + resourceName, + currentQuery, + currentResourceValues, + ].filter( ( item ) => typeof item !== 'undefined' ); + return { + results: store.getCollection( ...args ), + isLoading: store.hasFinishedResolution( 'getCollection', args ), + }; + }, + [ namespace, resourceName, currentResourceValues, currentQuery ] + ); + return { + results, + isLoading, + }; +}; diff --git a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-products.js b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-products.js index 4461ba2eda2..dd05fac432e 100644 --- a/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-products.js +++ b/plugins/woocommerce-blocks/assets/js/base/hooks/use-store-products.js @@ -1,71 +1,40 @@ -/** - * External dependencies - */ -import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data'; -import { useSelect } from '@wordpress/data'; - /** * Internal dependencies */ -import { useShallowEqual } from './use-shallow-equal'; - -const DEFAULT_OPTIONS = { - namespace: '/wc/blocks', - modelName: 'products', -}; +import { useCollection } from './use-collection'; +import { useCollectionHeader } from './use-collection-header'; /** * This is a custom hook that is wired up to the `wc/store/collections` data - * store. Given a query object, this will ensure a component is kept up to date - * with the products matching that query in the store state. + * store for the `'wc/store/products'` route. Given a query object, this + * will ensure a component is kept up to date with the products matching that + * query in the store state. * * @param {Object} query An object containing any query arguments to be * included with the collection request for the * products. Does not have to be included. - * @param {Object} options An optional object for adjusting the namespace and - * modelName for the products query. * * @return {Object} This hook will return an object with three properties: * - products An array of product objects. - * - totalProducts The total number of products that match the - * given query parameters. + * - totalProducts The total number of products that match + * the given query parameters. * - productsLoading A boolean indicating whether the products * are still loading or not. */ -export const useStoreProducts = ( query, options = DEFAULT_OPTIONS ) => { - const { namespace, modelName } = options; - if ( ! namespace || ! modelName ) { - throw new Error( - 'If you provide an options object, you must have valid values ' + - 'for the namespace and the modelName properties.' - ); - } - // ensure we feed the previous reference object if it's equivalent - const currentQuery = useShallowEqual( query ); - const { - products = [], - totalProducts = 0, - productsLoading = true, - } = useSelect( - ( select ) => { - const store = select( storeKey ); - // filter out query if it is undefined. - const args = [ namespace, modelName, currentQuery ].filter( - ( item ) => typeof item !== undefined - ); - return { - products: store.getCollection( ...args ), - totalProducts: store.getCollectionHeader( - 'x-wp-total', - ...args - ), - productsLoading: store.hasFinishedResolution( - 'getCollection', - args - ), - }; - }, - [ namespace, modelName, currentQuery ] +export const useStoreProducts = ( query ) => { + // @todo see @https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/1097 + // where the namespace is going to be changed. Not doing in this pull. + const collectionOptions = { + namespace: '/wc/blocks', + resourceName: 'products', + query, + }; + const { results: products, isLoading: productsLoading } = useCollection( + collectionOptions + ); + const { value: totalProducts } = useCollectionHeader( + 'x-wp-total', + collectionOptions ); return { products,