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,4 @@
export const ACTION_TYPES = {
RECEIVE_COLLECTION: 'RECEIVE_COLLECTION',
RESET_COLLECTION: 'RESET_COLLECTION',
};

View File

@@ -0,0 +1,101 @@
/**
* External dependencies
*/
import { apiFetch, select } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { STORE_KEY as SCHEMA_STORE_KEY } from '../schema/constants';
let Headers = window.Headers || null;
Headers = Headers
? new Headers()
: { get: () => undefined, has: () => undefined };
/**
* Returns an action object used in updating the store with the provided items
* retrieved from a request using the given querystring.
*
* This is a generic response action.
*
* @param {string} namespace The namespace for the collection route.
* @param {string} resourceName The resource name for the collection route.
* @param {string} [queryString=''] The query string for the collection
* @param {Array} [ids=[]] An array of ids (in correct order) for the
* model.
* @param {Object} [response={}] An object containing the response from the
* collection request.
* @param {Array<*>} response.items An array of items for the given collection.
* @param {Headers} response.headers A Headers object from the response
* @link https://developer.mozilla.org/en-US/docs/Web/API/Headers
* @param {bool} [replace=false] If true, signals to replace the current
* items in the state with the provided
* items.
* @return {
* {
* type: string,
* namespace: string,
* resourceName: string,
* queryString: string,
* ids: Array<*>,
* items: Array<*>,
* }
* } Object for action.
*/
export function receiveCollection(
namespace,
resourceName,
queryString = '',
ids = [],
response = { items: [], headers: Headers },
replace = false
) {
return {
type: replace ? types.RESET_COLLECTION : types.RECEIVE_COLLECTION,
namespace,
resourceName,
queryString,
ids,
response,
};
}
export function* __experimentalPersistItemToCollection(
namespace,
resourceName,
currentCollection,
data = {}
) {
const newCollection = [ ...currentCollection ];
const route = yield select(
SCHEMA_STORE_KEY,
'getRoute',
namespace,
resourceName
);
if ( ! route ) {
return;
}
const item = yield apiFetch( {
path: route,
method: 'POST',
data,
cache: 'no-store',
} );
if ( item ) {
newCollection.push( item );
yield receiveCollection(
namespace,
resourceName,
'',
[],
{
items: newCollection,
headers: Headers,
},
true
);
}
}

View File

@@ -0,0 +1,2 @@
export const STORE_KEY = 'wc/store/collections';
export const DEFAULT_EMPTY_ARRAY = [];

View File

@@ -0,0 +1,41 @@
/**
* External dependencies
*/
import triggerFetch from '@wordpress/api-fetch';
/**
* Dispatched a control action for triggering an api fetch call with no parsing.
* Typically this would be used in scenarios where headers are needed.
*
* @param {string} path The path for the request.
*
* @return {Object} The control action descriptor.
*/
export const apiFetchWithHeaders = ( path ) => {
return {
type: 'API_FETCH_WITH_HEADERS',
path,
};
};
/**
* Default export for registering the controls with the store.
*
* @return {Object} An object with the controls to register with the store on
* the controls property of the registration object.
*/
export const controls = {
API_FETCH_WITH_HEADERS( { path } ) {
return new Promise( ( resolve, reject ) => {
triggerFetch( { path, parse: false } )
.then( ( response ) => {
response.json().then( ( items ) => {
resolve( { items, headers: response.headers } );
} );
} )
.catch( ( error ) => {
reject( error );
} );
} );
},
};

View File

@@ -0,0 +1,25 @@
/**
* External dependencies
*/
import { registerStore } from '@wordpress/data';
import { controls as dataControls } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer from './reducers';
import { controls } from './controls';
registerStore( STORE_KEY, {
reducer,
actions,
controls: { ...dataControls, ...controls },
selectors,
resolvers,
} );
export const COLLECTIONS_STORE_KEY = STORE_KEY;

View File

@@ -0,0 +1,49 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { hasInState, updateState } from '../utils';
/**
* Reducer for receiving items to a collection.
*
* @param {Object} state The current state in the store.
* @param {Object} action Action object.
*
* @return {Object} New or existing state depending on if there are
* any changes.
*/
const receiveCollection = ( state = {}, action ) => {
const { type, namespace, resourceName, queryString, response } = action;
// ids are stringified so they can be used as an index.
const ids = action.ids ? JSON.stringify( action.ids ) : '[]';
switch ( type ) {
case types.RECEIVE_COLLECTION:
if (
hasInState( state, [
namespace,
resourceName,
ids,
queryString,
] )
) {
return state;
}
state = updateState(
state,
[ namespace, resourceName, ids, queryString ],
response
);
break;
case types.RESET_COLLECTION:
state = updateState(
state,
[ namespace, resourceName, ids, queryString ],
response
);
break;
}
return state;
};
export default receiveCollection;

View File

@@ -0,0 +1,71 @@
/**
* External dependencies
*/
import { select } from '@wordpress/data-controls';
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { receiveCollection, DEFAULT_EMPTY_ARRAY } from './actions';
import { STORE_KEY as SCHEMA_STORE_KEY } from '../schema/constants';
import { STORE_KEY } from './constants';
import { apiFetchWithHeaders } from './controls';
/**
* Resolver for retrieving a collection via a api route.
*
* @param {string} namespace
* @param {string} resourceName
* @param {Object} query
* @param {Array} ids
*/
export function* getCollection( namespace, resourceName, query, ids ) {
const route = yield select(
SCHEMA_STORE_KEY,
'getRoute',
namespace,
resourceName,
ids
);
const queryString = addQueryArgs( '', query );
if ( ! route ) {
yield receiveCollection( namespace, resourceName, queryString, ids );
return;
}
const { items = DEFAULT_EMPTY_ARRAY, headers } = yield apiFetchWithHeaders(
route + queryString
);
yield receiveCollection( namespace, resourceName, queryString, ids, {
items,
headers,
} );
}
/**
* Resolver for retrieving a specific collection header for the given arguments
*
* Note: This triggers the `getCollection` resolver if it hasn't been resolved
* yet.
*
* @param {string} header
* @param {string} namespace
* @param {string} resourceName
* @param {Object} query
* @param {Array} ids
*/
export function* getCollectionHeader(
header,
namespace,
resourceName,
query,
ids
) {
// feed the correct number of args in for the select so we don't resolve
// unnecessarily. Any undefined args will be excluded. This is important
// because resolver resolution is cached by both number and value of args.
const args = [ namespace, resourceName, query, ids ].filter(
( arg ) => typeof arg !== 'undefined'
);
//we call this simply to do any resolution of the collection if necessary.
yield select( STORE_KEY, 'getCollection', ...args );
}

