347 lines
9.6 KiB
TypeScript
347 lines
9.6 KiB
TypeScript
/**
|
|
* 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<T> {
|
|
[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<T>(
|
|
response: unknown,
|
|
): response is PaginatedResponse<T> {
|
|
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<ApiError> {
|
|
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<User>({
|
|
* name: 'users',
|
|
* endpoint: 'users',
|
|
* singularName: 'User'
|
|
* })
|
|
*
|
|
* export const { fetch, create, update, deleteItem, deleteItemsByIds, uploadCsv, setRefetch } = actions
|
|
* export default reducer
|
|
* ```
|
|
*/
|
|
export function createEntitySlice<T extends BaseEntity>(
|
|
config: EntitySliceConfig,
|
|
) {
|
|
const { name, endpoint, singularName } = config;
|
|
const displayName = capitalize(singularName || getSingularName(name));
|
|
const pluralDisplayName = capitalize(name);
|
|
|
|
// Create initial state
|
|
const initialState: InternalSliceState<T> = {
|
|
[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<T>,
|
|
FetchParams,
|
|
{ rejectValue: ApiError }
|
|
>(`${name}/fetch`, async (data: FetchParams) => {
|
|
const { id, query } = data;
|
|
const url = `${endpoint}${query || (id ? `/${id}` : '')}`;
|
|
const result = await axios.get<T | PaginatedResponse<T>>(url);
|
|
|
|
if (id) {
|
|
return result.data as T;
|
|
}
|
|
|
|
return {
|
|
rows: (result.data as PaginatedResponse<T>).rows,
|
|
count: (result.data as PaginatedResponse<T>).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<void, string, { rejectValue: ApiError }>(
|
|
`${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<T, Partial<T>, { rejectValue: ApiError }>(
|
|
`${name}/create${capitalize(name)}`,
|
|
async (data: Partial<T>, { rejectWithValue }) => {
|
|
try {
|
|
const result = await axios.post<T>(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<T> },
|
|
{ rejectValue: ApiError }
|
|
>(
|
|
`${name}/update${capitalize(name)}`,
|
|
async (payload: { id: string; data: Partial<T> }, { rejectWithValue }) => {
|
|
try {
|
|
const result = await axios.put<T>(`${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<boolean>) => {
|
|
state.refetch = action.payload;
|
|
},
|
|
clearState: (state) => {
|
|
(state as Record<string, unknown>)[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<T>(payload)) {
|
|
(state as Record<string, unknown>)[name] = payload.rows;
|
|
state.count = payload.count;
|
|
} else {
|
|
(state as Record<string, unknown>)[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<T extends BaseEntity> = ReturnType<
|
|
typeof createEntitySlice<T>
|
|
>['actions'];
|