devided state separation between redux and react query

This commit is contained in:
Dmitri 2026-07-06 16:12:49 +02:00
parent b413e7b1bb
commit 2d63d2db6d
19 changed files with 1017 additions and 244 deletions

View File

@ -84,7 +84,10 @@ The deploy order is:
2. Archive `HEAD` into a new release directory. 2. Archive `HEAD` into a new release directory.
3. Copy frontend env files from the live workspace when present: 3. Copy frontend env files from the live workspace when present:
`.env`, `.env.local`, `.env.production`, `.env.production.local`. `.env`, `.env.local`, `.env.production`, `.env.production.local`.
4. Run `npm ci`. 4. Run `npm ci || npm install`. `npm ci` is preferred for deterministic
installs, but the VM deploy must fall back to `npm install` when npm rejects
a lockfile or peer dependency mismatch during release creation. The fallback
runs inside the new immutable release directory, not in the live workspace.
5. Run `npm run build`. 5. Run `npm run build`.
6. Remove non-runtime build caches from the new release: 6. Remove non-runtime build caches from the new release:
`.next`, `.turbo`, `build/cache`. Production runtime assets stay in `.next`, `.turbo`, `build/cache`. Production runtime assets stay in
@ -106,6 +109,24 @@ pm2 jlist | jq '.[] | select(.name=="frontend-dev") | {
}' }'
``` ```
If a deploy creates a new release directory but `frontend-dev` still points to
an older release, inspect the executor VCS deploy path:
```bash
grep -nE "npm ci|npm install|npm run build|frontend-dev" /home/ubuntu/executor/vcs/vcs.js
pm2 logs fl-executor --lines 250 --nostream
```
The expected frontend install command in the executor deploy path is:
```bash
npm ci || npm install
```
This prevents the VM from staying on an old frontend release when `npm ci`
fails before `npm run build` because the checked-in lockfile and npm's current
peer dependency resolution disagree.
Retention defaults to the latest 2 release directories. Override it by setting Retention defaults to the latest 2 release directories. Override it by setting
`FRONTEND_RELEASES_KEEP` for the executor process before deploy. Do not delete `FRONTEND_RELEASES_KEEP` for the executor process before deploy. Do not delete
the active release directory; `next start` serves production assets from its the active release directory; `next start` serves production assets from its

View File

@ -31,28 +31,6 @@ Frontend:
## P1 - Frontend ## P1 - Frontend
### Redux и TanStack Query
Redux не нужно удалять полностью.
Оставить Redux для:
- auth/session UI;
- theme/style;
- layout/sidebar;
- constructor UI state;
- app preferences.
Целевое правило: server data не хранится в Redux. Existing entity Redux slices считаются legacy и заменяются на TanStack Query по одному flow.
TODO:
- Зафиксировать правило: Redux для client/app state, TanStack Query для server state.
- Не создавать новые entity CRUD Redux slices.
- Пилотно мигрировать один простой entity flow (`roles` или `permissions`) на TanStack Query.
- Не удалять старый Redux slice, пока все consumers entity не мигрированы.
- API reads/mutations делать через TanStack Query hooks, не через Redux thunks.
### Frontend TypeScript strictness ### Frontend TypeScript strictness
Цель: включить strict TypeScript как обязательный стандарт для frontend и закрыть legacy errors без сохранения JS/CommonJS как допустимого направления. Новый и изменённый код должен быть strict-compatible. Цель: включить strict TypeScript как обязательный стандарт для frontend и закрыть legacy errors без сохранения JS/CommonJS как допустимого направления. Новый и изменённый код должен быть strict-compatible.
@ -161,8 +139,7 @@ TODO:
3. Выбрать package manager и Node version. 3. Выбрать package manager и Node version.
4. Добавить минимальные smoke checks. 4. Добавить минимальные smoke checks.
5. Постепенно выносить helpers из больших frontend/backend файлов при изменениях. 5. Постепенно выносить helpers из больших frontend/backend файлов при изменениях.
6. Пилотно перевести один entity flow с Redux на TanStack Query. 6. Делать cleanup/dependency upgrades небольшими отдельными PR.
7. Делать cleanup/dependency upgrades небольшими отдельными PR.
## Definition of Done ## Definition of Done

View File