View File

@@ -0,0 +1,116 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { hasInState } from '../utils';
import { DEFAULT_EMPTY_ARRAY } from './constants';
const getFromState = ( {
state,
namespace,
resourceName,
query,
ids,
type = 'items',
fallback = DEFAULT_EMPTY_ARRAY,
} ) => {
// prep ids and query for state retrieval
ids = JSON.stringify( ids );
query = query !== null ? addQueryArgs( '', query ) : '';
if ( hasInState( state, [ namespace, resourceName, ids, query, type ] ) ) {
return state[ namespace ][ resourceName ][ ids ][ query ][ type ];
}
return fallback;
};
const getCollectionHeaders = (
state,
namespace,
resourceName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
return getFromState( {
state,
namespace,
resourceName,
query,
ids,
type: 'headers',
fallback: undefined,
} );
};
/**
* Retrieves the collection items from the state for the given arguments.
*
* @param {Object} state The current collections state.
* @param {string} namespace The namespace for the collection.
* @param {string} resourceName The resource name for the collection.
* @param {Object} [query=null] The query for the collection request.
* @param {Array} [ids=[]] Any ids for the collection request (these are
* values that would be added to the route for a
* route with id placeholders)
* @return {Array} an array of items stored in the collection.
*/
export const getCollection = (
state,
namespace,
resourceName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
return getFromState( { state, namespace, resourceName, query, ids } );
};
/**
* This selector enables retrieving a specific header value from a given
* collection request.
*
* Example:
*
* ```js
* const totalProducts = wp.data.select( COLLECTION_STORE_KEY )
* .getCollectionHeader( '/wc/blocks', 'products', 'x-wp-total' )
* ```
*
* @param {string} state The current collection state.
* @param {string} header The header to retrieve.
* @param {string} namespace The namespace for the collection.
* @param {string} resourceName The model name for the collection.
* @param {Object} [query=null] The query object on the collection request.
* @param {Array} [ids=[]] Any ids for the collection request (these are
* values that would be added to the route for a
* route with id placeholders)
*
* @return {*|null} The value for the specified header, null if there are no
* headers available and undefined if the header does not exist for the
* collection.
*/
export const getCollectionHeader = (
state,
header,
namespace,
resourceName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
const headers = getCollectionHeaders(
state,
namespace,
resourceName,
query,
ids
);
// Can't just do a truthy check because `getCollectionHeaders` resolver
// invokes the `getCollection` selector to trigger the resolution of the
// collection request. Its fallback is an empty array.
if ( headers && headers.get ) {
return headers.has( header ) ? headers.get( header ) : undefined;
}
return null;
};

View File

@@ -0,0 +1,99 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import receiveCollection from '../reducers';
import { ACTION_TYPES as types } from '../action-types';
describe( 'receiveCollection', () => {
const originalState = deepFreeze( {
'wc/blocks': {
products: {
'[]': {
'?someQuery=2': {
items: [ 'foo' ],
headers: { 'x-wp-total': 22 },
},
},
},
},
} );
it(
'returns original state when there is already an entry in the state ' +
'for the given arguments',
() => {
const testAction = {
type: types.RECEIVE_COLLECTION,
namespace: 'wc/blocks',
resourceName: 'products',
queryString: '?someQuery=2',
response: {
items: [ 'bar' ],
headers: { foo: 'bar' },
},
};
expect( receiveCollection( originalState, testAction ) ).toBe(
originalState
);
}
);
it(
'returns new state when items exist in collection but the type is ' +
'for a reset',
() => {
const testAction = {
type: types.RESET_COLLECTION,
namespace: 'wc/blocks',
resourceName: 'products',
queryString: '?someQuery=2',
response: {
items: [ 'cheeseburger' ],
headers: { foo: 'bar' },
},
};
const newState = receiveCollection( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect(
newState[ 'wc/blocks' ].products[ '[]' ][ '?someQuery=2' ]
).toEqual( {
items: [ 'cheeseburger' ],
headers: { foo: 'bar' },
} );
}
);
it( 'returns new state when items do not exist in collection yet', () => {
const testAction = {
type: types.RECEIVE_COLLECTION,
namespace: 'wc/blocks',
resourceName: 'products',
queryString: '?someQuery=3',
response: { items: [ 'cheeseburger' ], headers: { foo: 'bar' } },
};
const newState = receiveCollection( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect(
newState[ 'wc/blocks' ].products[ '[]' ][ '?someQuery=3' ]
).toEqual( { items: [ 'cheeseburger' ], headers: { foo: 'bar' } } );
} );
it( 'sets expected state when ids are passed in', () => {
const testAction = {
type: types.RECEIVE_COLLECTION,
namespace: 'wc/blocks',
resourceName: 'products/attributes',
queryString: '?something',
response: { items: [ 10, 20 ], headers: { foo: 'bar' } },
ids: [ 30, 42 ],
};
const newState = receiveCollection( originalState, testAction );
expect( newState ).not.toBe( originalState );
expect(
newState[ 'wc/blocks' ][ 'products/attributes' ][ '[30,42]' ][
'?something'
]
).toEqual( { items: [ 10, 20 ], headers: { foo: 'bar' } } );
} );
} );

View File

@@ -0,0 +1,159 @@
/**
* External dependencies
*/
import { select } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { getCollection, getCollectionHeader } from '../resolvers';
import { receiveCollection } from '../actions';
import { STORE_KEY as SCHEMA_STORE_KEY } from '../../schema/constants';
import { STORE_KEY } from '../constants';
import { apiFetchWithHeaders } from '../controls';
jest.mock( '@wordpress/data-controls' );
describe( 'getCollection', () => {
describe( 'yields with expected responses', () => {
let fulfillment;
const testArgs = [
'wc/blocks',
'products',
{ foo: 'bar' },
[ 20, 30 ],
];
const rewind = () => ( fulfillment = getCollection( ...testArgs ) );
test( 'with getRoute call invoked to retrieve route', () => {
rewind();
fulfillment.next();
expect( select ).toHaveBeenCalledWith(
SCHEMA_STORE_KEY,
'getRoute',
testArgs[ 0 ],
testArgs[ 1 ],
testArgs[ 3 ]
);
} );
test(
'when no route is retrieved, yields receiveCollection and ' +
'returns',
() => {
const { value } = fulfillment.next();
const expected = receiveCollection(
'wc/blocks',
'products',
'?foo=bar',
[ 20, 30 ],
{
items: [],
headers: {
get: () => undefined,
has: () => undefined,
},
}
);
expect( value.type ).toBe( expected.type );
expect( value.namespace ).toBe( expected.namespace );
expect( value.resourceName ).toBe( expected.resourceName );
expect( value.queryString ).toBe( expected.queryString );
expect( value.ids ).toEqual( expected.ids );
expect( Object.keys( value.response ) ).toEqual(
Object.keys( expected.response )
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
}
);
test(
'when route is retrieved, yields apiFetchWithHeaders control action with ' +
'expected route',
() => {
rewind();
fulfillment.next();
const { value } = fulfillment.next( 'https://example.org' );
expect( value ).toEqual(
apiFetchWithHeaders( 'https://example.org?foo=bar' )
);
}
);
test(
'when apiFetchWithHeaders does not return a valid response, ' +
'yields expected action',
() => {
const { value } = fulfillment.next( {} );
expect( value ).toEqual(
receiveCollection(
'wc/blocks',
'products',
'?foo=bar',
[ 20, 30 ],
{ items: undefined, headers: undefined }
)
);
}
);
test(
'when apiFetch returns a valid response, yields expected ' +
'action',
() => {
rewind();
fulfillment.next();
fulfillment.next( 'https://example.org' );
const { value } = fulfillment.next( {
items: [ '42', 'cheeseburgers' ],
headers: { foo: 'bar' },
} );
expect( value ).toEqual(
receiveCollection(
'wc/blocks',
'products',
'?foo=bar',
[ 20, 30 ],
{
items: [ '42', 'cheeseburgers' ],
headers: { foo: 'bar' },
}
)
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
}
);
} );
} );
describe( 'getCollectionHeader', () => {
let fulfillment;
const rewind = ( ...testArgs ) =>
( fulfillment = getCollectionHeader( ...testArgs ) );
it( 'yields expected select control when called with less args', () => {
rewind( 'x-wp-total', '/wc/blocks', 'products' );
const { value } = fulfillment.next();
expect( value ).toEqual(
select( STORE_KEY, 'getCollection', '/wc/blocks', 'products' )
);
} );
it( 'yields expected select control when called with all args', () => {
const args = [
'x-wp-total',
'/wc/blocks',
'products/attributes',
{ sort: 'ASC' },
[ 10 ],
];
rewind( ...args );
const { value } = fulfillment.next();
expect( value ).toEqual(
select(
STORE_KEY,
'/wc/blocks',
'products/attributes',
{ sort: 'ASC' },
[ 10 ]
)
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
} );
} );

View File

@@ -0,0 +1,117 @@
/**
* Internal dependencies
*/
import { getCollection, getCollectionHeader } from '../selectors';
const getHeaderMock = ( total ) => {
const headers = { total };
return {
get: ( key ) => headers[ key ] || null,
has: ( key ) => !! headers[ key ],
};
};
const state = {
'wc/blocks': {
products: {
'[]': {
'?someQuery=2': {
items: [ 'foo' ],
headers: getHeaderMock( 22 ),
},
},
},
'products/attributes': {
'[10]': {
'?someQuery=2': {
items: [ 'bar' ],
headers: getHeaderMock( 42 ),
},
},
},
'products/attributes/terms': {
'[10, 20]': {
'?someQuery=10': {
items: [ 42 ],
headers: getHeaderMock( 12 ),
},
},
},
},
};
describe( 'getCollection', () => {
it( 'returns empty array when namespace does not exist in state', () => {
expect( getCollection( state, 'invalid', 'products' ) ).toEqual( [] );
} );
it( 'returns empty array when resourceName does not exist in state', () => {
expect( getCollection( state, 'wc/blocks', 'invalid' ) ).toEqual( [] );
} );
it( 'returns empty array when query does not exist in state', () => {
expect( getCollection( state, 'wc/blocks', 'products' ) ).toEqual( [] );
} );
it( 'returns empty array when ids do not exist in state', () => {
expect(
getCollection(
state,
'wc/blocks',
'products/attributes',
'?someQuery=2',
[ 20 ]
)
).toEqual( [] );
} );
describe( 'returns expected values for items existing in state', () => {
test.each`
resourceName | ids | query | expected
${'products'} | ${'[]'} | ${{ someQuery: 2 }} | ${[ 'foo' ]}
${'products/attributes'} | ${'[10]'} | ${{ someQuery: 2 }} | ${[ 'bar' ]}
${'products/attributes/terms'} | ${'[10,30]'} | ${{ someQuery: 10 }} | ${[ 42 ]}
`(
'for "$resourceName", "$ids", and "$query"',
( { resourceName, ids, query } ) => {
expect(
getCollection(
state,
'wc/blocks',
resourceName,
query,
ids
)
);
}
);
} );
} );
describe( 'getCollectionHeader', () => {
it(
'returns undefined when there are headers but the specific header ' +
'does not exist',
() => {
expect(
getCollectionHeader(
state,
'invalid',
'wc/blocks',
'products',
{
someQuery: 2,
}
)
).toBeUndefined();
}
);
it( 'returns null when there are no headers for the given arguments', () => {
expect( getCollectionHeader( state, 'wc/blocks', 'invalid' ) ).toBe(
null
);
} );
it( 'returns expected header when it exists', () => {
expect(
getCollectionHeader( state, 'total', 'wc/blocks', 'products', {
someQuery: 2,
} )
).toBe( 22 );
} );
} );

View File

@@ -0,0 +1,6 @@
/**
* REST API namespace for rest requests against blocks namespace.
*
* @var {string}
*/
export const API_BLOCK_NAMESPACE = 'wc/blocks';

View File

@@ -0,0 +1,4 @@
export { SCHEMA_STORE_KEY } from './schema';
export { COLLECTIONS_STORE_KEY } from './collections';
export { QUERY_STATE_STORE_KEY } from './query-state';
export { API_BLOCK_NAMESPACE } from './constants';

View File

@@ -0,0 +1,4 @@
export const ACTION_TYPES = {
SET_QUERY_KEY_VALUE: 'SET_QUERY_KEY_VALUE',
SET_QUERY_CONTEXT_VALUE: 'SET_QUERY_CONTEXT_VALUE',
};

View File

@@ -0,0 +1,38 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
/**
* Action creator for setting a single query-state value for a given context.
*
* @param {string} context Context for query state being stored.
* @param {string} queryKey Key for query item.
* @param {*} value The value for the query item.
*
* @return {Object} The action object.
*/
export const setQueryValue = ( context, queryKey, value ) => {
return {
type: types.SET_QUERY_KEY_VALUE,
context,
queryKey,
value,
};
};
/**
* Action creator for setting query-state for a given context.
*
* @param {string} context Context for query state being stored.
* @param {*} value Query state being stored for the given context.
*
* @return {Object} The action object.
*/
export const setValueForQueryContext = ( context, value ) => {
return {
type: types.SET_QUERY_CONTEXT_VALUE,
context,
value,
};
};

View File

@@ -0,0 +1 @@
export const STORE_KEY = 'wc/store/query-state';

View File

@@ -0,0 +1,20 @@
/**
* External dependencies
*/
import { registerStore } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import reducer from './reducers';
registerStore( STORE_KEY, {
reducer,
actions,
selectors,
} );
export const QUERY_STATE_STORE_KEY = STORE_KEY;

View File

@@ -0,0 +1,46 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import { getStateForContext } from './utils';
/**
* Reducer for processing actions related to the query state store.
*
* @param {Object} state Current state in store.
* @param {Object} action Action being processed.
*/
const queryStateReducer = ( state = {}, action ) => {
const { type, context, queryKey, value } = action;
const prevState = getStateForContext( state, context );
let newState;
switch ( type ) {
case types.SET_QUERY_KEY_VALUE:
const prevStateObject =
prevState !== null ? JSON.parse( prevState ) : {};
// mutate it and JSON.stringify to compare
prevStateObject[ queryKey ] = value;
newState = JSON.stringify( prevStateObject );
if ( prevState !== newState ) {
state = {
...state,
[ context ]: newState,
};
}
break;
case types.SET_QUERY_CONTEXT_VALUE:
newState = JSON.stringify( value );
if ( prevState !== newState ) {
state = {
...state,
[ context ]: newState,
};
}
break;
}
return state;
};
export default queryStateReducer;

View File

@@ -0,0 +1,51 @@
/**
* Internal dependencies
*/
import { getStateForContext } from './utils';
/**
* Selector for retrieving a specific query-state for the given context.
*
* @param {Object} state Current state.
* @param {string} context Context for the query-state being retrieved.
* @param {string} queryKey Key for the specific query-state item.
* @param {*} defaultValue Default value for the query-state key if it doesn't
* currently exist in state.
*
* @return {*} The currently stored value or the defaultValue if not present.
*/
export const getValueForQueryKey = (
state,
context,
queryKey,
defaultValue = {}
) => {
let stateContext = getStateForContext( state, context );
if ( stateContext === null ) {
return defaultValue;
}
stateContext = JSON.parse( stateContext );
return typeof stateContext[ queryKey ] !== 'undefined'
? stateContext[ queryKey ]
: defaultValue;
};
/**
* Selector for retrieving the query-state for the given context.
*
* @param {Object} state The current state.
* @param {string} context The context for the query-state being retrieved.
* @param {*} defaultValue The default value to return if there is no state for
* the given context.
*
* @return {*} The currently stored query-state for the given context or
* defaultValue if not present in state.
*/
export const getValueForQueryContext = (
state,
context,
defaultValue = {}
) => {
const stateContext = getStateForContext( state, context );
return stateContext === null ? defaultValue : JSON.parse( stateContext );
};

View File

@@ -0,0 +1,136 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import queryStateReducer from '../reducers';
import { setQueryValue, setValueForQueryContext } from '../actions';
describe( 'queryStateReducer', () => {
const originalState = deepFreeze( {
contexta: JSON.stringify( {
foo: 'bar',
cheese: 'pizza',
} ),
} );
it(
'returns original state when the action is not of the type being ' +
'processed',
() => {
expect(
queryStateReducer( originalState, { type: 'invalid' } )
).toBe( originalState );
}
);
describe( 'SET_QUERY_KEY_VALUE action', () => {
it(
'returns original state when incoming query-state key value ' +
'matches what is already in the state',
() => {
expect(
queryStateReducer(
originalState,
setQueryValue( 'contexta', 'foo', 'bar' )
)
).toBe( originalState );
}
);
it(
'returns new state when incoming query-state key exist ' +
'but the value is a new value',
() => {
const newState = queryStateReducer(
originalState,
setQueryValue( 'contexta', 'foo', 'zed' )
);
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
contexta: JSON.stringify( {
foo: 'zed',
cheese: 'pizza',
} ),
} );
}
);
it(
'returns new state when incoming query-state key does not ' +
'exist',
() => {
const newState = queryStateReducer(
originalState,
setQueryValue( 'contexta', 'burger', 'pizza' )
);
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
contexta: JSON.stringify( {
foo: 'bar',
cheese: 'pizza',
burger: 'pizza',
} ),
} );
}
);
} );
describe( 'SET_QUERY_CONTEXT_VALUE action', () => {
it(
'returns original state when incoming context value matches ' +
'what is already in the state',
() => {
expect(
queryStateReducer(
originalState,
setValueForQueryContext( 'contexta', {
foo: 'bar',
cheese: 'pizza',
} )
)
).toBe( originalState );
}
);
it(
'returns new state when incoming context value is different ' +
'than what is already in the state',
() => {
const newState = queryStateReducer(
originalState,
setValueForQueryContext( 'contexta', {
bar: 'foo',
pizza: 'cheese',
} )
);
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
contexta: JSON.stringify( {
bar: 'foo',
pizza: 'cheese',
} ),
} );
}
);
it(
'returns new state when incoming context does not exist in the ' +
'state',
() => {
const newState = queryStateReducer(
originalState,
setValueForQueryContext( 'contextb', {
foo: 'bar',
} )
);
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
contexta: JSON.stringify( {
foo: 'bar',
cheese: 'pizza',
} ),
contextb: JSON.stringify( {
foo: 'bar',
} ),
} );
}
);
} );
} );

