Order product count api requests orders store (#33063)

* Add initial Order redux store

* Add unit tests for orders store

* Update fetch with headers to parse error

* Update action names, and consolidate request function

* Add chnagelog

* Update resolver to always include id and update reducer to prevent data overwrite
This commit is contained in:
louwie17 2022-05-27 11:12:00 -03:00 committed by GitHub
parent 0f0b2d5064
commit 83686b2980
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 773 additions and 7 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add new orders data store, for retrieving orders data. #33063

View File

@ -35,7 +35,12 @@ const controls = {
headers,
status,
data,
} ) );
} ) )
.catch( ( response ) => {
return response.json().then( ( data: unknown ) => {
throw data;
} );
} );
},
};

View File

@ -1,8 +1,3 @@
/**
* Internal dependencies
*/
import { RestApiError } from '../types';
export type SettingProperties = {
label?: string;
label_class?: string[];

View File

@ -17,6 +17,7 @@ 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 { ORDERS_STORE_NAME } from './orders';
export { PaymentGateway } from './payment-gateways/types';
// Export hooks
@ -69,6 +70,7 @@ export * from './countries/types';
export * from './onboarding/types';
export * from './plugins/types';
export * from './products/types';
export * from './orders/types';
/**
* Internal dependencies
@ -86,6 +88,7 @@ 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';
import type { ORDERS_STORE_NAME } from './orders';
export type WCDataStoreName =
| typeof REVIEWS_STORE_NAME
@ -100,7 +103,8 @@ export type WCDataStoreName =
| typeof ITEMS_STORE_NAME
| typeof COUNTRIES_STORE_NAME
| typeof PAYMENT_GATEWAYS_STORE_NAME
| typeof PRODUCTS_STORE_NAME;
| typeof PRODUCTS_STORE_NAME
| typeof ORDERS_STORE_NAME;
/**
* Internal dependencies
@ -111,6 +115,7 @@ import { PluginSelectors } from './plugins/selectors';
import { OnboardingSelectors } from './onboarding/selectors';
import { OptionsSelectors } from './options/types';
import { ProductsSelectors } from './products/selectors';
import { OrdersSelectors } from './orders/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.
@ -140,6 +145,8 @@ export type WCSelectorType< T > = T extends typeof REVIEWS_STORE_NAME
? WPDataSelectors
: T extends typeof PRODUCTS_STORE_NAME
? ProductsSelectors
: T extends typeof ORDERS_STORE_NAME
? OrdersSelectors
: never;
export interface WCDataSelector {

View File

@ -0,0 +1,10 @@
export enum TYPES {
GET_ORDER_SUCCESS = 'GET_ORDER_SUCCESS',
GET_ORDER_ERROR = 'GET_ORDER_ERROR',
GET_ORDERS_SUCCESS = 'GET_ORDERS_SUCCESS',
GET_ORDERS_ERROR = 'GET_ORDERS_ERROR',
GET_ORDERS_TOTAL_COUNT_SUCCESS = 'GET_ORDERS_TOTAL_COUNT_SUCCESS',
GET_ORDERS_TOTAL_COUNT_ERROR = 'GET_ORDERS_TOTAL_COUNT_ERROR',
}
export default TYPES;

View File

@ -0,0 +1,76 @@
/**
* Internal dependencies
*/
import TYPES from './action-types';
import { PartialOrder, OrdersQuery } from './types';
export function getOrderSuccess( id: number, order: PartialOrder ) {
return {
type: TYPES.GET_ORDER_SUCCESS as const,
id,
order,
};
}
export function getOrderError( query: Partial< OrdersQuery >, error: unknown ) {
return {
type: TYPES.GET_ORDER_ERROR as const,
query,
error,
};
}
export function getOrdersSuccess(
query: Partial< OrdersQuery >,
orders: PartialOrder[],
totalCount: number
) {
return {
type: TYPES.GET_ORDERS_SUCCESS as const,
orders,
query,
totalCount,
};
}
export function getOrdersError(
query: Partial< OrdersQuery >,
error: unknown
) {
return {
type: TYPES.GET_ORDERS_ERROR as const,
query,
error,
};
}
export function getOrdersTotalCountSuccess(
query: Partial< OrdersQuery >,
totalCount: number
) {
return {
type: TYPES.GET_ORDERS_TOTAL_COUNT_SUCCESS as const,
query,
totalCount,
};
}
export function getOrdersTotalCountError(
query: Partial< OrdersQuery >,
error: unknown
) {
return {
type: TYPES.GET_ORDERS_TOTAL_COUNT_ERROR as const,
query,
error,
};
}
export type Actions = ReturnType<
| typeof getOrderSuccess
| typeof getOrderError
| typeof getOrdersSuccess
| typeof getOrdersError
| typeof getOrdersTotalCountSuccess
| typeof getOrdersTotalCountError
>;

View File

@ -0,0 +1,3 @@
export const STORE_NAME = 'wc/admin/orders';
export const WC_ORDERS_NAMESPACE = '/wc/v3/orders';

View File

@ -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, { OrdersState, State } from './reducer';
import controls from '../controls';
registerStore< State >( STORE_NAME, {
reducer: reducer as Reducer< OrdersState >,
actions,
controls,
selectors,
resolvers,
} );
export const ORDERS_STORE_NAME = STORE_NAME;

View File

@ -0,0 +1,104 @@
/**
* External dependencies
*/
import { Reducer } from 'redux';
/**
* Internal dependencies
*/
import TYPES from './action-types';
import { Actions } from './actions';
import { PartialOrder } from './types';
import { getOrderResourceName, getTotalOrderCountResourceName } from './utils';
export type OrdersState = {
orders: Record<
string,
{
data: number[];
}
>;
ordersCount: Record< string, number >;
errors: Record< string, unknown >;
data: Record< number, PartialOrder >;
};
const reducer: Reducer< OrdersState, Actions > = (
state = {
orders: {},
ordersCount: {},
errors: {},
data: {},
},
payload
) => {
if ( payload && 'type' in payload ) {
switch ( payload.type ) {
case TYPES.GET_ORDER_SUCCESS:
const orderData = state.data || {};
return {
...state,
data: {
...orderData,
[ payload.id ]: {
...( orderData[ payload.id ] || {} ),
...payload.order,
},
},
};
case TYPES.GET_ORDERS_SUCCESS:
const ids: number[] = [];
const nextOrders = payload.orders.reduce<
Record< number, PartialOrder >
>( ( result, order ) => {
ids.push( order.id );
result[ order.id ] = {
...( state.data[ order.id ] || {} ),
...order,
};
return result;
}, {} );
const resourceName = getOrderResourceName( payload.query );
return {
...state,
orders: {
...state.orders,
[ resourceName ]: { data: ids },
},
data: {
...state.data,
...nextOrders,
},
};
case TYPES.GET_ORDERS_TOTAL_COUNT_SUCCESS:
const totalResourceName = getTotalOrderCountResourceName(
payload.query
);
return {
...state,
ordersCount: {
...state.ordersCount,
[ totalResourceName ]: payload.totalCount,
},
};
case TYPES.GET_ORDER_ERROR:
case TYPES.GET_ORDERS_ERROR:
case TYPES.GET_ORDERS_TOTAL_COUNT_ERROR:
return {
...state,
errors: {
...state.errors,
[ getOrderResourceName(
payload.query
) ]: payload.error,
},
};
default:
return state;
}
}
return state;
};
export type State = ReturnType< typeof reducer >;
export default reducer;

View File

@ -0,0 +1,60 @@
/**
* Internal dependencies
*/
import { WC_ORDERS_NAMESPACE } from './constants';
import { Order, OrdersQuery } from './types';
import {
getOrdersError,
getOrdersSuccess,
getOrdersTotalCountError,
getOrdersTotalCountSuccess,
} from './actions';
import { request } from '../utils';
export function* getOrders( query: Partial< OrdersQuery > ) {
// id is always required.
const ordersQuery = {
...query,
};
if (
ordersQuery &&
ordersQuery._fields &&
! ordersQuery._fields.includes( 'id' )
) {
ordersQuery._fields = [ 'id', ...ordersQuery._fields ];
}
try {
const {
items,
totalCount,
}: { items: Order[]; totalCount: number } = yield request<
OrdersQuery,
Order
>( WC_ORDERS_NAMESPACE, ordersQuery );
yield getOrdersTotalCountSuccess( query, totalCount );
yield getOrdersSuccess( query, items, totalCount );
return items;
} catch ( error ) {
yield getOrdersError( query, error );
return error;
}
}
export function* getOrdersTotalCount( query: Partial< OrdersQuery > ) {
try {
const totalsQuery = {
...query,
page: 1,
per_page: 1,
};
const { totalCount } = yield request< OrdersQuery, Order >(
WC_ORDERS_NAMESPACE,
totalsQuery
);
yield getOrdersTotalCountSuccess( query, totalCount );
return totalCount;
} catch ( error ) {
yield getOrdersTotalCountError( query, error );
return error;
}
}

View File

@ -0,0 +1,75 @@
/**
* External dependencies
*/
import createSelector from 'rememo';
/**
* Internal dependencies
*/
import { getOrderResourceName, getTotalOrderCountResourceName } from './utils';
import { OrdersState } from './reducer';
import { OrdersQuery, PartialOrder } from './types';
import { WPDataSelector, WPDataSelectors } from '../types';
export const getOrders = createSelector(
( state: OrdersState, query: OrdersQuery, defaultValue = undefined ) => {
const resourceName = getOrderResourceName( query );
const ids = state.orders[ resourceName ]
? state.orders[ resourceName ].data
: undefined;
if ( ! ids ) {
return defaultValue;
}
if ( query._fields ) {
return ids.map( ( id ) => {
return query._fields.reduce(
( product: PartialOrder, field: keyof PartialOrder ) => {
return {
...product,
[ field ]: state.data[ id ][ field ],
};
},
{} as PartialOrder
);
} );
}
return ids.map( ( id ) => {
return state.data[ id ];
} );
},
( state, query ) => {
const resourceName = getOrderResourceName( query );
const ids = state.orders[ resourceName ]
? state.orders[ resourceName ].data
: [];
return [
state.orders[ resourceName ],
...ids.map( ( id: number ) => {
return state.data[ id ];
} ),
];
}
);
export const getOrdersTotalCount = (
state: OrdersState,
query: OrdersQuery,
defaultValue = undefined
) => {
const resourceName = getTotalOrderCountResourceName( query );
const totalCount = state.ordersCount.hasOwnProperty( resourceName )
? state.ordersCount[ resourceName ]
: defaultValue;
return totalCount;
};
export const getOrdersError = ( state: OrdersState, query: OrdersQuery ) => {
const resourceName = getOrderResourceName( query );
return state.errors[ resourceName ];
};
export type OrdersSelectors = {
getOrders: WPDataSelector< typeof getOrders >;
getOrdersTotalCount: WPDataSelector< typeof getOrdersTotalCount >;
getOrdersError: WPDataSelector< typeof getOrdersError >;
} & WPDataSelectors;

View File

@ -0,0 +1,155 @@
/**
* Internal dependencies
*/
import reducer, { OrdersState } from '../reducer';
import TYPES from '../action-types';
import { getOrderResourceName, getTotalOrderCountResourceName } from '../utils';
import { Actions } from '../actions';
import { PartialOrder, OrdersQuery } from '../types';
const defaultState: OrdersState = {
orders: {},
ordersCount: {},
errors: {},
data: {},
};
describe( 'orders 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_ORDER_SUCCESS', () => {
const itemType = 'guyisms';
const initialState: OrdersState = {
orders: {
[ itemType ]: {
data: [ 1, 2 ],
},
},
ordersCount: {
'total-guyisms:{}': 2,
},
errors: {},
data: {
1: { id: 1, status: 'pending' },
2: { id: 2, status: 'completed' },
},
};
const update: PartialOrder = {
id: 2,
status: 'completed',
};
const state = reducer( initialState, {
type: TYPES.GET_ORDER_SUCCESS,
id: update.id,
order: update,
} );
expect( state.orders ).toEqual( initialState.orders );
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_ORDERS_SUCCESS', () => {
const orders: PartialOrder[] = [ { id: 1 }, { id: 2 } ];
const totalCount = 45;
const query: Partial< OrdersQuery > = { status: 'completed' };
const state = reducer( defaultState, {
type: TYPES.GET_ORDERS_SUCCESS,
orders,
query,
totalCount,
} );
const resourceName = getOrderResourceName( query );
expect( state.orders[ resourceName ].data ).toHaveLength( 2 );
expect( state.orders[ resourceName ].data.includes( 1 ) ).toBeTruthy();
expect( state.orders[ resourceName ].data.includes( 2 ) ).toBeTruthy();
expect( state.data[ 1 ] ).toEqual( orders[ 0 ] );
expect( state.data[ 2 ] ).toEqual( orders[ 1 ] );
} );
it( 'GET_ORDERS_SUCCESS, should only update new order data if added', () => {
const orders: PartialOrder[] = [
{ id: 1 },
{ id: 2, total: '36.00', currency: 'CAD' },
];
const initialState = {
...defaultState,
data: {
1: { id: 1, total: '20.00' },
2: { id: 2, total: '34.00' },
},
};
const totalCount = 45;
const query: Partial< OrdersQuery > = { status: 'completed' };
const state = reducer( initialState, {
type: TYPES.GET_ORDERS_SUCCESS,
orders,
query,
totalCount,
} );
const resourceName = getOrderResourceName( query );
expect( state.data[ 1 ].total ).toEqual( initialState.data[ 1 ].total );
expect( state.data[ 2 ] ).toEqual( orders[ 1 ] );
} );
it( 'should handle GET_ORDERS_TOTAL_COUNT_SUCCESS', () => {
const initialQuery: Partial< OrdersQuery > = {
status: 'completed',
page: 1,
per_page: 1,
_fields: [ 'id' ],
};
const resourceName = getTotalOrderCountResourceName( initialQuery );
const initialState: OrdersState = {
...defaultState,
ordersCount: {
[ resourceName ]: 1,
},
};
// Additional coverage for getTotalCountResourceName().
const similarQueryForTotals: Partial< OrdersQuery > = {
status: 'completed',
page: 2,
per_page: 10,
_fields: [ 'id', 'title', 'status' ],
};
const state = reducer( initialState, {
type: TYPES.GET_ORDERS_TOTAL_COUNT_SUCCESS,
query: similarQueryForTotals,
totalCount: 2,
} );
expect( state.ordersCount ).toEqual( {
[ resourceName ]: 2,
} );
} );
it( 'should handle GET_ORDERS_ERROR', () => {
const query: Partial< OrdersQuery > = { status: 'pending' };
const resourceName = getOrderResourceName( query );
const error = 'Baaam!';
const state = reducer( defaultState, {
type: TYPES.GET_ORDERS_ERROR,
query,
error,
} );
expect( state.errors[ resourceName ] ).toBe( error );
} );
} );

