This commit is contained in:
KhaiNguyen
2020-02-13 10:39:37 +07:00
commit 59401cb805
12867 changed files with 4646216 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
export * from './use-query-state';
export * from './use-shallow-equal';
export * from './use-store-products';
export * from './use-collection';
export * from './use-collection-header';
export * from './use-collection-data';
export * from './use-previous';

View File

@@ -0,0 +1,297 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { Component as ReactComponent } from '@wordpress/element';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useCollection } from '../use-collection';
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 <div error={ this.state.error } />;
}
return this.props.children;
}
}
describe( 'useCollection', () => {
let registry, mocks, renderer;
const getProps = ( testRenderer ) => {
const { results, isLoading } = testRenderer.root.findByType(
'div'
).props;
return {
results,
isLoading,
};
};
const getWrappedComponents = ( Component, props ) => (
<RegistryProvider value={ registry }>
<TestErrorBoundary>
<Component { ...props } />
</TestErrorBoundary>
</RegistryProvider>
);
const getTestComponent = () => ( { options } ) => {
const items = useCollection( options );
return <div { ...items } />;
};
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();
}
);
it( 'should return previous query results if `shouldSelect` is false', () => {
mocks.selectors.getCollection.mockImplementation(
( state, ...args ) => {
return args;
}
);
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'products',
resourceValues: [ 10, 20 ],
},
} )
);
} );
const { results } = getProps( renderer );
// rerender but with shouldSelect to false
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'productsb',
resourceValues: [ 10, 30 ],
shouldSelect: false,
},
} )
);
} );
const { results: results2 } = getProps( renderer );
expect( results2 ).toBe( results );
// expect 2 calls because internally, useSelect invokes callback twice
// on mount.
expect( mocks.selectors.getCollection ).toHaveBeenCalledTimes( 2 );
// rerender again but set shouldSelect to true again and we should see
// new results
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
options: {
namespace: 'test/store',
resourceName: 'productsb',
resourceValues: [ 10, 30 ],
shouldSelect: true,
},
} )
);
} );
const { results: results3 } = getProps( renderer );
expect( results3 ).not.toEqual( results );
expect( results3 ).toEqual( [
'test/store',
'productsb',
{},
[ 10, 30 ],
] );
} );
} );

View File

