1045 lines
30 KiB
Markdown
1045 lines
30 KiB
Markdown
# Frontend Stores Module
|
||
|
||
## Overview
|
||
|
||
The Stores module implements **Redux Toolkit** state management for the frontend application. It provides a centralized store with core UI/auth slices, entity CRUD slices, and runtime-setting slices for transition and global UI-control defaults.
|
||
|
||
**Location:** `frontend/src/stores/`
|
||
|
||
**Total Files:** 26+ TypeScript files
|
||
|
||
**Key Technologies:**
|
||
- Redux Toolkit 2.1.0
|
||
- Axios for API calls
|
||
- JWT for token handling
|
||
|
||
---
|
||
|
||
## Architecture
|
||
|
||
```
|
||
frontend/src/stores/
|
||
├── store.ts # Main Redux store configuration (~49 LOC)
|
||
├── hooks.ts # Typed hooks (useAppDispatch, useAppSelector) (~6 LOC)
|
||
├── createEntitySlice.ts # Entity slice factory (~337 LOC)
|
||
├── selectors.ts # Memoized selectors (~181 LOC)
|
||
│
|
||
├── Core Slices (4)
|
||
│ ├── authSlice.ts # Authentication state (~123 LOC)
|
||
│ ├── styleSlice.ts # UI styling/theming (~107 LOC)
|
||
│ ├── mainSlice.ts # Main app state (~32 LOC)
|
||
│
|
||
├── Entity/Runtime Setting Slices
|
||
│ ├── users/usersSlice.ts # (~25 LOC)
|
||
│ ├── roles/rolesSlice.ts # (~96 LOC)
|
||
│ ├── permissions/permissionsSlice.ts # (~25 LOC)
|
||
│ ├── projects/projectsSlice.ts # (~25 LOC)
|
||
│ ├── project_memberships/project_membershipsSlice.ts # (~25 LOC)
|
||
│ ├── assets/assetsSlice.ts # (~26 LOC)
|
||
│ ├── asset_variants/asset_variantsSlice.ts # (~25 LOC)
|
||
│ ├── presigned_url_requests/presigned_url_requestsSlice.ts # (~25 LOC)
|
||
│ ├── tour_pages/tour_pagesSlice.ts # (~25 LOC)
|
||
│ ├── project_audio_tracks/project_audio_tracksSlice.ts # (~25 LOC)
|
||
│ ├── publish_events/publish_eventsSlice.ts # (~25 LOC)
|
||
│ ├── pwa_caches/pwa_cachesSlice.ts # (~25 LOC)
|
||
│ ├── access_logs/access_logsSlice.ts # (~25 LOC)
|
||
│ ├── global_transition_defaults/globalTransitionDefaultsSlice.ts # Public GET (~138 LOC)
|
||
│ ├── project_transition_settings/projectTransitionSettingsSlice.ts # Public prod GET (~275 LOC)
|
||
│ ├── global_ui_control_defaults/globalUiControlDefaultsSlice.ts # Public GET singleton defaults
|
||
│ ├── project_ui_control_settings/projectUiControlSettingsSlice.ts # Project/env UI-control overrides
|
||
│ └── constructor/constructorSlice.ts # Constructor state (~110 LOC)
|
||
│
|
||
├── Data Files
|
||
│ └── introSteps.ts # Onboarding tour definitions (~122 LOC)
|
||
│
|
||
└── Legacy (deprecated)
|
||
└── usersSlice.ts # Old users slice (use users/usersSlice.ts)
|
||
```
|
||
|
||
---
|
||
|
||
## Store Configuration
|
||
|
||
**File:** `store.ts`
|
||
|
||
The main store combines all slice reducers:
|
||
|
||
```typescript
|
||
import { configureStore } from '@reduxjs/toolkit';
|
||
|
||
export const store = configureStore({
|
||
reducer: {
|
||
// Core slices
|
||
style: styleReducer,
|
||
main: mainReducer,
|
||
auth: authSlice,
|
||
|
||
// Entity and runtime-setting slices
|
||
users: usersSlice,
|
||
roles: rolesSlice,
|
||
permissions: permissionsSlice,
|
||
projects: projectsSlice,
|
||
project_memberships: project_membershipsSlice,
|
||
assets: assetsSlice,
|
||
asset_variants: asset_variantsSlice,
|
||
presigned_url_requests: presigned_url_requestsSlice,
|
||
tour_pages: tour_pagesSlice,
|
||
project_audio_tracks: project_audio_tracksSlice,
|
||
publish_events: publish_eventsSlice,
|
||
pwa_caches: pwa_cachesSlice,
|
||
access_logs: access_logsSlice,
|
||
global_transition_defaults: globalTransitionDefaultsSlice,
|
||
project_transition_settings: projectTransitionSettingsSlice,
|
||
global_ui_control_defaults: globalUiControlDefaultsSlice,
|
||
project_ui_control_settings: projectUiControlSettingsSlice,
|
||
},
|
||
});
|
||
|
||
// Inferred types for type safety
|
||
export type RootState = ReturnType<typeof store.getState>;
|
||
export type AppDispatch = typeof store.dispatch;
|
||
```
|
||
|
||
---
|
||
|
||
## Typed Hooks
|
||
|
||
**File:** `hooks.ts`
|
||
|
||
```typescript
|
||
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
|
||
import type { RootState, AppDispatch } from './store';
|
||
|
||
// Use throughout app instead of plain useDispatch and useSelector
|
||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||
```
|
||
|
||
**Usage:**
|
||
```typescript
|
||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||
|
||
const MyComponent = () => {
|
||
const dispatch = useAppDispatch();
|
||
const users = useAppSelector((state) => state.users.users);
|
||
const loading = useAppSelector((state) => state.users.loading);
|
||
|
||
// Dispatch actions with full type inference
|
||
dispatch(fetch({ query: '?limit=100' }));
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Entity Slice Factory
|
||
|
||
**File:** `createEntitySlice.ts` (~342 LOC)
|
||
|
||
The factory generates Redux slices with standard CRUD operations for any entity.
|
||
|
||
### Configuration Interface
|
||
|
||
```typescript
|
||
interface EntitySliceConfig {
|
||
name: string; // Slice name (e.g., 'users')
|
||
endpoint: string; // API endpoint (e.g., 'users')
|
||
singularName?: string; // For notifications (e.g., 'User')
|
||
}
|
||
```
|
||
|
||
### Generated State
|
||
|
||
```typescript
|
||
interface EntitySliceState<T> {
|
||
[entityName]: T[]; // Entity data array
|
||
loading: boolean; // Loading indicator
|
||
count: number; // Total count for pagination
|
||
refetch: boolean; // Trigger refetch flag
|
||
rolesWidgets: unknown[];// Widgets (for roles slice)
|
||
notify: NotificationState;
|
||
}
|
||
|
||
interface NotificationState {
|
||
showNotification: boolean;
|
||
textNotification: string;
|
||
typeNotification: 'warn' | 'error' | 'success' | 'info' | '';
|
||
}
|
||
```
|
||
|
||
### Generated Async Thunks
|
||
|
||
| Thunk | Action Type | Purpose |
|
||
|-------|-------------|---------|
|
||
| `fetch` | `{name}/fetch` | Fetch single by ID or list with query |
|
||
| `create` | `{name}/create{Name}` | Create new entity |
|
||
| `update` | `{name}/update{Name}` | Update existing entity |
|
||
| `deleteItem` | `{name}/delete{Name}` | Delete single entity |
|
||
| `deleteItemsByIds` | `{name}/deleteByIds` | Bulk delete by IDs |
|
||
| `uploadCsv` | `{name}/uploadCsv` | Import CSV file |
|
||
|
||
### Generated Reducers
|
||
|
||
| Reducer | Purpose |
|
||
|---------|---------|
|
||
| `setRefetch` | Trigger data refetch |
|
||
|
||
### Usage Example
|
||
|
||
```typescript
|
||
// Creating a slice (entity file)
|
||
import { createEntitySlice } from '../createEntitySlice';
|
||
import type { User } from '../../types/entities';
|
||
|
||
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;
|
||
```
|
||
|
||
```typescript
|
||
// Using in components
|
||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||
import { fetch, create, update, deleteItem } from '../stores/users/usersSlice';
|
||
|
||
const UsersPage = () => {
|
||
const dispatch = useAppDispatch();
|
||
const { users, loading, count, notify } = useAppSelector((state) => state.users);
|
||
|
||
// Fetch list with pagination
|
||
useEffect(() => {
|
||
dispatch(fetch({ query: '?page=1&limit=10' }));
|
||
}, []);
|
||
|
||
// Fetch single by ID
|
||
const loadUser = (id: string) => {
|
||
dispatch(fetch({ id }));
|
||
};
|
||
|
||
// Create new
|
||
const createUser = (data: Partial<User>) => {
|
||
dispatch(create(data));
|
||
};
|
||
|
||
// Update existing
|
||
const updateUser = (id: string, data: Partial<User>) => {
|
||
dispatch(update({ id, data }));
|
||
};
|
||
|
||
// Delete
|
||
const removeUser = (id: string) => {
|
||
dispatch(deleteItem(id));
|
||
};
|
||
};
|
||
```
|
||
|
||
### Factory Implementation Details
|
||
|
||
```typescript
|
||
// Fetch thunk handles both single and list
|
||
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; // Single entity
|
||
}
|
||
|
||
return {
|
||
rows: (result.data as PaginatedResponse<T>).rows,
|
||
count: (result.data as PaginatedResponse<T>).count,
|
||
};
|
||
});
|
||
|
||
// Extrareducers handle state updates
|
||
builder.addCase(fetch.fulfilled, (state, action) => {
|
||
const payload = action.payload;
|
||
if (isPaginatedResponse<T>(payload)) {
|
||
state[name] = payload.rows;
|
||
state.count = payload.count;
|
||
} else {
|
||
state[name] = payload; // Single entity stored in array
|
||
}
|
||
state.loading = false;
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## Core Slices
|
||
|
||
### 1. authSlice
|
||
|
||
**File:** `authSlice.ts` (~130 LOC)
|
||
|
||
**Purpose:** Authentication state management.
|
||
|
||
```typescript
|
||
interface AuthState {
|
||
isFetching: boolean;
|
||
errorMessage: string;
|
||
currentUser: User | null;
|
||
token: string;
|
||
notify: NotificationState;
|
||
}
|
||
```
|
||
|
||
**Async Thunks:**
|
||
|
||
| Thunk | Endpoint | Purpose |
|
||
|-------|----------|---------|
|
||
| `loginUser` | `POST /auth/signin/local` | Local authentication |
|
||
| `findMe` | `GET /auth/me` | Fetch current user |
|
||
| `passwordReset` | `PUT /auth/password-reset` | Reset password |
|
||
|
||
**Reducers:**
|
||
|
||
| Reducer | Purpose |
|
||
|---------|---------|
|
||
| `logoutUser` | Clear auth state, remove tokens |
|
||
|
||
**Token Handling:**
|
||
```typescript
|
||
builder.addCase(loginUser.fulfilled, (state, action) => {
|
||
const token = action.payload;
|
||
const user = jwt.decode(token);
|
||
|
||
state.token = token;
|
||
sessionStorage.setItem('token', token);
|
||
localStorage.setItem('token', token);
|
||
axios.defaults.headers.common['Authorization'] = 'Bearer ' + token;
|
||
});
|
||
```
|
||
|
||
**Usage:**
|
||
```typescript
|
||
import { loginUser, logoutUser, findMe } from '../stores/authSlice';
|
||
|
||
// Login
|
||
dispatch(loginUser({ email, password }));
|
||
|
||
// Logout
|
||
dispatch(logoutUser());
|
||
|
||
// Fetch current user
|
||
dispatch(findMe());
|
||
|
||
// Access state
|
||
const { currentUser, isFetching, errorMessage } = useAppSelector(state => state.auth);
|
||
```
|
||
|
||
---
|
||
|
||
### 2. styleSlice
|
||
|
||
**File:** `styleSlice.ts` (~107 LOC)
|
||
|
||
**Purpose:** UI theming and styling state.
|
||
|
||
```typescript
|
||
interface StyleState {
|
||
darkMode: boolean;
|
||
bgLayoutColor: string;
|
||
iconsColor: string;
|
||
activeLinkColor: string;
|
||
cardsColor: string;
|
||
focusRingColor: string;
|
||
corners: string;
|
||
cardsStyle: string;
|
||
linkColor: string;
|
||
borders: string;
|
||
shadow: string;
|
||
textSecondary: string;
|
||
// Aside menu styles
|
||
asideStyle: string;
|
||
asideScrollbarsStyle: string;
|
||
asideBrandStyle: string;
|
||
asideMenuItemStyle: string;
|
||
asideMenuItemActiveStyle: string;
|
||
asideMenuDropdownStyle: string;
|
||
// NavBar styles
|
||
navBarItemLabelStyle: string;
|
||
navBarItemLabelHoverStyle: string;
|
||
navBarItemLabelActiveColorStyle: string;
|
||
overlayStyle: string;
|
||
websiteHeder: string;
|
||
websiteSectionStyle: string;
|
||
}
|
||
```
|
||
|
||
**Reducers:**
|
||
|
||
| Reducer | Purpose |
|
||
|---------|---------|
|
||
| `setDarkMode` | Toggle dark/light mode |
|
||
| `setStyle` | Apply complete style theme |
|
||
|
||
**Dark Mode Handling:**
|
||
```typescript
|
||
setDarkMode: (state, action: PayloadAction<boolean | null>) => {
|
||
state.darkMode = action.payload !== null ? action.payload : !state.darkMode;
|
||
|
||
localStorage.setItem(localStorageDarkModeKey, state.darkMode ? '1' : '0');
|
||
|
||
document.body.classList[state.darkMode ? 'add' : 'remove']('dark-scrollbars');
|
||
document.documentElement.classList[state.darkMode ? 'add' : 'remove']('dark-scrollbars-compat');
|
||
}
|
||
```
|
||
|
||
**Usage:**
|
||
```typescript
|
||
import { setDarkMode, setStyle } from '../stores/styleSlice';
|
||
|
||
// Toggle dark mode
|
||
dispatch(setDarkMode(null)); // Toggle
|
||
dispatch(setDarkMode(true)); // Force dark
|
||
dispatch(setDarkMode(false)); // Force light
|
||
|
||
// Apply style theme
|
||
dispatch(setStyle('white'));
|
||
dispatch(setStyle('basic'));
|
||
|
||
// Access state
|
||
const { darkMode, corners, cardsStyle, iconsColor } = useAppSelector(state => state.style);
|
||
```
|
||
|
||
---
|
||
|
||
### 3. mainSlice
|
||
|
||
**File:** `mainSlice.ts` (~36 LOC)
|
||
|
||
**Purpose:** Main application UI state.
|
||
|
||
```typescript
|
||
interface MainState {
|
||
userName: string;
|
||
userEmail: string | null;
|
||
userAvatar: string | null;
|
||
isFieldFocusRegistered: boolean;
|
||
}
|
||
```
|
||
|
||
**Reducers:**
|
||
|
||
| Reducer | Purpose |
|
||
|---------|---------|
|
||
| `setUser` | Update user display info |
|
||
|
||
**Usage:**
|
||
```typescript
|
||
import { setUser } from '../stores/mainSlice';
|
||
|
||
dispatch(setUser({
|
||
name: 'John Doe',
|
||
email: 'john@example.com',
|
||
avatar: '/avatars/john.jpg',
|
||
}));
|
||
|
||
const { userName, userEmail, userAvatar } = useAppSelector(state => state.main);
|
||
```
|
||
|
||
---
|
||
|
||
## Entity Slices
|
||
|
||
All 13 entity slices follow the factory pattern:
|
||
|
||
### Standard Entity Slice Pattern
|
||
|
||
```typescript
|
||
/**
|
||
* {Entity} Redux Slice
|
||
*/
|
||
|
||
import { createEntitySlice } from '../createEntitySlice';
|
||
import type { EntityType } from '../../types/entities';
|
||
|
||
const { slice, actions, reducer } = createEntitySlice<EntityType>({
|
||
name: 'entity_name',
|
||
endpoint: 'api_endpoint',
|
||
singularName: 'Entity Name',
|
||
});
|
||
|
||
export const {
|
||
fetch,
|
||
create,
|
||
update,
|
||
deleteItem,
|
||
deleteItemsByIds,
|
||
uploadCsv,
|
||
setRefetch,
|
||
} = actions;
|
||
|
||
export const entitySlice = slice;
|
||
export default reducer;
|
||
```
|
||
|
||
### Entity Slice Inventory
|
||
|
||
| Slice | Entity Type | Endpoint | Singular Name |
|
||
|-------|-------------|----------|---------------|
|
||
| `users` | `User` | `users` | User |
|
||
| `roles` | `Role` | `roles` | Role |
|
||
| `permissions` | `PermissionEntity` | `permissions` | Permission |
|
||
| `projects` | `Project` | `projects` | Project |
|
||
| `project_memberships` | `ProjectMembership` | `project_memberships` | Project Membership |
|
||
| `assets` | `Asset` | `assets` | Asset |
|
||
| `asset_variants` | `AssetVariant` | `asset_variants` | Asset Variant |
|
||
| `presigned_url_requests` | `PresignedUrlRequest` | `presigned_url_requests` | Presigned URL Request |
|
||
| `tour_pages` | `TourPage` | `tour_pages` | Tour Page |
|
||
| `project_audio_tracks` | `ProjectAudioTrack` | `project_audio_tracks` | Project Audio Track |
|
||
| `publish_events` | `PublishEvent` | `publish_events` | Publish Event |
|
||
| `pwa_caches` | `PwaCache` | `pwa_caches` | PWA Cache |
|
||
| `access_logs` | `AccessLog` | `access_logs` | Access Log |
|
||
| `global_transition_defaults` | `GlobalTransitionDefaults` | `global-transition-defaults` | Global defaults (singleton, public GET) |
|
||
| `project_transition_settings` | `ProjectTransitionSettingsEntity` | `project-transition-settings` | Per-project settings (public prod GET) |
|
||
|
||
## Type Definitions
|
||
|
||
### Redux Types (`types/redux.ts`)
|
||
|
||
```typescript
|
||
// Notification state structure
|
||
export interface NotificationState {
|
||
showNotification: boolean;
|
||
textNotification: string;
|
||
typeNotification: 'warn' | 'error' | 'success' | 'info' | '';
|
||
}
|
||
|
||
// Generic entity slice state
|
||
export interface EntitySliceState<T> {
|
||
data: T[];
|
||
loading: boolean;
|
||
count: number;
|
||
refetch: boolean;
|
||
notify: NotificationState;
|
||
}
|
||
|
||
// Slice factory configuration
|
||
export interface EntitySliceConfig {
|
||
name: string;
|
||
endpoint: string;
|
||
singularName?: string;
|
||
}
|
||
|
||
// Auth state structure
|
||
export interface AuthState {
|
||
currentUser: User | null;
|
||
token: string | null;
|
||
isFetching: boolean;
|
||
errorMessage: string | null;
|
||
notify: NotificationState;
|
||
}
|
||
```
|
||
|
||
### API Types (`types/api.ts`)
|
||
|
||
```typescript
|
||
// Paginated response from API
|
||
export interface PaginatedResponse<T> {
|
||
rows: T[];
|
||
count: number;
|
||
}
|
||
|
||
// Fetch parameters for entity queries
|
||
export interface FetchParams {
|
||
id?: string;
|
||
query?: string;
|
||
limit?: number;
|
||
page?: number;
|
||
}
|
||
|
||
// API error response structure
|
||
export interface ApiError {
|
||
message?: string;
|
||
statusCode?: number;
|
||
errors?: Record<string, { _errors: string[] }>;
|
||
}
|
||
|
||
// Sort model for data grid
|
||
export interface SortModel {
|
||
field: string;
|
||
sort: 'asc' | 'desc';
|
||
}
|
||
|
||
// Query parameters for list requests
|
||
export interface ListQueryParams {
|
||
page?: number;
|
||
limit?: number;
|
||
sort?: 'asc' | 'desc';
|
||
field?: string;
|
||
[filterKey: string]: string | number | boolean | undefined;
|
||
}
|
||
```
|
||
|
||
### Entity Types (`types/entities.ts`)
|
||
|
||
```typescript
|
||
// Base entity interface
|
||
export interface BaseEntity {
|
||
id: string;
|
||
createdAt?: string;
|
||
updatedAt?: string;
|
||
}
|
||
|
||
// User entity
|
||
export interface User extends BaseEntity {
|
||
firstName?: string;
|
||
lastName?: string;
|
||
email: string;
|
||
disabled?: boolean;
|
||
avatar?: ImageFile[];
|
||
app_role?: Role | null;
|
||
custom_permissions?: PermissionEntity[];
|
||
}
|
||
|
||
// Project entity
|
||
export interface Project extends BaseEntity {
|
||
name: string;
|
||
slug?: string;
|
||
description?: string;
|
||
logo_url?: string;
|
||
favicon_url?: string;
|
||
og_image_url?: string;
|
||
}
|
||
|
||
// Asset entity
|
||
export interface Asset extends BaseEntity {
|
||
project?: Project | string | null;
|
||
name: string;
|
||
asset_type: 'image' | 'video' | 'audio' | 'file';
|
||
type?: 'general' | 'icon' | 'background_image' | 'audio' | 'video' | 'transition' | 'logo' | 'favicon' | 'document';
|
||
cdn_url?: string;
|
||
storage_key?: string;
|
||
mime_type?: string;
|
||
size_mb?: number;
|
||
width_px?: number;
|
||
height_px?: number;
|
||
duration_sec?: number;
|
||
is_public?: boolean;
|
||
is_deleted?: boolean;
|
||
}
|
||
|
||
// ... (13 total entity types)
|
||
```
|
||
|
||
---
|
||
|
||
## Notification Helpers
|
||
|
||
**File:** `helpers/notifyStateHandler.ts`
|
||
|
||
Utility functions for managing notification state:
|
||
|
||
```typescript
|
||
// Reset notification state
|
||
export const resetNotify = (state) => {
|
||
state.notify.showNotification = false;
|
||
state.notify.typeNotification = '';
|
||
state.notify.textNotification = '';
|
||
};
|
||
|
||
// Handle rejected action with error notification
|
||
export const rejectNotify = (state, action) => {
|
||
if (typeof action.payload === 'string') {
|
||
state.notify.textNotification = action.payload;
|
||
} else if (typeof action === 'object') {
|
||
// Parse validation errors
|
||
const obj = { ...action.payload?.errors };
|
||
delete obj['_errors'];
|
||
|
||
let msg = '';
|
||
for (const key in obj) {
|
||
msg += `${key}: ${obj[key]['_errors']}; \n `;
|
||
}
|
||
state.notify.textNotification = msg;
|
||
} else {
|
||
state.notify.textNotification = 'Network error';
|
||
}
|
||
|
||
state.notify.typeNotification = 'error';
|
||
state.notify.showNotification = true;
|
||
};
|
||
|
||
// Handle fulfilled action with success notification
|
||
export const fulfilledNotify = (state, msg) => {
|
||
state.notify.textNotification = msg;
|
||
state.notify.typeNotification = 'success';
|
||
state.notify.showNotification = true;
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Onboarding Tour Steps
|
||
|
||
**File:** `introSteps.ts` (~135 LOC)
|
||
|
||
Defines step configurations for the app onboarding tour:
|
||
|
||
```typescript
|
||
interface Step {
|
||
element: string; // CSS selector
|
||
intro: string; // HTML content
|
||
position?: string; // Tooltip position
|
||
tooltipClass?: string; // Custom CSS class
|
||
disableInteraction?: boolean;
|
||
}
|
||
|
||
// Exported step arrays
|
||
export const loginSteps: Step[] = [...]; // Login page tour
|
||
export const appSteps: Step[] = [...]; // Main app tour
|
||
export const usersSteps: Step[] = [...]; // Users page tour
|
||
export const rolesSteps: Step[] = [...]; // Roles page tour
|
||
```
|
||
|
||
---
|
||
|
||
## Data Flow Diagram
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Component │
|
||
│ useAppDispatch() + useAppSelector() │
|
||
└─────────────────────┬───────────────────────────────────────────┘
|
||
│
|
||
│ dispatch(fetch({ query }))
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Async Thunk │
|
||
│ fetch, create, update, deleteItem, etc. │
|
||
└─────────────────────┬───────────────────────────────────────────┘
|
||
│
|
||
│ axios.get/post/put/delete
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Backend API │
|
||
│ /api/{endpoint} │
|
||
└─────────────────────┬───────────────────────────────────────────┘
|
||
│
|
||
│ Response
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Extra Reducers │
|
||
│ pending → loading: true │
|
||
│ fulfilled → data: payload, loading: false │
|
||
│ rejected → notify: error, loading: false │
|
||
└─────────────────────┬───────────────────────────────────────────┘
|
||
│
|
||
│ State update
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Store │
|
||
│ RootState with 17 slices │
|
||
└─────────────────────┬───────────────────────────────────────────┘
|
||
│
|
||
│ useAppSelector subscription
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────┐
|
||
│ Component Re-render │
|
||
│ UI updates with new data │
|
||
└─────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## Usage Patterns
|
||
|
||
### 1. List Page Pattern
|
||
|
||
```typescript
|
||
import { useEffect } from 'react';
|
||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||
import { fetch, setRefetch } from '../stores/users/usersSlice';
|
||
|
||
const UsersListPage = () => {
|
||
const dispatch = useAppDispatch();
|
||
const { users, loading, count, refetch } = useAppSelector(state => state.users);
|
||
const [page, setPage] = useState(1);
|
||
|
||
// Initial fetch
|
||
useEffect(() => {
|
||
dispatch(fetch({ query: `?page=${page}&limit=10` }));
|
||
}, [page, dispatch]);
|
||
|
||
// Refetch trigger
|
||
useEffect(() => {
|
||
if (refetch) {
|
||
dispatch(fetch({ query: `?page=${page}&limit=10` }));
|
||
dispatch(setRefetch(false));
|
||
}
|
||
}, [refetch, page, dispatch]);
|
||
|
||
return (
|
||
<DataGrid
|
||
rows={users}
|
||
loading={loading}
|
||
rowCount={count}
|
||
onPageChange={setPage}
|
||
/>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 2. Edit Page Pattern
|
||
|
||
```typescript
|
||
import { useEffect, useState } from 'react';
|
||
import { useRouter } from 'next/router';
|
||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||
import { fetch, update } from '../stores/users/usersSlice';
|
||
|
||
const UserEditPage = () => {
|
||
const router = useRouter();
|
||
const { id } = router.query;
|
||
const dispatch = useAppDispatch();
|
||
const { users, loading, notify } = useAppSelector(state => state.users);
|
||
|
||
// Fetch entity on mount
|
||
useEffect(() => {
|
||
if (id) {
|
||
dispatch(fetch({ id: id as string }));
|
||
}
|
||
}, [id, dispatch]);
|
||
|
||
// Get current entity
|
||
const user = Array.isArray(users) ? users[0] : users;
|
||
|
||
const handleSubmit = async (data: Partial<User>) => {
|
||
await dispatch(update({ id: id as string, data }));
|
||
router.push('/users/users-list');
|
||
};
|
||
|
||
return (
|
||
<Formik initialValues={user} onSubmit={handleSubmit}>
|
||
{/* Form fields */}
|
||
</Formik>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 3. Create Page Pattern
|
||
|
||
```typescript
|
||
import { useAppDispatch, useAppSelector } from '../stores/hooks';
|
||
import { create } from '../stores/users/usersSlice';
|
||
|
||
const UserNewPage = () => {
|
||
const dispatch = useAppDispatch();
|
||
const router = useRouter();
|
||
const { loading, notify } = useAppSelector(state => state.users);
|
||
|
||
const handleSubmit = async (data: Partial<User>) => {
|
||
const result = await dispatch(create(data));
|
||
if (result.meta.requestStatus === 'fulfilled') {
|
||
router.push('/users/users-list');
|
||
}
|
||
};
|
||
|
||
return (
|
||
<Formik initialValues={{}} onSubmit={handleSubmit}>
|
||
{/* Form fields */}
|
||
</Formik>
|
||
);
|
||
};
|
||
```
|
||
|
||
### 4. Delete Pattern
|
||
|
||
```typescript
|
||
import { deleteItem, deleteItemsByIds, setRefetch } from '../stores/users/usersSlice';
|
||
|
||
// Delete single
|
||
const handleDelete = async (id: string) => {
|
||
await dispatch(deleteItem(id));
|
||
dispatch(setRefetch(true));
|
||
};
|
||
|
||
// Bulk delete
|
||
const handleBulkDelete = async (ids: string[]) => {
|
||
await dispatch(deleteItemsByIds(ids));
|
||
dispatch(setRefetch(true));
|
||
};
|
||
```
|
||
|
||
### 5. Notification Handling
|
||
|
||
```typescript
|
||
import { useEffect } from 'react';
|
||
import { toast } from 'react-toastify';
|
||
|
||
const MyComponent = () => {
|
||
const { notify } = useAppSelector(state => state.users);
|
||
|
||
useEffect(() => {
|
||
if (notify.showNotification) {
|
||
if (notify.typeNotification === 'success') {
|
||
toast.success(notify.textNotification);
|
||
} else if (notify.typeNotification === 'error') {
|
||
toast.error(notify.textNotification);
|
||
}
|
||
}
|
||
}, [notify]);
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## Store Provider Setup
|
||
|
||
**File:** `_app.tsx`
|
||
|
||
```typescript
|
||
import { Provider } from 'react-redux';
|
||
import { store } from '../stores/store';
|
||
|
||
function MyApp({ Component, pageProps }) {
|
||
return (
|
||
<Provider store={store}>
|
||
<Component {...pageProps} />
|
||
</Provider>
|
||
);
|
||
}
|
||
|
||
export default MyApp;
|
||
```
|
||
|
||
---
|
||
|
||
## State Management Guidelines
|
||
|
||
### When to Use Redux (Default)
|
||
|
||
Redux is the **default choice** for application state. Use Redux slices for:
|
||
|
||
| State Type | Example | Why Redux |
|
||
|------------|---------|-----------|
|
||
| **Entity Data** | Users, Projects, Assets, Tour Pages | Shared across multiple components, API-backed |
|
||
| **Authentication** | Current user, JWT token | App-wide, persisted to localStorage |
|
||
| **UI Preferences** | Dark mode, theme settings | Persisted, affects entire app |
|
||
| **Form State** | Complex multi-step forms | Survives navigation, shareable |
|
||
| **Notifications** | Toast messages | Triggered from anywhere |
|
||
|
||
### When Local Hooks Are Acceptable
|
||
|
||
Local React hooks (`useState`, custom hooks) are appropriate for:
|
||
|
||
| State Type | Example | Why Local |
|
||
|------------|---------|-----------|
|
||
| **Ephemeral UI State** | Modal open/closed, dropdown expanded | Component-scoped, no persistence needed |
|
||
| **Session-Scoped Navigation** | Page history in `usePageNavigation` | Isolated to single route, resets on unmount |
|
||
| **Animation State** | Transition progress, fade states | Highly transient, no sharing needed |
|
||
| **Form Field Focus** | Which input is focused | Local to form component |
|
||
| **Derived/Computed Values** | Filtered lists, sorted data | Computed from Redux state |
|
||
|
||
### Decision Tree
|
||
|
||
```
|
||
Is the state needed by multiple unrelated components?
|
||
├── Yes → Use Redux
|
||
└── No → Does the state need to persist across route changes?
|
||
├── Yes → Use Redux
|
||
└── No → Is the state tied to API data?
|
||
├── Yes → Use Redux
|
||
└── No → Local hook is acceptable
|
||
```
|
||
|
||
### Example: usePageNavigation (Local Hook)
|
||
|
||
The `usePageNavigation` hook uses local `useState` because:
|
||
- ✅ Only used by RuntimePresentation OR constructor (never both simultaneously)
|
||
- ✅ Session-scoped - history resets when leaving the tour
|
||
- ✅ No cross-component sharing needed
|
||
- ✅ No persistence requirement
|
||
- ✅ Simple data structure (array + string)
|
||
|
||
```typescript
|
||
// Appropriate for local hook
|
||
const { currentPageId, pageHistory, applyPageSelection } = usePageNavigation({
|
||
pages,
|
||
trackHistory: true,
|
||
});
|
||
```
|
||
|
||
### Example: Constructor Elements (Redux)
|
||
|
||
The `constructorSlice` uses Redux because:
|
||
- ✅ Multiple components need access (canvas, sidebar, settings panels)
|
||
- ✅ Complex nested state (elements, selections, undo history)
|
||
- ✅ DevTools debugging valuable
|
||
- ✅ Actions dispatched from many places
|
||
|
||
```typescript
|
||
// Appropriate for Redux
|
||
const elements = useAppSelector((state) => state.constructor.elements);
|
||
dispatch(updateElement({ id, changes }));
|
||
```
|
||
|
||
### Anti-Patterns to Avoid
|
||
|
||
| Anti-Pattern | Problem | Solution |
|
||
|--------------|---------|----------|
|
||
| Redux for modal open/close | Over-engineering | Local `useState` |
|
||
| Local state for entity data | Data inconsistency | Redux slice |
|
||
| Prop drilling 3+ levels | Maintenance burden | Redux or Context |
|
||
| Redux for animation frames | Performance issues | Local `useRef` |
|
||
|
||
---
|
||
|
||
## Summary
|
||
|
||
| Category | Count | Description |
|
||
|----------|-------|-------------|
|
||
| Core Slices | 4 | auth, style, main, openAi |
|
||
| Entity Slices | 15 | Generated via factory pattern (includes 2 transition slices) |
|
||
| Constructor Slice | 1 | Tour editor state |
|
||
| Type Files | 3 | redux.ts, entities.ts, api.ts |
|
||
| Total Slices | 20 | Combined in store.ts |
|
||
|
||
### Public Access Slices
|
||
|
||
Some slices support public API access for runtime presentations:
|
||
|
||
| Slice | Public Access | Notes |
|
||
|-------|--------------|-------|
|
||
| `global_transition_defaults` | GET always public | No auth headers needed |
|
||
| `project_transition_settings` | GET production only | Backend determines from URL path |
|
||
|
||
These slices don't require special headers - the backend determines public access from the URL path itself.
|
||
| Total Files | 22+ | In stores directory |
|
||
|
||
**Key Patterns:**
|
||
- **Redux is default** for app-wide, persistent, shared state
|
||
- **Local hooks acceptable** for ephemeral, component-scoped state
|
||
- Factory pattern eliminates ~2,600 LOC of boilerplate (13 entities × ~200 LOC each)
|
||
- Typed hooks ensure type safety throughout
|
||
- Consistent notification handling via helpers
|
||
- Async thunks with proper error handling
|
||
|
||
---
|
||
|
||
## Related Documentation
|
||
|
||
- [Frontend Architecture](./frontend-architecture.md) - Overall frontend structure
|
||
- [Hooks Module](./hooks-module.md) - Custom React hooks
|
||
- [Components Module](./components-module.md) - React components
|
||
- [Backend API Endpoints](../../backend/docs/api-endpoints.md) - API reference
|