@ -599,21 +599,21 @@ function ProjectActions({ user, project }) {
} }
``` ```
### Redux Integration ### TanStack Query Integration
**File:** `frontend/src/stores/permissions/permissionsSlice.ts` **File:** `frontend/src/hooks/queries/usePermissionsQuery.ts`
```typescript ```typescript
// Fetch all permissions // Fetch all permissions
dispatch(permissionsActions.fetch({ query: '' })); const { data: permissions } = usePermissionsQuery();
// Create new permission // Create new permission
dispatch(permissionsActions.create({ const createPermission = useCreatePermissionMutation();
data: { name: 'READ_CUSTOM_ENTITY' } await createPermission.mutateAsync({ name: 'READ_CUSTOM_ENTITY' });
}));
// Delete permission // Delete permission
dispatch(permissionsActions.deleteItem(permissionId)); const deletePermission = useDeletePermissionMutation();
await deletePermission.mutateAsync(permissionId);
``` ```
## Seeding Process ## Seeding Process
@ -738,7 +738,7 @@ const rolePermissionMap = {
| `backend/src/routes/permissions.ts` | Permissions API routes | | `backend/src/routes/permissions.ts` | Permissions API routes |
| `frontend/src/helpers/userPermissions.ts` | Client-side permission helper | | `frontend/src/helpers/userPermissions.ts` | Client-side permission helper |
| `frontend/src/types/permissions.ts` | TypeScript Permission enum | | `frontend/src/types/permissions.ts` | TypeScript Permission enum |
| `frontend/src/stores/permissions/permissionsSlice.ts` | Redux state for permissions | | `frontend/src/hooks/queries/usePermissionsQuery.ts` | TanStack Query hooks for permissions |
## Best Practices ## Best Practices

View File

@ -2,7 +2,16 @@
## Overview ## 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. The Stores module implements **Redux Toolkit** state management for client/app
state in the frontend application. It provides a centralized store with core
UI/auth slices, legacy entity CRUD slices, and runtime-setting slices for
transition and global UI-control defaults.
New server-state flows should use TanStack Query hooks from
`frontend/src/hooks/queries/` for API reads, mutations, cache invalidation, and
background refetching. Existing entity Redux slices are legacy compatibility
surfaces and should be migrated one flow at a time rather than removed while
consumers still depend on them.
**Location:** `frontend/src/stores/` **Location:** `frontend/src/stores/`
@ -24,7 +33,7 @@ frontend/src/stores/
├── createEntitySlice.ts # Entity slice factory (~337 LOC) ├── createEntitySlice.ts # Entity slice factory (~337 LOC)
├── selectors.ts # Memoized selectors (~181 LOC) ├── selectors.ts # Memoized selectors (~181 LOC)
├── Core Slices (4) ├── Core Slices (3)
│ ├── authSlice.ts # Authentication state (~123 LOC) │ ├── authSlice.ts # Authentication state (~123 LOC)
│ ├── styleSlice.ts # UI styling/theming (~107 LOC) │ ├── styleSlice.ts # UI styling/theming (~107 LOC)
│ ├── mainSlice.ts # Main app state (~32 LOC) │ ├── mainSlice.ts # Main app state (~32 LOC)
@ -32,7 +41,6 @@ frontend/src/stores/
├── Entity/Runtime Setting Slices ├── Entity/Runtime Setting Slices
│ ├── users/usersSlice.ts # (~25 LOC) │ ├── users/usersSlice.ts # (~25 LOC)
│ ├── roles/rolesSlice.ts # (~96 LOC) │ ├── roles/rolesSlice.ts # (~96 LOC)
│ ├── permissions/permissionsSlice.ts # (~25 LOC)
│ ├── projects/projectsSlice.ts # (~25 LOC) │ ├── projects/projectsSlice.ts # (~25 LOC)
│ ├── project_memberships/project_membershipsSlice.ts # (~25 LOC) │ ├── project_memberships/project_membershipsSlice.ts # (~25 LOC)
│ ├── assets/assetsSlice.ts # (~26 LOC) │ ├── assets/assetsSlice.ts # (~26 LOC)
@ -77,7 +85,6 @@ export const store = configureStore({
// Entity and runtime-setting slices // Entity and runtime-setting slices
users: usersSlice, users: usersSlice,
roles: rolesSlice, roles: rolesSlice,
permissions: permissionsSlice,
projects: projectsSlice, projects: projectsSlice,
project_memberships: project_membershipsSlice, project_memberships: project_membershipsSlice,
assets: assetsSlice, assets: assetsSlice,
@ -460,7 +467,10 @@ const { userName, userEmail, userAvatar } = useAppSelector(state => state.main);
## Entity Slices ## Entity Slices
All 13 entity slices follow the factory pattern: Remaining entity/runtime-setting slices follow the factory pattern unless a
flow has already moved to TanStack Query. The `permissions` flow is handled by
`frontend/src/hooks/queries/usePermissionsQuery.ts` and no longer has a Redux
slice.
### Standard Entity Slice Pattern ### Standard Entity Slice Pattern
@ -498,7 +508,6 @@ export default reducer;
|-------|-------------|----------|---------------| |-------|-------------|----------|---------------|
| `users` | `User` | `users` | User | | `users` | `User` | `users` | User |
| `roles` | `Role` | `roles` | Role | | `roles` | `Role` | `roles` | Role |
| `permissions` | `PermissionEntity` | `permissions` | Permission |
| `projects` | `Project` | `projects` | Project | | `projects` | `Project` | `projects` | Project |
| `project_memberships` | `ProjectMembership` | `project_memberships` | Project Membership | | `project_memberships` | `ProjectMembership` | `project_memberships` | Project Membership |
| `assets` | `Asset` | `assets` | Asset | | `assets` | `Asset` | `assets` | Asset |
@ -926,18 +935,32 @@ export default MyApp;
## State Management Guidelines ## State Management Guidelines
### When to Use Redux (Default) ### When to Use Redux
Redux is the **default choice** for application state. Use Redux slices for: Redux is for client/app state. Use Redux slices for:
| State Type | Example | Why Redux | | 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 | | **Authentication** | Current user, JWT token | App-wide, persisted to localStorage |
| **UI Preferences** | Dark mode, theme settings | Persisted, affects entire app | | **UI Preferences** | Dark mode, theme settings | Persisted, affects entire app |
| **Form State** | Complex multi-step forms | Survives navigation, shareable | | **Layout/App UI** | Sidebar, theme, app preferences | Shared client state |
| **Constructor UI State** | Selected elements, canvas state | Shared builder interactions |
| **Notifications** | Toast messages | Triggered from anywhere | | **Notifications** | Toast messages | Triggered from anywhere |
### When to Use TanStack Query
TanStack Query is for server state. Use query hooks for:
| State Type | Example | Why TanStack Query |
|------------|---------|--------------------|
| **Entity Lists** | Users, Projects, Assets, Permissions | API-backed data with cache/invalidation |
| **Entity Details** | Single project or permission record | Request dedupe and stale-time control |
| **Mutations** | Create/update/delete API calls | Invalidate affected cached data |
| **Background Refetch** | Data that can become stale on the server | Built-in freshness model |
Do not create new entity CRUD Redux slices for server data. Keep existing entity
slices only until their consumers are migrated.
### When Local Hooks Are Acceptable ### When Local Hooks Are Acceptable
Local React hooks (`useState`, custom hooks) are appropriate for: Local React hooks (`useState`, custom hooks) are appropriate for:
@ -954,11 +977,13 @@ Local React hooks (`useState`, custom hooks) are appropriate for:
``` ```
Is the state needed by multiple unrelated components? Is the state needed by multiple unrelated components?
├── Yes → Use Redux ├── Yes → Is it API-backed server data?
└── No → Does the state need to persist across route changes? │ ├── Yes → Use TanStack Query
├── Yes → Use Redux │ └── No → Use Redux
└── No → Is the state tied to API data? └── No → Is the state tied to API data?
├── Yes → Use Redux ├── Yes → Use TanStack Query
└── No → Does it need to persist across route changes?
├── Yes → Use Redux or feature-local persistent state
└── No → Local hook is acceptable └── No → Local hook is acceptable
``` ```
@ -998,7 +1023,8 @@ dispatch(updateElement({ id, changes }));
| Anti-Pattern | Problem | Solution | | Anti-Pattern | Problem | Solution |
|--------------|---------|----------| |--------------|---------|----------|
| Redux for modal open/close | Over-engineering | Local `useState` | | Redux for modal open/close | Over-engineering | Local `useState` |
| Local state for entity data | Data inconsistency | Redux slice | | Redux thunk for new entity API reads | Server data in client store | TanStack Query hook |
| Local state for entity data | Data inconsistency | TanStack Query hook |
| Prop drilling 3+ levels | Maintenance burden | Redux or Context | | Prop drilling 3+ levels | Maintenance burden | Redux or Context |
| Redux for animation frames | Performance issues | Local `useRef` | | Redux for animation frames | Performance issues | Local `useRef` |
@ -1008,8 +1034,8 @@ dispatch(updateElement({ id, changes }));
| Category | Count | Description | | Category | Count | Description |
|----------|-------|-------------| |----------|-------|-------------|
| Core Slices | 4 | auth, style, main, openAi | | Core Slices | 3 | auth, style, main |
| Entity Slices | 15 | Generated via factory pattern (includes 2 transition slices) | | Entity/Runtime Slices | 16 | Existing Redux slices; new server-state flows should use TanStack Query |
| Constructor Slice | 1 | Tour editor state | | Constructor Slice | 1 | Tour editor state |
| Type Files | 3 | redux.ts, entities.ts, api.ts | | Type Files | 3 | redux.ts, entities.ts, api.ts |
| Total Slices | 20 | Combined in store.ts | | Total Slices | 20 | Combined in store.ts |
@ -1027,7 +1053,8 @@ These slices don't require special headers - the backend determines public acces
| Total Files | 22+ | In stores directory | | Total Files | 22+ | In stores directory |
**Key Patterns:** **Key Patterns:**
- **Redux is default** for app-wide, persistent, shared state - **Redux is for client/app state** such as auth UI, theme, layout, constructor UI state, and app preferences
- **TanStack Query is for server state** such as lists, details, mutations, invalidation, and background refetch
- **Local hooks acceptable** for ephemeral, component-scoped state - **Local hooks acceptable** for ephemeral, component-scoped state
- Factory pattern eliminates ~2,600 LOC of boilerplate (13 entities × ~200 LOC each) - Factory pattern eliminates ~2,600 LOC of boilerplate (13 entities × ~200 LOC each)
- Typed hooks ensure type safety throughout - Typed hooks ensure type safety throughout

View File

@ -1,23 +1,310 @@
import { createTableComponent } from '../Factory/createTableComponent'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { import {
fetch, DataGrid,
update, GridColDef,
deleteItem, GridEventListener,
setRefetch, GridPaginationModel,
deleteItemsByIds, GridRowEditStopReasons,
} from '../../stores/permissions/permissionsSlice'; GridRowModel,
import { loadColumns } from './configurePermissionsCols'; GridRowSelectionModel,
import type { PermissionEntity } from '../../types/entities'; GridRowsProp,
GridSortModel,
} from '@mui/x-data-grid';
import { Field, Form, Formik } from 'formik';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
const TablePermissions = createTableComponent<PermissionEntity>({ import BaseButton from '../BaseButton';
entityName: 'permissions', import CardBox from '../CardBox';
sliceSelector: (state) => state.permissions, import { loadColumns } from './configurePermissionsCols';
fetchAction: fetch, import { useAppSelector } from '../../stores/hooks';
updateAction: update, import {
deleteAction: deleteItem, buildPermissionListFilters,
deleteByIdsAction: deleteItemsByIds, useDeletePermissionMutation,
setRefetchAction: setRefetch, useDeletePermissionsByIdsMutation,
loadColumnsFunction: loadColumns, usePermissionsListQuery,
}); useUpdatePermissionMutation,
} from '../../hooks/queries';
import type { Filter, FilterFields, FilterItem } from '../../types/filters';
interface TablePermissionsProps {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
filters: Filter[];
showGrid?: boolean;
}
const TablePermissions = ({
filterItems,
setFilterItems,
filters,
}: TablePermissionsProps) => {
const currentUser = useAppSelector((state) => state.auth.currentUser);
const focusRing = useAppSelector((state) => state.style.focusRingColor);
const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const corners = useAppSelector((state) => state.style.corners);
const [columns, setColumns] = useState<GridColDef[]>([]);
const [rowSelectionModel, setRowSelectionModel] =
useState<GridRowSelectionModel>([]);
const [paginationModel, setPaginationModel] = useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const [sortModel, setSortModel] = useState<GridSortModel>([]);
const { mutateAsync: updatePermission } = useUpdatePermissionMutation();
const { mutateAsync: deletePermission } = useDeletePermissionMutation();
const { mutateAsync: deletePermissionsByIds } =
useDeletePermissionsByIdsMutation();
const controlClasses =
'w-full py-2 px-2 my-2 rounded dark:placeholder-gray-400 ' +
`${bgColor} ${focusRing} ${corners} ` +
'dark:bg-slate-800 border';
const onDelete = useCallback(
async (id: string) => {
try {
await deletePermission(id);
toast.success('Permission has been deleted');
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Error deleting permission',
);
}
},
[deletePermission],
);
useEffect(() => {
let isActive = true;
async function loadPermissionColumns() {
const nextColumns = await loadColumns(
(id) => {
void onDelete(id);
},
'permissions',
currentUser,
);
if (isActive) {
setColumns(nextColumns);
}
}
loadPermissionColumns();
return () => {
isActive = false;
};
}, [currentUser, onDelete]);
const queryParams = useMemo(() => {
const sort = sortModel[0];
return {
page: paginationModel.page + 1,
limit: paginationModel.pageSize,
sortBy: sort?.field,
sortOrder: sort?.sort,
filters: buildPermissionListFilters(filterItems, filters),
};
}, [filterItems, filters, paginationModel, sortModel]);
const { data, isFetching } = usePermissionsListQuery(queryParams);
const rows = data?.rows || [];
const count = data?.count || 0;
const handleRowEditStop: GridEventListener<'rowEditStop'> = (
params,
event,
) => {
if (params.reason === GridRowEditStopReasons.rowFocusOut) {
event.defaultMuiPrevented = true;
}
};
const processRowUpdate = async (
newRow: GridRowModel,
): Promise<GridRowModel> => {
await updatePermission({
id: newRow.id as string,
data: { name: String(newRow.name || '') },
});
toast.success('Permission has been updated');
return newRow;
};
const handleProcessRowUpdateError = (error: Error) => {
toast.error(`Error updating row: ${error.message}`);
};
const handleFilterChange =
(id: string) =>
(event: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const value = event.target.value;
const name = event.target.name as keyof FilterFields;
setFilterItems(
filterItems.map((item) => {
if (item.id !== id) return item;
if (name === 'selectedField') {
return {
id,
fields: {
selectedField: value,
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
},
};
}
return { id, fields: { ...item.fields, [name]: value } };
}),
);
setPaginationModel((current) => ({ ...current, page: 0 }));
};
const deleteFilter = (id: string) => {
setFilterItems(filterItems.filter((item) => item.id !== id));
setPaginationModel((current) => ({ ...current, page: 0 }));
};
const handleResetFilters = () => {
setFilterItems([]);
setPaginationModel((current) => ({ ...current, page: 0 }));
};
const handleBulkDelete = async () => {
if (rowSelectionModel.length === 0) return;
try {
await deletePermissionsByIds(rowSelectionModel as string[]);
setRowSelectionModel([]);
toast.success('Permissions have been deleted');
} catch (error) {
toast.error(
error instanceof Error ? error.message : 'Error deleting permissions',
);
}
};
return (
<>
{filterItems.length > 0 && (
<CardBox className='mb-4'>
<Formik initialValues={{}} onSubmit={() => undefined}>
<Form>
{filterItems.map((filterItem) => (
<div key={filterItem.id} className='flex mb-4 gap-3'>
<div className='flex flex-col w-full'>
<div className='text-gray-500 font-bold text-sm'>
Filter
</div>
<Field
className={controlClasses}
name='selectedField'
component='select'
value={filterItem.fields.selectedField}
onChange={handleFilterChange(filterItem.id)}
>
<option value=''>Select field</option>
{filters.map((filter) => (
<option key={filter.title} value={filter.title}>
{filter.label}
</option>
))}
</Field>
</div>
<div className='flex flex-col w-full'>
<div className='text-gray-500 font-bold text-sm'>
Contains
</div>
<Field
className={controlClasses}
name='filterValue'
placeholder='Filter value'
value={filterItem.fields.filterValue}
onChange={handleFilterChange(filterItem.id)}
/>
</div>
<div className='flex flex-col'>
<div className='text-gray-500 font-bold text-sm'>
Action
</div>
<BaseButton
className='my-2'
type='button'
color='danger'
label='Delete'
onClick={() => deleteFilter(filterItem.id)}
/>
</div>
</div>
))}
<div className='flex gap-3'>
<BaseButton
className='my-2'
color='info'
label='Reset Filters'
onClick={handleResetFilters}
/>
</div>
</Form>
</Formik>
</CardBox>
)}
{rowSelectionModel.length > 0 && (
<div className='mb-4 flex gap-2'>
<BaseButton
color='danger'
label={`Delete Selected (${rowSelectionModel.length})`}
onClick={handleBulkDelete}
/>
</div>
)}
<CardBox className='mb-6 overflow-hidden'>
<div style={{ width: '100%', minHeight: 400 }}>
<DataGrid
rows={rows as GridRowsProp}
columns={columns}
rowCount={count}
loading={isFetching}
pageSizeOptions={[5, 10, 25, 50]}
paginationModel={paginationModel}
paginationMode='server'
onPaginationModelChange={setPaginationModel}
sortingMode='server'
sortModel={sortModel}
onSortModelChange={setSortModel}
checkboxSelection
disableRowSelectionOnClick
rowSelectionModel={rowSelectionModel}
onRowSelectionModelChange={setRowSelectionModel}
editMode='row'
onRowEditStop={handleRowEditStop}
processRowUpdate={processRowUpdate}
onProcessRowUpdateError={handleProcessRowUpdateError}
getRowId={(row) => row.id}
sx={{
border: 'none',
'& .MuiDataGrid-cell': {
borderColor: 'rgba(0,0,0,0.1)',
},
'& .MuiDataGrid-columnHeaders': {
backgroundColor: 'rgba(0,0,0,0.02)',
},
}}
/>
</div>
</CardBox>
<ToastContainer />
</>
);
};
export default TablePermissions; export default TablePermissions;

View File

@ -38,7 +38,18 @@ export {
useCreateRoleMutation, useCreateRoleMutation,
useDeleteRoleMutation, useDeleteRoleMutation,
} from './useRolesQuery'; } from './useRolesQuery';
export { usePermissionsQuery } from './usePermissionsQuery'; export {
usePermissionsQuery,
usePermissionsListQuery,
usePermissionQuery,
useCreatePermissionMutation,
useUpdatePermissionMutation,
useDeletePermissionMutation,
useDeletePermissionsByIdsMutation,
useUploadPermissionsCsvMutation,
downloadPermissionsCsv,
buildPermissionListFilters,
} from './usePermissionsQuery';
export { useAccessLogsQuery } from './useAccessLogsQuery'; export { useAccessLogsQuery } from './useAccessLogsQuery';
export { useAssetVariantsQuery } from './useAssetVariantsQuery'; export { useAssetVariantsQuery } from './useAssetVariantsQuery';
export { export {

View File

@ -4,32 +4,293 @@
* React Query hooks for fetching permission data. * React Query hooks for fetching permission data.
*/ */
import { useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios'; import axios from 'axios';
import { queryKeys } from '../../lib/queryClient'; import { queryKeys } from '../../lib/queryClient';
import type { PermissionEntity } from '../../types/entities';
interface Permission { export interface PermissionListParams {
id: string; page?: number;
name: string; limit?: number;
sortBy?: string;
sortOrder?: string | null;
filters?: Record<string, string | string[]>;
} }
interface PermissionListResponse { interface PermissionListResponse {
rows: Permission[]; rows: PermissionEntity[];
count: number; count: number;
} }
function buildPermissionsQuery(params?: PermissionListParams) {
if (!params) return '';
const searchParams = new URLSearchParams();
searchParams.set('page', String(params.page || 1));
searchParams.set('limit', String(params.limit || 100));
if (params.sortBy && params.sortOrder) {
searchParams.set('sortBy', params.sortBy);
searchParams.set('sortOrder', params.sortOrder);
}
Object.entries(params.filters || {}).forEach(([key, value]) => {
const values = Array.isArray(value) ? value : [value];
values.forEach((item) => {
if (item) {
searchParams.append(key, item);
}
});
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
export function appendPermissionFilterParam(
params: Record<string, string | string[]>,
key: string,
value: string,
) {
if (!value) return;
const currentValue = params[key];
if (Array.isArray(currentValue)) {
params[key] = [...currentValue, value];
return;
}
if (currentValue) {
params[key] = [currentValue, value];
return;
}
params[key] = value;
}
export function buildPermissionListFilters(
filterItems: Array<{
fields: {
selectedField: string;
filterValue: string;
filterValueFrom: string;
filterValueTo: string;
};
}>,
filters: Array<{ title: string; number?: boolean; date?: boolean }>,
) {
const params: Record<string, string | string[]> = {};
filterItems.forEach((item) => {
const selectedField = item.fields.selectedField;
const isRangeFilter = filters.find(
(filter) =>
filter.title === selectedField && (filter.number || filter.date),
);
if (isRangeFilter) {
appendPermissionFilterParam(
params,
`${selectedField}Range`,
item.fields.filterValueFrom,
);
appendPermissionFilterParam(
params,
`${selectedField}Range`,
item.fields.filterValueTo,
);
return;
}
appendPermissionFilterParam(params, selectedField, item.fields.filterValue);
});
return params;
}
/** /**
* Fetch list of permissions * Fetch paginated list of permissions
*/ */
export function usePermissionsQuery() { export function usePermissionsListQuery(params?: PermissionListParams) {
return useQuery({ return useQuery({
queryKey: queryKeys.permissions.list(), queryKey: queryKeys.permissions.list(params),
queryFn: async (): Promise<Permission[]> => { queryFn: async (): Promise<PermissionListResponse> => {
const response = await axios.get<PermissionListResponse>('permissions'); const response = await axios.get<PermissionListResponse>(
return response.data.rows; `permissions${buildPermissionsQuery(params)}`,
);
return response.data;
}, },
staleTime: 30 * 60 * 1000, // Permissions rarely change staleTime: 30 * 60 * 1000, // Permissions rarely change
}); });
} }
/**
* Fetch permission rows for consumers that do not need pagination metadata
*/
export function usePermissionsQuery(params?: PermissionListParams) {
return useQuery({
queryKey: [...queryKeys.permissions.list(params), 'rows'] as const,
queryFn: async (): Promise<PermissionEntity[]> => {
const response = await axios.get<PermissionListResponse>(
`permissions${buildPermissionsQuery(params)}`,
);
return response.data.rows;
},
staleTime: 30 * 60 * 1000,
});
}
/**
* Fetch single permission by ID
*/
export function usePermissionQuery(permissionId: string | undefined) {
return useQuery({
queryKey: queryKeys.permissions.detail(permissionId || ''),
queryFn: async (): Promise<PermissionEntity> => {
const response = await axios.get<PermissionEntity>(
`permissions/${permissionId}`,
);
return response.data;
},
enabled: !!permissionId,
staleTime: 30 * 60 * 1000,
});
}
/**
* Create permission mutation
*/
export function useCreatePermissionMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (
data: Partial<PermissionEntity>,
): Promise<PermissionEntity> => {
const response = await axios.post<PermissionEntity>('permissions', {
data,
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.permissions.all });
},
});
}
/**
* Update permission mutation
*/
export function useUpdatePermissionMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
data,
}: {
id: string;
data: Partial<PermissionEntity>;
}): Promise<boolean> => {
const response = await axios.put<boolean>(`permissions/${id}`, {
id,
data,
});
return response.data;
},
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.permissions.all });
queryClient.invalidateQueries({
queryKey: queryKeys.permissions.detail(variables.id),
});
},
});
}
/**
* Delete permission mutation
*/
export function useDeletePermissionMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string): Promise<boolean> => {
const response = await axios.delete<boolean>(`permissions/${id}`);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.permissions.all });
},
});
}
/**
* Delete multiple permissions
*/
export function useDeletePermissionsByIdsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (ids: string[]): Promise<boolean> => {
const response = await axios.post<boolean>('permissions/deleteByIds', {
data: ids,
});
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.permissions.all });
},
});
}
/**
* Upload permissions from CSV
*/
export function useUploadPermissionsCsvMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (file: File): Promise<boolean> => {
const formData = new FormData();
formData.append('file', file);
formData.append('filename', file.name);
const response = await axios.post<boolean>(
'permissions/bulk-import',
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
);
return response.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.permissions.all });
},
});
}
/**
* Download permissions CSV
*/
export async function downloadPermissionsCsv() {
const response = await axios({
url: '/permissions?filetype=csv',
method: 'GET',
responseType: 'blob',
});
const type = response.headers['content-type'] as string;
const blob = new Blob([response.data], { type });
const objectUrl = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = objectUrl;
link.download = 'permissions.csv';
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(objectUrl);
}
export default usePermissionsQuery; export default usePermissionsQuery;

