@rematch/loading
Version:
Loading indicator plugin for Rematch
342 lines (307 loc) • 8.52 kB
text/typescript
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
})
},
}
}