@@ -0,0 +1,89 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
/**
* Internal dependencies
*/
import { usePrevious } from '../use-previous';
describe( 'usePrevious', () => {
const TestComponent = ( { testValue, validation } ) => {
const previousValue = usePrevious( testValue, validation );
return <div testValue={ testValue } previousValue={ previousValue } />;
};
let renderer;
beforeEach( () => ( renderer = null ) );
it( 'should be undefined at first pass', () => {
act( () => {
renderer = TestRenderer.create( <TestComponent testValue={ 1 } /> );
} );
const testValue = renderer.root.findByType( 'div' ).props.testValue;
const testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 1 );
expect( testPreviousValue ).toBe( undefined );
} );
it( 'test new and previous value', () => {
let testValue;
let testPreviousValue;
act( () => {
renderer = TestRenderer.create( <TestComponent testValue={ 1 } /> );
} );
act( () => {
renderer.update( <TestComponent testValue={ 2 } /> );
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 2 );
expect( testPreviousValue ).toBe( 1 );
act( () => {
renderer.update( <TestComponent testValue={ 3 } /> );
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 3 );
expect( testPreviousValue ).toBe( 2 );
} );
it( 'should not update value if validation fails', () => {
let testValue;
let testPreviousValue;
act( () => {
renderer = TestRenderer.create(
<TestComponent testValue={ 1 } validation={ Number.isFinite } />
);
} );
act( () => {
renderer.update(
<TestComponent testValue="abc" validation={ Number.isFinite } />
);
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 'abc' );
expect( testPreviousValue ).toBe( 1 );
act( () => {
renderer.update(
<TestComponent testValue={ 3 } validation={ Number.isFinite } />
);
} );
testValue = renderer.root.findByType( 'div' ).props.testValue;
testPreviousValue = renderer.root.findByType( 'div' ).props
.previousValue;
expect( testValue ).toBe( 3 );
expect( testPreviousValue ).toBe( 1 );
} );
} );

View File

@@ -0,0 +1,259 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import {
useQueryStateByContext,
useQueryStateByKey,
useSynchronizedQueryState,
} from '../use-query-state';
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
QUERY_STATE_STORE_KEY: 'test/store',
} ) );
describe( 'Testing Query State Hooks', () => {
let registry, mocks;
beforeAll( () => {
registry = createRegistry();
mocks = {};
} );
/**
* Test helper to return a tuple containing the expected query value and the
* expected query state action creator from the given rendered test instance.
*
* @param {Object} testRenderer An instance of the created test component.
*
* @return {Array} A tuple containing the expected query value as the first
* element and the expected query action creator as the
* second argument.
*/
const getProps = ( testRenderer ) => {
const props = testRenderer.root.findByType( 'div' ).props;
return [ props.queryState, props.setQueryState ];
};
/**
* Returns the given component wrapped in the registry provider for
* instantiating using the TestRenderer using the current prepared registry
* for the TestRenderer to instantiate with.
*
* @param {React.Component} Component The test component to wrap.
* @param {Object} props Props to feed the wrapped component.
*
* @return {React.Component}
*/
const getWrappedComponent = ( Component, props ) => (
<RegistryProvider value={ registry }>
<Component { ...props } />
</RegistryProvider>
);
/**
* Returns a TestComponent for the provided hook to test with, and the
* expected PropKeys for obtaining the values to be fed to the hook as
* arguments.
*
* @param {Function} hookTested The hook being tested to use in the
* test comopnent.
* @param {Array} propKeysForArgs An array of keys for the props that
* will be used on the test component that
* will have values fed to the tested
* hook.
*
* @return {React.Component} A component ready for testing with!
*/
const getTestComponent = ( hookTested, propKeysForArgs ) => ( props ) => {
const args = propKeysForArgs.map( ( key ) => props[ key ] );
const [ queryValue, setQueryValue ] = hookTested( ...args );
return (
<div queryState={ queryValue } setQueryState={ setQueryValue } />
);
};
/**
* A helper for setting up the `mocks` object and the `registry` mock before
* each test.
*
* @param {string} actionMockName This should be the name of the action
* that the hook returns. This will be
* mocked using `mocks.action` when
* registered in the mock registry.
* @param {string} selectorMockName This should be the mame of the selector
* that the hook uses. This will be mocked
* using `mocks.selector` when registered
* in the mock registry.
*/
const setupMocks = ( actionMockName, selectorMockName ) => {
mocks.action = jest.fn().mockReturnValue( { type: 'testAction' } );
mocks.selector = jest.fn().mockReturnValue( { foo: 'bar' } );
registry.registerStore( storeKey, {
reducer: () => ( {} ),
actions: {
[ actionMockName ]: mocks.action,
},
selectors: {
[ selectorMockName ]: mocks.selector,
},
} );
};
describe( 'useQueryStateByContext', () => {
const TestComponent = getTestComponent( useQueryStateByContext, [
'context',
] );
let renderer;
beforeEach( () => {
renderer = null;
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
} );
afterEach( () => {
act( () => {
renderer.unmount();
} );
} );
it(
'calls useSelect with the provided context and returns expected' +
' values',
() => {
const { action, selector } = mocks;
act( () => {
renderer = TestRenderer.create(
getWrappedComponent( TestComponent, {
context: 'test-context',
} )
);
} );
const [ queryState, setQueryState ] = getProps( renderer );
// the {} is because all selectors are called internally in the
// registry with the first argument being the state which is empty.
expect( selector ).toHaveBeenLastCalledWith(
{},
'test-context',
undefined
);
expect( queryState ).toEqual( { foo: 'bar' } );
expect( action ).not.toHaveBeenCalled();
//execute dispatcher and make sure it's called.
act( () => {
setQueryState( { foo: 'bar' } );
} );
expect( action ).toHaveBeenCalledWith( 'test-context', {
foo: 'bar',
} );
}
);
} );
describe( 'useQueryStateByKey', () => {
const TestComponent = getTestComponent( useQueryStateByKey, [
'queryKey',
undefined,
'context',
] );
let renderer;
beforeEach( () => {
renderer = null;
setupMocks( 'setQueryValue', 'getValueForQueryKey' );
} );
afterEach( () => {
act( () => {
renderer.unmount();
} );
} );
it(
'calls useSelect with the provided context and returns expected' +
' values',
() => {
const { selector, action } = mocks;
act( () => {
renderer = TestRenderer.create(
getWrappedComponent( TestComponent, {
context: 'test-context',
queryKey: 'someValue',
} )
);
} );
const [ queryState, setQueryState ] = getProps( renderer );
// the {} is because all selectors are called internally in the
// registry with the first argument being the state which is empty.
expect( selector ).toHaveBeenLastCalledWith(
{},
'test-context',
'someValue',
undefined
);
expect( queryState ).toEqual( { foo: 'bar' } );
expect( action ).not.toHaveBeenCalled();
//execute dispatcher and make sure it's called.
act( () => {
setQueryState( { foo: 'bar' } );
} );
expect( action ).toHaveBeenCalledWith(
'test-context',
'someValue',
{ foo: 'bar' }
);
}
);
} );
// @todo, these tests only add partial coverage because the state is not
// actually updated by the action dispatch via our mocks.
describe( 'useSynchronizedQueryState', () => {
const TestComponent = getTestComponent( useSynchronizedQueryState, [
'synchronizedQuery',
'context',
] );
const initialQuery = { a: 'b' };
let renderer;
beforeEach( () => {
setupMocks( 'setValueForQueryContext', 'getValueForQueryContext' );
} );
it( 'returns provided query state on initial render', () => {
const { action, selector } = mocks;
act( () => {
renderer = TestRenderer.create(
getWrappedComponent( TestComponent, {
context: 'test-context',
synchronizedQuery: initialQuery,
} )
);
} );
const [ queryState ] = getProps( renderer );
expect( queryState ).toBe( initialQuery );
expect( selector ).toHaveBeenLastCalledWith(
{},
'test-context',
undefined
);
expect( action ).toHaveBeenCalledWith( 'test-context', {
foo: 'bar',
a: 'b',
} );
} );
it( 'returns merged queryState on subsequent render', () => {
act( () => {
renderer.update(
getWrappedComponent( TestComponent, {
context: 'test-context',
synchronizedQuery: initialQuery,
} )
);
} );
// note our test doesn't interact with an actual reducer so the
// store state is not updated. Here we're just verifying that
// what is is returned by the state selector mock is returned.
// However we DO expect this to be a new object.
const [ queryState ] = getProps( renderer );
expect( queryState ).not.toBe( initialQuery );
expect( queryState ).toEqual( { foo: 'bar' } );
} );
} );
} );