View File

@@ -0,0 +1,63 @@
/**
* Internal dependencies
*/
import { getValueForQueryKey, getValueForQueryContext } from '../selectors';
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
const testState = deepFreeze( {
contexta: JSON.stringify( {
foo: 'bar',
cheese: 'pizza',
} ),
} );
describe( 'getValueForQueryKey', () => {
it(
'returns provided default value when there is no state for the ' +
'given context',
() => {
expect(
getValueForQueryKey( testState, 'invalid', 'foo', 42 )
).toBe( 42 );
}
);
it(
'returns provided default value when there is no value for the ' +
'given context and queryKey',
() => {
expect(
getValueForQueryKey( testState, 'contexta', 'pizza', 42 )
).toBe( 42 );
}
);
it( 'returns expected value when context and queryKey exist', () => {
expect( getValueForQueryKey( testState, 'contexta', 'foo', 42 ) ).toBe(
'bar'
);
} );
} );
describe( 'getValueForQueryContext', () => {
it(
'returns provided default value when there is no state for the ' +
'given context',
() => {
expect( getValueForQueryContext( testState, 'invalid', 42 ) ).toBe(
42
);
}
);
it(
'returns expected value when selecting a context that exists in ' +
'state',
() => {
expect(
getValueForQueryContext( testState, 'contexta', 42 )
).toEqual( JSON.parse( testState.contexta ) );
}
);
} );

