initial commit

This commit is contained in:
2021-12-10 12:03:04 +00:00
commit c46c7ddbf0
3643 changed files with 582794 additions and 0 deletions

View File

@ -0,0 +1,7 @@
export const ACTION_TYPES = {
RECEIVE_COLLECTION: 'RECEIVE_COLLECTION',
RESET_COLLECTION: 'RESET_COLLECTION',
ERROR: 'ERROR',
RECEIVE_LAST_MODIFIED: 'RECEIVE_LAST_MODIFIED',
INVALIDATE_RESOLUTION_FOR_STORE: 'INVALIDATE_RESOLUTION_FOR_STORE',
};

View File

@ -0,0 +1,85 @@
/**
* Internal dependencies
*/
import { ACTION_TYPES as types } from './action-types';
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 {boolean} [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 receiveCollectionError(
namespace,
resourceName,
queryString,
ids,
error
) {
return {
type: 'ERROR',
namespace,
resourceName,
queryString,
ids,
response: {
items: [],
headers: Headers,
error,
},
};
}
export function receiveLastModified( timestamp ) {
return {
type: types.RECEIVE_LAST_MODIFIED,
timestamp,
};
}

View File

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

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 '../shared-controls';
registerStore( STORE_KEY, {
reducer,
actions,
controls: { ...dataControls, ...controls },
selectors,
resolvers,
} );
export const COLLECTIONS_STORE_KEY = STORE_KEY;

View File

@ -0,0 +1,72 @@
/**
* 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 ) => {
// Update last modified and previous last modified values.
if ( action.type === types.RECEIVE_LAST_MODIFIED ) {
if ( action.timestamp === state.lastModified ) {
return state;
}
return {
...state,
lastModified: action.timestamp,
};
}
// When invalidating data, remove stored values from state.
if ( action.type === types.INVALIDATE_RESOLUTION_FOR_STORE ) {
return {};
}
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;
case types.ERROR:
state = updateState(
state,
[ namespace, resourceName, ids, queryString ],
response
);
break;
}
return state;
};
export default receiveCollection;

View File

@ -0,0 +1,109 @@
/**
* External dependencies
*/
import { select, dispatch } from '@wordpress/data-controls';
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { receiveCollection, receiveCollectionError } from './actions';
import { STORE_KEY as SCHEMA_STORE_KEY } from '../schema/constants';
import { STORE_KEY, DEFAULT_EMPTY_ARRAY } from './constants';
import { apiFetchWithHeaders } from '../shared-controls';
/**
* Check if the store needs invalidating due to a change in last modified headers.
*
* @param {number} timestamp Last update timestamp.
*/
function* invalidateModifiedCollection( timestamp ) {
const lastModified = yield select( STORE_KEY, 'getCollectionLastModified' );
if ( ! lastModified ) {
yield dispatch( STORE_KEY, 'receiveLastModified', timestamp );
} else if ( timestamp > lastModified ) {
yield dispatch( STORE_KEY, 'invalidateResolutionForStore' );
yield dispatch( STORE_KEY, 'receiveLastModified', timestamp );
}
}
/**
* 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;
}
try {
const {
response = DEFAULT_EMPTY_ARRAY,
headers,
} = yield apiFetchWithHeaders( { path: route + queryString } );
if ( headers && headers.get && headers.has( 'last-modified' ) ) {
// Do any invalidation before the collection is received to prevent
// this query running again.
yield invalidateModifiedCollection(
parseInt( headers.get( 'last-modified' ), 10 )
);
}
yield receiveCollection( namespace, resourceName, queryString, ids, {
items: response,
headers,
} );
} catch ( error ) {
yield receiveCollectionError(
namespace,
resourceName,
queryString,
ids,
error
);
}
}
/**
* 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,144 @@
/**
* 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 } );
};
export const getCollectionError = (
state,
namespace,
resourceName,
query = null,
ids = DEFAULT_EMPTY_ARRAY
) => {
return getFromState( {
state,
namespace,
resourceName,
query,
ids,
type: 'error',
fallback: null,
} );
};
/**
* 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;
};
/**
* Gets the last modified header for the collection.
*
* @param {string} state The current collection state.
* @return {number} Timestamp.
*/
export const getCollectionLastModified = ( state ) => {
return state.lastModified || 0;
};

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,161 @@
/**
* 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 '../../shared-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( {
path: '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: [], headers: undefined }
)
);
}
);
test(
'when apiFetch returns a valid response, yields expected ' +
'action',
() => {
rewind();
fulfillment.next();
fulfillment.next( 'https://example.org' );
const { value } = fulfillment.next( {
response: [ '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, 20 ] } | ${ { someQuery: 10 } } | ${ [ 42 ] }
`(
'for "$resourceName", "$ids", and "$query"',
( { resourceName, ids, query, expected } ) => {
expect(
getCollection(
state,
'wc/blocks',
resourceName,
query,
ids
)
).toEqual( expected );
}
);
} );
} );
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 );
} );
} );