View File

@@ -0,0 +1,54 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
/**
* Internal dependencies
*/
import { useShallowEqual } from '../use-shallow-equal';
describe( 'useShallowEqual', () => {
const TestComponent = ( { testValue } ) => {
const newValue = useShallowEqual( testValue );
return <div newValue={ newValue } />;
};
let renderer;
beforeEach( () => ( renderer = null ) );
it.each`
testValueA | aType | testValueB | bType | expectEqual
${{ a: 'b', foo: 'bar' }} | ${'object'} | ${{ foo: 'bar', a: 'b' }} | ${'object'} | ${true}
${{ a: 'b', foo: 'bar' }} | ${'object'} | ${{ foo: 'bar', a: 'c' }} | ${'object'} | ${false}
${[ 'b', 'bar' ]} | ${'array'} | ${[ 'b', 'bar' ]} | ${'array'} | ${true}
${[ 'b', 'bar' ]} | ${'array'} | ${[ 'bar', 'b' ]} | ${'array'} | ${false}
${1} | ${'number'} | ${1} | ${'number'} | ${true}
${1} | ${'number'} | ${'1'} | ${'string'} | ${false}
${'1'} | ${'string'} | ${'1'} | ${'string'} | ${true}
${1} | ${'number'} | ${2} | ${'number'} | ${false}
${1} | ${'number'} | ${true} | ${'bool'} | ${false}
${0} | ${'number'} | ${false} | ${'bool'} | ${false}
${true} | ${'bool'} | ${true} | ${'bool'} | ${true}
`(
'$testValueA ($aType) and $testValueB ($bType) are expected to be equal ($expectEqual)',
( { testValueA, testValueB, expectEqual } ) => {
let testPropValue;
act( () => {
renderer = TestRenderer.create(
<TestComponent testValue={ testValueA } />
);
} );
testPropValue = renderer.root.findByType( 'div' ).props.newValue;
expect( testPropValue ).toBe( testValueA );
// do update
act( () => {
renderer.update( <TestComponent testValue={ testValueB } /> );
} );
testPropValue = renderer.root.findByType( 'div' ).props.newValue;
if ( expectEqual ) {
expect( testPropValue ).toBe( testValueA );
} else {
expect( testPropValue ).toBe( testValueB );
}
}
);
} );