View File

@@ -0,0 +1,3 @@
export const getStateForContext = ( state, context ) => {
return typeof state[ context ] === 'undefined' ? null : state[ context ];
};

View File

@@ -0,0 +1,3 @@
export const ACTION_TYPES = {
RECEIVE_MODEL_ROUTES: 'RECEIVE_MODEL_ROUTES',
};

View File

@@ -0,0 +1,22 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types.js';
import { API_BLOCK_NAMESPACE } from '../constants';
/**
* Returns an action object used to update the store with the provided list
* of model routes.
*
* @param {Object} routes An array of routes to add to the store state.
* @param {string} namespace
*
* @return {Object} The action object.
*/
export function receiveRoutes( routes, namespace = API_BLOCK_NAMESPACE ) {
return {
type: types.RECEIVE_MODEL_ROUTES,
routes,
namespace,
};
}

View File

@@ -0,0 +1,5 @@
/**
* Identifier key for this store reducer.
* @type {string}
*/
export const STORE_KEY = 'wc/store/schema';

View File

@@ -0,0 +1,24 @@
/**
* External dependencies
*/
import { registerStore } from '@wordpress/data';
import { controls } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
import * as selectors from './selectors';
import * as actions from './actions';
import * as resolvers from './resolvers';
import reducer from './reducers';
registerStore( STORE_KEY, {
reducer,
actions,
controls,
selectors,
resolvers,
} );
export const SCHEMA_STORE_KEY = STORE_KEY;

View File

@@ -0,0 +1,57 @@
/**
* External dependencies
*/
import { combineReducers } from '@wordpress/data';
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
import {
extractResourceNameFromRoute,
getRouteIds,
simplifyRouteWithId,
} from './utils';
import { hasInState, updateState } from '../utils';
/**
* Reducer for routes
*
* @param {Object} state The current state.
* @param {Object} action The action object for parsing.
*
* @return {Object} The new (or original) state.
*/
export const receiveRoutes = ( state = {}, action ) => {
const { type, routes, namespace } = action;
if ( type === types.RECEIVE_MODEL_ROUTES ) {
routes.forEach( ( route ) => {
const resourceName = extractResourceNameFromRoute(
namespace,
route
);
if ( resourceName && resourceName !== namespace ) {
const routeIdNames = getRouteIds( route );
const savedRoute = simplifyRouteWithId( route, routeIdNames );
if (
! hasInState( state, [
namespace,
resourceName,
savedRoute,
] )
) {
state = updateState(
state,
[ namespace, resourceName, savedRoute ],
routeIdNames
);
}
}
} );
}
return state;
};
export default combineReducers( {
routes: receiveRoutes,
} );

View File

