/** * Dedicated @wordpress/data store for the bulk-labels modal's * fetch-once resources. * * The orders shipping-context is a GET-by-ids collection, so it's a * core-data *entity* (see register-entity.ts). These two aren't: * - GET /wcshipping/v1/packages is a singleton object, and * - POST /wcshipping/v1/label/auto-assign-packages is body-driven, * neither of which maps onto a core-data entity. A small store with * resolvers is the idiomatic @wordpress/data tool for them — it gives * the same automatic caching / dedupe / resolution-state the entity * pattern does, without each hook hand-rolling apiFetch + useState. */ import { createReduxStore } from '@wordpress/data'; import { apiFetch, controls as wpControls } from '@wordpress/data-controls'; import { addQueryArgs } from '@wordpress/url'; import { BULK_LABELS_STORE_NAME } from 'data/constants'; import { getAutoAssignPackagesPath, getBatchLabelPurchasePath, getPackagesPath, } from 'data/routes'; // Mirror the single-label rate/purchase flow: without this the // /wcshipping/v1/packages response filters out the FedEx & UPSDAP // predefined schema, so saved predefined boxes lose their dimensions // (dropped from the dropdown) and auto-assigned FedEx/UPSDAP boxes // can't be rated. const FEATURES_SUPPORTED_BY_CLIENT = [ 'upsdap', 'fedex' ]; import { buildAssignablePackages, type RawPackagesResponse, } from './packages-transform'; import type { AssignablePackage, AutoAssignedPackagesMap, BatchPurchaseResponse, BatchPurchaseShipment, } from './types'; interface BulkLabelsState { assignablePackages?: AssignablePackage[]; autoAssigned: Record< string, AutoAssignedPackagesMap >; } const DEFAULT_STATE: BulkLabelsState = { assignablePackages: undefined, autoAssigned: {}, }; const SET_ASSIGNABLE_PACKAGES = 'SET_ASSIGNABLE_PACKAGES'; const SET_AUTO_ASSIGNED_PACKAGES = 'SET_AUTO_ASSIGNED_PACKAGES'; /** Stable cache key for an auto-assign batch, order-independent. */ const autoAssignKey = ( orderIds: number[] ): string => [ ...orderIds ].sort( ( a, b ) => a - b ).join( ',' ); const actions = { setAssignablePackages( packages: AssignablePackage[] ) { return { type: SET_ASSIGNABLE_PACKAGES, packages } as const; }, setAutoAssignedPackages( key: string, results: AutoAssignedPackagesMap ) { return { type: SET_AUTO_ASSIGNED_PACKAGES, key, results } as const; }, *purchaseBatchLabels( origin: Record< string, unknown >, shipments: BatchPurchaseShipment[], signal?: AbortSignal ): Generator< ReturnType< typeof apiFetch >, BatchPurchaseResponse, BatchPurchaseResponse > { const response: BatchPurchaseResponse = yield apiFetch( { path: getBatchLabelPurchasePath(), method: 'POST', data: { origin, shipments }, signal, } ); return response ?? {}; }, }; type Action = | ReturnType< typeof actions.setAssignablePackages > | ReturnType< typeof actions.setAutoAssignedPackages >; const reducer = ( state: BulkLabelsState = DEFAULT_STATE, action: Action ): BulkLabelsState => { switch ( action.type ) { case SET_ASSIGNABLE_PACKAGES: return { ...state, assignablePackages: action.packages }; case SET_AUTO_ASSIGNED_PACKAGES: return { ...state, autoAssigned: { ...state.autoAssigned, [ action.key ]: action.results, }, }; default: return state; } }; const selectors = { getAssignablePackages( state: BulkLabelsState ): AssignablePackage[] | undefined { return state.assignablePackages; }, getAutoAssignedPackages( state: BulkLabelsState, orderIds: number[] ): AutoAssignedPackagesMap | undefined { return state.autoAssigned[ autoAssignKey( orderIds ) ]; }, }; const resolvers = { *getAssignablePackages() { const response: RawPackagesResponse = yield apiFetch( { path: addQueryArgs( getPackagesPath(), { features_supported_by_client: FEATURES_SUPPORTED_BY_CLIENT, } ), } ); return actions.setAssignablePackages( buildAssignablePackages( response ) ); }, *getAutoAssignedPackages( orderIds: number[] ) { if ( ! orderIds || orderIds.length === 0 ) { return actions.setAutoAssignedPackages( autoAssignKey( orderIds ?? [] ), {} ); } const response: AutoAssignedPackagesMap = yield apiFetch( { path: getAutoAssignPackagesPath(), method: 'POST', data: { order_ids: orderIds }, } ); return actions.setAutoAssignedPackages( autoAssignKey( orderIds ), response ?? {} ); }, }; /** * Shape of `select( BULK_LABELS_STORE_NAME )` — the data selectors plus * the resolution-state meta-selectors @wordpress/data adds for any * selector that has a resolver. Lets the hooks stay typed without * pulling the (lazily-registered) store descriptor. */ export interface BulkLabelsSelect { getAssignablePackages: () => AssignablePackage[] | undefined; getAutoAssignedPackages: ( orderIds: number[] ) => AutoAssignedPackagesMap | undefined; hasFinishedResolution: ( selector: string, args: unknown[] ) => boolean; getResolutionError: ( selector: string, args: unknown[] ) => unknown; } export interface BulkLabelsDispatch { purchaseBatchLabels: ( origin: Record< string, unknown >, shipments: BatchPurchaseShipment[], signal?: AbortSignal ) => Promise< BatchPurchaseResponse >; } /** * Normalize whatever `getResolutionError()` hands back into an `Error`. * The data layer may return the raw REST error object (`{ code, * message }`) rather than an `Error`, so an `instanceof Error` check * alone silently drops real failures. */ export const toError = ( value: unknown ): Error | null => { if ( ! value ) { return null; } if ( value instanceof Error ) { return value; } if ( typeof value === 'string' ) { return new Error( value ); } if ( typeof value === 'object' ) { const maybe = value as { message?: unknown; code?: unknown }; if ( typeof maybe.message === 'string' && maybe.message ) { return new Error( maybe.message ); } if ( typeof maybe.code === 'string' && maybe.code ) { return new Error( maybe.code ); } } return new Error( 'Request failed.' ); }; export const createBulkLabelsStore = () => createReduxStore( BULK_LABELS_STORE_NAME, { reducer, actions, selectors, resolvers, controls: wpControls, } );