View File

@@ -0,0 +1,109 @@
/**
* External dependencies
*/
import TestRenderer, { act } from 'react-test-renderer';
import { createRegistry, RegistryProvider } from '@wordpress/data';
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
/**
* Internal dependencies
*/
import { useStoreProducts } from '../use-store-products';
jest.mock( '@woocommerce/block-data', () => ( {
__esModule: true,
COLLECTIONS_STORE_KEY: 'test/store',
} ) );
describe( 'useStoreProducts', () => {
let registry, mocks, renderer;
const getProps = ( testRenderer ) => {
const {
products,
totalProducts,
productsLoading,
} = testRenderer.root.findByType( 'div' ).props;
return {
products,
totalProducts,
productsLoading,
};
};
const getWrappedComponents = ( Component, props ) => (
<RegistryProvider value={ registry }>
<Component { ...props } />
</RegistryProvider>
);
const getTestComponent = () => ( { query } ) => {
const items = useStoreProducts( query );
return <div { ...items } />;
};
const setUpMocks = () => {
mocks = {
selectors: {
getCollection: jest
.fn()
.mockImplementation( () => ( { foo: 'bar' } ) ),
getCollectionHeader: jest.fn().mockReturnValue( 22 ),
hasFinishedResolution: jest.fn().mockReturnValue( true ),
},
};
registry.registerStore( storeKey, {
reducer: () => ( {} ),
selectors: mocks.selectors,
} );
};
beforeEach( () => {
registry = createRegistry();
mocks = {};
renderer = null;
setUpMocks();
} );
it(
'should return expected behaviour for equivalent query on props ' +
'across renders',
() => {
const TestComponent = getTestComponent();
act( () => {
renderer = TestRenderer.create(
getWrappedComponents( TestComponent, {
query: { bar: 'foo' },
} )
);
} );
const { products } = getProps( renderer );
// rerender
act( () => {
renderer.update(
getWrappedComponents( TestComponent, {
query: { bar: 'foo' },
} )
);
} );
// re-render should result in same products object because although
// query-state is a different instance, it's still equivalent.
const { products: newProducts } = getProps( renderer );
expect( newProducts ).toBe( products );
// 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, {
query: { foo: 'bar' },
} )
);
} );
const { products: productsVerification } = getProps( renderer );
expect( productsVerification ).not.toBe( products );
expect( productsVerification ).toEqual( products );
renderer.unmount();
}
);
} );

View File

@@ -0,0 +1,124 @@
/**
* External dependencies
*/
import { useState, useEffect, useMemo } from '@wordpress/element';
import {
useQueryStateByContext,
useQueryStateByKey,
useCollection,
} from '@woocommerce/base-hooks';
import { useQueryStateContext } from '@woocommerce/base-context/query-state-context';
import { useDebounce } from 'use-debounce';
import { sortBy } from 'lodash';
/**
* Internal dependencies
*/
import { useShallowEqual } from './use-shallow-equal';
const buildCollectionDataQuery = ( collectionDataQueryState ) => {
const query = collectionDataQueryState;
if ( collectionDataQueryState.calculate_attribute_counts ) {
query.calculate_attribute_counts = sortBy(
collectionDataQueryState.calculate_attribute_counts.map(
( { taxonomy, queryType } ) => {
return {
taxonomy,
query_type: queryType,
};
}
),
[ 'taxonomy', 'query_type' ]
);
}
return query;
};
export const useCollectionData = ( {
queryAttribute,
queryPrices,
queryState,
} ) => {
let context = useQueryStateContext();
context = `${ context }-collection-data`;
const [ collectionDataQueryState ] = useQueryStateByContext( context );
const [
calculateAttributesQueryState,
setCalculateAttributesQueryState,
] = useQueryStateByKey( 'calculate_attribute_counts', [], context );
const [
calculatePriceRangeQueryState,
setCalculatePriceRangeQueryState,
] = useQueryStateByKey( 'calculate_price_range', null, context );
const currentQueryAttribute = useShallowEqual( queryAttribute || {} );
const currentQueryPrices = useShallowEqual( queryPrices );
useEffect( () => {
if (
typeof currentQueryAttribute === 'object' &&
Object.keys( currentQueryAttribute ).length
) {
const foundAttribute = calculateAttributesQueryState.find(
( attribute ) => {
return (
attribute.taxonomy === currentQueryAttribute.taxonomy
);
}
);
if ( ! foundAttribute ) {
setCalculateAttributesQueryState( [
...calculateAttributesQueryState,
currentQueryAttribute,
] );
}
}
}, [
currentQueryAttribute,
calculateAttributesQueryState,
setCalculateAttributesQueryState,
] );
useEffect( () => {
if (
calculatePriceRangeQueryState !== currentQueryPrices &&
currentQueryPrices !== undefined
) {
setCalculatePriceRangeQueryState( currentQueryPrices );
}
}, [
currentQueryPrices,
setCalculatePriceRangeQueryState,
calculatePriceRangeQueryState,
] );
// Defer the select query so all collection-data query vars can be gathered.
const [ shouldSelect, setShouldSelect ] = useState( false );
const [ debouncedShouldSelect ] = useDebounce( shouldSelect, 200 );
if ( ! shouldSelect ) {
setShouldSelect( true );
}
const collectionDataQueryVars = useMemo( () => {
return buildCollectionDataQuery( collectionDataQueryState );
}, [ collectionDataQueryState ] );
return useCollection( {
namespace: '/wc/store',
resourceName: 'products/collection-data',
query: {
...queryState,
page: undefined,
per_page: undefined,
orderby: undefined,
order: undefined,
...collectionDataQueryVars,
},
shouldSelect: debouncedShouldSelect,
} );
};