@@ -0,0 +1,38 @@
/**
* External dependencies
*/
import { select, apiFetch } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { receiveRoutes } from './actions';
import { STORE_KEY } from './constants';
/**
* Resolver for the getRoute selector.
*
* Note: All this essentially does is ensure the routes for the given namespace
* have been resolved.
*
* @param {string} namespace The namespace of the route being resolved.
*/
export function* getRoute( namespace ) {
// we call this simply to do any resolution of all endpoints if necessary.
// allows for jit population of routes for a given namespace.
yield select( STORE_KEY, 'getRoutes', namespace );
}
/**
* Resolver for the getRoutes selector.
*
* @param {string} namespace The namespace of the routes being resolved.
*/
export function* getRoutes( namespace ) {
const routeResponse = yield apiFetch( { path: namespace } );
const routes =
routeResponse && routeResponse.routes
? Object.keys( routeResponse.routes )
: [];
yield receiveRoutes( routes, namespace );
}

View File

@@ -0,0 +1,160 @@
/**
* External dependencies
*/
import { sprintf } from '@wordpress/i18n';
import { createRegistrySelector } from '@wordpress/data';
/**
* Internal dependencies
*/
import { STORE_KEY } from './constants';
/**
* Returns the requested route for the given arguments.
*
* @param {Object} state The original state.
* @param {string} namespace The namespace for the route.
* @param {string} resourceName The resource being requested
* (eg. products/attributes)
* @param {Array} [ids] This is for any ids that might be implemented in
* the route request. It is not for any query
* parameters.
*
* Ids example:
* If you are looking for the route for a single product on the `wc/blocks`
* namespace, then you'd have `[ 20 ]` as the ids. This would produce something
* like `/wc/blocks/products/20`
*
*
* @throws {Error} If there is no route for the given arguments, then this will
* throw
*
* @return {string} The route if it is available.
*/
export const getRoute = createRegistrySelector(
( select ) => ( state, namespace, resourceName, ids = [] ) => {
const hasResolved = select( STORE_KEY ).hasFinishedResolution(
'getRoutes',
[ namespace ]
);
state = state.routes;
let error = '';
if ( ! state[ namespace ] ) {
error = sprintf(
'There is no route for the given namespace (%s) in the store',
namespace
);
} else if ( ! state[ namespace ][ resourceName ] ) {
error = sprintf(
'There is no route for the given resource name (%s) in the store',
resourceName
);
}
if ( error !== '' ) {
if ( hasResolved ) {
throw new Error( error );
}
return '';
}
const route = getRouteFromResourceEntries(
state[ namespace ][ resourceName ],
ids
);
if ( route === '' ) {
if ( hasResolved ) {
throw new Error(
sprintf(
'While there is a route for the given namespace (%s) and resource name (%s), there is no route utilizing the number of ids you included in the select arguments. The available routes are: (%s)',
namespace,
resourceName,
JSON.stringify( state[ namespace ][ resourceName ] )
)
);
}
}
return route;
}
);
/**
* Return all the routes for a given namespace.
*
* @param {Object} state The current state.
* @param {string} namespace The namespace to return routes for.
*
* @return {Array} An array of all routes for the given namespace.
*/
export const getRoutes = createRegistrySelector(
( select ) => ( state, namespace ) => {
const hasResolved = select( STORE_KEY ).hasFinishedResolution(
'getRoutes',
[ namespace ]
);
const routes = state.routes[ namespace ];
if ( ! routes ) {
if ( hasResolved ) {
throw new Error(
sprintf(
'There is no route for the given namespace (%s) in the store',
namespace
)
);
}
return [];
}
let namespaceRoutes = [];
for ( const resourceName in routes ) {
namespaceRoutes = [
...namespaceRoutes,
...Object.keys( routes[ resourceName ] ),
];
}
return namespaceRoutes;
}
);
/**
* Returns the route from the given slice of the route state.
*
* @param {Object} stateSlice This will be a slice of the route state from a
* given namespace and resource name.
* @param {Array} [ids=[]] Any id references that are to be replaced in
* route placeholders.
*
* @returns {string} The route or an empty string if nothing found.
*/
const getRouteFromResourceEntries = ( stateSlice, ids = [] ) => {
// convert to array for easier discovery
stateSlice = Object.entries( stateSlice );
const match = stateSlice.find( ( [ , idNames ] ) => {
return ids.length === idNames.length;
} );
const [ matchingRoute, routePlaceholders ] = match || [];
// if we have a matching route, let's return it.
if ( matchingRoute ) {
return ids.length === 0
? matchingRoute
: assembleRouteWithPlaceholders(
matchingRoute,
routePlaceholders,
ids
);
}
return '';
};
/**
* For a given route, route parts and ids,
*
* @param {string} route
* @param {Array} routeParts
* @param {Array} ids
*
* @returns {string}
*/
const assembleRouteWithPlaceholders = ( route, routePlaceholders, ids ) => {
routePlaceholders.forEach( ( part, index ) => {
route = route.replace( `{${ part }}`, ids[ index ] );
} );
return route;
};

View File

@@ -0,0 +1,73 @@
/**
* Internal dependencies
*/
import { receiveRoutes } from '../reducers';
import { ACTION_TYPES as types } from '../action-types';
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
describe( 'receiveRoutes', () => {
it( 'returns original state when action type is not a match', () => {
expect( receiveRoutes( undefined, { type: 'invalid' } ) ).toEqual( {} );
} );
it( 'returns original state when the given endpoints already exists', () => {
const routes = [
'wc/blocks/products/attributes',
'wc/blocks/products/attributes/(?P<attribute_id>[d]+)/terms/(?P<id>[d]+)',
];
const originalState = deepFreeze( {
'wc/blocks': {
'products/attributes': {
'wc/blocks/products/attributes': [],
},
'products/attributes/terms': {
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
'attribute_id',
'id',
],
},
},
} );
const newState = receiveRoutes( originalState, {
type: types.RECEIVE_MODEL_ROUTES,
namespace: 'wc/blocks',
routes,
} );
expect( newState ).toBe( originalState );
} );
it( 'returns expected state when new route added', () => {
const action = {
type: types.RECEIVE_MODEL_ROUTES,
namespace: 'wc/blocks',
routes: [ 'wc/blocks/products/attributes' ],
};
const originalState = deepFreeze( {
'wc/blocks': {
'products/attributes/terms': {
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
'attribute_id',
'id',
],
},
},
} );
const newState = receiveRoutes( originalState, action );
expect( newState ).not.toBe( originalState );
expect( newState ).toEqual( {
'wc/blocks': {
'products/attributes': {
'wc/blocks/products/attributes': [],
},
'products/attributes/terms': {
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
'attribute_id',
'id',
],
},
},
} );
} );
} );