View File

@ -0,0 +1,54 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildPermissionListFilters } from './queries/usePermissionsQuery';
test('buildPermissionListFilters preserves duplicate text filters', () => {
const filters = [{ title: 'name' }];
const result = buildPermissionListFilters(
[
{
fields: {
selectedField: 'name',
filterValue: 'read',
filterValueFrom: '',
filterValueTo: '',
},
},
{
fields: {
selectedField: 'name',
filterValue: 'write',
filterValueFrom: '',
filterValueTo: '',
},
},
],
filters,
);
assert.deepEqual(result, { name: ['read', 'write'] });
});
test('buildPermissionListFilters preserves range endpoints as repeated params', () => {
const filters = [{ title: 'createdAt', date: true }];
const result = buildPermissionListFilters(
[
{
fields: {
selectedField: 'createdAt',
filterValue: '',
filterValueFrom: '2026-01-01',
filterValueTo: '2026-01-31',
},
},
],
filters,
);
assert.deepEqual(result, {
createdAtRange: ['2026-01-01', '2026-01-31'],
});
});

View File

@ -104,7 +104,10 @@ export const queryKeys = {
// Permissions // Permissions
permissions: { permissions: {
all: ['permissions'] as const, all: ['permissions'] as const,
list: () => [...queryKeys.permissions.all, 'list'] as const, list: (filters?: unknown) =>
[...queryKeys.permissions.all, 'list', filters] as const,
detail: (id: string) =>
[...queryKeys.permissions.all, 'detail', id] as const,
}, },
// Access Logs // Access Logs

View File

@ -1,9 +1,6 @@
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js'; import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head'; import Head from 'next/head';
import React, { ReactElement, useEffect, useState } from 'react'; import React, { ReactElement } from 'react';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
import CardBox from '../../components/CardBox'; import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated'; import LayoutAuthenticated from '../../layouts/Authenticated';
@ -16,21 +13,12 @@ import FormField from '../../components/FormField';
import BaseDivider from '../../components/BaseDivider'; import BaseDivider from '../../components/BaseDivider';
import BaseButtons from '../../components/BaseButtons'; import BaseButtons from '../../components/BaseButtons';
import BaseButton from '../../components/BaseButton'; import BaseButton from '../../components/BaseButton';
import FormCheckRadio from '../../components/FormCheckRadio';
import FormCheckRadioGroup from '../../components/FormCheckRadioGroup';
import FormFilePicker from '../../components/FormFilePicker';
import FormImagePicker from '../../components/FormImagePicker';
import { SelectField } from '../../components/SelectField';
import { SelectFieldMany } from '../../components/SelectFieldMany';
import { SwitchField } from '../../components/SwitchField';
import { update, fetch } from '../../stores/permissions/permissionsSlice';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { saveFile } from '../../helpers/fileSaver'; import {
import dataFormatter from '../../helpers/dataFormatter'; usePermissionQuery,
import ImageField from '../../components/ImageField'; useUpdatePermissionMutation,
import type { PermissionEntity } from '../../types/entities'; } from '../../hooks/queries';
const initVals = { const initVals = {
name: '', name: '',
@ -38,44 +26,20 @@ const initVals = {
const EditPermissions = () => { const EditPermissions = () => {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch();
const [initialValues, setInitialValues] = useState(initVals);
const permissionsState = useAppSelector((state) => state.permissions);
const permissions = permissionsState.data;
const permission = permissions[0];
const { permissionsId } = router.query; const { permissionsId } = router.query;
const idStr = Array.isArray(permissionsId) ? permissionsId[0] : permissionsId; const idStr = Array.isArray(permissionsId) ? permissionsId[0] : permissionsId;
const { data: permission } = usePermissionQuery(idStr);
const updatePermission = useUpdatePermissionMutation();
useEffect(() => { const initialValues = permission
if (idStr) { ? {
dispatch(fetch({ id: idStr })); name: permission.name || '',
} }
}, [idStr, dispatch]); : initVals;
useEffect(() => {
if (permission && typeof permission === 'object') {
const newInitialVal = { ...initVals };
Object.keys(initVals).forEach((el) => {
if (el in permission) {
(newInitialVal as Record<string, unknown>)[el] = (
permission as unknown as Record<string, unknown>
)[el];
}
});
setInitialValues(newInitialVal);
}
}, [permission]);
const handleSubmit = async (data: typeof initVals) => { const handleSubmit = async (data: typeof initVals) => {
if (idStr) { if (idStr) {
await dispatch( await updatePermission.mutateAsync({ id: idStr, data });
update({
id: idStr,
data: data as unknown as Partial<PermissionEntity>,
}),
);
await router.push('/permissions/permissions-list'); await router.push('/permissions/permissions-list');
} }
}; };

View File

@ -14,10 +14,11 @@ import BaseDivider from '../../components/BaseDivider';
import BaseButtons from '../../components/BaseButtons'; import BaseButtons from '../../components/BaseButtons';
import BaseButton from '../../components/BaseButton'; import BaseButton from '../../components/BaseButton';
import { update, fetch } from '../../stores/permissions/permissionsSlice';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { useEditPageSync } from '../../hooks/useEditPageSync'; import {
usePermissionQuery,
useUpdatePermissionMutation,
} from '../../hooks/queries';
const initVals = { const initVals = {
name: '', name: '',
@ -25,17 +26,20 @@ const initVals = {
const EditPermissionsPage = () => { const EditPermissionsPage = () => {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id;
const { data: permission } = usePermissionQuery(idStr);
const updatePermission = useUpdatePermissionMutation();
const { values: initialValues, id } = useEditPageSync({ const initialValues = permission
entitySelector: (state) => state.permissions.data, ? {
fetchAction: fetch, name: permission.name || '',
initialValues: initVals, }
}); : initVals;
const handleSubmit = async (data: typeof initVals) => { const handleSubmit = async (data: typeof initVals) => {
if (id) { if (idStr) {
await dispatch(update({ id, data })); await updatePermission.mutateAsync({ id: idStr, data });
await router.push('/permissions/permissions-list'); await router.push('/permissions/permissions-list');
} }
}; };

View File

@ -2,22 +2,145 @@
* Permissions List Page * Permissions List Page
*/ */
import { createListPage } from '../../factories/createListPage'; import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import { uniqueId } from 'lodash';
import React, { ReactElement, useState } from 'react';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import CardBoxModal from '../../components/CardBoxModal';
import DragDropFilePicker from '../../components/DragDropFilePicker';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import TablePermissions from '../../components/Permissions/TablePermissions'; import TablePermissions from '../../components/Permissions/TablePermissions';
import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions';
import { useAppSelector } from '../../stores/hooks';
import { import {
uploadCsv, downloadPermissionsCsv,
setRefetch, useUploadPermissionsCsvMutation,
} from '../../stores/permissions/permissionsSlice'; } from '../../hooks/queries';
import type { Filter, FilterItem } from '../../types/filters';
const filters = [{ label: 'Name', title: 'name' }]; const filters: Filter[] = [{ label: 'Name', title: 'name' }];
export default createListPage({ const PermissionsListPage = () => {
entityName: 'permissions', const { currentUser } = useAppSelector((state) => state.auth);
entityTitle: 'Permissions', const [filterItems, setFilterItems] = useState<FilterItem[]>([]);
TableComponent: TablePermissions, const [csvFile, setCsvFile] = useState<File | null>(null);
filters, const [isModalActive, setIsModalActive] = useState(false);
readPermission: 'READ_PERMISSIONS', const uploadCsv = useUploadPermissionsCsvMutation();
createPermission: 'CREATE_PERMISSIONS',
uploadCsvAction: uploadCsv, const hasCreatePermission =
setRefetchAction: setRefetch, currentUser && hasPermission(currentUser, 'CREATE_PERMISSIONS');
});
const addFilter = () => {
setFilterItems([
...filterItems,
{
id: uniqueId(),
fields: {
filterValue: '',
filterValueFrom: '',
filterValueTo: '',
selectedField: filters[0]?.title || '',
},
},
]);
};
const onModalConfirm = async () => {
if (!csvFile) return;
await uploadCsv.mutateAsync(csvFile);
setCsvFile(null);
setIsModalActive(false);
};
const onModalCancel = () => {
setCsvFile(null);
setIsModalActive(false);
};
return (
<>
<Head>
<title>{getPageTitle('Permissions')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Permissions'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6' cardBoxClassName='flex flex-wrap'>
{hasCreatePermission && (
<BaseButton
className='mr-3'
href='/permissions/permissions-new'
color='info'
label='New Item'
/>
)}
<BaseButton
className='mr-3'
color='info'
label='Filter'
onClick={addFilter}
/>
<BaseButton
className='mr-3'
color='info'
label='Download CSV'
onClick={downloadPermissionsCsv}
/>
{hasCreatePermission && (
<BaseButton
color='info'
label='Upload CSV'
onClick={() => setIsModalActive(true)}
/>
)}
<div className='md:inline-flex items-center ms-auto'>
<div id='delete-rows-button'></div>
</div>
</CardBox>
<CardBox className='mb-6' hasTable>
<TablePermissions
filterItems={filterItems}
setFilterItems={setFilterItems}
filters={filters}
showGrid={false}
/>
</CardBox>
</SectionMain>
<CardBoxModal
title='Upload CSV'
buttonColor='info'
buttonLabel='Confirm'
isActive={isModalActive}
onConfirm={onModalConfirm}
onCancel={onModalCancel}
>
<DragDropFilePicker
file={csvFile}
setFile={setCsvFile}
formats='.csv'
/>
</CardBoxModal>
</>
);
};
PermissionsListPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='READ_PERMISSIONS'>
{page}
</LayoutAuthenticated>
);
};
export default PermissionsListPage;

View File

@ -19,8 +19,7 @@ import BaseDivider from '../../components/BaseDivider';
import BaseButtons from '../../components/BaseButtons'; import BaseButtons from '../../components/BaseButtons';
import BaseButton from '../../components/BaseButton'; import BaseButton from '../../components/BaseButton';
import { create } from '../../stores/permissions/permissionsSlice'; import { useCreatePermissionMutation } from '../../hooks/queries';
import { useAppDispatch } from '../../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
const initialValues = { const initialValues = {
@ -29,10 +28,10 @@ const initialValues = {
const PermissionsNew = () => { const PermissionsNew = () => {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch(); const createPermission = useCreatePermissionMutation();
const handleSubmit = async (data: typeof initialValues) => { const handleSubmit = async (data: typeof initialValues) => {
await dispatch(create(data)); await createPermission.mutateAsync(data);
await router.push('/permissions/permissions-list'); await router.push('/permissions/permissions-list');
}; };

View File

@ -9,29 +9,24 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
import { getPageTitle } from '../../config'; import { getPageTitle } from '../../config';
import TablePermissions from '../../components/Permissions/TablePermissions'; import TablePermissions from '../../components/Permissions/TablePermissions';
import BaseButton from '../../components/BaseButton'; import BaseButton from '../../components/BaseButton';
import axios from 'axios'; import { useAppSelector } from '../../stores/hooks';
import Link from 'next/link';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import CardBoxModal from '../../components/CardBoxModal'; import CardBoxModal from '../../components/CardBoxModal';
import DragDropFilePicker from '../../components/DragDropFilePicker'; import DragDropFilePicker from '../../components/DragDropFilePicker';
import { import {
setRefetch, downloadPermissionsCsv,
uploadCsv, useUploadPermissionsCsvMutation,
} from '../../stores/permissions/permissionsSlice'; } from '../../hooks/queries';
import { hasPermission } from '../../helpers/userPermissions'; import { hasPermission } from '../../helpers/userPermissions';
import type { Filter, FilterItem } from '../../types/filters';
const PermissionsTablesPage = () => { const PermissionsTablesPage = () => {
const [filterItems, setFilterItems] = useState([]); const [filterItems, setFilterItems] = useState<FilterItem[]>([]);
const [csvFile, setCsvFile] = useState<File | null>(null); const [csvFile, setCsvFile] = useState<File | null>(null);
const [isModalActive, setIsModalActive] = useState(false); const [isModalActive, setIsModalActive] = useState(false);
const [showTableView, setShowTableView] = useState(false);
const { currentUser } = useAppSelector((state) => state.auth); const { currentUser } = useAppSelector((state) => state.auth);
const uploadCsv = useUploadPermissionsCsvMutation();
const dispatch = useAppDispatch(); const [filters] = useState<Filter[]>([{ label: 'Name', title: 'name' }]);
const [filters] = useState([{ label: 'Name', title: 'name' }]);
const hasCreatePermission = const hasCreatePermission =
currentUser && hasPermission(currentUser, 'CREATE_PERMISSIONS'); currentUser && hasPermission(currentUser, 'CREATE_PERMISSIONS');
@ -50,24 +45,9 @@ const PermissionsTablesPage = () => {
setFilterItems([...filterItems, newItem]); setFilterItems([...filterItems, newItem]);
}; };
const getPermissionsCSV = async () => {
const response = await axios({
url: '/permissions?filetype=csv',
method: 'GET',
responseType: 'blob',
});
const type = response.headers['content-type'] as string;
const blob = new Blob([response.data], { type: type });
const link = document.createElement('a');
link.href = window.URL.createObjectURL(blob);
link.download = 'permissionsCSV.csv';
link.click();
};
const onModalConfirm = async () => { const onModalConfirm = async () => {
if (!csvFile) return; if (!csvFile) return;
await dispatch(uploadCsv(csvFile)); await uploadCsv.mutateAsync(csvFile);
dispatch(setRefetch(true));
setCsvFile(null); setCsvFile(null);
setIsModalActive(false); setIsModalActive(false);
}; };
@ -110,7 +90,7 @@ const PermissionsTablesPage = () => {
className={'mr-3'} className={'mr-3'}
color='info' color='info'
label='Download CSV' label='Download CSV'
onClick={getPermissionsCSV} onClick={downloadPermissionsCsv}
/> />
{hasCreatePermission && ( {hasCreatePermission && (

View File

@ -1,14 +1,6 @@
import React, { ReactElement, useEffect } from 'react'; import React, { ReactElement } from 'react';
import Head from 'next/head'; import Head from 'next/head';
import DatePicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
import dayjs from 'dayjs';
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import { fetch } from '../../stores/permissions/permissionsSlice';
import { saveFile } from '../../helpers/fileSaver';
import dataFormatter from '../../helpers/dataFormatter';
import ImageField from '../../components/ImageField';
import LayoutAuthenticated from '../../layouts/Authenticated'; import LayoutAuthenticated from '../../layouts/Authenticated';
import { getPageTitle } from '../../config'; import { getPageTitle } from '../../config';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
@ -17,30 +9,18 @@ import CardBox from '../../components/CardBox';
import BaseButton from '../../components/BaseButton'; import BaseButton from '../../components/BaseButton';
import BaseDivider from '../../components/BaseDivider'; import BaseDivider from '../../components/BaseDivider';
import { mdiChartTimelineVariant } from '@mdi/js'; import { mdiChartTimelineVariant } from '@mdi/js';
import { SwitchField } from '../../components/SwitchField'; import { usePermissionQuery } from '../../hooks/queries';
import FormField from '../../components/FormField';
import type { PermissionEntity } from '../../types/entities';
const PermissionsView = () => { const PermissionsView = () => {
const router = useRouter(); const router = useRouter();
const dispatch = useAppDispatch();
const permissionsState = useAppSelector((state) => state.permissions);
const permissions = permissionsState.data;
const permission = permissions[0];
const { id } = router.query; const { id } = router.query;
const idStr = Array.isArray(id) ? id[0] : id; const idStr = Array.isArray(id) ? id[0] : id;
const { data: permission } = usePermissionQuery(idStr);
function removeLastCharacter(str: string) { function removeLastCharacter(str: string) {
return str.slice(0, -1); return str.slice(0, -1);
} }
useEffect(() => {
if (idStr) {
dispatch(fetch({ id: idStr }));
}
}, [dispatch, idStr]);
return ( return (
<> <>
<Head> <Head>

View File

@ -1,25 +0,0 @@
/**
* Permissions Redux Slice
*/
import { createEntitySlice } from '../createEntitySlice';
import type { PermissionEntity } from '../../types/entities';
const { slice, actions, reducer } = createEntitySlice<PermissionEntity>({
name: 'permissions',
endpoint: 'permissions',
singularName: 'Permission',
});
export const {
fetch,
create,
update,
deleteItem,
deleteItemsByIds,
uploadCsv,
setRefetch,
} = actions;
export const permissionsSlice = slice;
export default reducer;

View File

@ -6,7 +6,6 @@ import constructorReducer from './constructor/constructorSlice';
import usersSlice from './users/usersSlice'; import usersSlice from './users/usersSlice';
import rolesSlice from './roles/rolesSlice'; import rolesSlice from './roles/rolesSlice';
import permissionsSlice from './permissions/permissionsSlice';
import projectsSlice from './projects/projectsSlice'; import projectsSlice from './projects/projectsSlice';
import project_membershipsSlice from './project_memberships/project_membershipsSlice'; import project_membershipsSlice from './project_memberships/project_membershipsSlice';
import assetsSlice from './assets/assetsSlice'; import assetsSlice from './assets/assetsSlice';
@ -31,7 +30,6 @@ export const store = configureStore({
users: usersSlice, users: usersSlice,
roles: rolesSlice, roles: rolesSlice,
permissions: permissionsSlice,
projects: projectsSlice, projects: projectsSlice,
project_memberships: project_membershipsSlice, project_memberships: project_membershipsSlice,
assets: assetsSlice, assets: assetsSlice,

View File

@ -26,6 +26,12 @@ const permissions = [
'READ_ACCESS_LOGS', 'READ_ACCESS_LOGS',
].map((name, index) => ({ id: `permission-${index}`, name })); ].map((name, index) => ({ id: `permission-${index}`, name }));
export const testPermissions = [
{ id: 'permission-read-projects', name: 'READ_PROJECTS' },
{ id: 'permission-read-permissions', name: 'READ_PERMISSIONS' },
{ id: 'permission-update-permissions', name: 'UPDATE_PERMISSIONS' },
];
export const testUser = { export const testUser = {
id: 'user-playwright-admin', id: 'user-playwright-admin',
email: 'admin@example.test', email: 'admin@example.test',
@ -236,6 +242,37 @@ export async function mockFrontendApi(page: Page) {
return fulfillJson(route, rowsResponse(testAssets)); return fulfillJson(route, rowsResponse(testAssets));
} }
if (path.startsWith('/permissions/')) {
const id = path.split('/').pop();
const permission = testPermissions.find((item) => item.id === id);
if (request.method() === 'GET') {
return fulfillJson(route, permission || testPermissions[0]);
}
if (request.method() === 'PUT' || request.method() === 'DELETE') {
return fulfillJson(route, true);
}
}
if (path === '/permissions') {
if (request.method() === 'POST') {
return fulfillJson(route, {
id: 'permission-created',
...(request.postDataJSON() as { data?: object }).data,
});
}
const nameFilter = url.searchParams.getAll('name').filter(Boolean);
const rows = nameFilter.length
? testPermissions.filter((permission) =>
nameFilter.some((value) => permission.name.includes(value)),
)
: testPermissions;
return fulfillJson(route, rowsResponse(rows));
}
if (path === '/project-element-defaults') { if (path === '/project-element-defaults') {
return fulfillJson(route, rowsResponse([])); return fulfillJson(route, rowsResponse([]));
} }

View File

@ -0,0 +1,72 @@
import { expect, test } from '@playwright/test';
import {
authenticate,
collectConsoleFailures,
mockFrontendApi,
testPermissions,
} from './fixtures';
test.beforeEach(async ({ page }) => {
await mockFrontendApi(page);
await authenticate(page);
});
test('permissions list uses query-backed table filtering', async ({ page }) => {
const consoleFailures = collectConsoleFailures(page);
await page.goto('/permissions/permissions-list');
await expect(page.getByRole('heading', { name: 'Permissions' })).toBeVisible();
await expect(page.getByRole('link', { name: 'New Item' })).toBeVisible();
await expect(page.getByText(testPermissions[0].name)).toBeVisible();
await page.getByRole('button', { name: 'Filter' }).click();
await page.locator('select[name="selectedField"]').selectOption('name');
const filteredRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
request.method() === 'GET' &&
url.pathname.endsWith('/permissions') &&
url.searchParams.get('name') === testPermissions[1].name
);
});
await page.locator('input[name="filterValue"]').fill(testPermissions[1].name);
await filteredRequest;
await expect(page.getByText(testPermissions[1].name)).toBeVisible();
await expect(page.getByText(testPermissions[0].name)).not.toBeVisible();
consoleFailures.assertClean();
});
test('permissions edit page submits through TanStack Query mutation', async ({
page,
}) => {
const consoleFailures = collectConsoleFailures(page);
const permission = testPermissions[1];
await page.goto(`/permissions/permissions-edit/?id=${permission.id}`);
await expect(page.getByRole('heading', { name: 'Edit permissions' })).toBeVisible();
await expect(page.locator('input[name="name"]')).toHaveValue(permission.name);
const updateRequest = page.waitForRequest((request) => {
const url = new URL(request.url());
return (
request.method() === 'PUT' &&
url.pathname.endsWith(`/permissions/${permission.id}`)
);
});
await page.locator('input[name="name"]').fill('READ_PERMISSIONS_UPDATED');
await page.getByRole('button', { name: 'Submit' }).click();
const request = await updateRequest;
expect(request.postDataJSON()).toEqual({
id: permission.id,
data: { name: 'READ_PERMISSIONS_UPDATED' },
});
await expect(page).toHaveURL(/\/permissions\/permissions-list/);
consoleFailures.assertClean();
});