Merge pull request #32964 from woocommerce/update/32524_order_product_count_api_requests
Add products data store
This commit is contained in:
commit
434fab9ac5
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: enhancement
|
||||
|
||||
Add product data store for retrieving product list.
|
|
@ -2,10 +2,12 @@
|
|||
* External dependencies
|
||||
*/
|
||||
import { controls as dataControls } from '@wordpress/data-controls';
|
||||
import { Action } from '@wordpress/data';
|
||||
import apiFetch, { APIFetchOptions } from '@wordpress/api-fetch';
|
||||
import { AnyAction } from 'redux';
|
||||
|
||||
export const fetchWithHeaders = ( options: APIFetchOptions ) => {
|
||||
export const fetchWithHeaders = (
|
||||
options: APIFetchOptions
|
||||
): AnyAction & { options: APIFetchOptions } => {
|
||||
return {
|
||||
type: 'FETCH_WITH_HEADERS',
|
||||
options,
|
||||
|
@ -20,7 +22,7 @@ export type FetchWithHeadersResponse< Data > = {
|
|||
|
||||
const controls = {
|
||||
...dataControls,
|
||||
FETCH_WITH_HEADERS( action: Action ) {
|
||||
FETCH_WITH_HEADERS( action: AnyAction ) {
|
||||
return apiFetch< Response >( { ...action.options, parse: false } )
|
||||
.then( ( response ) => {
|
||||
return Promise.all( [
|
||||
|
|
|
@ -16,6 +16,7 @@ export { NAVIGATION_STORE_NAME } from './navigation';
|
|||
export { OPTIONS_STORE_NAME } from './options';
|
||||
export { ITEMS_STORE_NAME } from './items';
|
||||
export { PAYMENT_GATEWAYS_STORE_NAME } from './payment-gateways';
|
||||
export { PRODUCTS_STORE_NAME } from './products';
|
||||
export { PaymentGateway } from './payment-gateways/types';
|
||||
|
||||
// Export hooks
|
||||
|
@ -67,6 +68,7 @@ export * from './types';
|
|||
export * from './countries/types';
|
||||
export * from './onboarding/types';
|
||||
export * from './plugins/types';
|
||||
export * from './products/types';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -83,6 +85,7 @@ import type { REPORTS_STORE_NAME } from './reports';
|
|||
import type { ITEMS_STORE_NAME } from './items';
|
||||
import type { COUNTRIES_STORE_NAME } from './countries';
|
||||
import type { PAYMENT_GATEWAYS_STORE_NAME } from './payment-gateways';
|
||||
import type { PRODUCTS_STORE_NAME } from './products';
|
||||
|
||||
export type WCDataStoreName =
|
||||
| typeof REVIEWS_STORE_NAME
|
||||
|
@ -96,7 +99,8 @@ export type WCDataStoreName =
|
|||
| typeof REPORTS_STORE_NAME
|
||||
| typeof ITEMS_STORE_NAME
|
||||
| typeof COUNTRIES_STORE_NAME
|
||||
| typeof PAYMENT_GATEWAYS_STORE_NAME;
|
||||
| typeof PAYMENT_GATEWAYS_STORE_NAME
|
||||
| typeof PRODUCTS_STORE_NAME;
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
|
@ -106,6 +110,7 @@ import { PaymentSelectors } from './payment-gateways/selectors';
|
|||
import { PluginSelectors } from './plugins/selectors';
|
||||
import { OnboardingSelectors } from './onboarding/selectors';
|
||||
import { OptionsSelectors } from './options/types';
|
||||
import { ProductsSelectors } from './products/selectors';
|
||||
|
||||
// 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.
|
||||
|
@ -133,6 +138,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
|
|||
? WPDataSelectors
|
||||
: T extends typeof COUNTRIES_STORE_NAME
|
||||
? WPDataSelectors
|
||||
: T extends typeof PRODUCTS_STORE_NAME
|
||||
? ProductsSelectors
|
||||
: never;
|
||||
|
||||
export interface WCDataSelector {
|
||||
|
|
|
@ -1,38 +1,18 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { apiFetch } from '@wordpress/data-controls';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { NAMESPACE } from '../constants';
|
||||
import { setError, setItems, setItemsTotalCount } from './actions';
|
||||
import { fetchWithHeaders } from '../controls';
|
||||
|
||||
function* request( itemType, query ) {
|
||||
const endpoint =
|
||||
itemType === 'categories' ? 'products/categories' : itemType;
|
||||
const url = addQueryArgs( `${ NAMESPACE }/${ endpoint }`, query );
|
||||
const isUnboundedRequest = query.per_page === -1;
|
||||
const fetch = isUnboundedRequest ? apiFetch : fetchWithHeaders;
|
||||
const response = yield fetch( {
|
||||
path: url,
|
||||
method: 'GET',
|
||||
} );
|
||||
|
||||
if ( isUnboundedRequest ) {
|
||||
return { items: response, totalCount: response.length };
|
||||
}
|
||||
const totalCount = parseInt( response.headers.get( 'x-wp-total' ), 10 );
|
||||
|
||||
return { items: response.data, totalCount };
|
||||
}
|
||||
import { request } from '../utils';
|
||||
|
||||
export function* getItems( itemType, query ) {
|
||||
try {
|
||||
const { items, totalCount } = yield request( itemType, query );
|
||||
const endpoint =
|
||||
itemType === 'categories' ? 'products/categories' : itemType;
|
||||
const { items, totalCount } = yield request(
|
||||
`${ NAMESPACE }/${ endpoint }`,
|
||||
query
|
||||
);
|
||||
yield setItemsTotalCount( itemType, query, totalCount );
|
||||
yield setItems( itemType, query, items );
|
||||
} catch ( error ) {
|
||||
|
@ -51,7 +31,12 @@ export function* getItemsTotalCount( itemType, query ) {
|
|||
page: 1,
|
||||
per_page: 1,
|
||||
};
|
||||
const { totalCount } = yield request( itemType, totalsQuery );
|
||||
const endpoint =
|
||||
itemType === 'categories' ? 'products/categories' : itemType;
|
||||
const { totalCount } = yield request(
|
||||
`${ NAMESPACE }/${ endpoint }`,
|
||||
totalsQuery
|
||||
);
|
||||
yield setItemsTotalCount( itemType, query, totalCount );
|
||||
} catch ( error ) {
|
||||
yield setError( itemType, query, error );
|
||||
|
|
|
@ -12,7 +12,7 @@ import {
|
|||
OnboardingState,
|
||||
ExtensionList,
|
||||
ProfileItemsState,
|
||||
Product,
|
||||
OnboardingProductType,
|
||||
} from './types';
|
||||
import { WPDataSelectors } from '../types';
|
||||
import { Plugin } from '../plugins/types';
|
||||
|
@ -92,7 +92,9 @@ export const getEmailPrefill = ( state: OnboardingState ): string => {
|
|||
return state.emailPrefill || '';
|
||||
};
|
||||
|
||||
export const getProductTypes = ( state: OnboardingState ): Product[] => {
|
||||
export const getProductTypes = (
|
||||
state: OnboardingState
|
||||
): OnboardingProductType[] => {
|
||||
return state.productTypes || [];
|
||||
};
|
||||
|
||||
|
|
|
@ -63,7 +63,7 @@ export type OnboardingState = {
|
|||
profileItems: ProfileItemsState;
|
||||
taskLists: Record< string, TaskListType >;
|
||||
paymentMethods: Plugin[];
|
||||
productTypes: Product[];
|
||||
productTypes: OnboardingProductType[];
|
||||
emailPrefill: string;
|
||||
// TODO clarify what the error record's type is
|
||||
errors: Record< string, unknown >;
|
||||
|
@ -137,7 +137,7 @@ export type MethodFields = {
|
|||
value?: string;
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
export type OnboardingProductType = {
|
||||
default?: boolean;
|
||||
label: string;
|
||||
product?: number;
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
export enum TYPES {
|
||||
GET_PRODUCT_SUCCESS = 'GET_PRODUCT_SUCCESS',
|
||||
GET_PRODUCT_ERROR = 'GET_PRODUCT_ERROR',
|
||||
GET_PRODUCTS_SUCCESS = 'GET_PRODUCTS_SUCCESS',
|
||||
GET_PRODUCTS_ERROR = 'GET_PRODUCTS_ERROR',
|
||||
GET_PRODUCTS_TOTAL_COUNT_SUCCESS = 'GET_PRODUCTS_TOTAL_COUNT_SUCCESS',
|
||||
GET_PRODUCTS_TOTAL_COUNT_ERROR = 'GET_PRODUCTS_TOTAL_COUNT_ERROR',
|
||||
}
|
||||
|
||||
export default TYPES;
|
|
@ -0,0 +1,79 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import TYPES from './action-types';
|
||||
import { PartialProduct, ProductQuery } from './types';
|
||||
|
||||
export function getProductSuccess( id: number, product: PartialProduct ) {
|
||||
return {
|
||||
type: TYPES.GET_PRODUCT_SUCCESS as const,
|
||||
id,
|
||||
product,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductError(
|
||||
query: Partial< ProductQuery >,
|
||||
error: unknown
|
||||
) {
|
||||
return {
|
||||
type: TYPES.GET_PRODUCT_ERROR as const,
|
||||
query,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductsSuccess(
|
||||
query: Partial< ProductQuery >,
|
||||
products: PartialProduct[],
|
||||
totalCount: number
|
||||
) {
|
||||
return {
|
||||
type: TYPES.GET_PRODUCTS_SUCCESS as const,
|
||||
products,
|
||||
query,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductsError(
|
||||
query: Partial< ProductQuery >,
|
||||
error: unknown
|
||||
) {
|
||||
return {
|
||||
type: TYPES.GET_PRODUCTS_ERROR as const,
|
||||
query,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductsTotalCountSuccess(
|
||||
query: Partial< ProductQuery >,
|
||||
totalCount: number
|
||||
) {
|
||||
return {
|
||||
type: TYPES.GET_PRODUCTS_TOTAL_COUNT_SUCCESS as const,
|
||||
query,
|
||||
totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function getProductsTotalCountError(
|
||||
query: Partial< ProductQuery >,
|
||||
error: unknown
|
||||
) {
|
||||
return {
|
||||
type: TYPES.GET_PRODUCTS_TOTAL_COUNT_ERROR as const,
|
||||
query,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export type Actions = ReturnType<
|
||||
| typeof getProductSuccess
|
||||
| typeof getProductError
|
||||
| typeof getProductsSuccess
|
||||
| typeof getProductsError
|
||||
| typeof getProductsTotalCountSuccess
|
||||
| typeof getProductsTotalCountError
|
||||
>;
|
|
@ -0,0 +1,3 @@
|
|||
export const STORE_NAME = 'wc/admin/products';
|
||||
|
||||
export const WC_PRODUCT_NAMESPACE = '/wc/v3/products';
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
|
||||
import { registerStore } from '@wordpress/data';
|
||||
import { Reducer } from 'redux';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { STORE_NAME } from './constants';
|
||||
import * as selectors from './selectors';
|
||||
import * as actions from './actions';
|
||||
import * as resolvers from './resolvers';
|
||||
import reducer, { ProductState, State } from './reducer';
|
||||
import controls from '../controls';
|
||||
|
||||
registerStore< State >( STORE_NAME, {
|
||||
reducer: reducer as Reducer< ProductState >,
|
||||
actions,
|
||||
controls,
|
||||
selectors,
|
||||
resolvers,
|
||||
} );
|
||||
|
||||
export const PRODUCTS_STORE_NAME = STORE_NAME;
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Reducer } from 'redux';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import TYPES from './action-types';
|
||||
import { Actions } from './actions';
|
||||
import { PartialProduct } from './types';
|
||||
import {
|
||||
getProductResourceName,
|
||||
getTotalProductCountResourceName,
|
||||
} from './utils';
|
||||
|
||||
export type ProductState = {
|
||||
products: Record<
|
||||
string,
|
||||
{
|
||||
data: number[];
|
||||
}
|
||||
>;
|
||||
productsCount: Record< string, number >;
|
||||
errors: Record< string, unknown >;
|
||||
data: Record< number, PartialProduct >;
|
||||
};
|
||||
|
||||
const reducer: Reducer< ProductState, Actions > = (
|
||||
state = {
|
||||
products: {},
|
||||
productsCount: {},
|
||||
errors: {},
|
||||
data: {},
|
||||
},
|
||||
payload
|
||||
) => {
|
||||
if ( payload && 'type' in payload ) {
|
||||
switch ( payload.type ) {
|
||||
case TYPES.GET_PRODUCT_SUCCESS:
|
||||
const productData = state.data || {};
|
||||
return {
|
||||
...state,
|
||||
data: {
|
||||
...productData,
|
||||
[ payload.id ]: {
|
||||
...( productData[ payload.id ] || {} ),
|
||||
...payload.product,
|
||||
},
|
||||
},
|
||||
};
|
||||
case TYPES.GET_PRODUCTS_SUCCESS:
|
||||
const ids: number[] = [];
|
||||
const nextProducts = payload.products.reduce<
|
||||
Record< number, PartialProduct >
|
||||
>( ( result, product ) => {
|
||||
ids.push( product.id );
|
||||
result[ product.id ] = {
|
||||
...( state.data[ product.id ] || {} ),
|
||||
...product,
|
||||
};
|
||||
return result;
|
||||
}, {} );
|
||||
const resourceName = getProductResourceName( payload.query );
|
||||
|
||||
return {
|
||||
...state,
|
||||
products: {
|
||||
...state.products,
|
||||
[ resourceName ]: { data: ids },
|
||||
},
|
||||
data: {
|
||||
...state.data,
|
||||
...nextProducts,
|
||||
},
|
||||
};
|
||||
case TYPES.GET_PRODUCTS_TOTAL_COUNT_SUCCESS:
|
||||
const totalResourceName = getTotalProductCountResourceName(
|
||||
payload.query
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
productsCount: {
|
||||
...state.productsCount,
|
||||
[ totalResourceName ]: payload.totalCount,
|
||||
},
|
||||
};
|
||||
case TYPES.GET_PRODUCT_ERROR:
|
||||
case TYPES.GET_PRODUCTS_ERROR:
|
||||
case TYPES.GET_PRODUCTS_TOTAL_COUNT_ERROR:
|
||||
return {
|
||||
...state,
|
||||
errors: {
|
||||
...state.errors,
|
||||
[ getProductResourceName(
|
||||
payload.query
|
||||
) ]: payload.error,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
return state;
|
||||
};
|
||||
|
||||
export type State = ReturnType< typeof reducer >;
|
||||
export default reducer;
|
|
@ -0,0 +1,60 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { WC_PRODUCT_NAMESPACE } from './constants';
|
||||
import { Product, ProductQuery } from './types';
|
||||
import {
|
||||
getProductsError,
|
||||
getProductsSuccess,
|
||||
getProductsTotalCountError,
|
||||
getProductsTotalCountSuccess,
|
||||
} from './actions';
|
||||
import { request } from '../utils';
|
||||
|
||||
export function* getProducts( query: Partial< ProductQuery > ) {
|
||||
// id is always required.
|
||||
const productsQuery = {
|
||||
...query,
|
||||
};
|
||||
if (
|
||||
productsQuery &&
|
||||
productsQuery._fields &&
|
||||
! productsQuery._fields.includes( 'id' )
|
||||
) {
|
||||
productsQuery._fields = [ 'id', ...productsQuery._fields ];
|
||||
}
|
||||
try {
|
||||
const {
|
||||
items,
|
||||
totalCount,
|
||||
}: { items: Product[]; totalCount: number } = yield request<
|
||||
ProductQuery,
|
||||
Product
|
||||
>( WC_PRODUCT_NAMESPACE, productsQuery );
|
||||
yield getProductsTotalCountSuccess( query, totalCount );
|
||||
yield getProductsSuccess( query, items, totalCount );
|
||||
return items;
|
||||
} catch ( error ) {
|
||||
yield getProductsError( query, error );
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function* getProductsTotalCount( query: Partial< ProductQuery > ) {
|
||||
try {
|
||||
const totalsQuery = {
|
||||
...query,
|
||||
page: 1,
|
||||
per_page: 1,
|
||||
};
|
||||
const { totalCount } = yield request< ProductQuery, Product >(
|
||||
WC_PRODUCT_NAMESPACE,
|
||||
totalsQuery
|
||||
);
|
||||
yield getProductsTotalCountSuccess( query, totalCount );
|
||||
return totalCount;
|
||||
} catch ( error ) {
|
||||
yield getProductsTotalCountError( query, error );
|
||||
throw error;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import createSelector from 'rememo';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import {
|
||||
getProductResourceName,
|
||||
getTotalProductCountResourceName,
|
||||
} from './utils';
|
||||
import { WPDataSelector, WPDataSelectors } from '../types';
|
||||
import { ProductState } from './reducer';
|
||||
import { PartialProduct, ProductQuery } from './types';
|
||||
|
||||
export const getProducts = createSelector(
|
||||
( state: ProductState, query: ProductQuery, defaultValue = undefined ) => {
|
||||
const resourceName = getProductResourceName( query );
|
||||
const ids = state.products[ resourceName ]
|
||||
? state.products[ resourceName ].data
|
||||
: undefined;
|
||||
if ( ! ids ) {
|
||||
return defaultValue;
|
||||
}
|
||||
if ( query._fields ) {
|
||||
return ids.map( ( id ) => {
|
||||
return query._fields.reduce(
|
||||
(
|
||||
product: PartialProduct,
|
||||
field: keyof PartialProduct
|
||||
) => {
|
||||
return {
|
||||
...product,
|
||||
[ field ]: state.data[ id ][ field ],
|
||||
};
|
||||
},
|
||||
{} as PartialProduct
|
||||
);
|
||||
} );
|
||||
}
|
||||
return ids.map( ( id ) => {
|
||||
return state.data[ id ];
|
||||
} );
|
||||
},
|
||||
( state, query ) => {
|
||||
const resourceName = getProductResourceName( query );
|
||||
const ids = state.products[ resourceName ]
|
||||
? state.products[ resourceName ].data
|
||||
: undefined;
|
||||
return [
|
||||
state.products[ resourceName ],
|
||||
...( ids || [] ).map( ( id: number ) => {
|
||||
return state.data[ id ];
|
||||
} ),
|
||||
];
|
||||
}
|
||||
);
|
||||
|
||||
export const getProductsTotalCount = (
|
||||
state: ProductState,
|
||||
query: ProductQuery,
|
||||
defaultValue = undefined
|
||||
) => {
|
||||
const resourceName = getTotalProductCountResourceName( query );
|
||||
const totalCount = state.productsCount.hasOwnProperty( resourceName )
|
||||
? state.productsCount[ resourceName ]
|
||||
: defaultValue;
|
||||
return totalCount;
|
||||
};
|
||||
|
||||
export const getProductsError = (
|
||||
state: ProductState,
|
||||
query: ProductQuery
|
||||
) => {
|
||||
const resourceName = getProductResourceName( query );
|
||||
return state.errors[ resourceName ];
|
||||
};
|
||||
|
||||
export type ProductsSelectors = {
|
||||
getProducts: WPDataSelector< typeof getProducts >;
|
||||
getProductsTotalCount: WPDataSelector< typeof getProductsTotalCount >;
|
||||
getProductsError: WPDataSelector< typeof getProductsError >;
|
||||
} & WPDataSelectors;
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import reducer, { ProductState } from '../reducer';
|
||||
import TYPES from '../action-types';
|
||||
import {
|
||||
getProductResourceName,
|
||||
getTotalProductCountResourceName,
|
||||
} from '../utils';
|
||||
import { Actions } from '../actions';
|
||||
import { PartialProduct, ProductQuery } from '../types';
|
||||
|
||||
const defaultState: ProductState = {
|
||||
products: {},
|
||||
productsCount: {},
|
||||
errors: {},
|
||||
data: {},
|
||||
};
|
||||
|
||||
describe( 'products reducer', () => {
|
||||
it( 'should return a default state', () => {
|
||||
const state = reducer( undefined, {} as Actions );
|
||||
expect( state ).toEqual( defaultState );
|
||||
expect( state ).not.toBe( defaultState );
|
||||
} );
|
||||
|
||||
it( 'should handle GET_PRODUCT_SUCCESS', () => {
|
||||
const itemType = 'guyisms';
|
||||
const initialState: ProductState = {
|
||||
products: {
|
||||
[ itemType ]: {
|
||||
data: [ 1, 2 ],
|
||||
},
|
||||
},
|
||||
productsCount: {
|
||||
'total-guyisms:{}': 2,
|
||||
},
|
||||
errors: {},
|
||||
data: {
|
||||
1: { id: 1, name: 'Donkey', status: 'draft' },
|
||||
2: { id: 2, name: 'Sauce', status: 'publish' },
|
||||
},
|
||||
};
|
||||
const update: PartialProduct = {
|
||||
id: 2,
|
||||
status: 'draft',
|
||||
};
|
||||
|
||||
const state = reducer( initialState, {
|
||||
type: TYPES.GET_PRODUCT_SUCCESS,
|
||||
id: update.id,
|
||||
product: update,
|
||||
} );
|
||||
|
||||
expect( state.products ).toEqual( initialState.products );
|
||||
expect( state.errors ).toEqual( initialState.errors );
|
||||
|
||||
expect( state.data[ 1 ] ).toEqual( initialState.data[ 1 ] );
|
||||
expect( state.data[ 2 ].id ).toEqual( initialState.data[ 2 ].id );
|
||||
expect( state.data[ 2 ].title ).toEqual( initialState.data[ 2 ].title );
|
||||
expect( state.data[ 2 ].status ).toEqual( update.status );
|
||||
} );
|
||||
|
||||
it( 'should handle GET_PRODUCTS_SUCCESS', () => {
|
||||
const products: PartialProduct[] = [
|
||||
{ id: 1, name: 'Yum!' },
|
||||
{ id: 2, name: 'Dynamite!' },
|
||||
];
|
||||
const totalCount = 45;
|
||||
const query: Partial< ProductQuery > = { status: 'draft' };
|
||||
const state = reducer( defaultState, {
|
||||
type: TYPES.GET_PRODUCTS_SUCCESS,
|
||||
products,
|
||||
query,
|
||||
totalCount,
|
||||
} );
|
||||
|
||||
const resourceName = getProductResourceName( query );
|
||||
|
||||
expect( state.products[ resourceName ].data ).toHaveLength( 2 );
|
||||
expect(
|
||||
state.products[ resourceName ].data.includes( 1 )
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
state.products[ resourceName ].data.includes( 2 )
|
||||
).toBeTruthy();
|
||||
|
||||
expect( state.data[ 1 ] ).toEqual( products[ 0 ] );
|
||||
expect( state.data[ 2 ] ).toEqual( products[ 1 ] );
|
||||
} );
|
||||
|
||||
it( 'GET_PRODUCTS_SUCCESS should not remove previously added fields, only update new ones', () => {
|
||||
const initialState: ProductState = {
|
||||
...defaultState,
|
||||
data: {
|
||||
1: { id: 1, name: 'Yum!', price: '10.00', description: 'test' },
|
||||
2: {
|
||||
id: 2,
|
||||
name: 'Dynamite!',
|
||||
price: '10.00',
|
||||
description: 'dynamite',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const products: PartialProduct[] = [
|
||||
{ id: 1, name: 'Yum! Updated' },
|
||||
{ id: 2, name: 'Dynamite!' },
|
||||
];
|
||||
const totalCount = 45;
|
||||
const query: Partial< ProductQuery > = { status: 'draft' };
|
||||
const state = reducer( initialState, {
|
||||
type: TYPES.GET_PRODUCTS_SUCCESS,
|
||||
products,
|
||||
query,
|
||||
totalCount,
|
||||
} );
|
||||
|
||||
const resourceName = getProductResourceName( query );
|
||||
|
||||
expect( state.products[ resourceName ].data ).toHaveLength( 2 );
|
||||
expect(
|
||||
state.products[ resourceName ].data.includes( 1 )
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
state.products[ resourceName ].data.includes( 2 )
|
||||
).toBeTruthy();
|
||||
|
||||
expect( state.data[ 1 ].name ).toEqual( products[ 0 ].name );
|
||||
expect( state.data[ 1 ].price ).toEqual( initialState.data[ 1 ].price );
|
||||
expect( state.data[ 1 ].description ).toEqual(
|
||||
initialState.data[ 1 ].description
|
||||
);
|
||||
expect( state.data[ 2 ] ).toEqual( initialState.data[ 2 ] );
|
||||
} );
|
||||
|
||||
it( 'should handle GET_PRODUCTS_TOTAL_COUNT_SUCCESS', () => {
|
||||
const initialQuery: Partial< ProductQuery > = {
|
||||
status: 'publish',
|
||||
page: 1,
|
||||
per_page: 1,
|
||||
_fields: [ 'id' ],
|
||||
};
|
||||
const resourceName = getTotalProductCountResourceName( initialQuery );
|
||||
const initialState: ProductState = {
|
||||
...defaultState,
|
||||
productsCount: {
|
||||
[ resourceName ]: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// Additional coverage for getTotalCountResourceName().
|
||||
const similarQueryForTotals: Partial< ProductQuery > = {
|
||||
status: 'publish',
|
||||
page: 2,
|
||||
per_page: 10,
|
||||
_fields: [ 'id', 'title', 'status' ],
|
||||
};
|
||||
|
||||
const state = reducer( initialState, {
|
||||
type: TYPES.GET_PRODUCTS_TOTAL_COUNT_SUCCESS,
|
||||
query: similarQueryForTotals,
|
||||
totalCount: 2,
|
||||
} );
|
||||
|
||||
expect( state.productsCount ).toEqual( {
|
||||
[ resourceName ]: 2,
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'should handle GET_PRODUCTS_ERROR', () => {
|
||||
const query: Partial< ProductQuery > = { status: 'draft' };
|
||||
const resourceName = getProductResourceName( query );
|
||||
const error = 'Baaam!';
|
||||
const state = reducer( defaultState, {
|
||||
type: TYPES.GET_PRODUCTS_ERROR,
|
||||
query,
|
||||
error,
|
||||
} );
|
||||
|
||||
expect( state.errors[ resourceName ] ).toBe( error );
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { ProductQuery } from '../types';
|
||||
import { getTotalProductCountResourceName } from '../utils';
|
||||
|
||||
describe( 'getTotalProductCountResourceName()', () => {
|
||||
it( "Ignores query params that don't affect total counts", () => {
|
||||
const fullQuery: Partial< ProductQuery > = {
|
||||
page: 2,
|
||||
per_page: 10,
|
||||
_fields: [ 'id', 'title', 'status', 'price' ],
|
||||
status: 'publish',
|
||||
};
|
||||
|
||||
const slimQuery: Partial< ProductQuery > = {
|
||||
page: 1,
|
||||
per_page: 1,
|
||||
_fields: [ 'id' ],
|
||||
status: 'publish',
|
||||
};
|
||||
|
||||
expect( getTotalProductCountResourceName( fullQuery ) ).toEqual(
|
||||
getTotalProductCountResourceName( slimQuery )
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'Accounts for query params that do affect total counts', () => {
|
||||
const firstQuery: Partial< ProductQuery > = {
|
||||
page: 2,
|
||||
per_page: 10,
|
||||
_fields: [ 'id', 'title', 'status', 'price' ],
|
||||
status: 'publish',
|
||||
};
|
||||
|
||||
const secondQuery: Partial< ProductQuery > = {
|
||||
page: 1,
|
||||
per_page: 1,
|
||||
_fields: [ 'id' ],
|
||||
status: 'draft',
|
||||
};
|
||||
|
||||
expect( getTotalProductCountResourceName( firstQuery ) ).not.toEqual(
|
||||
getTotalProductCountResourceName( secondQuery )
|
||||
);
|
||||
} );
|
||||
} );
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Schema } from '@wordpress/core-data';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { BaseQueryParams } from '../types';
|
||||
|
||||
export type ProductType = 'simple' | 'grouped' | 'external' | 'variable';
|
||||
export type ProductStatus =
|
||||
| 'draft'
|
||||
| 'pending'
|
||||
| 'private'
|
||||
| 'publish'
|
||||
| 'any'
|
||||
| 'future';
|
||||
|
||||
export type Product<
|
||||
Status = ProductStatus,
|
||||
Type = ProductType
|
||||
> = Schema.Post & {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
permalink: string;
|
||||
date_created: string;
|
||||
date_created_gmt: string;
|
||||
date_modified: string;
|
||||
date_modified_gmt: string;
|
||||
type: Type;
|
||||
status: Status;
|
||||
featured: boolean;
|
||||
description: string;
|
||||
short_description: string;
|
||||
sku: string;
|
||||
price: string;
|
||||
regular_price: string;
|
||||
sale_price: string;
|
||||
};
|
||||
|
||||
export type PartialProduct = Partial< Product > & Pick< Product, 'id' >;
|
||||
|
||||
export type ProductQuery<
|
||||
Status = ProductStatus,
|
||||
Type = ProductType
|
||||
> = BaseQueryParams< keyof Product > & {
|
||||
orderby:
|
||||
| 'date'
|
||||
| 'id'
|
||||
| 'include'
|
||||
| 'title'
|
||||
| 'slug'
|
||||
| 'price'
|
||||
| 'popularity'
|
||||
| 'rating';
|
||||
slug: string;
|
||||
status: Status;
|
||||
type: Type;
|
||||
sku: string;
|
||||
featured: boolean;
|
||||
category: string;
|
||||
tag: string;
|
||||
shipping_class: string;
|
||||
attribute: string;
|
||||
attribute_term: string;
|
||||
tax_class: 'standard' | 'reduced-rate' | 'zero-rate';
|
||||
on_sale: boolean;
|
||||
min_price: string;
|
||||
max_price: string;
|
||||
stock_status: 'instock' | 'outofstock';
|
||||
};
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { getResourceName } from '../utils';
|
||||
import { ProductQuery } from './types';
|
||||
|
||||
const PRODUCT_PREFIX = 'product';
|
||||
|
||||
/**
|
||||
* Generate a resource name for products.
|
||||
*
|
||||
* @param {Object} query Query for products.
|
||||
* @return {string} Resource name for products.
|
||||
*/
|
||||
export function getProductResourceName( query: Partial< ProductQuery > ) {
|
||||
return getResourceName( PRODUCT_PREFIX, query );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a resource name for product totals count.
|
||||
*
|
||||
* It omits query parameters from the identifier that don't affect
|
||||
* totals values like pagination and response field filtering.
|
||||
*
|
||||
* @param {Object} query Query for product totals count.
|
||||
* @return {string} Resource name for product totals.
|
||||
*/
|
||||
export function getTotalProductCountResourceName(
|
||||
query: Partial< ProductQuery >
|
||||
) {
|
||||
// Disable eslint rule because we're using this spread to omit properties
|
||||
// that don't affect item totals count results.
|
||||
// eslint-disable-next-line no-unused-vars, camelcase
|
||||
const { _fields, page, per_page, ...totalsQuery } = query;
|
||||
|
||||
return getProductResourceName( totalsQuery );
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
export * from './wp-data';
|
||||
export * from './rule-processor';
|
||||
export * from './api';
|
||||
export * from './query-params';
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
export type BaseQueryParams< Fields = string > = {
|
||||
context: string;
|
||||
page: number;
|
||||
per_page: number;
|
||||
search: string;
|
||||
after: string;
|
||||
before: string;
|
||||
exclude: string;
|
||||
include: string;
|
||||
offset: number;
|
||||
order: 'asc' | 'desc';
|
||||
orderby: 'date' | 'id' | 'include' | 'title' | 'slug';
|
||||
parent: number[];
|
||||
parent_exclude: number[];
|
||||
_fields: Fields[];
|
||||
};
|
|
@ -1,26 +0,0 @@
|
|||
export function getResourceName( prefix, identifier ) {
|
||||
const identifierString = JSON.stringify(
|
||||
identifier,
|
||||
Object.keys( identifier ).sort()
|
||||
);
|
||||
return `${ prefix }:${ identifierString }`;
|
||||
}
|
||||
|
||||
export function getResourcePrefix( resourceName ) {
|
||||
const hasPrefixIndex = resourceName.indexOf( ':' );
|
||||
return hasPrefixIndex < 0
|
||||
? resourceName
|
||||
: resourceName.substring( 0, hasPrefixIndex );
|
||||
}
|
||||
|
||||
export function isResourcePrefix( resourceName, prefix ) {
|
||||
const resourcePrefix = getResourcePrefix( resourceName );
|
||||
return resourcePrefix === prefix;
|
||||
}
|
||||
|
||||
export function getResourceIdentifier( resourceName ) {
|
||||
const identifierString = resourceName.substring(
|
||||
resourceName.indexOf( ':' ) + 1
|
||||
);
|
||||
return JSON.parse( identifierString );
|
||||
}
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { addQueryArgs } from '@wordpress/url';
|
||||
import { apiFetch } from '@wordpress/data-controls';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { BaseQueryParams } from './types/query-params';
|
||||
import { fetchWithHeaders } from './controls';
|
||||
|
||||
export function getResourceName(
|
||||
prefix: string,
|
||||
identifier: Record< string, unknown >
|
||||
) {
|
||||
const identifierString = JSON.stringify(
|
||||
identifier,
|
||||
Object.keys( identifier ).sort()
|
||||
);
|
||||
return `${ prefix }:${ identifierString }`;
|
||||
}
|
||||
|
||||
export function getResourcePrefix( resourceName: string ) {
|
||||
const hasPrefixIndex = resourceName.indexOf( ':' );
|
||||
return hasPrefixIndex < 0
|
||||
? resourceName
|
||||
: resourceName.substring( 0, hasPrefixIndex );
|
||||
}
|
||||
|
||||
export function isResourcePrefix( resourceName: string, prefix: string ) {
|
||||
const resourcePrefix = getResourcePrefix( resourceName );
|
||||
return resourcePrefix === prefix;
|
||||
}
|
||||
|
||||
export function getResourceIdentifier( resourceName: string ) {
|
||||
const identifierString = resourceName.substring(
|
||||
resourceName.indexOf( ':' ) + 1
|
||||
);
|
||||
return JSON.parse( identifierString );
|
||||
}
|
||||
|
||||
export function* request< Query extends BaseQueryParams, DataType >(
|
||||
namespace: string,
|
||||
query: Partial< Query >
|
||||
) {
|
||||
const url: string = addQueryArgs( namespace, query );
|
||||
const isUnboundedRequest = query.per_page === -1;
|
||||
const fetch = isUnboundedRequest ? apiFetch : fetchWithHeaders;
|
||||
const response:
|
||||
| DataType[]
|
||||
| ( { data: DataType[] } & Response ) = yield fetch( {
|
||||
path: url,
|
||||
method: 'GET',
|
||||
} );
|
||||
|
||||
if ( isUnboundedRequest && ! ( 'data' in response ) ) {
|
||||
return { items: response, totalCount: response.length };
|
||||
}
|
||||
if ( ! isUnboundedRequest && 'data' in response ) {
|
||||
const totalCount = parseInt(
|
||||
response.headers.get( 'x-wp-total' ) || '',
|
||||
10
|
||||
);
|
||||
|
||||
return { items: response.data, totalCount };
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue