Add data store attribute terms (#33721)

* Add product attribute terms store

* Add ability to add params to URL

* Allow IdQuery for all crud functions

* Add tests around item parent keys

* Add tests around new utils

* Throw error when not all params are replaced in REST URL

* Add require attributes for terms

* Allow urlParameters to be specified

* Use namespace to detect url parameters

* Fix up selector return after applying namespace

* Clean queries to prevent sending URL param data to endpoint

* Add tests around new utils

* Remove unused method import

* Remove urlParameters argument no longer being used

* Add changelog entry
This commit is contained in:
Joshua T Flowers 2022-07-21 12:28:30 -04:00 committed by GitHub
parent ecd17484bb
commit 4c1a82dc26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 577 additions and 84 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add product attribute terms data store

View File

@ -1,15 +1,15 @@
/** /**
* External dependencies * External dependencies
*/ */
import { addQueryArgs } from '@wordpress/url';
import { apiFetch } from '@wordpress/data-controls'; import { apiFetch } from '@wordpress/data-controls';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { cleanQuery, getUrlParameters, getRestPath, parseId } from './utils';
import CRUD_ACTIONS from './crud-actions'; import CRUD_ACTIONS from './crud-actions';
import TYPES from './action-types'; import TYPES from './action-types';
import { IdType, Item, ItemQuery } from './types'; import { IdType, IdQuery, Item, ItemQuery } from './types';
type ResolverOptions = { type ResolverOptions = {
resourceName: string; resourceName: string;
@ -25,50 +25,53 @@ export function createItemError( query: Partial< ItemQuery >, error: unknown ) {
}; };
} }
export function createItemSuccess( id: IdType, item: Item ) { export function createItemSuccess( key: IdType, item: Item ) {
return { return {
type: TYPES.CREATE_ITEM_SUCCESS as const, type: TYPES.CREATE_ITEM_SUCCESS as const,
id, key,
item, item,
}; };
} }
export function deleteItemError( id: IdType, error: unknown ) { export function deleteItemError( key: IdType, error: unknown ) {
return { return {
type: TYPES.DELETE_ITEM_ERROR as const, type: TYPES.DELETE_ITEM_ERROR as const,
id, key,
error, error,
errorType: CRUD_ACTIONS.DELETE_ITEM, errorType: CRUD_ACTIONS.DELETE_ITEM,
}; };
} }
export function deleteItemSuccess( id: IdType, force: boolean, item: Item ) { export function deleteItemSuccess( key: IdQuery, force: boolean, item: Item ) {
return { return {
type: TYPES.DELETE_ITEM_SUCCESS as const, type: TYPES.DELETE_ITEM_SUCCESS as const,
id, key,
force, force,
item, item,
}; };
} }
export function getItemError( id: unknown, error: unknown ) { export function getItemError( key: IdType, error: unknown ) {
return { return {
type: TYPES.GET_ITEM_ERROR as const, type: TYPES.GET_ITEM_ERROR as const,
id, key,
error, error,
errorType: CRUD_ACTIONS.GET_ITEM, errorType: CRUD_ACTIONS.GET_ITEM,
}; };
} }
export function getItemSuccess( id: IdType, item: Item ) { export function getItemSuccess( key: IdType, item: Item ) {
return { return {
type: TYPES.GET_ITEM_SUCCESS as const, type: TYPES.GET_ITEM_SUCCESS as const,
id, key,
item, item,
}; };
} }
export function getItemsError( query: unknown, error: unknown ) { export function getItemsError(
query: Partial< ItemQuery > | undefined,
error: unknown
) {
return { return {
type: TYPES.GET_ITEMS_ERROR as const, type: TYPES.GET_ITEMS_ERROR as const,
query, query,
@ -77,27 +80,32 @@ export function getItemsError( query: unknown, error: unknown ) {
}; };
} }
export function getItemsSuccess( query: unknown, items: Item[] ) { export function getItemsSuccess(
query: Partial< ItemQuery > | undefined,
items: Item[],
urlParameters: IdType[]
) {
return { return {
type: TYPES.GET_ITEMS_SUCCESS as const, type: TYPES.GET_ITEMS_SUCCESS as const,
items, items,
query, query,
urlParameters,
}; };
} }
export function updateItemError( id: unknown, error: unknown ) { export function updateItemError( key: IdType, error: unknown ) {
return { return {
type: TYPES.UPDATE_ITEM_ERROR as const, type: TYPES.UPDATE_ITEM_ERROR as const,
id, key,
error, error,
errorType: CRUD_ACTIONS.UPDATE_ITEM, errorType: CRUD_ACTIONS.UPDATE_ITEM,
}; };
} }
export function updateItemSuccess( id: IdType, item: Item ) { export function updateItemSuccess( key: IdType, item: Item ) {
return { return {
type: TYPES.UPDATE_ITEM_SUCCESS as const, type: TYPES.UPDATE_ITEM_SUCCESS as const,
id, key,
item, item,
}; };
} }
@ -107,13 +115,20 @@ export const createDispatchActions = ( {
resourceName, resourceName,
}: ResolverOptions ) => { }: ResolverOptions ) => {
const createItem = function* ( query: Partial< ItemQuery > ) { const createItem = function* ( query: Partial< ItemQuery > ) {
const urlParameters = getUrlParameters( namespace, query );
try { try {
const item: Item = yield apiFetch( { const item: Item = yield apiFetch( {
path: addQueryArgs( namespace, query ), path: getRestPath(
namespace,
cleanQuery( query, namespace ),
urlParameters
),
method: 'POST', method: 'POST',
} ); } );
const { key } = parseId( item.id, urlParameters );
yield createItemSuccess( item.id, item ); yield createItemSuccess( key, item );
return item; return item;
} catch ( error ) { } catch ( error ) {
yield createItemError( query, error ); yield createItemError( query, error );
@ -121,32 +136,49 @@ export const createDispatchActions = ( {
} }
}; };
const deleteItem = function* ( id: IdType, force = true ) { const deleteItem = function* ( idQuery: IdQuery, force = true ) {
const urlParameters = getUrlParameters( namespace, idQuery );
const { id, key } = parseId( idQuery, urlParameters );
try { try {
const item: Item = yield apiFetch( { const item: Item = yield apiFetch( {
path: addQueryArgs( `${ namespace }/${ id }`, { force } ), path: getRestPath(
`${ namespace }/${ id }`,
{ force },
urlParameters
),
method: 'DELETE', method: 'DELETE',
} ); } );
yield deleteItemSuccess( id, force, item ); yield deleteItemSuccess( key, force, item );
return item; return item;
} catch ( error ) { } catch ( error ) {
yield deleteItemError( id, error ); yield deleteItemError( key, error );
throw error; throw error;
} }
}; };
const updateItem = function* ( id: IdType, query: Partial< ItemQuery > ) { const updateItem = function* (
idQuery: IdQuery,
query: Partial< ItemQuery >
) {
const urlParameters = getUrlParameters( namespace, idQuery );
const { id, key } = parseId( idQuery, urlParameters );
try { try {
const item: Item = yield apiFetch( { const item: Item = yield apiFetch( {
path: addQueryArgs( `${ namespace }/${ id }`, query ), path: getRestPath(
`${ namespace }/${ id }`,
cleanQuery( query, namespace ),
urlParameters
),
method: 'PUT', method: 'PUT',
} ); } );
yield updateItemSuccess( item.id, item ); yield updateItemSuccess( key, item );
return item; return item;
} catch ( error ) { } catch ( error ) {
yield updateItemError( query, error ); yield updateItemError( key, error );
throw error; throw error;
} }
}; };

View File

@ -36,7 +36,11 @@ export const createCrudDataStore = ( {
pluralResourceName, pluralResourceName,
namespace, namespace,
} ); } );
const selectors = createSelectors( { resourceName, pluralResourceName } ); const selectors = createSelectors( {
resourceName,
pluralResourceName,
namespace,
} );
registerStore( storeName, { registerStore( storeName, {
reducer: reducer as Reducer< ResourceState >, reducer: reducer as Reducer< ResourceState >,

View File

@ -8,6 +8,7 @@ import { Reducer } from 'redux';
*/ */
import { Actions } from './actions'; import { Actions } from './actions';
import CRUD_ACTIONS from './crud-actions'; import CRUD_ACTIONS from './crud-actions';
import { getKey } from './utils';
import { getResourceName } from '../utils'; import { getResourceName } from '../utils';
import { IdType, Item, ItemQuery } from './types'; import { IdType, Item, ItemQuery } from './types';
import { TYPES } from './action-types'; import { TYPES } from './action-types';
@ -56,25 +57,25 @@ export const createReducer = () => {
...state, ...state,
data: { data: {
...itemData, ...itemData,
[ payload.id ]: { [ payload.key ]: {
...( itemData[ payload.id ] || {} ), ...( itemData[ payload.key ] || {} ),
...payload.item, ...payload.item,
}, },
}, },
}; };
case TYPES.DELETE_ITEM_SUCCESS: case TYPES.DELETE_ITEM_SUCCESS:
const itemIds = Object.keys( state.data ); const itemKeys = Object.keys( state.data );
const nextData = itemIds.reduce< Data >( const nextData = itemKeys.reduce< Data >(
( items: Data, id: string ) => { ( items: Data, key: string ) => {
if ( id !== payload.id.toString() ) { if ( key !== payload.key.toString() ) {
items[ id ] = state.data[ id ]; items[ key ] = state.data[ key ];
return items; return items;
} }
if ( payload.force ) { if ( payload.force ) {
return items; return items;
} }
items[ id ] = payload.item; items[ key ] = payload.item;
return items; return items;
}, },
{} as Data {} as Data
@ -93,7 +94,7 @@ export const createReducer = () => {
errors: { errors: {
...state.errors, ...state.errors,
[ getResourceName( payload.errorType, { [ getResourceName( payload.errorType, {
id: payload.id, key: payload.key,
} ) ]: payload.error, } ) ]: payload.error,
}, },
}; };
@ -104,9 +105,10 @@ export const createReducer = () => {
const nextResources = payload.items.reduce< const nextResources = payload.items.reduce<
Record< string, Item > Record< string, Item >
>( ( result, item ) => { >( ( result, item ) => {
ids.push( item.id ); const key = getKey( item.id, payload.urlParameters );
result[ item.id ] = { ids.push( key );
...( state.data[ item.id ] || {} ), result[ key ] = {
...( state.data[ key ] || {} ),
...item, ...item,
}; };
return result; return result;

View File

@ -6,14 +6,15 @@ import { apiFetch } from '@wordpress/data-controls';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { cleanQuery, getUrlParameters, getRestPath, parseId } from './utils';
import { import {
getItemError, getItemError,
getItemSuccess, getItemSuccess,
getItemsError, getItemsError,
getItemsSuccess, getItemsSuccess,
} from './actions'; } from './actions';
import { IdQuery, Item, ItemQuery } from './types';
import { request } from '../utils'; import { request } from '../utils';
import { Item, ItemQuery } from './types';
type ResolverOptions = { type ResolverOptions = {
resourceName: string; resourceName: string;
@ -26,25 +27,32 @@ export const createResolvers = ( {
pluralResourceName, pluralResourceName,
namespace, namespace,
}: ResolverOptions ) => { }: ResolverOptions ) => {
const getItem = function* ( id: number ) { const getItem = function* ( idQuery: IdQuery ) {
const urlParameters = getUrlParameters( namespace, idQuery );
const { id, key } = parseId( idQuery, urlParameters );
try { try {
const item: Item = yield apiFetch( { const item: Item = yield apiFetch( {
path: `${ namespace }/${ id }`, path: getRestPath(
`${ namespace }/${ id }`,
{},
urlParameters
),
method: 'GET', method: 'GET',
} ); } );
yield getItemSuccess( item.id, item ); yield getItemSuccess( key, item );
return item; return item;
} catch ( error ) { } catch ( error ) {
yield getItemError( id, error ); yield getItemError( key, error );
throw error; throw error;
} }
}; };
const getItems = function* ( query?: Partial< ItemQuery > ) { const getItems = function* ( query?: Partial< ItemQuery > ) {
// Require ID when requesting specific fields to later update the resource data. const urlParameters = getUrlParameters( namespace, query || {} );
const resourceQuery = query ? { ...query } : {}; const resourceQuery = cleanQuery( query || {}, namespace );
// Require ID when requesting specific fields to later update the resource data.
if ( if (
resourceQuery && resourceQuery &&
resourceQuery._fields && resourceQuery._fields &&
@ -54,12 +62,13 @@ export const createResolvers = ( {
} }
try { try {
const path = getRestPath( namespace, {}, urlParameters );
const { items }: { items: Item[] } = yield request< const { items }: { items: Item[] } = yield request<
ItemQuery, ItemQuery,
Item Item
>( namespace, resourceQuery ); >( path, resourceQuery );
yield getItemsSuccess( query, items ); yield getItemsSuccess( query, items, urlParameters );
return items; return items;
} catch ( error ) { } catch ( error ) {
yield getItemsError( query, error ); yield getItemsError( query, error );

View File

@ -6,14 +6,16 @@ import createSelector from 'rememo';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { applyNamespace, getUrlParameters, parseId } from './utils';
import { getResourceName } from '../utils'; import { getResourceName } from '../utils';
import { IdType, Item, ItemQuery } from './types'; import { IdQuery, IdType, Item, ItemQuery } from './types';
import { ResourceState } from './reducer'; import { ResourceState } from './reducer';
import CRUD_ACTIONS from './crud-actions'; import CRUD_ACTIONS from './crud-actions';
type SelectorOptions = { type SelectorOptions = {
resourceName: string; resourceName: string;
pluralResourceName: string; pluralResourceName: string;
namespace: string;
}; };
export const getItemCreateError = ( export const getItemCreateError = (
@ -24,17 +26,35 @@ export const getItemCreateError = (
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
export const getItemDeleteError = ( state: ResourceState, id: IdType ) => { export const getItemDeleteError = (
const itemQuery = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { id } ); state: ResourceState,
idQuery: IdQuery,
namespace: string
) => {
const urlParameters = getUrlParameters( namespace, idQuery );
const { key } = parseId( idQuery, urlParameters );
const itemQuery = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { key } );
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
export const getItem = ( state: ResourceState, id: IdType ) => { export const getItem = (
return state.data[ id ]; state: ResourceState,
idQuery: IdQuery,
namespace: string
) => {
const urlParameters = getUrlParameters( namespace, idQuery );
const { key } = parseId( idQuery, urlParameters );
return state.data[ key ];
}; };
export const getItemError = ( state: ResourceState, id: IdType ) => { export const getItemError = (
const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEM, { id } ); state: ResourceState,
idQuery: IdQuery,
namespace: string
) => {
const urlParameters = getUrlParameters( namespace, idQuery );
const { key } = parseId( idQuery, urlParameters );
const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } );
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
@ -67,11 +87,13 @@ export const getItems = createSelector(
} ); } );
} }
return ids const data = ids
.map( ( id: IdType ) => { .map( ( id: IdType ) => {
return state.data[ id ]; return state.data[ id ];
} ) } )
.filter( ( item ) => item !== undefined ); .filter( ( item ) => item !== undefined );
return data;
}, },
( state, query ) => { ( state, query ) => {
const itemQuery = getResourceName( const itemQuery = getResourceName(
@ -81,6 +103,7 @@ export const getItems = createSelector(
const ids = state.items[ itemQuery ] const ids = state.items[ itemQuery ]
? state.items[ itemQuery ].data ? state.items[ itemQuery ].data
: undefined; : undefined;
return [ return [
state.items[ itemQuery ], state.items[ itemQuery ],
...( ids || [] ).map( ( id: string ) => { ...( ids || [] ).map( ( id: string ) => {
@ -95,22 +118,47 @@ export const getItemsError = ( state: ResourceState, query?: ItemQuery ) => {
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
export const getItemUpdateError = ( state: ResourceState, id: IdType ) => { export const getItemUpdateError = (
const itemQuery = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, { id } ); state: ResourceState,
idQuery: IdQuery,
urlParameters: IdType[]
) => {
const params = parseId( idQuery, urlParameters );
const { key } = params;
const itemQuery = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, {
key,
params,
} );
return state.errors[ itemQuery ]; return state.errors[ itemQuery ];
}; };
export const createSelectors = ( { export const createSelectors = ( {
resourceName, resourceName,
pluralResourceName, pluralResourceName,
namespace,
}: SelectorOptions ) => { }: SelectorOptions ) => {
return { return {
[ `get${ resourceName }` ]: getItem, [ `get${ resourceName }` ]: applyNamespace( getItem, namespace ),
[ `get${ resourceName }Error` ]: getItemError, [ `get${ resourceName }Error` ]: applyNamespace(
[ `get${ pluralResourceName }` ]: getItems, getItemError,
[ `get${ pluralResourceName }Error` ]: getItemsError, namespace
[ `get${ resourceName }CreateError` ]: getItemCreateError, ),
[ `get${ resourceName }DeleteError` ]: getItemDeleteError, [ `get${ pluralResourceName }` ]: applyNamespace( getItems, namespace ),
[ `get${ resourceName }UpdateError` ]: getItemUpdateError, [ `get${ pluralResourceName }Error` ]: applyNamespace(
getItemsError,
namespace
),
[ `get${ resourceName }CreateError` ]: applyNamespace(
getItemCreateError,
namespace
),
[ `get${ resourceName }DeleteError` ]: applyNamespace(
getItemDeleteError,
namespace
),
[ `get${ resourceName }UpdateError` ]: applyNamespace(
getItemUpdateError,
namespace
),
}; };
}; };

View File

@ -44,7 +44,7 @@ describe( 'crud reducer', () => {
const state = reducer( initialState, { const state = reducer( initialState, {
type: TYPES.GET_ITEM_SUCCESS, type: TYPES.GET_ITEM_SUCCESS,
id: update.id, key: update.id,
item: update, item: update,
} ); } );
@ -67,6 +67,7 @@ describe( 'crud reducer', () => {
type: TYPES.GET_ITEMS_SUCCESS, type: TYPES.GET_ITEMS_SUCCESS,
items, items,
query, query,
urlParameters: [],
} ); } );
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query );
@ -79,6 +80,26 @@ describe( 'crud reducer', () => {
expect( state.data[ 2 ] ).toEqual( items[ 1 ] ); expect( state.data[ 2 ] ).toEqual( items[ 1 ] );
} ); } );
it( 'should handle GET_ITEMS_SUCCESS with urlParameters', () => {
const items: Item[] = [
{ id: 1, name: 'Yum!' },
{ id: 2, name: 'Dynamite!' },
];
const query: Partial< ItemQuery > = { status: 'draft' };
const state = reducer( defaultState, {
type: TYPES.GET_ITEMS_SUCCESS,
items,
query,
urlParameters: [ 5 ],
} );
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query );
expect( state.items[ resourceName ].data ).toHaveLength( 2 );
expect( state.data[ '5/1' ] ).toEqual( items[ 0 ] );
expect( state.data[ '5/2' ] ).toEqual( items[ 1 ] );
} );
it( 'GET_ITEMS_SUCCESS should not remove previously added fields, only update new ones', () => { it( 'GET_ITEMS_SUCCESS should not remove previously added fields, only update new ones', () => {
const initialState: ResourceState = { const initialState: ResourceState = {
...defaultState, ...defaultState,
@ -102,6 +123,7 @@ describe( 'crud reducer', () => {
type: TYPES.GET_ITEMS_SUCCESS, type: TYPES.GET_ITEMS_SUCCESS,
items, items,
query, query,
urlParameters: [],
} ); } );
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query );
@ -133,12 +155,12 @@ describe( 'crud reducer', () => {
} ); } );
it( 'should handle GET_ITEM_ERROR', () => { it( 'should handle GET_ITEM_ERROR', () => {
const id = 3; const key = 3;
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { id } ); const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } );
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.GET_ITEM_ERROR, type: TYPES.GET_ITEM_ERROR,
id, key,
error, error,
errorType: CRUD_ACTIONS.GET_ITEM, errorType: CRUD_ACTIONS.GET_ITEM,
} ); } );
@ -147,12 +169,12 @@ describe( 'crud reducer', () => {
} ); } );
it( 'should handle GET_ITEM_ERROR', () => { it( 'should handle GET_ITEM_ERROR', () => {
const id = 3; const key = 3;
const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { id } ); const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } );
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.GET_ITEM_ERROR, type: TYPES.GET_ITEM_ERROR,
id, key,
error, error,
errorType: CRUD_ACTIONS.GET_ITEM, errorType: CRUD_ACTIONS.GET_ITEM,
} ); } );
@ -175,14 +197,14 @@ describe( 'crud reducer', () => {
} ); } );
it( 'should handle UPDATE_ITEM_ERROR', () => { it( 'should handle UPDATE_ITEM_ERROR', () => {
const id = 2; const key = 2;
const resourceName = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, { const resourceName = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, {
id, key,
} ); } );
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.UPDATE_ITEM_ERROR, type: TYPES.UPDATE_ITEM_ERROR,
id, key,
error, error,
errorType: CRUD_ACTIONS.UPDATE_ITEM, errorType: CRUD_ACTIONS.UPDATE_ITEM,
} ); } );
@ -212,7 +234,7 @@ describe( 'crud reducer', () => {
const state = reducer( initialState, { const state = reducer( initialState, {
type: TYPES.UPDATE_ITEM_SUCCESS, type: TYPES.UPDATE_ITEM_SUCCESS,
id: item.id, key: item.id,
item, item,
} ); } );
@ -234,7 +256,7 @@ describe( 'crud reducer', () => {
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.CREATE_ITEM_SUCCESS, type: TYPES.CREATE_ITEM_SUCCESS,
id: item.id, key: item.id,
item, item,
} ); } );
@ -267,13 +289,13 @@ describe( 'crud reducer', () => {
let state = reducer( initialState, { let state = reducer( initialState, {
type: TYPES.DELETE_ITEM_SUCCESS, type: TYPES.DELETE_ITEM_SUCCESS,
id: item1Updated.id, key: item1Updated.id,
item: item1Updated, item: item1Updated,
force: true, force: true,
} ); } );
state = reducer( state, { state = reducer( state, {
type: TYPES.DELETE_ITEM_SUCCESS, type: TYPES.DELETE_ITEM_SUCCESS,
id: item2Updated.id, key: item2Updated.id,
item: item2Updated, item: item2Updated,
force: false, force: false,
} ); } );
@ -284,14 +306,14 @@ describe( 'crud reducer', () => {
} ); } );
it( 'should handle DELETE_ITEM_ERROR', () => { it( 'should handle DELETE_ITEM_ERROR', () => {
const id = 2; const key = 2;
const resourceName = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { const resourceName = getResourceName( CRUD_ACTIONS.DELETE_ITEM, {
id, key,
} ); } );
const error = 'Baaam!'; const error = 'Baaam!';
const state = reducer( defaultState, { const state = reducer( defaultState, {
type: TYPES.DELETE_ITEM_ERROR, type: TYPES.DELETE_ITEM_ERROR,
id, key,
error, error,
errorType: CRUD_ACTIONS.DELETE_ITEM, errorType: CRUD_ACTIONS.DELETE_ITEM,
} ); } );

View File

@ -6,6 +6,7 @@ import { createSelectors } from '../selectors';
const selectors = createSelectors( { const selectors = createSelectors( {
resourceName: 'Product', resourceName: 'Product',
pluralResourceName: 'Products', pluralResourceName: 'Products',
namespace: '',
} ); } );
describe( 'crud selectors', () => { describe( 'crud selectors', () => {

View File

@ -0,0 +1,116 @@
/**
* Internal dependencies
*/
import {
applyNamespace,
cleanQuery,
getKey,
getNamespaceKeys,
getRestPath,
getUrlParameters,
parseId,
} from '../utils';
describe( 'utils', () => {
it( 'should get the rest path when no parameters are given', () => {
const path = getRestPath( 'test/path', {}, [] );
expect( path ).toEqual( 'test/path' );
} );
it( 'should replace the rest path parameters when provided', () => {
const path = getRestPath( 'test/{parent}/path', {}, [ 'insert' ] );
expect( path ).toEqual( 'test/insert/path' );
} );
it( 'should replace the rest path parameters when multiple are provided', () => {
const path = getRestPath( 'test/{parent}/{other}/path', {}, [
'insert',
'next',
] );
expect( path ).toEqual( 'test/insert/next/path' );
} );
it( 'should throw an error when not all parameters are replaced', () => {
expect( () =>
getRestPath( 'test/{parent}/{other}/path', {}, [ 'insert' ] )
).toThrow( Error );
} );
it( 'should get the key when no parent is provided', () => {
const key = getKey( 3, [] );
expect( key ).toEqual( 3 );
} );
it( 'should get the key when a parent is provided', () => {
const key = getKey( 3, [ 5 ] );
expect( key ).toEqual( '5/3' );
} );
it( 'should get the correct ID information when only an ID is given', () => {
const parsed = parseId( 3 );
expect( parsed.key ).toEqual( 3 );
expect( parsed.id ).toEqual( 3 );
} );
it( 'should get the correct ID information when an object is given', () => {
const parsed = parseId( { id: 3, parent_id: 5 }, [ 5 ] );
expect( parsed.key ).toEqual( '5/3' );
expect( parsed.id ).toEqual( 3 );
} );
it( 'should apply the namespace as an argument to a given function', () => {
const namespace = 'test/namespace';
const mockedCallback = jest.fn();
const wrappedFunction = applyNamespace( mockedCallback, namespace );
wrappedFunction( 'a' );
expect( mockedCallback ).toBeCalledTimes( 1 );
expect( mockedCallback ).toBeCalledWith( 'a', 'test/namespace' );
} );
it( 'should get the keys from a namespace', () => {
const namespace = 'test/{first}/namespace/{second}';
const keys = getNamespaceKeys( namespace );
expect( keys.length ).toBe( 2 );
expect( keys[ 0 ] ).toBe( 'first' );
expect( keys[ 1 ] ).toBe( 'second' );
} );
it( 'should return an empty array from a namespace without params', () => {
const namespace = 'test/namespace';
const keys = getNamespaceKeys( namespace );
expect( keys.length ).toBe( 0 );
} );
it( 'should get the URL parameters given a namespace and query', () => {
const namespace = 'test/{my_attribute}/namespace/{other_attribute}';
const params = getUrlParameters( namespace, {
id: 5,
my_attribute: 'flavortown',
other_attribute: 'donkeysauce',
} );
expect( params.length ).toBe( 2 );
expect( params[ 0 ] ).toBe( 'flavortown' );
expect( params[ 1 ] ).toBe( 'donkeysauce' );
} );
it( 'should return an empty array when no namespace variables are matched', () => {
const namespace = 'test/{my_attribute}/namespace';
const params = getUrlParameters( namespace, {
id: 5,
different_attribute: 'flavortown',
} );
expect( params.length ).toBe( 0 );
} );
it( 'should remove namespace params from a given query', () => {
const namespace = 'test/{my_attribute}/namespace';
const query = {
other_attribute: 'a',
my_attribute: 'b',
};
const params = cleanQuery( query, namespace );
expect( params.other_attribute ).toBe( 'a' );
expect( params.my_attribute ).toBeUndefined();
} );
} );

View File

@ -14,6 +14,13 @@ import {
export type IdType = number | string; export type IdType = number | string;
export type IdQuery =
| IdType
| {
id: IdType;
[ key: string ]: IdType;
};
export type Item = { export type Item = {
id: IdType; id: IdType;
[ key: string ]: unknown; [ key: string ]: unknown;
@ -21,6 +28,11 @@ export type Item = {
export type ItemQuery = BaseQueryParams & { export type ItemQuery = BaseQueryParams & {
[ key: string ]: unknown; [ key: string ]: unknown;
parent_id?: IdType;
};
export type Params = {
[ key: string ]: IdType;
}; };
type WithRequiredProperty< Type, Key extends keyof Type > = Type & { type WithRequiredProperty< Type, Key extends keyof Type > = Type & {

View File

@ -0,0 +1,162 @@
/**
* External dependencies
*/
import { addQueryArgs } from '@wordpress/url';
/**
* Internal dependencies
*/
import { IdQuery, IdType, ItemQuery } from './types';
/**
* Get a REST path given a template path and URL params.
*
* @param templatePath Path with variable names.
* @param query Item query.
* @param parameters Array of items to replace in the templatePath.
* @return string REST path.
*/
export const getRestPath = (
templatePath: string,
query: Partial< ItemQuery >,
parameters: IdType[]
) => {
let path = templatePath;
path.match( /{(.*?)}/g )?.forEach( ( str, i ) => {
path = path.replace( str, parameters[ i ].toString() );
} );
const regex = new RegExp( /{|}/ );
if ( regex.test( path.toString() ) ) {
throw new Error( 'Not all URL parameters were replaced' );
}
return addQueryArgs( path, query );
};
/**
* Get a key from an item ID and optional parent.
*
* @param query Item Query.
* @param urlParameters Parameters used for URL.
* @return string
*/
export const getKey = ( query: IdQuery, urlParameters: IdType[] = [] ) => {
const id =
typeof query === 'string' || typeof query === 'number'
? query
: query.id;
if ( ! urlParameters.length ) {
return id;
}
let prefix = '';
urlParameters.forEach( ( param ) => {
prefix = param + '/';
} );
return `${ prefix }${ id }`;
};
/**
* Parse an ID query into a ID string.
*
* @param query Id Query
* @return string ID.
*/
export const parseId = ( query: IdQuery, urlParameters: IdType[] = [] ) => {
if ( typeof query === 'string' || typeof query === 'number' ) {
return {
id: query,
key: query,
};
}
return {
id: query.id,
key: getKey( query, urlParameters ),
};
};
/**
* Create a new function that adds in the namespace.
*
* @param fn Function to wrap.
* @param namespace Namespace to pass to last argument of function.
* @return Wrapped function
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const applyNamespace = < T extends ( ...args: any[] ) => unknown >(
fn: T,
namespace: string
) => {
return ( ...args: Parameters< T > ) => {
return fn( ...args, namespace );
};
};
/**
* Get the key names from a namespace string.
*
* @param namespace Namespace to get keys from.
* @return Array of keys.
*/
export const getNamespaceKeys = ( namespace: string ) => {
const keys: string[] = [];
namespace.match( /{(.*?)}/g )?.forEach( ( match ) => {
const key = match.substr( 1, match.length - 2 );
keys.push( key );
} );
return keys;
};
/**
* Get URL parameters from namespace and provided query.
*
* @param namespace Namespace string to replace params in.
* @param query Query object with key values.
* @return Array of URL parameter values.
*/
export const getUrlParameters = (
namespace: string,
query: IdQuery | Partial< ItemQuery >
) => {
if ( typeof query !== 'object' ) {
return [];
}
const params: IdType[] = [];
const keys = getNamespaceKeys( namespace );
keys.forEach( ( key ) => {
if ( query.hasOwnProperty( key ) ) {
params.push( query[ key ] as IdType );
}
} );
return params;
};
/**
* Clean a query of all namespaced params.
*
* @param query Query to clean.
* @param namespace
* @return Cleaned query object.
*/
export const cleanQuery = (
query: Partial< ItemQuery >,
namespace: string
) => {
const cleaned = { ...query };
const keys = getNamespaceKeys( namespace );
keys.forEach( ( key ) => {
delete cleaned[ key ];
} );
return cleaned;
};

View File

@ -22,6 +22,7 @@ export { EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME } from './product-attributes
export { EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME } from './product-shipping-classes'; export { EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME } from './product-shipping-classes';
export { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones'; export { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones';
export { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags'; export { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags';
export { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
export { PaymentGateway } from './payment-gateways/types'; export { PaymentGateway } from './payment-gateways/types';
// Export hooks // Export hooks
@ -97,6 +98,7 @@ import type { EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME } from './product-attri
import type { EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME } from './product-shipping-classes'; import type { EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME } from './product-shipping-classes';
import type { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones'; import type { EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME } from './shipping-zones';
import type { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags'; import type { EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME } from './product-tags';
import type { EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME } from './product-attribute-terms';
export type WCDataStoreName = export type WCDataStoreName =
| typeof REVIEWS_STORE_NAME | typeof REVIEWS_STORE_NAME
@ -114,6 +116,7 @@ export type WCDataStoreName =
| typeof PRODUCTS_STORE_NAME | typeof PRODUCTS_STORE_NAME
| typeof ORDERS_STORE_NAME | typeof ORDERS_STORE_NAME
| typeof EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME
| typeof EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME
| typeof EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME
| typeof EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME | typeof EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME
| typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME; | typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME;
@ -132,6 +135,7 @@ import { ProductAttributeSelectors } from './product-attributes/types';
import { ProductShippingClassSelectors } from './product-shipping-classes/types'; import { ProductShippingClassSelectors } from './product-shipping-classes/types';
import { ShippingZonesSelectors } from './shipping-zones/types'; import { ShippingZonesSelectors } from './shipping-zones/types';
import { ProductTagSelectors } from './product-tags/types'; import { ProductTagSelectors } from './product-tags/types';
import { ProductAttributeTermsSelectors } from './product-attribute-terms/types';
// As we add types to all the package selectors we can fill out these unknown types with real ones. See one // As we add types to all the package selectors we can fill out these unknown types with real ones. See one
// of the already typed selectors for an example of how you can do this. // of the already typed selectors for an example of how you can do this.
@ -167,6 +171,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
? ProductShippingClassSelectors ? ProductShippingClassSelectors
: T extends typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME : T extends typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME
? ProductTagSelectors ? ProductTagSelectors
: T extends typeof EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME
? ProductAttributeTermsSelectors
: T extends typeof ORDERS_STORE_NAME : T extends typeof ORDERS_STORE_NAME
? OrdersSelectors ? OrdersSelectors
: T extends typeof EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME : T extends typeof EXPERIMENTAL_SHIPPING_ZONES_STORE_NAME
@ -181,6 +187,7 @@ export interface WCDataSelector {
export { ActionDispatchers as PluginsStoreActions } from './plugins/actions'; export { ActionDispatchers as PluginsStoreActions } from './plugins/actions';
export { ActionDispatchers as ProductAttributesActions } from './product-attributes/types'; export { ActionDispatchers as ProductAttributesActions } from './product-attributes/types';
export { ActionDispatchers as ProductTagsActions } from './product-tags/types'; export { ActionDispatchers as ProductTagsActions } from './product-tags/types';
export { ActionDispatchers as ProductAttributeTermsActions } from './product-attribute-terms/types';
export { ActionDispatchers as ProductsStoreActions } from './products/actions'; export { ActionDispatchers as ProductsStoreActions } from './products/actions';
export { ActionDispatchers as ProductShippingClassesActions } from './product-shipping-classes/types'; export { ActionDispatchers as ProductShippingClassesActions } from './product-shipping-classes/types';
export { ActionDispatchers as ShippingZonesActions } from './shipping-zones/types'; export { ActionDispatchers as ShippingZonesActions } from './shipping-zones/types';

View File

@ -0,0 +1,4 @@
export const STORE_NAME = 'wc/admin/products/attributes/terms';
export const WC_PRODUCT_ATTRIBUTE_TERMS_NAMESPACE =
'/wc/v3/products/attributes/{attribute_id}/terms';

View File

@ -0,0 +1,14 @@
/**
* Internal dependencies
*/
import { STORE_NAME, WC_PRODUCT_ATTRIBUTE_TERMS_NAMESPACE } from './constants';
import { createCrudDataStore } from '../crud';
createCrudDataStore( {
storeName: STORE_NAME,
resourceName: 'ProductAttributeTerm',
pluralResourceName: 'ProductAttributeTerms',
namespace: WC_PRODUCT_ATTRIBUTE_TERMS_NAMESPACE,
} );
export const EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME = STORE_NAME;

View File

@ -0,0 +1,56 @@
/**
* External dependencies
*/
import { DispatchFromMap } from '@automattic/data-stores';
/**
* Internal dependencies
*/
import { CrudActions, CrudSelectors } from '../crud/types';
type ProductAttributeTerm = {
id: number;
slug: string;
name: string;
description: string;
menu_order: number;
count: number;
};
type Query = {
context: string;
page: number;
per_page: number;
search: string;
exclude: number[];
include: number[];
offset: number;
order: string;
orderby: string;
hide_empty: boolean;
product: number;
slug: string;
};
type ReadOnlyProperties = 'id' | 'count';
type MutableProperties = Partial<
Omit< ProductAttributeTerm, ReadOnlyProperties >
>;
type ProductAttributeTermActions = CrudActions<
'ProductAttributeTerm',
ProductAttributeTerm,
MutableProperties,
'name'
>;
export type ProductAttributeTermsSelectors = CrudSelectors<
'ProductAttributeTerm',
'ProductAttributeTerms',
ProductAttributeTerm,
Query,
MutableProperties
>;
export type ActionDispatchers = DispatchFromMap< ProductAttributeTermActions >;