View File

@@ -0,0 +1,55 @@
/**
* External dependencies
*/
import { select, apiFetch } from '@wordpress/data-controls';
/**
* Internal dependencies
*/
import { getRoute, getRoutes } from '../resolvers';
import { receiveRoutes } from '../actions';
import { STORE_KEY } from '../constants';
jest.mock( '@wordpress/data-controls' );
describe( 'getRoute', () => {
it( 'yields select control response', () => {
const fulfillment = getRoute( 'wc/blocks' );
fulfillment.next();
expect( select ).toHaveBeenCalledWith(
STORE_KEY,
'getRoutes',
'wc/blocks'
);
const { done } = fulfillment.next();
expect( done ).toBe( true );
} );
} );
describe( 'getRoutes', () => {
describe( 'yields with expected responses', () => {
let fulfillment;
const rewind = () => ( fulfillment = getRoutes( 'wc/blocks' ) );
test( 'with apiFetch control invoked', () => {
rewind();
fulfillment.next();
expect( apiFetch ).toHaveBeenCalledWith( { path: 'wc/blocks' } );
} );
test( 'with receiveRoutes action with valid response', () => {
const testResponse = {
routes: {
'/wc/blocks/products/attributes': [],
},
};
const { value } = fulfillment.next( testResponse );
expect( value ).toEqual(
receiveRoutes( Object.keys( testResponse.routes ), 'wc/blocks' )
);
} );
test( 'with receiveRoutesAction with invalid response', () => {
rewind();
fulfillment.next();
const { value } = fulfillment.next( {} );
expect( value ).toEqual( receiveRoutes( [], 'wc/blocks' ) );
} );
} );
} );

View File

@@ -0,0 +1,104 @@
/**
* External dependencies
*/
import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
import { getRoute, getRoutes } from '../selectors';
const mockHasFinishedResolution = jest.fn().mockReturnValue( false );
jest.mock( '@wordpress/data', () => ( {
__esModule: true,
createRegistrySelector: ( callback ) =>
callback( () => ( {
hasFinishedResolution: mockHasFinishedResolution,
} ) ),
} ) );
const testState = deepFreeze( {
routes: {
'wc/blocks': {
'products/attributes': {
'wc/blocks/products/attributes': [],
},
'products/attributes/terms': {
'wc/blocks/products/attributes/{attribute_id}/terms/{id}': [
'attribute_id',
'id',
],
},
},
},
} );
describe( 'getRoute', () => {
const invokeTest = ( namespace, resourceName, ids = [] ) => () => {
return getRoute( testState, namespace, resourceName, ids );
};
describe( 'with throwing errors', () => {
beforeEach( () => mockHasFinishedResolution.mockReturnValue( true ) );
it( 'throws an error if there is no route for the given namespace', () => {
expect( invokeTest( 'invalid' ) ).toThrowError( /given namespace/ );
} );
it(
'throws an error if there are routes for the given namespace, but no ' +
'route for the given resource',
() => {
expect( invokeTest( 'wc/blocks', 'invalid' ) ).toThrowError();
}
);
it(
'throws an error if there are routes for the given namespace and ' +
'resource name, but no routes for the given ids',
() => {
expect(
invokeTest( 'wc/blocks', 'products/attributes', [ 10 ] )
).toThrowError( /number of ids you included/ );
}
);
} );
describe( 'with no throwing of errors if resolution has not finished', () => {
beforeEach( () => mockHasFinishedResolution.mockReturnValue( false ) );
it.each`
description | args
${'is no route for the given namespace'} | ${[ 'invalid' ]}
${'are no routes for the given namespace, but no route for the given resource'} | ${[ 'wc/blocks', 'invalid' ]}
${'are routes for the given namespace and resource name, but no routes for the given ids'} | ${[ 'wc/blocks', 'products/attributes', [ 10 ] ]}
`( 'does not throw an error if there $description', ( { args } ) => {
expect( invokeTest( ...args ) ).not.toThrowError();
} );
} );
describe( 'returns expected value for given valid arguments', () => {
test( 'when there is a route with no placeholders', () => {
expect( invokeTest( 'wc/blocks', 'products/attributes' )() ).toBe(
'wc/blocks/products/attributes'
);
} );
test( 'when there is a route with placeholders', () => {
expect(
invokeTest( 'wc/blocks', 'products/attributes/terms', [
10,
20,
] )()
).toBe( 'wc/blocks/products/attributes/10/terms/20' );
} );
} );
} );
describe( 'getRoutes', () => {
const invokeTest = ( namespace ) => () => {
return getRoutes( testState, namespace );
};
it( 'throws an error if there is no route for the given namespace', () => {
mockHasFinishedResolution.mockReturnValue( true );
expect( invokeTest( 'invalid' ) ).toThrowError( /given namespace/ );
} );
it( 'returns expected routes for given namespace', () => {
expect( invokeTest( 'wc/blocks' )() ).toEqual( [
'wc/blocks/products/attributes',
'wc/blocks/products/attributes/{attribute_id}/terms/{id}',
] );
} );
} );

View File

