UNPKG

@rematch/loading

Version:

Loading indicator plugin for Rematch

342 lines (307 loc) 8.52 kB
import { ExtractRematchDispatchersFromEffects, Plugin, Models, Reducer, NamedModel, Action, } from '@rematch/core' export type LoadingPluginType = 'number' | 'boolean' | 'full' export interface LoadingConfig { name?: string whitelist?: string[] blacklist?: string[] type?: LoadingPluginType /** * @deprecated Use `type: 'number'` instead */ asNumber?: boolean } type PickLoadingPluginType<WhichType extends LoadingPluginType> = WhichType extends 'number' ? number : WhichType extends 'full' ? DetailedPayload : boolean interface LoadingState< TModels extends Models<TModels>, WhichType extends LoadingPluginType > { global: PickLoadingPluginType<WhichType> models: { [modelName in keyof TModels]: PickLoadingPluginType<WhichType> } effects: { [modelName in keyof TModels]: { [effectName in keyof ExtractRematchDispatchersFromEffects< TModels[modelName]['effects'], TModels >]: PickLoadingPluginType<WhichType> } } } interface InitialState<WhichType extends LoadingPluginType> { global: PickLoadingPluginType<WhichType> models: { [modelName: string]: PickLoadingPluginType<WhichType> } effects: { [modelName: string]: { [effectName: string]: PickLoadingPluginType<WhichType> } } } type Converter<WhichType extends LoadingPluginType> = ( cnt: number, detailedPayload?: DetailedPayload ) => PickLoadingPluginType<WhichType> interface LoadingModel< TModels extends Models<TModels>, WhichType extends LoadingPluginType > extends NamedModel<TModels, LoadingState<TModels, WhichType>> { reducers: { hide: Reducer<LoadingState<TModels, WhichType>> show: Reducer<LoadingState<TModels, WhichType>> } } export interface ExtraModelsFromLoading< TModels extends Models<TModels>, TConfig extends LoadingConfig = { type: 'boolean' } > extends Models<TModels> { loading: LoadingModel< TModels, TConfig['type'] extends LoadingPluginType ? TConfig['type'] : 'boolean' > } type DetailedPayload = { error: unknown success: boolean loading?: boolean } const createLoadingAction = <TModels extends Models<TModels>, WhichType extends LoadingPluginType>( converter: Converter<WhichType>, i: number, cntState: InitialState<'number'> ): Reducer<LoadingState<TModels, WhichType>> => ( state, payload: Action<{ name: string action: string detailedPayload: DetailedPayload }>['payload'] ): LoadingState<TModels, WhichType> => { const { name, action, detailedPayload } = payload || { name: '', action: '', } cntState.global += i cntState.models[name] += i cntState.effects[name][action] += i return { ...state, global: converter(cntState.global, detailedPayload), models: { ...state.models, [name]: converter(cntState.models[name], detailedPayload), }, effects: { ...state.effects, [name]: { ...state.effects[name], [action]: converter(cntState.effects[name][action], detailedPayload), }, }, } } const validateConfig = (config: LoadingConfig): void => { if (process.env.NODE_ENV !== 'production') { if (config.name && typeof config.name !== 'string') { throw new Error('loading plugin config name must be a string') } if (config.asNumber && typeof config.asNumber !== 'boolean') { throw new Error('loading plugin config asNumber must be a boolean') } if (config.asNumber) { console.warn( [ '@rematch/loading deprecation warning:', '\n', '"asNumber" property from @rematch/loading is deprecated, consider replacing "asNumber" to "type": "number".', '\n', 'In future Rematch versions, "asNumber" will be removed.', ].join(' ') ) } if (config.whitelist && !Array.isArray(config.whitelist)) { throw new Error( 'loading plugin config whitelist must be an array of strings' ) } if (config.blacklist && !Array.isArray(config.blacklist)) { throw new Error( 'loading plugin config blacklist must be an array of strings' ) } if (config.whitelist && config.blacklist) { throw new Error( 'loading plugin config cannot have both a whitelist & a blacklist' ) } } } function assignExtraPayload<T, B>(insert: boolean, error: T, success: B) { return insert ? { error, success } : null } export default < TModels extends Models<TModels>, TExtraModels extends Models<TModels>, TConfig extends LoadingConfig >( config: TConfig = {} as TConfig ): Plugin< TModels, TExtraModels, ExtraModelsFromLoading< TModels, TConfig extends LoadingConfig ? TConfig : { type: 'boolean' } > > => { validateConfig(config) const loadingModelName = config.name || 'loading' if (config.asNumber) { config.type = 'number' } const isAsNumber = config.type === 'number' const isAsDetailed = config.type === 'full' const converter: Converter<LoadingPluginType> = (cnt, detailedPayload) => { if (isAsNumber) return cnt if (isAsDetailed && detailedPayload) { return { ...detailedPayload, loading: cnt > 0 } as DetailedPayload } if (isAsDetailed) { return { loading: cnt > 0, success: false, error: false } } return cnt > 0 } const loadingInitialState: InitialState<LoadingPluginType> = { global: converter(0), models: {}, effects: {}, } const cntState: InitialState<'number'> = { global: 0, models: {}, effects: {}, } const loading: LoadingModel<TModels, LoadingPluginType> = { name: loadingModelName, reducers: { hide: createLoadingAction(converter, -1, cntState), show: createLoadingAction(converter, 1, cntState), }, state: loadingInitialState as LoadingState<TModels, LoadingPluginType>, } const initialLoadingValue = converter(0) return { config: { models: { loading, }, }, onModel({ name }, rematch): void { // do not run dispatch on "loading" model if (name === loadingModelName) { return } cntState.models[name] = 0 cntState.effects[name] = {} loadingInitialState.models[name] = initialLoadingValue as number loadingInitialState.effects[name] = {} const modelActions = rematch.dispatch[name] // map over effects within models Object.keys(modelActions).forEach((action: string) => { if (rematch.dispatch[name][action].isEffect === false) { return } cntState.effects[name][action] = 0 loadingInitialState.effects[name][action] = initialLoadingValue as number const actionType = `${name}/${action}` // ignore items not in whitelist if (config.whitelist && !config.whitelist.includes(actionType)) { return } // ignore items in blacklist if (config.blacklist && config.blacklist.includes(actionType)) { return } // copy orig effect pointer const origEffect = rematch.dispatch[name][action] // create function with pre & post loading calls const effectWrapper = (...props: any): any => { try { // show loading rematch.dispatch[loadingModelName].show({ name, action, detailedPayload: assignExtraPayload(isAsDetailed, false, false), }) // dispatch the original action const effectResult = origEffect(...props) // check if result is a promise if (effectResult?.then) { // hide loading when promise finishes either with success or error return effectResult .then((r: any) => { rematch.dispatch[loadingModelName].hide({ name, action, detailedPayload: assignExtraPayload( isAsDetailed, false, true ), }) return r }) .catch((err: any) => { rematch.dispatch[loadingModelName].hide({ name, action, detailedPayload: assignExtraPayload( isAsDetailed, err, false ), }) throw err }) } // original action doesn't return a promise so there's nothing to wait for rematch.dispatch[loadingModelName].hide({ name, action, detailedPayload: assignExtraPayload(isAsDetailed, false, true), }) // return the original result of this reducer return effectResult } catch (error) { rematch.dispatch[loadingModelName].hide({ name, action, detailedPayload: assignExtraPayload(isAsDetailed, error, false), }) throw error } } effectWrapper.isEffect = true // replace existing effect with new wrapper rematch.dispatch[name][action] = effectWrapper }) }, } }