View File

@@ -0,0 +1,90 @@
/**
* 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 resource name 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,
];
return {
value: store.getCollectionHeader( ...args ),
isLoading: store.hasFinishedResolution(
'getCollectionHeader',
args
),
};
},
[
headerKey,
namespace,
resourceName,
currentResourceValues,
currentQuery,
]
);
return {
value,
isLoading,
};
};

View File

@@ -0,0 +1,95 @@
/**
* External dependencies
*/
import { COLLECTIONS_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect } from '@wordpress/data';
import { useRef } from '@wordpress/element';
/**
* 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' }`
* @param {boolean} options.shouldSelect If false, the previous results will be
* returned and internal selects will not
* fire.
*
* @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 = {},
shouldSelect = true,
} = options;
if ( ! namespace || ! resourceName ) {
throw new Error(
'The options object must have valid values for the namespace and ' +
'the resource properties.'
);
}
const currentResults = useRef( { results: [], isLoading: true } );
// ensure we feed the previous reference if it's equivalent
const currentQuery = useShallowEqual( query );
const currentResourceValues = useShallowEqual( resourceValues );
const results = useSelect(
( select ) => {
if ( ! shouldSelect ) {
return null;
}
const store = select( storeKey );
// filter out query if it is undefined.
const args = [
namespace,
resourceName,
currentQuery,
currentResourceValues,
];
return {
results: store.getCollection( ...args ),
isLoading: ! store.hasFinishedResolution(
'getCollection',
args
),
};
},
[
namespace,
resourceName,
currentResourceValues,
currentQuery,
shouldSelect,
]
);
// if selector was not bailed, then update current results. Otherwise return
// previous results
if ( results !== null ) {
currentResults.current = results;
}
return currentResults.current;
};

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { useRef, useEffect } from 'react';
/**
* Use Previous based on https://usehooks.com/usePrevious/.
* @param {mixed} value
* @param {Function} [validation] Function that needs to validate for the value
* to be updated.
*/
export const usePrevious = ( value, validation ) => {
const ref = useRef();
useEffect( () => {
if (
ref.current !== value &&
( ! validation || validation( value, ref.current ) )
) {
ref.current = value;
}
}, [ value, ref.current ] );
return ref.current;
};

View File

