/** * Entity Slice Factory */ import { createSlice, createAsyncThunk, PayloadAction, AsyncThunk, } from '@reduxjs/toolkit'; import axios, { AxiosError } from 'axios'; import { fulfilledNotify, rejectNotify, resetNotify, } from '../helpers/notifyStateHandler'; import type { EntitySliceConfig, NotificationState, EntitySliceState, } from '../types/redux'; import type { PaginatedResponse, FetchParams, ApiError } from '../types/api'; import type { BaseEntity } from '../types/entities'; // Internal state type that matches legacy structure for backward compatibility interface InternalSliceState { [key: string]: T[] | boolean | number | NotificationState | unknown[]; loading: boolean; count: number; refetch: boolean; rolesWidgets: unknown[]; notify: NotificationState; } // Type guard to check if response is paginated function isPaginatedResponse( response: unknown, ): response is PaginatedResponse { return ( typeof response === 'object' && response !== null && 'rows' in response && 'count' in response ); } // Type guard to check for axios error function isAxiosError(error: unknown): error is AxiosError { return axios.isAxiosError(error); } // Get singular form of entity name for notifications function getSingularName(name: string, singularName?: string): string { if (singularName) return singularName; // Simple pluralization handling - removes trailing 's' return name.endsWith('s') ? name.slice(0, -1) : name; } // Capitalize first letter function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } /** * Creates a Redux slice for an entity with standard CRUD operations * * @param config - Configuration for the entity slice * @returns Object containing the slice, reducer, and all action creators * * @example * ```typescript * const { slice, actions, reducer } = createEntitySlice({ * name: 'users', * endpoint: 'users', * singularName: 'User' * }) * * export const { fetch, create, update, deleteItem, deleteItemsByIds, uploadCsv, setRefetch } = actions * export default reducer * ``` */ export function createEntitySlice( config: EntitySliceConfig, ) { const { name, endpoint, singularName } = config; const displayName = capitalize(singularName || getSingularName(name)); const pluralDisplayName = capitalize(name); // Create initial state const initialState: InternalSliceState = { [name]: [] as T[], loading: false, count: 0, refetch: false, rolesWidgets: [], notify: { showNotification: false, textNotification: '', typeNotification: '', }, }; // Fetch thunk - gets single entity by ID or list with query const fetch = createAsyncThunk< T | PaginatedResponse, FetchParams, { rejectValue: ApiError } >(`${name}/fetch`, async (data: FetchParams) => { const { id, query } = data; const url = `${endpoint}${query || (id ? `/${id}` : '')}`; const result = await axios.get>(url); if (id) { return result.data as T; } return { rows: (result.data as PaginatedResponse).rows, count: (result.data as PaginatedResponse).count, }; }); // Delete by IDs thunk - bulk delete const deleteItemsByIds = createAsyncThunk< void, string[], { rejectValue: ApiError } >(`${name}/deleteByIds`, async (ids: string[], { rejectWithValue }) => { try { await axios.post(`${endpoint}/deleteByIds`, { data: ids }); } catch (error) { if (isAxiosError(error) && error.response) { return rejectWithValue(error.response.data as ApiError); } throw error; } }); // Delete single item thunk const deleteItem = createAsyncThunk( `${name}/delete${capitalize(name)}`, async (id: string, { rejectWithValue }) => { try { await axios.delete(`${endpoint}/${id}`); } catch (error) { if (isAxiosError(error) && error.response) { return rejectWithValue(error.response.data as ApiError); } throw error; } }, ); // Create thunk const create = createAsyncThunk, { rejectValue: ApiError }>( `${name}/create${capitalize(name)}`, async (data: Partial, { rejectWithValue }) => { try { const result = await axios.post(endpoint, { data }); return result.data; } catch (error) { if (isAxiosError(error) && error.response) { return rejectWithValue(error.response.data as ApiError); } throw error; } }, ); // Update thunk const update = createAsyncThunk< T, { id: string; data: Partial }, { rejectValue: ApiError } >( `${name}/update${capitalize(name)}`, async (payload: { id: string; data: Partial }, { rejectWithValue }) => { try { const result = await axios.put(`${endpoint}/${payload.id}`, { id: payload.id, data: payload.data, }); return result.data; } catch (error) { if (isAxiosError(error) && error.response) { return rejectWithValue(error.response.data as ApiError); } throw error; } }, ); // CSV upload thunk const uploadCsv = createAsyncThunk< { imported: number }, File, { rejectValue: ApiError } >(`${name}/uploadCsv`, async (file: File, { rejectWithValue }) => { try { const formData = new FormData(); formData.append('file', file); formData.append('filename', file.name); const result = await axios.post<{ imported: number }>( `${endpoint}/bulk-import`, formData, { headers: { 'Content-Type': 'multipart/form-data', }, }, ); return result.data; } catch (error) { if (isAxiosError(error) && error.response) { return rejectWithValue(error.response.data as ApiError); } throw error; } }); // Create the slice const slice = createSlice({ name, initialState, reducers: { setRefetch: (state, action: PayloadAction) => { state.refetch = action.payload; }, clearState: (state) => { (state as Record)[name] = []; state.count = 0; }, }, extraReducers: (builder) => { // Fetch handlers builder.addCase(fetch.pending, (state) => { state.loading = true; resetNotify(state); }); builder.addCase(fetch.rejected, (state, action) => { state.loading = false; rejectNotify(state, action); }); builder.addCase(fetch.fulfilled, (state, action) => { const payload = action.payload; if (isPaginatedResponse(payload)) { (state as Record)[name] = payload.rows; state.count = payload.count; } else { (state as Record)[name] = payload; } state.loading = false; }); // Delete by IDs handlers builder.addCase(deleteItemsByIds.pending, (state) => { state.loading = true; resetNotify(state); }); builder.addCase(deleteItemsByIds.fulfilled, (state) => { state.loading = false; fulfilledNotify(state, `${pluralDisplayName} has been deleted`); }); builder.addCase(deleteItemsByIds.rejected, (state, action) => { state.loading = false; rejectNotify(state, action); }); // Delete single item handlers builder.addCase(deleteItem.pending, (state) => { state.loading = true; resetNotify(state); }); builder.addCase(deleteItem.fulfilled, (state) => { state.loading = false; fulfilledNotify(state, `${displayName} has been deleted`); }); builder.addCase(deleteItem.rejected, (state, action) => { state.loading = false; rejectNotify(state, action); }); // Create handlers builder.addCase(create.pending, (state) => { state.loading = true; resetNotify(state); }); builder.addCase(create.fulfilled, (state) => { state.loading = false; fulfilledNotify(state, `${displayName} has been created`); }); builder.addCase(create.rejected, (state, action) => { state.loading = false; rejectNotify(state, action); }); // Update handlers builder.addCase(update.pending, (state) => { state.loading = true; resetNotify(state); }); builder.addCase(update.fulfilled, (state) => { state.loading = false; fulfilledNotify(state, `${displayName} has been updated`); }); builder.addCase(update.rejected, (state, action) => { state.loading = false; rejectNotify(state, action); }); // Upload CSV handlers builder.addCase(uploadCsv.pending, (state) => { state.loading = true; resetNotify(state); }); builder.addCase(uploadCsv.fulfilled, (state) => { state.loading = false; fulfilledNotify(state, `${pluralDisplayName} has been uploaded`); }); builder.addCase(uploadCsv.rejected, (state, action) => { state.loading = false; rejectNotify(state, action); }); }, }); return { slice, reducer: slice.reducer, actions: { ...slice.actions, fetch, create, update, deleteItem, deleteItemsByIds, uploadCsv, }, }; } // Export types for use in components export type EntityActions = ReturnType< typeof createEntitySlice >['actions'];