fronts-client/src/lib/createAsyncResourceBundle/index.ts (459 lines of code) (raw):

import without from 'lodash/without'; import isEqual from 'lodash/isEqual'; import type { Action } from 'types/Action'; import { attemptFriendlyErrorMessage } from 'util/error'; interface BaseResource { id: string; } const FETCH_START = 'FETCH_START'; const FETCH_SUCCESS = 'FETCH_SUCCESS'; const FETCH_SUCCESS_IGNORE = 'FETCH_SUCCESS_IGNORE'; // clears loading ids for unchanged collections during collection polling const FETCH_ERROR = 'FETCH_ERROR'; const UPDATE_START = 'UPDATE_START'; const UPDATE_SUCCESS = 'UPDATE_SUCCESS'; const UPDATE_ERROR = 'UPDATE_ERROR'; interface FetchStartAction { entity: string; type: 'FETCH_START'; payload: { ids?: string[] | string }; } interface FetchSuccessAction<Resource> { entity: string; type: 'FETCH_SUCCESS'; payload: | { data: Resource | Resource[] | any; order?: string[]; ignoreOrder: false; pagination?: IPagination; time: number; } | { data: Resource | Resource[] | any; ignoreOrder: true; // If this is true, `lastFetchOrder` stays the same time: number; }; } interface FetchSuccessIgnoreAction<Resource> { entity: string; type: 'FETCH_SUCCESS_IGNORE'; payload: { data: Resource | Resource[] | any; time: number; }; } interface FetchErrorAction { entity: string; type: 'FETCH_ERROR'; payload: { error: string; time: number; ids?: string | string[]; }; } interface UpdateStartAction<Resource> { entity: string; type: 'UPDATE_START'; payload: { id?: string | string; data: Resource | any }; } interface UpdateSuccessAction<Resource> { entity: string; type: 'UPDATE_SUCCESS'; payload: { data: Resource | any; id: string; time: number }; } interface UpdateErrorAction { entity: string; type: 'UPDATE_ERROR'; payload: { error: string; id: string; time: number; }; } type Actions<Resource> = | FetchStartAction | FetchSuccessAction<Resource> | FetchSuccessIgnoreAction<Resource> | FetchErrorAction | UpdateStartAction<Resource> | UpdateSuccessAction<Resource> | UpdateErrorAction; const defaultArray = [] as string[]; const globalLoadingIndicator = '@@ALL'; const getStatusIdsFromData = ( data: BaseResource | BaseResource[] | any, ): string[] | string => data instanceof Array ? data.map((resource: BaseResource) => resource.id || '') : data.id || ''; const applyStatusIds = ( currentIds: string[], incomingIds?: string | string[], ) => currentIds.concat(incomingIds || globalLoadingIndicator); const removeStatusIds = ( currentIds: string[], incomingIds: string[] | string = '', ): string[] => incomingIds instanceof Array ? without(currentIds, ...incomingIds, globalLoadingIndicator) : without<string>(currentIds, incomingIds); function formatIncomingResourceData<Resource extends BaseResource>( data: { [id: string]: Resource } | {}, newData: Resource | Resource[], resourceName: string, ): Resource | { [id: string]: Resource } { if (newData instanceof Array) { const result: { [id: string]: Resource } = { ...data, ...newData.reduce((acc, model: BaseResource, index) => { if (!model.id) { throw new Error( `[asyncResourceBundle]: Cannot apply new data - incoming resource ${resourceName} is missing ID at index ${index}.`, ); } return { ...acc, [model.id]: model, }; }, {}), }; return result; } if (!newData.id) { throw new Error( `[asyncResourceBundle]: Cannot apply new data - incoming resource ${resourceName} with keys ${Object.keys( newData, ).join(', ')} is missing id.`, ); } return { ...data, [newData.id]: newData, }; } function getOrderFromIncomingResourceData<Resource extends BaseResource>( newData: Resource | Resource[], resourceName: string, currentOrder: string[] = defaultArray, newOrder?: string[], ): string[] { const order = newOrder || (newData instanceof Array ? (newData as Resource[]).map((model, index) => { if (!model.id) { throw new Error( `[asyncResourceBundle]: Cannot apply new data - incoming resource ${resourceName} is missing ID at index ${index}.`, ); } return model.id; }) : []); return isEqual(currentOrder, order) ? currentOrder : order; } interface IPagination { pageSize: number; totalPages: number; currentPage: number; } interface State<Resource> { data: Resource | { [id: string]: Resource } | any; pagination: IPagination | null; lastError: string | null; error: string | null; lastSuccessfulFetchTimestamp: number | null; loadingIds: string[]; updatingIds: string[]; // The ids of the resources that were last added to the state, in the order they came in. // Used to store order information when indexById is true -- see the resource creation options. lastFetchOrder?: string[]; } // @todo -- figure out a way to provide root state definition // without circular dependencies type RootState = any; /** * Creates a bundle of actions, selectors, and a reducer to handle * common actions and selections for data that needs to be fetched: * start, success and error actions, storing and selecting error states, * and storing and selecting staleness data, as well as storing the * fetched data itself. * * Consumers can add add their own actions and selectors, and extend * the given reducer, to provide additional functionality. */ function createAsyncResourceBundle<Resource>( // The name of the entity for which this reducer is responsible entityName: string, options: { // The key the reducer provided by this bundle is mounted at. // Defaults to entityName if none is given. selectLocalState?: (state: RootState) => State<Resource>; // Do we index the incoming data by id, or just add it to the state as-is? indexById?: boolean; // Provides a namespace for the created actions, separated by a slash, // e.g.the resource 'books' namespaced with 'shared' becomes SHARED/BOOKS namespace?: string; // The initial state of the reducer data. Defaults to an empty object. initialData?: unknown; } = { indexById: false, }, ) { const { indexById } = options; const selectLocalState = options.selectLocalState ? options.selectLocalState : (state: any): State<Resource> => state[entityName]; const selectPagination = (state: RootState) => selectLocalState(state).pagination; const selectCurrentError = (state: RootState) => selectLocalState(state).error; const selectLastError = (state: RootState) => selectLocalState(state).lastError; const selectLastSuccessfulFetchTimestamp = (state: RootState) => selectLocalState(state).lastSuccessfulFetchTimestamp; const selectIsLoading = (state: RootState) => !!selectLocalState(state).loadingIds.length; const selectIsLoadingById = (state: RootState, id: string) => selectLocalState(state).loadingIds.indexOf(id) !== -1; const selectById = (state: RootState, id: string): Resource | undefined => selectLocalState(state).data[id]; const selectIsLoadingInitialDataById = (state: RootState, id: string) => !selectById(state, id) && selectLocalState(state).loadingIds.indexOf(id) !== -1; const selectLastFetchOrder = (state: RootState): string[] => selectLocalState(state).lastFetchOrder || defaultArray; const selectAll = (state: RootState) => selectLocalState(state)?.data || {}; const initialState: State<Resource> = { data: options.initialData || {}, pagination: null, lastError: null, error: null, lastSuccessfulFetchTimestamp: null, loadingIds: [], updatingIds: [], }; const fetchStartAction = (ids?: string[] | string): FetchStartAction => ({ entity: entityName, type: FETCH_START, payload: { ids }, }); const fetchSuccessAction = ( data: Resource | Resource[] | any, { pagination, order, ignoreOrder, }: | { ignoreOrder?: undefined; pagination?: IPagination; order?: string[] } | { ignoreOrder: boolean; pagination?: undefined; order?: undefined; } = {}, ): FetchSuccessAction<Resource> => { const time = Date.now(); return { entity: entityName, type: FETCH_SUCCESS, payload: ignoreOrder ? { data, ignoreOrder, time } : { data, ignoreOrder: false, pagination, order, time }, }; }; const fetchSuccessIgnoreAction = ( data: Resource | Resource[] | any, ): FetchSuccessIgnoreAction<Resource> => ({ entity: entityName, type: FETCH_SUCCESS_IGNORE, payload: { data, time: Date.now() }, }); const fetchErrorAction = ( error: unknown, ids?: string | string[], ): FetchErrorAction => ({ entity: entityName, type: FETCH_ERROR, payload: { error: attemptFriendlyErrorMessage(error), ids, time: Date.now(), }, }); const updateStartAction = (data: Resource): UpdateStartAction<Resource> => ({ entity: entityName, type: UPDATE_START, payload: { data }, }); const updateSuccessAction = ( id: string, data?: Resource, ): UpdateSuccessAction<Resource> => ({ entity: entityName, type: UPDATE_SUCCESS, payload: { id, data, time: Date.now() }, }); const updateErrorAction = ( error: unknown, id: string, ): UpdateErrorAction => ({ entity: entityName, type: UPDATE_ERROR, payload: { error: attemptFriendlyErrorMessage(error), id, time: Date.now(), }, }); const isAction = ( action: Actions<Resource> | Action, ): action is Actions<Resource> => { return (action as Actions<Resource>).entity !== undefined; }; return { initialState, reducer: ( state: State<Resource> = initialState, action: Actions<Resource> | Action, ): State<Resource> => { if (!isAction(action)) { return state; } // The entity property lets us scope by module, whilst keeping // the 'type' property typed as string literal unions. if (action.entity !== entityName) { return state; } switch (action.type) { case FETCH_START: { return { ...state, loadingIds: applyStatusIds(state.loadingIds, action.payload.ids), }; } case FETCH_SUCCESS: { const pagination = action.payload.ignoreOrder || isEqual(state.pagination, action.payload.pagination) ? state.pagination : action.payload.pagination || null; const lastFetchOrder = action.payload.ignoreOrder ? state.lastFetchOrder : getOrderFromIncomingResourceData( action.payload.data, entityName, state.lastFetchOrder, action.payload.order, ); return { ...state, data: !indexById ? action.payload.data : formatIncomingResourceData( state.data, action.payload.data, entityName, ), // Only update pagination if the values have changed. This saves components // having to rerender when pagination information hasn't changed. pagination, lastSuccessfulFetchTimestamp: action.payload.time, error: null, loadingIds: indexById ? removeStatusIds( state.loadingIds, getStatusIdsFromData(action.payload.data), ) : [], lastFetchOrder, }; } case FETCH_SUCCESS_IGNORE: { return { ...state, error: null, lastSuccessfulFetchTimestamp: action.payload.time, loadingIds: indexById ? removeStatusIds( state.loadingIds, getStatusIdsFromData(action.payload.data), ) : [], }; } case FETCH_ERROR: { if ( !action.payload || !action.payload.error || !action.payload.time ) { return state; } return { ...state, lastError: action.payload.error, error: action.payload.error, loadingIds: indexById ? removeStatusIds(state.loadingIds, action.payload.ids) : [], }; } case UPDATE_START: { return { ...state, data: !indexById ? action.payload.data : formatIncomingResourceData( state.data, action.payload.data, entityName, ), updatingIds: applyStatusIds( state.updatingIds, indexById ? action.payload.data.id : undefined, ), }; } case UPDATE_SUCCESS: { let data; if (action.payload.data) { data = !indexById ? action.payload.data : formatIncomingResourceData( state.data, action.payload.data, entityName, ); } else { data = state.data; // eslint-disable-line prefer-destructuring } return { ...state, data, lastSuccessfulFetchTimestamp: action.payload.time, error: null, updatingIds: removeStatusIds(state.updatingIds, action.payload.id), }; } case UPDATE_ERROR: { return { ...state, error: action.payload.error, lastError: action.payload.error, updatingIds: removeStatusIds(state.updatingIds, action.payload.id), }; } default: { return state; } } }, selectLocalState, actionNames: { fetchStart: FETCH_START, fetchSuccess: FETCH_SUCCESS, fetchSuccessIgnore: FETCH_SUCCESS_IGNORE, fetchError: FETCH_ERROR, updateStart: UPDATE_START, updateSuccess: UPDATE_SUCCESS, updateError: UPDATE_ERROR, }, actions: { fetchStart: fetchStartAction, fetchSuccess: fetchSuccessAction, fetchSuccessIgnore: fetchSuccessIgnoreAction, fetchError: fetchErrorAction, updateStart: updateStartAction, updateSuccess: updateSuccessAction, updateError: updateErrorAction, }, selectors: { selectPagination, selectCurrentError, selectLastError, selectLastSuccessfulFetchTimestamp, selectIsLoading, selectIsLoadingById, selectIsLoadingInitialDataById, selectById, selectLastFetchOrder, selectAll, }, }; } export { Actions, State, IPagination, globalLoadingIndicator }; export default createAsyncResourceBundle;