View File

@ -0,0 +1,47 @@
/**
* Internal dependencies
*/
import { OrdersQuery } from '../types';
import { getTotalOrderCountResourceName } from '../utils';
describe( 'getTotalOrderCountResourceName()', () => {
it( "Ignores query params that don't affect total counts", () => {
const fullQuery: Partial< OrdersQuery > = {
page: 2,
per_page: 10,
_fields: [ 'id', 'status', 'total' ],
status: 'completed',
};
const slimQuery: Partial< OrdersQuery > = {
page: 1,
per_page: 1,
_fields: [ 'id' ],
status: 'completed',
};
expect( getTotalOrderCountResourceName( fullQuery ) ).toEqual(
getTotalOrderCountResourceName( slimQuery )
);
} );
it( 'Accounts for query params that do affect total counts', () => {
const firstQuery: Partial< OrdersQuery > = {
page: 2,
per_page: 10,
_fields: [ 'id', 'status', 'total' ],
status: 'completed',
};
const secondQuery: Partial< OrdersQuery > = {
page: 1,
per_page: 1,
_fields: [ 'id' ],
status: 'pending',
};
expect( getTotalOrderCountResourceName( firstQuery ) ).not.toEqual(
getTotalOrderCountResourceName( secondQuery )
);
} );
} );

