From 4c1a82dc26866a7c5a1cf0050dd9ebec2804d580 Mon Sep 17 00:00:00 2001 From: Joshua T Flowers Date: Thu, 21 Jul 2022 12:28:30 -0400 Subject: [PATCH] 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 --- .../changelog/add-data-store-attribute-terms | 4 + packages/js/data/src/crud/actions.ts | 88 +++++++--- packages/js/data/src/crud/index.ts | 6 +- packages/js/data/src/crud/reducer.ts | 26 +-- packages/js/data/src/crud/resolvers.ts | 27 ++- packages/js/data/src/crud/selectors.ts | 82 +++++++-- packages/js/data/src/crud/test/reducer.ts | 56 ++++-- packages/js/data/src/crud/test/selectors.ts | 1 + packages/js/data/src/crud/test/utils.ts | 116 +++++++++++++ packages/js/data/src/crud/types.ts | 12 ++ packages/js/data/src/crud/utils.ts | 162 ++++++++++++++++++ packages/js/data/src/index.ts | 7 + .../src/product-attribute-terms/constants.ts | 4 + .../data/src/product-attribute-terms/index.ts | 14 ++ .../data/src/product-attribute-terms/types.ts | 56 ++++++ 15 files changed, 577 insertions(+), 84 deletions(-) create mode 100644 packages/js/data/changelog/add-data-store-attribute-terms create mode 100644 packages/js/data/src/crud/test/utils.ts create mode 100644 packages/js/data/src/crud/utils.ts create mode 100644 packages/js/data/src/product-attribute-terms/constants.ts create mode 100644 packages/js/data/src/product-attribute-terms/index.ts create mode 100644 packages/js/data/src/product-attribute-terms/types.ts diff --git a/packages/js/data/changelog/add-data-store-attribute-terms b/packages/js/data/changelog/add-data-store-attribute-terms new file mode 100644 index 00000000000..fc096db0989 --- /dev/null +++ b/packages/js/data/changelog/add-data-store-attribute-terms @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add product attribute terms data store diff --git a/packages/js/data/src/crud/actions.ts b/packages/js/data/src/crud/actions.ts index 42960bde82f..8cd00bcf018 100644 --- a/packages/js/data/src/crud/actions.ts +++ b/packages/js/data/src/crud/actions.ts @@ -1,15 +1,15 @@ /** * External dependencies */ -import { addQueryArgs } from '@wordpress/url'; import { apiFetch } from '@wordpress/data-controls'; /** * Internal dependencies */ +import { cleanQuery, getUrlParameters, getRestPath, parseId } from './utils'; import CRUD_ACTIONS from './crud-actions'; import TYPES from './action-types'; -import { IdType, Item, ItemQuery } from './types'; +import { IdType, IdQuery, Item, ItemQuery } from './types'; type ResolverOptions = { 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 { type: TYPES.CREATE_ITEM_SUCCESS as const, - id, + key, item, }; } -export function deleteItemError( id: IdType, error: unknown ) { +export function deleteItemError( key: IdType, error: unknown ) { return { type: TYPES.DELETE_ITEM_ERROR as const, - id, + key, error, errorType: CRUD_ACTIONS.DELETE_ITEM, }; } -export function deleteItemSuccess( id: IdType, force: boolean, item: Item ) { +export function deleteItemSuccess( key: IdQuery, force: boolean, item: Item ) { return { type: TYPES.DELETE_ITEM_SUCCESS as const, - id, + key, force, item, }; } -export function getItemError( id: unknown, error: unknown ) { +export function getItemError( key: IdType, error: unknown ) { return { type: TYPES.GET_ITEM_ERROR as const, - id, + key, error, errorType: CRUD_ACTIONS.GET_ITEM, }; } -export function getItemSuccess( id: IdType, item: Item ) { +export function getItemSuccess( key: IdType, item: Item ) { return { type: TYPES.GET_ITEM_SUCCESS as const, - id, + key, item, }; } -export function getItemsError( query: unknown, error: unknown ) { +export function getItemsError( + query: Partial< ItemQuery > | undefined, + error: unknown +) { return { type: TYPES.GET_ITEMS_ERROR as const, 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 { type: TYPES.GET_ITEMS_SUCCESS as const, items, query, + urlParameters, }; } -export function updateItemError( id: unknown, error: unknown ) { +export function updateItemError( key: IdType, error: unknown ) { return { type: TYPES.UPDATE_ITEM_ERROR as const, - id, + key, error, errorType: CRUD_ACTIONS.UPDATE_ITEM, }; } -export function updateItemSuccess( id: IdType, item: Item ) { +export function updateItemSuccess( key: IdType, item: Item ) { return { type: TYPES.UPDATE_ITEM_SUCCESS as const, - id, + key, item, }; } @@ -107,13 +115,20 @@ export const createDispatchActions = ( { resourceName, }: ResolverOptions ) => { const createItem = function* ( query: Partial< ItemQuery > ) { + const urlParameters = getUrlParameters( namespace, query ); + try { const item: Item = yield apiFetch( { - path: addQueryArgs( namespace, query ), + path: getRestPath( + namespace, + cleanQuery( query, namespace ), + urlParameters + ), method: 'POST', } ); + const { key } = parseId( item.id, urlParameters ); - yield createItemSuccess( item.id, item ); + yield createItemSuccess( key, item ); return item; } catch ( 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 { const item: Item = yield apiFetch( { - path: addQueryArgs( `${ namespace }/${ id }`, { force } ), + path: getRestPath( + `${ namespace }/${ id }`, + { force }, + urlParameters + ), method: 'DELETE', } ); - yield deleteItemSuccess( id, force, item ); + yield deleteItemSuccess( key, force, item ); return item; } catch ( error ) { - yield deleteItemError( id, error ); + yield deleteItemError( key, 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 { const item: Item = yield apiFetch( { - path: addQueryArgs( `${ namespace }/${ id }`, query ), + path: getRestPath( + `${ namespace }/${ id }`, + cleanQuery( query, namespace ), + urlParameters + ), method: 'PUT', } ); - yield updateItemSuccess( item.id, item ); + yield updateItemSuccess( key, item ); return item; } catch ( error ) { - yield updateItemError( query, error ); + yield updateItemError( key, error ); throw error; } }; diff --git a/packages/js/data/src/crud/index.ts b/packages/js/data/src/crud/index.ts index a59fe38ea38..d446fb17add 100644 --- a/packages/js/data/src/crud/index.ts +++ b/packages/js/data/src/crud/index.ts @@ -36,7 +36,11 @@ export const createCrudDataStore = ( { pluralResourceName, namespace, } ); - const selectors = createSelectors( { resourceName, pluralResourceName } ); + const selectors = createSelectors( { + resourceName, + pluralResourceName, + namespace, + } ); registerStore( storeName, { reducer: reducer as Reducer< ResourceState >, diff --git a/packages/js/data/src/crud/reducer.ts b/packages/js/data/src/crud/reducer.ts index 767975da27d..ffb639a0e77 100644 --- a/packages/js/data/src/crud/reducer.ts +++ b/packages/js/data/src/crud/reducer.ts @@ -8,6 +8,7 @@ import { Reducer } from 'redux'; */ import { Actions } from './actions'; import CRUD_ACTIONS from './crud-actions'; +import { getKey } from './utils'; import { getResourceName } from '../utils'; import { IdType, Item, ItemQuery } from './types'; import { TYPES } from './action-types'; @@ -56,25 +57,25 @@ export const createReducer = () => { ...state, data: { ...itemData, - [ payload.id ]: { - ...( itemData[ payload.id ] || {} ), + [ payload.key ]: { + ...( itemData[ payload.key ] || {} ), ...payload.item, }, }, }; case TYPES.DELETE_ITEM_SUCCESS: - const itemIds = Object.keys( state.data ); - const nextData = itemIds.reduce< Data >( - ( items: Data, id: string ) => { - if ( id !== payload.id.toString() ) { - items[ id ] = state.data[ id ]; + const itemKeys = Object.keys( state.data ); + const nextData = itemKeys.reduce< Data >( + ( items: Data, key: string ) => { + if ( key !== payload.key.toString() ) { + items[ key ] = state.data[ key ]; return items; } if ( payload.force ) { return items; } - items[ id ] = payload.item; + items[ key ] = payload.item; return items; }, {} as Data @@ -93,7 +94,7 @@ export const createReducer = () => { errors: { ...state.errors, [ getResourceName( payload.errorType, { - id: payload.id, + key: payload.key, } ) ]: payload.error, }, }; @@ -104,9 +105,10 @@ export const createReducer = () => { const nextResources = payload.items.reduce< Record< string, Item > >( ( result, item ) => { - ids.push( item.id ); - result[ item.id ] = { - ...( state.data[ item.id ] || {} ), + const key = getKey( item.id, payload.urlParameters ); + ids.push( key ); + result[ key ] = { + ...( state.data[ key ] || {} ), ...item, }; return result; diff --git a/packages/js/data/src/crud/resolvers.ts b/packages/js/data/src/crud/resolvers.ts index e24f524eb3c..8159e9987d7 100644 --- a/packages/js/data/src/crud/resolvers.ts +++ b/packages/js/data/src/crud/resolvers.ts @@ -6,14 +6,15 @@ import { apiFetch } from '@wordpress/data-controls'; /** * Internal dependencies */ +import { cleanQuery, getUrlParameters, getRestPath, parseId } from './utils'; import { getItemError, getItemSuccess, getItemsError, getItemsSuccess, } from './actions'; +import { IdQuery, Item, ItemQuery } from './types'; import { request } from '../utils'; -import { Item, ItemQuery } from './types'; type ResolverOptions = { resourceName: string; @@ -26,25 +27,32 @@ export const createResolvers = ( { pluralResourceName, namespace, }: ResolverOptions ) => { - const getItem = function* ( id: number ) { + const getItem = function* ( idQuery: IdQuery ) { + const urlParameters = getUrlParameters( namespace, idQuery ); + const { id, key } = parseId( idQuery, urlParameters ); try { const item: Item = yield apiFetch( { - path: `${ namespace }/${ id }`, + path: getRestPath( + `${ namespace }/${ id }`, + {}, + urlParameters + ), method: 'GET', } ); - yield getItemSuccess( item.id, item ); + yield getItemSuccess( key, item ); return item; } catch ( error ) { - yield getItemError( id, error ); + yield getItemError( key, error ); throw error; } }; const getItems = function* ( query?: Partial< ItemQuery > ) { - // Require ID when requesting specific fields to later update the resource data. - const resourceQuery = query ? { ...query } : {}; + const urlParameters = getUrlParameters( namespace, query || {} ); + const resourceQuery = cleanQuery( query || {}, namespace ); + // Require ID when requesting specific fields to later update the resource data. if ( resourceQuery && resourceQuery._fields && @@ -54,12 +62,13 @@ export const createResolvers = ( { } try { + const path = getRestPath( namespace, {}, urlParameters ); const { items }: { items: Item[] } = yield request< ItemQuery, Item - >( namespace, resourceQuery ); + >( path, resourceQuery ); - yield getItemsSuccess( query, items ); + yield getItemsSuccess( query, items, urlParameters ); return items; } catch ( error ) { yield getItemsError( query, error ); diff --git a/packages/js/data/src/crud/selectors.ts b/packages/js/data/src/crud/selectors.ts index 61f687cd429..f2ae557c63e 100644 --- a/packages/js/data/src/crud/selectors.ts +++ b/packages/js/data/src/crud/selectors.ts @@ -6,14 +6,16 @@ import createSelector from 'rememo'; /** * Internal dependencies */ +import { applyNamespace, getUrlParameters, parseId } from './utils'; import { getResourceName } from '../utils'; -import { IdType, Item, ItemQuery } from './types'; +import { IdQuery, IdType, Item, ItemQuery } from './types'; import { ResourceState } from './reducer'; import CRUD_ACTIONS from './crud-actions'; type SelectorOptions = { resourceName: string; pluralResourceName: string; + namespace: string; }; export const getItemCreateError = ( @@ -24,17 +26,35 @@ export const getItemCreateError = ( return state.errors[ itemQuery ]; }; -export const getItemDeleteError = ( state: ResourceState, id: IdType ) => { - const itemQuery = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { id } ); +export const getItemDeleteError = ( + 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 ]; }; -export const getItem = ( state: ResourceState, id: IdType ) => { - return state.data[ id ]; +export const getItem = ( + 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 ) => { - const itemQuery = getResourceName( CRUD_ACTIONS.GET_ITEM, { id } ); +export const getItemError = ( + 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 ]; }; @@ -67,11 +87,13 @@ export const getItems = createSelector( } ); } - return ids + const data = ids .map( ( id: IdType ) => { return state.data[ id ]; } ) .filter( ( item ) => item !== undefined ); + + return data; }, ( state, query ) => { const itemQuery = getResourceName( @@ -81,6 +103,7 @@ export const getItems = createSelector( const ids = state.items[ itemQuery ] ? state.items[ itemQuery ].data : undefined; + return [ state.items[ itemQuery ], ...( ids || [] ).map( ( id: string ) => { @@ -95,22 +118,47 @@ export const getItemsError = ( state: ResourceState, query?: ItemQuery ) => { return state.errors[ itemQuery ]; }; -export const getItemUpdateError = ( state: ResourceState, id: IdType ) => { - const itemQuery = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, { id } ); +export const getItemUpdateError = ( + 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 ]; }; export const createSelectors = ( { resourceName, pluralResourceName, + namespace, }: SelectorOptions ) => { return { - [ `get${ resourceName }` ]: getItem, - [ `get${ resourceName }Error` ]: getItemError, - [ `get${ pluralResourceName }` ]: getItems, - [ `get${ pluralResourceName }Error` ]: getItemsError, - [ `get${ resourceName }CreateError` ]: getItemCreateError, - [ `get${ resourceName }DeleteError` ]: getItemDeleteError, - [ `get${ resourceName }UpdateError` ]: getItemUpdateError, + [ `get${ resourceName }` ]: applyNamespace( getItem, namespace ), + [ `get${ resourceName }Error` ]: applyNamespace( + getItemError, + namespace + ), + [ `get${ pluralResourceName }` ]: applyNamespace( getItems, namespace ), + [ `get${ pluralResourceName }Error` ]: applyNamespace( + getItemsError, + namespace + ), + [ `get${ resourceName }CreateError` ]: applyNamespace( + getItemCreateError, + namespace + ), + [ `get${ resourceName }DeleteError` ]: applyNamespace( + getItemDeleteError, + namespace + ), + [ `get${ resourceName }UpdateError` ]: applyNamespace( + getItemUpdateError, + namespace + ), }; }; diff --git a/packages/js/data/src/crud/test/reducer.ts b/packages/js/data/src/crud/test/reducer.ts index c45f8e04358..2f6f7bb93e2 100644 --- a/packages/js/data/src/crud/test/reducer.ts +++ b/packages/js/data/src/crud/test/reducer.ts @@ -44,7 +44,7 @@ describe( 'crud reducer', () => { const state = reducer( initialState, { type: TYPES.GET_ITEM_SUCCESS, - id: update.id, + key: update.id, item: update, } ); @@ -67,6 +67,7 @@ describe( 'crud reducer', () => { type: TYPES.GET_ITEMS_SUCCESS, items, query, + urlParameters: [], } ); const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); @@ -79,6 +80,26 @@ describe( 'crud reducer', () => { 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', () => { const initialState: ResourceState = { ...defaultState, @@ -102,6 +123,7 @@ describe( 'crud reducer', () => { type: TYPES.GET_ITEMS_SUCCESS, items, query, + urlParameters: [], } ); const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEMS, query ); @@ -133,12 +155,12 @@ describe( 'crud reducer', () => { } ); it( 'should handle GET_ITEM_ERROR', () => { - const id = 3; - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { id } ); + const key = 3; + const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.GET_ITEM_ERROR, - id, + key, error, errorType: CRUD_ACTIONS.GET_ITEM, } ); @@ -147,12 +169,12 @@ describe( 'crud reducer', () => { } ); it( 'should handle GET_ITEM_ERROR', () => { - const id = 3; - const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { id } ); + const key = 3; + const resourceName = getResourceName( CRUD_ACTIONS.GET_ITEM, { key } ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.GET_ITEM_ERROR, - id, + key, error, errorType: CRUD_ACTIONS.GET_ITEM, } ); @@ -175,14 +197,14 @@ describe( 'crud reducer', () => { } ); it( 'should handle UPDATE_ITEM_ERROR', () => { - const id = 2; + const key = 2; const resourceName = getResourceName( CRUD_ACTIONS.UPDATE_ITEM, { - id, + key, } ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.UPDATE_ITEM_ERROR, - id, + key, error, errorType: CRUD_ACTIONS.UPDATE_ITEM, } ); @@ -212,7 +234,7 @@ describe( 'crud reducer', () => { const state = reducer( initialState, { type: TYPES.UPDATE_ITEM_SUCCESS, - id: item.id, + key: item.id, item, } ); @@ -234,7 +256,7 @@ describe( 'crud reducer', () => { const state = reducer( defaultState, { type: TYPES.CREATE_ITEM_SUCCESS, - id: item.id, + key: item.id, item, } ); @@ -267,13 +289,13 @@ describe( 'crud reducer', () => { let state = reducer( initialState, { type: TYPES.DELETE_ITEM_SUCCESS, - id: item1Updated.id, + key: item1Updated.id, item: item1Updated, force: true, } ); state = reducer( state, { type: TYPES.DELETE_ITEM_SUCCESS, - id: item2Updated.id, + key: item2Updated.id, item: item2Updated, force: false, } ); @@ -284,14 +306,14 @@ describe( 'crud reducer', () => { } ); it( 'should handle DELETE_ITEM_ERROR', () => { - const id = 2; + const key = 2; const resourceName = getResourceName( CRUD_ACTIONS.DELETE_ITEM, { - id, + key, } ); const error = 'Baaam!'; const state = reducer( defaultState, { type: TYPES.DELETE_ITEM_ERROR, - id, + key, error, errorType: CRUD_ACTIONS.DELETE_ITEM, } ); diff --git a/packages/js/data/src/crud/test/selectors.ts b/packages/js/data/src/crud/test/selectors.ts index 99116eb2a98..9f3ca064458 100644 --- a/packages/js/data/src/crud/test/selectors.ts +++ b/packages/js/data/src/crud/test/selectors.ts @@ -6,6 +6,7 @@ import { createSelectors } from '../selectors'; const selectors = createSelectors( { resourceName: 'Product', pluralResourceName: 'Products', + namespace: '', } ); describe( 'crud selectors', () => { diff --git a/packages/js/data/src/crud/test/utils.ts b/packages/js/data/src/crud/test/utils.ts new file mode 100644 index 00000000000..1fb371c9369 --- /dev/null +++ b/packages/js/data/src/crud/test/utils.ts @@ -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(); + } ); +} ); diff --git a/packages/js/data/src/crud/types.ts b/packages/js/data/src/crud/types.ts index 5143746f899..4953ab3e61c 100644 --- a/packages/js/data/src/crud/types.ts +++ b/packages/js/data/src/crud/types.ts @@ -14,6 +14,13 @@ import { export type IdType = number | string; +export type IdQuery = + | IdType + | { + id: IdType; + [ key: string ]: IdType; + }; + export type Item = { id: IdType; [ key: string ]: unknown; @@ -21,6 +28,11 @@ export type Item = { export type ItemQuery = BaseQueryParams & { [ key: string ]: unknown; + parent_id?: IdType; +}; + +export type Params = { + [ key: string ]: IdType; }; type WithRequiredProperty< Type, Key extends keyof Type > = Type & { diff --git a/packages/js/data/src/crud/utils.ts b/packages/js/data/src/crud/utils.ts new file mode 100644 index 00000000000..d0d76ba353f --- /dev/null +++ b/packages/js/data/src/crud/utils.ts @@ -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; +}; diff --git a/packages/js/data/src/index.ts b/packages/js/data/src/index.ts index e54be8b4ceb..a8c7ac00c94 100644 --- a/packages/js/data/src/index.ts +++ b/packages/js/data/src/index.ts @@ -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_SHIPPING_ZONES_STORE_NAME } from './shipping-zones'; 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 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_SHIPPING_ZONES_STORE_NAME } from './shipping-zones'; 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 = | typeof REVIEWS_STORE_NAME @@ -114,6 +116,7 @@ export type WCDataStoreName = | typeof PRODUCTS_STORE_NAME | typeof ORDERS_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_ATTRIBUTES_STORE_NAME + | typeof EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME | typeof EXPERIMENTAL_PRODUCT_SHIPPING_CLASSES_STORE_NAME | typeof EXPERIMENTAL_SHIPPING_ZONES_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 { ShippingZonesSelectors } from './shipping-zones/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 // 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 : T extends typeof EXPERIMENTAL_PRODUCT_TAGS_STORE_NAME ? ProductTagSelectors + : T extends typeof EXPERIMENTAL_PRODUCT_ATTRIBUTE_TERMS_STORE_NAME + ? ProductAttributeTermsSelectors : T extends typeof ORDERS_STORE_NAME ? OrdersSelectors : 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 ProductAttributesActions } from './product-attributes/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 ProductShippingClassesActions } from './product-shipping-classes/types'; export { ActionDispatchers as ShippingZonesActions } from './shipping-zones/types'; diff --git a/packages/js/data/src/product-attribute-terms/constants.ts b/packages/js/data/src/product-attribute-terms/constants.ts new file mode 100644 index 00000000000..86eb556f0d5 --- /dev/null +++ b/packages/js/data/src/product-attribute-terms/constants.ts @@ -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'; diff --git a/packages/js/data/src/product-attribute-terms/index.ts b/packages/js/data/src/product-attribute-terms/index.ts new file mode 100644 index 00000000000..dc8e1d046ca --- /dev/null +++ b/packages/js/data/src/product-attribute-terms/index.ts @@ -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; diff --git a/packages/js/data/src/product-attribute-terms/types.ts b/packages/js/data/src/product-attribute-terms/types.ts new file mode 100644 index 00000000000..eb451eafc8a --- /dev/null +++ b/packages/js/data/src/product-attribute-terms/types.ts @@ -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 >;