@@ -0,0 +1,132 @@
/**
* External dependencies
*/
import { QUERY_STATE_STORE_KEY as storeKey } from '@woocommerce/block-data';
import { useSelect, useDispatch } from '@wordpress/data';
import { useRef, useEffect, useCallback } from '@wordpress/element';
import { useQueryStateContext } from '@woocommerce/base-context/query-state-context';
import { assign } from 'lodash';
/**
* Internal dependencies
*/
import { useShallowEqual } from './use-shallow-equal';
/**
* A custom hook that exposes the current query state and a setter for the query
* state store for the given context.
*
* "Query State" is a wp.data store that keeps track of an arbitrary object of
* query keys and their values.
*
* @param {string} [context] What context to retrieve the query state for. If not
* provided, this hook will attempt to get the context
* from the query state context provided by the
* QueryStateContextProvider
*
* @return {Array} An array that has two elements. The first element is the
* query state value for the given context. The second element
* is a dispatcher function for setting the query state.
*/
export const useQueryStateByContext = ( context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const queryState = useSelect(
( select ) => {
const store = select( storeKey );
return store.getValueForQueryContext( context, undefined );
},
[ context ]
);
const { setValueForQueryContext } = useDispatch( storeKey );
const setQueryState = useCallback(
( value ) => {
setValueForQueryContext( context, value );
},
[ context ]
);
return [ queryState, setQueryState ];
};
/**
* A custom hook that exposes the current query state value and a setter for the
* given context and query key.
*
* "Query State" is a wp.data store that keeps track of an arbitrary object of
* query keys and their values.
*
* @param {*} queryKey The specific query key to retrieve the value for.
* @param {*} defaultValue Default value if query does not exist.
* @param {string} [context] What context to retrieve the query state for. If
* not provided will attempt to use what is provided
* by query state context.
*
* @return {*} Whatever value is set at the query state index using the
* provided context and query key.
*/
export const useQueryStateByKey = ( queryKey, defaultValue, context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const queryValue = useSelect(
( select ) => {
const store = select( storeKey );
return store.getValueForQueryKey( context, queryKey, defaultValue );
},
[ context, queryKey ]
);
const { setQueryValue } = useDispatch( storeKey );
const setQueryValueByKey = useCallback(
( value ) => {
setQueryValue( context, queryKey, value );
},
[ context, queryKey ]
);
return [ queryValue, setQueryValueByKey ];
};
/**
* A custom hook that works similarly to useQueryStateByContext. However, this
* hook allows for synchronizing with a provided queryState object.
*
* This hook does the following things with the provided `synchronizedQuery`
* object:
*
* - whenever synchronizedQuery varies between renders, the queryState will be
* updated to a merged object of the internal queryState and the provided
* object. Note, any values from the same properties between objects will
* be set from synchronizedQuery.
* - if there are no changes between renders, then the existing internal
* queryState is always returned.
* - on initial render, the synchronizedQuery value is returned.
*
* Typically, this hook would be used in a scenario where there may be external
* triggers for updating the query state (i.e. initial population of query
* state by hydration or component attributes, or routing url changes that
* affect query state).
*
* @param {Object} synchronizedQuery A provided query state object to
* synchronize internal query state with.
* @param {string} [context] What context to retrieve the query state
* for. If not provided, will be pulled from
* the QueryStateContextProvider in the tree.
*/
export const useSynchronizedQueryState = ( synchronizedQuery, context ) => {
const queryStateContext = useQueryStateContext();
context = context || queryStateContext;
const [ queryState, setQueryState ] = useQueryStateByContext( context );
const currentSynchronizedQuery = useShallowEqual( synchronizedQuery );
// used to ensure we allow initial synchronization to occur before
// returning non-synced state.
const isInitialized = useRef( false );
// update queryState anytime incoming synchronizedQuery changes
useEffect( () => {
setQueryState( assign( {}, queryState, currentSynchronizedQuery ) );
isInitialized.current = true;
}, [ currentSynchronizedQuery ] );
return isInitialized.current
? [ queryState, setQueryState ]
: [ synchronizedQuery, setQueryState ];
};

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { useRef } from '@wordpress/element';
import isShallowEqual from '@wordpress/is-shallow-equal';
/**
* A custom hook that compares the provided value across renders and returns the
* previous instance if shallow equality with previous instance exists.
*
* This is particularly useful when non-primitive types are used as
* dependencies for react hooks.
*
* @param {mixed} value Value to keep the same if satisfies shallow equality.
*
* @return {mixed} The previous cached instance of the value if the current has
* shallow equality with it.
*/
export const useShallowEqual = ( value ) => {
const ref = useRef();
if ( ! isShallowEqual( value, ref.current ) ) {
ref.current = value;
}
return ref.current;
};

View File

@@ -0,0 +1,44 @@
/**
* Internal dependencies
*/
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 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.
*
* @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.
* - productsLoading A boolean indicating whether the products
* are still loading or not.
*/
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/store',
resourceName: 'products',
};
const { results: products, isLoading: productsLoading } = useCollection( {
...collectionOptions,
query,
} );
const { value: totalProducts } = useCollectionHeader( 'x-wp-total', {
...collectionOptions,
query,
} );
return {
products,
totalProducts,
productsLoading,
};
};