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,