@@ -0,0 +1,59 @@
/**
* Internal dependencies
*/
import {
extractResourceNameFromRoute,
getRouteIds,
simplifyRouteWithId,
} from '../utils';
describe( 'extractResourceNameFromRoute', () => {
it.each`
namespace | route | expected
${'wc/blocks'} | ${'wc/blocks/products'} | ${'products'}
${'wc/other'} | ${'wc/blocks/product'} | ${'wc/blocks/product'}
${'wc/blocks'} | ${'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)'} | ${'products/attributes'}
${'wc/blocks'} | ${'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms'} | ${'products/attributes/terms'}
${'wc/blocks'} | ${'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[d]+)'} | ${'products/attributes/terms'}
${'wc/blocks'} | ${'wc/blocks/cart/(?P<id>[\\s]+)'} | ${'cart'}
`(
'returns "$expected" when namespace is "$namespace" and route is "$route"',
( { namespace, route, expected } ) => {
expect( extractResourceNameFromRoute( namespace, route ) ).toBe(
expected
);
}
);
} );
describe( 'getRouteIds', () => {
it.each`
route | expected
${'wc/blocks/products'} | ${[]}
${'wc/blocks/products/(?P<id>[\\d]+)'} | ${[ 'id' ]}
${'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[\\d]+)'} | ${[ 'attribute_id', 'id' ]}
`(
'returns "$expected" when route is "$route"',
( { route, expected } ) => {
expect( getRouteIds( route ) ).toEqual( expected );
}
);
} );
describe( 'simplifyRouteWithId', () => {
it.each`
route | matchIds | expected
${'wc/blocks/products'} | ${[]} | ${'wc/blocks/products'}
${'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)'} | ${[ 'attribute_id' ]} | ${'wc/blocks/products/attributes/{attribute_id}'}
${'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms'} | ${[ 'attribute_id' ]} | ${'wc/blocks/products/attributes/{attribute_id}/terms'}
${'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[\\d]+)'} | ${[ 'attribute_id', 'id' ]} | ${'wc/blocks/products/attributes/{attribute_id}/terms/{id}'}
${'wc/blocks/products/attributes/(?P<attribute_id>[\\d]+)/terms/(?P<id>[\\d]+)'} | ${[ 'id', 'attribute_id' ]} | ${'wc/blocks/products/attributes/{attribute_id}/terms/{id}'}
${'wc/blocks/cart/(?P<id>[\\s]+)'} | ${[ 'id' ]} | ${'wc/blocks/cart/{id}'}
${'wc/blocks/cart/(?P<id>[\\s]+)'} | ${[ 'attribute_id' ]} | ${'wc/blocks/cart/(?P<id>[\\s]+)'}
`(
'returns "$expected" when route is "$route" and matchIds is "$matchIds"',
( { route, matchIds, expected } ) => {
expect( simplifyRouteWithId( route, matchIds ) ).toBe( expected );
}
);
} );

View File

@@ -0,0 +1,65 @@
/**
* This returns a resource name string as an index for a given route.
*
* For example:
* /wc/blocks/products/attributes/(?P<id>[\d]+)/terms
* returns
* /products/attributes/terms
*
* @param {string} namespace
* @param {string} route
*
* @return {string} The resource name extracted from the route.
*/
export const extractResourceNameFromRoute = ( namespace, route ) => {
route = route.replace( `${ namespace }/`, '' );
return route.replace( /\/\(\?P\<[a-z_]*\>\[\\*[a-z]\]\+\)/g, '' );
};
/**
* Returns an array of the identifier for the named capture groups in a given
* route.
*
* For example, if the route was this:
* /wc/blocks/products/attributes/(?P<attribute_id>[\d]+)/terms/(?P<id>[\d]+)
*
* ...then the following would get returned
* [ 'attribute_id', 'id' ]
*
* @param {string} route - The route to extract identifier names from.
*
* @return {Array} An array of named route identifier names.
*/
export const getRouteIds = ( route ) => {
const matches = route.match( /\<[a-z_]*\>/g );
if ( ! Array.isArray( matches ) || matches.length === 0 ) {
return [];
}
return matches.map( ( match ) => match.replace( /<|>/g, '' ) );
};
/**
* This replaces regex placeholders in routes with the relevant named string
* found in the matchIds.
*
* Something like:
* /wc/blocks/products/attributes/(?P<attribute_id>[\d]+)/terms/(?P<id>[\d]+)
*
* ..ends up as:
* /wc/blocks/products/attributes/{attribute_id}/terms/{id}
*
* @param {string} route The route to manipulate
* @param {Array} matchIds An array of named ids ( [ attribute_id, id ] )
*
* @return {string} The route with new id placeholders
*/
export const simplifyRouteWithId = ( route, matchIds ) => {
if ( ! Array.isArray( matchIds ) || matchIds.length === 0 ) {
return route;
}
matchIds.forEach( ( matchId ) => {
const expression = `\\(\\?P<${ matchId }>.*?\\)`;
route = route.replace( new RegExp( expression ), `{${ matchId }}` );
} );
return route;
};

View File

@@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { has } from 'lodash';
/**
* Utility for returning whether the given path exists in the state.
*
* @param {Object} state The state being checked
* @param {Array} path The path to check
*
* @return {bool} True means this exists in the state.
*/
export default function hasInState( state, path ) {
return has( state, path );
}

View File

@@ -0,0 +1,2 @@
export { default as hasInState } from './has-in-state';
export { default as updateState } from './update-state';

View File

@@ -0,0 +1,17 @@
/**
* External dependencies
*/
import { setWith, clone } from 'lodash';
/**
* Utility for updating state and only cloning objects in the path that changed.
*
* @param {Object} state The state being updated
* @param {Array} path The path being updated
* @param {*} value The value to update for the path
*
* @return {Object} The new state
*/
export default function updateState( state, path, value ) {
return setWith( clone( state ), path, value, clone );
}