View File

@ -0,0 +1,162 @@
/**
* External dependencies
*/
import { Schema } from '@wordpress/core-data';
/**
* Internal dependencies
*/
import { BaseQueryParams } from '../types';
export type Address = {
first_name: string;
last_name: string;
company: string;
address_1: string;
address_2: string;
city: string;
state: string;
postcode: string;
country: string;
email: string;
phone: string;
};
export type OrderTax = {
id: number;
total: string;
subtotal: string;
};
export type OrderTaxLine = {
id: number;
rate_code: string;
rate_id: string;
label: string;
compound: boolean;
tax_total: string;
shipping_tax_total: string;
};
export type OrderFeeLine = {
id: number;
name: string;
tax_class: string;
tax_status: string;
total: string;
total_tax: string;
taxes: OrderTax[];
};
export type OrderShippingLine = {
id: number;
method_title: string;
method_id: string;
total: string;
total_tax: string;
taxes: Omit< OrderTax, 'subtotal' >[];
};
export type OrderCoupon = {
id: number;
code: string;
discount: string;
discount_tax: string;
};
export type OrderMetaData = {
key: string;
label: string;
value: unknown;
};
export type OrderRefund = {
id: number;
reason: string;
total: string;
};
export type OrderLineItem = {
id: number;
name: string;
sku: string;
product_id: string | number;
variation_id: number;
quantity: number;
tax_class: string;
price: string;
subtotal: string;
subtotal_tax: string;
total: string;
total_tax: string;
taxes: OrderTax[];
meta_data: OrderMetaData[];
};
export type OrderStatus =
| 'processing'
| 'pending'
| 'on-hold'
| 'completed'
| 'cancelled'
| 'refunded'
| 'failed';
export type Order< Status = OrderStatus > = Omit< Schema.Post, 'status' > & {
id: number;
number: string;
order_key: string;
created_via: string;
status: Status;
currency: string;
version: number;
prices_include_tax: boolean;
customer_id: number;
discount_total: string;
discount_tax: string;
shipping_total: string;
shipping_tax: string;
cart_tax: string;
total: string;
total_tax: string;
billing: Address;
shipping: Address;
payment_method: string;
payment_method_title: string;
set_paid: boolean;
transaction_id: string;
customer_ip_address: string;
customer_user_agent: string;
customer_note: string;
date_completed: string;
date_paid: string;
cart_hash: string;
line_items: OrderLineItem[];
tax_lines: OrderTaxLine[];
shipping_lines: OrderShippingLine[];
fee_lines: OrderFeeLine[];
coupon_lines: OrderCoupon[];
refunds: OrderRefund[];
};
export type PartialOrder = Partial< Order > & Pick< Order, 'id' >;
type OrdersQueryStatus =
| 'any'
| 'pending'
| 'processing'
| 'on-hold'
| 'completed'
| 'cancelled'
| 'refunded'
| 'failed'
| 'trash';
export type OrdersQuery< Status = OrdersQueryStatus > = BaseQueryParams<
keyof Order
> & {
status: Status;
customer: number;
product: number;
dp: number;
};

View File

@ -0,0 +1,37 @@
/**
* Internal dependencies
*/
import { getResourceName } from '../utils';
import { OrdersQuery } from './types';
const PRODUCT_PREFIX = 'order';
/**
* Generate a resource name for orders.
*
* @param {Object} query Query for orders.
* @return {string} Resource name for orders.
*/
export function getOrderResourceName( query: Partial< OrdersQuery > ) {
return getResourceName( PRODUCT_PREFIX, query );
}
/**
* Generate a resource name for order 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 order totals count.
* @return {string} Resource name for order totals.
*/
export function getTotalOrderCountResourceName(
query: Partial< OrdersQuery >
) {
// 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 getOrderResourceName( totalsQuery );
}