39948-vm/frontend/src/stores/createEntitySlice.ts
2026-03-31 12:13:06 +04:00

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'];