devided state separation between redux and react query
This commit is contained in:
parent
b413e7b1bb
commit
2d63d2db6d
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
54
frontend/src/hooks/usePermissionsQuery.helpers.test.ts
Normal file
54
frontend/src/hooks/usePermissionsQuery.helpers.test.ts
Normal 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'],
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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([]));
|
||||||
}
|
}
|
||||||
|
|||||||
72
frontend/tests/e2e/permissions.spec.ts
Normal file
72
frontend/tests/e2e/permissions.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user