import { ActionCreatorWithoutPayload, ActionCreatorWithPayload, ActionReducerMapBuilder, AsyncThunk, Draft, } from "@reduxjs/toolkit"; import { difference, findIndex, isNull, isString, omit, pullAll, pullAllWith, } from "lodash"; import { ReducerUtility } from "."; import { conditionalLog } from "../../utilities/logger"; interface ActionParam { range?: AsyncThunk; all?: AsyncThunk; ids?: AsyncThunk; removeIds?: ActionCreatorWithPayload; reset?: ActionCreatorWithoutPayload; dirty?: ID extends null ? ActionCreatorWithoutPayload : ActionCreatorWithPayload; } export function createAsyncItemReducer( builder: ActionReducerMapBuilder, getItem: (state: Draft) => Draft>, actions: Pick, "all" | "dirty"> ) { const { all, dirty } = actions; all && builder .addCase(all.pending, (state) => { const item = getItem(state); item.state = "loading"; item.error = null; }) .addCase(all.fulfilled, (state, action) => { const item = getItem(state); item.state = "succeeded"; item.content = action.payload as Draft; }) .addCase(all.rejected, (state, action) => { const item = getItem(state); item.state = "failed"; item.error = action.error.message ?? null; }); dirty && builder.addCase(dirty, (state) => { const item = getItem(state); if (item.state !== "uninitialized") { item.state = "dirty"; } }); } export function createAsyncListReducer( builder: ActionReducerMapBuilder, getList: (state: Draft) => Draft>, actions: ActionParam ) { const { ids, removeIds, all, dirty } = actions; ids && builder .addCase(ids.pending, (state) => { const list = getList(state); list.state = "loading"; list.error = null; }) .addCase(ids.fulfilled, (state, action) => { const list = getList(state); const { meta: { arg }, } = action; const strIds = arg.map(String); const keyName = list.keyName as keyof T; action.payload.forEach((v) => { const idx = findIndex(list.content, [keyName, v[keyName]]); if (idx !== -1) { list.content.splice(idx, 1, v as Draft); } else { list.content.unshift(v as Draft); } }); ReducerUtility.updateDirty(list, strIds); ReducerUtility.updateDidLoaded(list, strIds); }) .addCase(ids.rejected, (state, action) => { const list = getList(state); list.state = "failed"; list.error = action.error.message ?? null; }); removeIds && builder.addCase(removeIds, (state, action) => { const list = getList(state); const keyName = list.keyName as keyof T; const removeIds = action.payload.map(String); pullAllWith(list.content, removeIds, (lhs, rhs) => { return String((lhs as T)[keyName]) === rhs; }); ReducerUtility.removeDirty(list, removeIds); ReducerUtility.removeDidLoaded(list, removeIds); }); all && builder .addCase(all.pending, (state) => { const list = getList(state); list.state = "loading"; list.error = null; }) .addCase(all.fulfilled, (state, action) => { const list = getList(state); list.state = "succeeded"; list.content = action.payload as Draft; list.dirtyEntities = []; const ids = action.payload.map((v) => String(v[list.keyName as keyof T]) ); ReducerUtility.updateDidLoaded(list, ids); }) .addCase(all.rejected, (state, action) => { const list = getList(state); list.state = "failed"; list.error = action.error.message ?? null; }); dirty && builder.addCase(dirty, (state, action) => { const list = getList(state); ReducerUtility.markDirty(list, action.payload.map(String)); }); } export function createAsyncEntityReducer( builder: ActionReducerMapBuilder, getEntity: (state: Draft) => Draft>, actions: ActionParam, ID> ) { const { all, removeIds, ids, range, dirty, reset } = actions; const checkSizeUpdate = (entity: Draft>, newSize: number) => { if (entity.content.ids.length !== newSize) { // Reset Entity State entity.dirtyEntities = []; entity.content.ids = Array(newSize).fill(null); entity.content.entities = {}; } }; range && builder .addCase(range.pending, (state) => { const entity = getEntity(state); entity.state = "loading"; entity.error = null; }) .addCase(range.fulfilled, (state, action) => { const entity = getEntity(state); const { meta: { arg: { start, length }, }, payload: { data, total }, } = action; const keyName = entity.content.keyName as keyof T; checkSizeUpdate(entity, total); data.forEach((v) => { const key = String(v[keyName]); entity.content.entities[key] = v as Draft; }); const idsToUpdate = data.map((v) => String(v[keyName])); // Remove duplicated ids const pulledSize = total - pullAll(entity.content.ids, idsToUpdate).length; entity.content.ids.push(...Array(pulledSize).fill(null)); entity.content.ids.splice(start, length, ...idsToUpdate); ReducerUtility.updateDirty(entity, idsToUpdate); ReducerUtility.updateDidLoaded(entity, idsToUpdate); }) .addCase(range.rejected, (state, action) => { const entity = getEntity(state); entity.state = "failed"; entity.error = action.error.message ?? null; }); ids && builder .addCase(ids.pending, (state) => { const entity = getEntity(state); entity.state = "loading"; entity.error = null; }) .addCase(ids.fulfilled, (state, action) => { const entity = getEntity(state); const { meta: { arg }, payload: { data, total }, } = action; const keyName = entity.content.keyName as keyof T; checkSizeUpdate(entity, total); const idsToAdd = data.map((v) => String(v[keyName])); // For new ids, remove null from list and add them const newIds = difference( idsToAdd, entity.content.ids.filter(isString) ); const newSize = entity.content.ids.unshift(...newIds); Array(newSize - total) .fill(undefined) .forEach(() => { const idx = entity.content.ids.findIndex(isNull); conditionalLog(idx === -1, "Error when deleting ids from entity"); entity.content.ids.splice(idx, 1); }); data.forEach((v) => { const key = String(v[keyName]); entity.content.entities[key] = v as Draft; }); const allIds = arg.map(String); ReducerUtility.updateDirty(entity, allIds); ReducerUtility.updateDidLoaded(entity, allIds); }) .addCase(ids.rejected, (state, action) => { const entity = getEntity(state); entity.state = "failed"; entity.error = action.error.message ?? null; }); removeIds && builder.addCase(removeIds, (state, action) => { const entity = getEntity(state); conditionalLog( entity.state === "loading", "Try to delete async entity when it's now loading" ); const idsToDelete = action.payload.map(String); pullAll(entity.content.ids, idsToDelete); ReducerUtility.removeDirty(entity, idsToDelete); ReducerUtility.removeDidLoaded(entity, idsToDelete); omit(entity.content.entities, idsToDelete); }); all && builder .addCase(all.pending, (state) => { const entity = getEntity(state); entity.state = "loading"; entity.error = null; }) .addCase(all.fulfilled, (state, action) => { const entity = getEntity(state); const { payload: { data, total }, } = action; conditionalLog( data.length !== total, "Length of data is mismatch with total length" ); const keyName = entity.content.keyName as keyof T; entity.state = "succeeded"; entity.dirtyEntities = []; entity.content.ids = data.map((v) => String(v[keyName])); entity.content.entities = data.reduce< Draft<{ [id: string]: T; }> >((prev, curr) => { const id = String(curr[keyName]); prev[id] = curr as Draft; return prev; }, {}); const allIds = entity.content.ids.filter(isString); ReducerUtility.updateDidLoaded(entity, allIds); }) .addCase(all.rejected, (state, action) => { const entity = getEntity(state); entity.state = "failed"; entity.error = action.error.message ?? null; }); dirty && builder.addCase(dirty, (state, action) => { const entity = getEntity(state); ReducerUtility.markDirty(entity, action.payload.map(String)); }); reset && builder.addCase(reset, (state) => { const entity = getEntity(state); entity.content.entities = {}; entity.content.ids = []; entity.didLoaded = []; entity.dirtyEntities = []; entity.error = null; entity.state = "uninitialized"; }); }