@gjv/redux-slice-factory
Version:
Generic factory functions for common slice data structures
326 lines (291 loc) • 14.5 kB
text/typescript
import {
createEntityAdapter,
createSelector,
createSlice,
CaseReducer,
Comparer,
EntityId,
EntitySelectors,
EntityState as ReduxEntityState,
PayloadAction,
Update,
SerializedError,
createNextState,
} from '@reduxjs/toolkit'
import StatusEnum from '../../constants/StatusEnum'
import EntityState, { IEntityState } from '../../models/entity-state'
import { IMetaSliceReducers, IMetaSliceSelectors, ISlice, ISliceName, ISliceOptions, ISliceSelectors } from '../../types'
import { getISOString } from '../../utilities'
/**
* @public
*/
export interface IEntitySliceReducers <
TSliceState,
TEntity,
TStatusEnum extends keyof typeof StatusEnum | & string = keyof typeof StatusEnum,
TError extends SerializedError = SerializedError
> extends IMetaSliceReducers<TSliceState, TStatusEnum, TError> {
/**
* This will modify the slice. This adds one entity to the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter}
*/
addOne: CaseReducer<TSliceState, PayloadAction<TEntity>>;
/**
* This will modify the slice. This adds many entities to the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter}
*/
addMany: CaseReducer<TSliceState, PayloadAction<Array<TEntity> | Record<EntityId, TEntity>>>;
/**
* This will hydrate the slice. This sets one entity of the slice and lastHydrated, and resets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.setOne}
*/
hydrateOne: CaseReducer<TSliceState, PayloadAction<TEntity>>;
/**
* This will hydrate the slice. This sets many entities of the slice and lastHydrated, and resets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.setMany}
*/
hydrateMany: CaseReducer<TSliceState, PayloadAction<Array<TEntity> | Record<EntityId, TEntity>>>;
/**
* This will hydrate the slice. This sets all entities of the slice and lastHydrated, and resets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.setAll}
*/
hydrateAll: CaseReducer<TSliceState, PayloadAction<Array<TEntity> | Record<EntityId, TEntity>>>;
/**
* This will modify the slice. This updates one entity of the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.updateOne}
*/
updateOne: CaseReducer<TSliceState, PayloadAction<Update<TEntity>>>;
/**
* This will modify the slice. This updates many entities of the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.updateMany}
*/
updateMany: CaseReducer<TSliceState, PayloadAction<Array<Update<TEntity>>>>;
/**
* This will modify the slice. This updates or inserts one entity of the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.upsertOne}
*/
upsertOne: CaseReducer<TSliceState, PayloadAction<TEntity>>;
/**
* This will modify the slice. This updates or inserts many entities of the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.upsertMany}
*/
upsertMany: CaseReducer<TSliceState, PayloadAction<Array<TEntity> | Record<EntityId, TEntity>>>;
/**
* This will modify the slice. This removes one entity of the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.removeOne}
*/
removeOne: CaseReducer<TSliceState, PayloadAction<EntityId>>;
/**
* This will modify the slice. This removes many entities of the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.removeMany}
*/
removeMany: CaseReducer<TSliceState, PayloadAction<Array<EntityId>>>;
/**
* This will modify the slice. This removes all entities of the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.removeAll}
*/
removeAll: CaseReducer<TSliceState, PayloadAction>;
/**
* This will reset the slice to its initial state.
*/
reset: CaseReducer<TSliceState, PayloadAction>;
/**
* This will modify the slice. This sets all entities of the slice and sets lastModified.
* @see {@link @reduxjs/toolkit#EntityStateAdapter.setAll}
*/
setAll: CaseReducer<TSliceState, PayloadAction<Array<TEntity> | Record<EntityId, TEntity>>>;
}
/**
* @public
*/
export interface IEntitySliceSelectors<
TGlobalState,
TEntity,
TStatusEnum extends keyof typeof StatusEnum | & string = keyof typeof StatusEnum,
TError extends SerializedError = SerializedError
> extends
EntitySelectors<TEntity, TGlobalState>,
ISliceSelectors<TGlobalState, IEntityState<TEntity, TStatusEnum, TError>>,
IMetaSliceSelectors<TGlobalState, IEntityState<TEntity, TStatusEnum, TError>, TStatusEnum, TError> {}
/**
* @public
*/
export type IEntitySlice<
TGlobalState,
TEntity,
TStatusEnum extends keyof typeof StatusEnum | & string = keyof typeof StatusEnum,
TError extends SerializedError = SerializedError
> = ISlice<
TGlobalState,
IEntityState<TEntity, TStatusEnum, TError>,
IEntitySliceReducers<IEntityState<TEntity, TStatusEnum, TError>, TEntity, TStatusEnum, TError>,
IEntitySliceSelectors<TGlobalState, TEntity, TStatusEnum, TError>
>
/**
* @public
*/
export interface ICreateEntitySliceOptions<
TGlobalState,
TEntity,
TStatusEnum extends keyof typeof StatusEnum | & string = keyof typeof StatusEnum,
TError extends SerializedError = SerializedError
> extends ISliceOptions<TGlobalState, IEntityState<TEntity, TStatusEnum, TError>> {
selectId: (o: TEntity) => EntityId;
sortComparer: false | Comparer<TEntity>;
}
/**
* @public
*/
function createEntitySlice<
TGlobalState,
TEntity,
TStatusEnum extends keyof typeof StatusEnum |& string = keyof typeof StatusEnum,
TError extends SerializedError = SerializedError
>(options: ICreateEntitySliceOptions<TGlobalState, TEntity, TStatusEnum, TError>): IEntitySlice<TGlobalState, TEntity, TStatusEnum, TError> {
type ISliceState = IEntityState<TEntity, TStatusEnum, TError>
const defaultCanRequestSelector = (sliceState: ISliceState): boolean => sliceState.status !== StatusEnum.Requesting
&& sliceState.error === null
&& sliceState.lastModified === null
const defaultShouldRequestSelector = (sliceState: ISliceState, canRequest: boolean): boolean => canRequest && sliceState.lastHydrated === null
const {
name,
selectId,
sortComparer,
selectSliceState,
selectShouldRequest = defaultShouldRequestSelector,
selectCanRequest = defaultCanRequestSelector,
initialState = {},
createTimestamp = getISOString,
} = options
// intentional, necessary with immer
/* eslint-disable no-param-reassign */
const setEntityState = (state: ISliceState, entityState: ReduxEntityState<TEntity>): void => {
state.ids = entityState.ids
state.entities = entityState.entities
}
const setError = (state: ISliceState, error: TError | null): void => {
state.error = error === null ? null : error
}
const setStatus = (state: ISliceState, status: TStatusEnum): void => {
state.status = status
}
const setLastModified = (state: ISliceState, lastModified: string | null): void => {
state.lastModified = lastModified
}
const setLastHydrated = (state: ISliceState, lastHydrated: string | null): void => {
state.lastHydrated = lastHydrated
}
/* eslint-enable no-param-reassign */
const modifyState = (state: ISliceState, entityState: ReduxEntityState<TEntity>): void => {
setEntityState(state, entityState)
// TODO: should not have a side effect: https://redux.js.org/style-guide/style-guide#reducers-must-not-have-side-effects
setLastModified(state, createTimestamp())
}
const hydrateState = (state: ISliceState, entityState: ReduxEntityState<TEntity>): void => {
setEntityState(state, entityState)
setLastModified(state, null)
// TODO: should not have a side effect: https://redux.js.org/style-guide/style-guide#reducers-must-not-have-side-effects
setLastHydrated(state, createTimestamp())
}
const entityAdapter = createEntityAdapter({
selectId: selectId,
sortComparer: sortComparer,
})
const initialEntityState = entityAdapter.getInitialState(initialState)
const initialSliceState = EntityState.create<TEntity, TStatusEnum, TError>(initialEntityState)
/* eslint-disable no-param-reassign */
const slice = createSlice<ISliceState, IEntitySliceReducers<ISliceState, TEntity, TStatusEnum, TError>, ISliceName<TGlobalState>>({
name: name,
initialState: initialSliceState,
reducers: {
addOne: (state, action) => {
const newEntityState = entityAdapter.addOne(state as ISliceState, action.payload)
modifyState(state as ISliceState, newEntityState)
},
addMany: (state, action) => {
const newEntityState = entityAdapter.addMany(state as ISliceState, action.payload)
modifyState(state as ISliceState, newEntityState)
},
hydrateOne: (state, action) => {
const newEntityState = entityAdapter.upsertOne(state as ISliceState, action.payload)
hydrateState(state as ISliceState, newEntityState)
},
hydrateMany: (state, action) => {
const newEntityState = entityAdapter.upsertMany(state as ISliceState, action.payload)
hydrateState(state as ISliceState, newEntityState)
},
hydrateAll: (state, action) => {
const newEntityState = entityAdapter.setAll(state as ISliceState, action.payload)
hydrateState(state as ISliceState, newEntityState)
},
updateOne: (state, action) => {
const newEntityState = entityAdapter.updateOne(state as ISliceState, action.payload)
modifyState(state as ISliceState, newEntityState)
},
updateMany: (state, action) => {
const newEntityState = entityAdapter.updateMany(state as ISliceState, action.payload)
modifyState(state as ISliceState, newEntityState)
},
upsertOne: (state, action) => {
const newEntityState = entityAdapter.upsertOne(state as ISliceState, action.payload)
modifyState(state as ISliceState, newEntityState)
},
upsertMany: (state, action) => {
const newEntityState = entityAdapter.upsertMany(state as ISliceState, action.payload)
modifyState(state as ISliceState, newEntityState)
},
removeOne: (state, action) => {
const newEntityState = entityAdapter.removeOne(state as ISliceState, action.payload)
modifyState(state as ISliceState, newEntityState)
},
removeMany: (state, action) => {
const newEntityState = entityAdapter.removeMany(state as ISliceState, action.payload)
modifyState(state as ISliceState, newEntityState)
},
removeAll: (state) => {
const newEntityState = entityAdapter.removeAll(state as ISliceState)
modifyState(state as ISliceState, newEntityState)
},
reset: () => initialSliceState,
setAll: (state, action) => {
const newEntityState = entityAdapter.setAll(state as ISliceState, action.payload)
modifyState(state as ISliceState, newEntityState)
},
setError: (state, action) => {
setError(state as ISliceState, action.payload)
},
setStatus: (state, action) => {
setStatus(state as ISliceState, action.payload)
},
setMetaState: (state, action) => {
state.status = createNextState(state.status, () => action.payload.status) ?? state.status
state.error = createNextState(state.error, () => action.payload.error) ?? state.error
},
},
})
/* eslint-disable no-param-reassign */
type ISelectors = IEntitySliceSelectors<TGlobalState, TEntity, TStatusEnum, TError>
const entitySelectors = entityAdapter.getSelectors((state: TGlobalState) => selectSliceState(state))
const selectors: ISelectors = {
selectIds: entitySelectors.selectIds,
selectEntities: entitySelectors.selectEntities,
selectAll: entitySelectors.selectAll,
selectTotal: entitySelectors.selectTotal,
selectById: entitySelectors.selectById,
/* TODO remove type assertion */
selectSliceState: createSelector([selectSliceState], (sliceState) => sliceState) as unknown as ISelectors['selectSliceState'],
selectStatus: createSelector([selectSliceState], (sliceState) => sliceState.status) as unknown as ISelectors['selectStatus'],
selectError: createSelector([selectSliceState], (sliceState) => sliceState.error) as unknown as ISelectors['selectError'],
selectLastModified: createSelector([selectSliceState], (sliceState) => sliceState.lastModified) as unknown as ISelectors['selectLastModified'],
selectLastHydrated: createSelector([selectSliceState], (sliceState) => sliceState.lastHydrated) as unknown as ISelectors['selectLastHydrated'],
selectCanRequest: createSelector([selectSliceState], (sliceState) => selectCanRequest(sliceState)) as unknown as ISelectors['selectCanRequest'],
selectShouldRequest: createSelector([selectSliceState], (sliceState) => selectShouldRequest(sliceState, selectCanRequest(sliceState))) as unknown as ISelectors['selectShouldRequest'],
}
return {
name: slice.name,
reducer: slice.reducer,
actions: slice.actions,
selectors: selectors,
}
}
export default createEntitySlice