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.
|
||||
3. Copy frontend env files from the live workspace when present:
|
||||
`.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`.
|
||||
6. Remove non-runtime build caches from the new release:
|
||||
`.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
|
||||
`FRONTEND_RELEASES_KEEP` for the executor process before deploy. Do not delete
|
||||
the active release directory; `next start` serves production assets from its
|
||||
|
||||
@ -31,28 +31,6 @@ 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
|
||||
|
||||
Цель: включить strict TypeScript как обязательный стандарт для frontend и закрыть legacy errors без сохранения JS/CommonJS как допустимого направления. Новый и изменённый код должен быть strict-compatible.
|
||||
@ -161,8 +139,7 @@ TODO:
|
||||
3. Выбрать package manager и Node version.
|
||||
4. Добавить минимальные smoke checks.
|
||||
5. Постепенно выносить helpers из больших frontend/backend файлов при изменениях.
|
||||
6. Пилотно перевести один entity flow с Redux на TanStack Query.
|
||||
7. Делать cleanup/dependency upgrades небольшими отдельными PR.
|
||||
6. Делать cleanup/dependency upgrades небольшими отдельными PR.
|
||||
|
||||
## 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
|
||||
// Fetch all permissions
|
||||
dispatch(permissionsActions.fetch({ query: '' }));
|
||||
const { data: permissions } = usePermissionsQuery();
|
||||
|
||||
// Create new permission
|
||||
dispatch(permissionsActions.create({
|
||||
data: { name: 'READ_CUSTOM_ENTITY' }
|
||||
}));
|
||||
const createPermission = useCreatePermissionMutation();
|
||||
await createPermission.mutateAsync({ name: 'READ_CUSTOM_ENTITY' });
|
||||
|
||||
// Delete permission
|
||||
dispatch(permissionsActions.deleteItem(permissionId));
|
||||
const deletePermission = useDeletePermissionMutation();
|
||||
await deletePermission.mutateAsync(permissionId);
|
||||
```
|
||||
|
||||
## Seeding Process
|
||||
@ -738,7 +738,7 @@ const rolePermissionMap = {
|
||||
| `backend/src/routes/permissions.ts` | Permissions API routes |
|
||||
| `frontend/src/helpers/userPermissions.ts` | Client-side permission helper |
|
||||
| `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
|
||||
|
||||
|
||||
@ -2,7 +2,16 @@
|
||||
|
||||
## 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/`
|
||||
|
||||
@ -24,7 +33,7 @@ frontend/src/stores/
|
||||
├── createEntitySlice.ts # Entity slice factory (~337 LOC)
|
||||
├── selectors.ts # Memoized selectors (~181 LOC)
|
||||
│
|
||||
├── Core Slices (4)
|
||||
├── Core Slices (3)
|
||||
│ ├── authSlice.ts # Authentication state (~123 LOC)
|
||||
│ ├── styleSlice.ts # UI styling/theming (~107 LOC)
|
||||
│ ├── mainSlice.ts # Main app state (~32 LOC)
|
||||
@ -32,7 +41,6 @@ frontend/src/stores/
|
||||
├── Entity/Runtime Setting Slices
|
||||
│ ├── users/usersSlice.ts # (~25 LOC)
|
||||
│ ├── roles/rolesSlice.ts # (~96 LOC)
|
||||
│ ├── permissions/permissionsSlice.ts # (~25 LOC)
|
||||
│ ├── projects/projectsSlice.ts # (~25 LOC)
|
||||
│ ├── project_memberships/project_membershipsSlice.ts # (~25 LOC)
|
||||
│ ├── assets/assetsSlice.ts # (~26 LOC)
|
||||
@ -77,7 +85,6 @@ export const store = configureStore({
|
||||
// Entity and runtime-setting slices
|
||||
users: usersSlice,
|
||||
roles: rolesSlice,
|
||||
permissions: permissionsSlice,
|
||||
projects: projectsSlice,
|
||||
project_memberships: project_membershipsSlice,
|
||||
assets: assetsSlice,
|
||||
@ -460,7 +467,10 @@ const { userName, userEmail, userAvatar } = useAppSelector(state => state.main);
|
||||
|
||||
## 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
|
||||
|
||||
@ -498,7 +508,6 @@ export default reducer;
|
||||
|-------|-------------|----------|---------------|
|
||||
| `users` | `User` | `users` | User |
|
||||
| `roles` | `Role` | `roles` | Role |
|
||||
| `permissions` | `PermissionEntity` | `permissions` | Permission |
|
||||
| `projects` | `Project` | `projects` | Project |
|
||||
| `project_memberships` | `ProjectMembership` | `project_memberships` | Project Membership |
|
||||
| `assets` | `Asset` | `assets` | Asset |
|
||||
@ -926,18 +935,32 @@ export default MyApp;
|
||||
|
||||
## 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 |
|
||||
|------------|---------|-----------|
|
||||
| **Entity Data** | Users, Projects, Assets, Tour Pages | Shared across multiple components, API-backed |
|
||||
| **Authentication** | Current user, JWT token | App-wide, persisted to localStorage |
|
||||
| **UI Preferences** | Dark mode, theme settings | Persisted, affects entire app |
|
||||
| **Form State** | Complex multi-step forms | Survives navigation, shareable |
|
||||
| **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 |
|
||||
|
||||
### 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
|
||||
|
||||
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?
|
||||
├── Yes → Use Redux
|
||||
└── No → Does the state need to persist across route changes?
|
||||
├── Yes → Use Redux
|
||||
└── No → Is the state tied to API data?
|
||||
├── Yes → Use Redux
|
||||
├── Yes → Is it API-backed server data?
|
||||
│ ├── Yes → Use TanStack Query
|
||||
│ └── No → Use Redux
|
||||
└── No → Is the state tied to API data?
|
||||
├── 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
|
||||
```
|
||||
|
||||
@ -998,7 +1023,8 @@ dispatch(updateElement({ id, changes }));
|
||||
| Anti-Pattern | Problem | Solution |
|
||||
|--------------|---------|----------|
|
||||
| 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 |
|
||||
| Redux for animation frames | Performance issues | Local `useRef` |
|
||||
|
||||
@ -1008,8 +1034,8 @@ dispatch(updateElement({ id, changes }));
|
||||
|
||||
| Category | Count | Description |
|
||||
|----------|-------|-------------|
|
||||
| Core Slices | 4 | auth, style, main, openAi |
|
||||
| Entity Slices | 15 | Generated via factory pattern (includes 2 transition slices) |
|
||||
| Core Slices | 3 | auth, style, main |
|
||||
| Entity/Runtime Slices | 16 | Existing Redux slices; new server-state flows should use TanStack Query |
|
||||
| Constructor Slice | 1 | Tour editor state |
|
||||
| Type Files | 3 | redux.ts, entities.ts, api.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 |
|
||||
|
||||
**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
|
||||
- Factory pattern eliminates ~2,600 LOC of boilerplate (13 entities × ~200 LOC each)
|
||||
- Typed hooks ensure type safety throughout
|
||||
|
||||
@ -1,23 +1,310 @@
|
||||
import { createTableComponent } from '../Factory/createTableComponent';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
fetch,
|
||||
update,
|
||||
deleteItem,
|
||||
setRefetch,
|
||||
deleteItemsByIds,
|
||||
} from '../../stores/permissions/permissionsSlice';
|
||||
import { loadColumns } from './configurePermissionsCols';
|
||||
import type { PermissionEntity } from '../../types/entities';
|
||||
DataGrid,
|
||||
GridColDef,
|
||||
GridEventListener,
|
||||
GridPaginationModel,
|
||||
GridRowEditStopReasons,
|
||||
GridRowModel,
|
||||
GridRowSelectionModel,
|
||||
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>({
|
||||
entityName: 'permissions',
|
||||
sliceSelector: (state) => state.permissions,
|
||||
fetchAction: fetch,
|
||||
updateAction: update,
|
||||
deleteAction: deleteItem,
|
||||
deleteByIdsAction: deleteItemsByIds,
|
||||
setRefetchAction: setRefetch,
|
||||
loadColumnsFunction: loadColumns,
|
||||
});
|
||||
import BaseButton from '../BaseButton';
|
||||
import CardBox from '../CardBox';
|
||||
import { loadColumns } from './configurePermissionsCols';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import {
|
||||
buildPermissionListFilters,
|
||||
useDeletePermissionMutation,
|
||||
useDeletePermissionsByIdsMutation,
|
||||
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;
|
||||
|
||||
@ -38,7 +38,18 @@ export {
|
||||
useCreateRoleMutation,
|
||||
useDeleteRoleMutation,
|
||||
} from './useRolesQuery';
|
||||
export { usePermissionsQuery } from './usePermissionsQuery';
|
||||
export {
|
||||
usePermissionsQuery,
|
||||
usePermissionsListQuery,
|
||||
usePermissionQuery,
|
||||
useCreatePermissionMutation,
|
||||
useUpdatePermissionMutation,
|
||||
useDeletePermissionMutation,
|
||||
useDeletePermissionsByIdsMutation,
|
||||
useUploadPermissionsCsvMutation,
|
||||
downloadPermissionsCsv,
|
||||
buildPermissionListFilters,
|
||||
} from './usePermissionsQuery';
|
||||
export { useAccessLogsQuery } from './useAccessLogsQuery';
|
||||
export { useAssetVariantsQuery } from './useAssetVariantsQuery';
|
||||
export {
|
||||
|
||||
@ -4,32 +4,293 @@
|
||||
* 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 { queryKeys } from '../../lib/queryClient';
|
||||
import type { PermissionEntity } from '../../types/entities';
|
||||
|
||||
interface Permission {
|
||||
id: string;
|
||||
name: string;
|
||||
export interface PermissionListParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: string | null;
|
||||
filters?: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
interface PermissionListResponse {
|
||||
rows: Permission[];
|
||||
rows: PermissionEntity[];
|
||||
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({
|
||||
queryKey: queryKeys.permissions.list(),
|
||||
queryFn: async (): Promise<Permission[]> => {
|
||||
const response = await axios.get<PermissionListResponse>('permissions');
|
||||
return response.data.rows;
|
||||
queryKey: queryKeys.permissions.list(params),
|
||||
queryFn: async (): Promise<PermissionListResponse> => {
|
||||
const response = await axios.get<PermissionListResponse>(
|
||||
`permissions${buildPermissionsQuery(params)}`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
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;
|
||||
|
||||
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: {
|
||||
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
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import { mdiChartTimelineVariant, mdiUpload } from '@mdi/js';
|
||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||
import Head from 'next/head';
|
||||
import React, { ReactElement, useEffect, useState } from 'react';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import 'react-datepicker/dist/react-datepicker.css';
|
||||
import dayjs from 'dayjs';
|
||||
import React, { ReactElement } from 'react';
|
||||
|
||||
import CardBox from '../../components/CardBox';
|
||||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||||
@ -16,21 +13,12 @@ import FormField from '../../components/FormField';
|
||||
import BaseDivider from '../../components/BaseDivider';
|
||||
import BaseButtons from '../../components/BaseButtons';
|
||||
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 { saveFile } from '../../helpers/fileSaver';
|
||||
import dataFormatter from '../../helpers/dataFormatter';
|
||||
import ImageField from '../../components/ImageField';
|
||||
import type { PermissionEntity } from '../../types/entities';
|
||||
import {
|
||||
usePermissionQuery,
|
||||
useUpdatePermissionMutation,
|
||||
} from '../../hooks/queries';
|
||||
|
||||
const initVals = {
|
||||
name: '',
|
||||
@ -38,44 +26,20 @@ const initVals = {
|
||||
|
||||
const EditPermissions = () => {
|
||||
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 idStr = Array.isArray(permissionsId) ? permissionsId[0] : permissionsId;
|
||||
const { data: permission } = usePermissionQuery(idStr);
|
||||
const updatePermission = useUpdatePermissionMutation();
|
||||
|
||||
useEffect(() => {
|
||||
if (idStr) {
|
||||
dispatch(fetch({ id: idStr }));
|
||||
}
|
||||
}, [idStr, dispatch]);
|
||||
|
||||
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 initialValues = permission
|
||||
? {
|
||||
name: permission.name || '',
|
||||
}
|
||||
: initVals;
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
if (idStr) {
|
||||
await dispatch(
|
||||
update({
|
||||
id: idStr,
|
||||
data: data as unknown as Partial<PermissionEntity>,
|
||||
}),
|
||||
);
|
||||
await updatePermission.mutateAsync({ id: idStr, data });
|
||||
await router.push('/permissions/permissions-list');
|
||||
}
|
||||
};
|
||||
|
||||
@ -14,10 +14,11 @@ import BaseDivider from '../../components/BaseDivider';
|
||||
import BaseButtons from '../../components/BaseButtons';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
|
||||
import { update, fetch } from '../../stores/permissions/permissionsSlice';
|
||||
import { useAppDispatch } from '../../stores/hooks';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEditPageSync } from '../../hooks/useEditPageSync';
|
||||
import {
|
||||
usePermissionQuery,
|
||||
useUpdatePermissionMutation,
|
||||
} from '../../hooks/queries';
|
||||
|
||||
const initVals = {
|
||||
name: '',
|
||||
@ -25,17 +26,20 @@ const initVals = {
|
||||
|
||||
const EditPermissionsPage = () => {
|
||||
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({
|
||||
entitySelector: (state) => state.permissions.data,
|
||||
fetchAction: fetch,
|
||||
initialValues: initVals,
|
||||
});
|
||||
const initialValues = permission
|
||||
? {
|
||||
name: permission.name || '',
|
||||
}
|
||||
: initVals;
|
||||
|
||||
const handleSubmit = async (data: typeof initVals) => {
|
||||
if (id) {
|
||||
await dispatch(update({ id, data }));
|
||||
if (idStr) {
|
||||
await updatePermission.mutateAsync({ id: idStr, data });
|
||||
await router.push('/permissions/permissions-list');
|
||||
}
|
||||
};
|
||||
|
||||
@ -2,22 +2,145 @@
|
||||
* 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 { getPageTitle } from '../../config';
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import {
|
||||
uploadCsv,
|
||||
setRefetch,
|
||||
} from '../../stores/permissions/permissionsSlice';
|
||||
downloadPermissionsCsv,
|
||||
useUploadPermissionsCsvMutation,
|
||||
} 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({
|
||||
entityName: 'permissions',
|
||||
entityTitle: 'Permissions',
|
||||
TableComponent: TablePermissions,
|
||||
filters,
|
||||
readPermission: 'READ_PERMISSIONS',
|
||||
createPermission: 'CREATE_PERMISSIONS',
|
||||
uploadCsvAction: uploadCsv,
|
||||
setRefetchAction: setRefetch,
|
||||
});
|
||||
const PermissionsListPage = () => {
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
const [filterItems, setFilterItems] = useState<FilterItem[]>([]);
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [isModalActive, setIsModalActive] = useState(false);
|
||||
const uploadCsv = useUploadPermissionsCsvMutation();
|
||||
|
||||
const hasCreatePermission =
|
||||
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 BaseButton from '../../components/BaseButton';
|
||||
|
||||
import { create } from '../../stores/permissions/permissionsSlice';
|
||||
import { useAppDispatch } from '../../stores/hooks';
|
||||
import { useCreatePermissionMutation } from '../../hooks/queries';
|
||||
import { useRouter } from 'next/router';
|
||||
|
||||
const initialValues = {
|
||||
@ -29,10 +28,10 @@ const initialValues = {
|
||||
|
||||
const PermissionsNew = () => {
|
||||
const router = useRouter();
|
||||
const dispatch = useAppDispatch();
|
||||
const createPermission = useCreatePermissionMutation();
|
||||
|
||||
const handleSubmit = async (data: typeof initialValues) => {
|
||||
await dispatch(create(data));
|
||||
await createPermission.mutateAsync(data);
|
||||
await router.push('/permissions/permissions-list');
|
||||
};
|
||||
|
||||
|
||||
@ -9,29 +9,24 @@ import SectionTitleLineWithButton from '../../components/SectionTitleLineWithBut
|
||||
import { getPageTitle } from '../../config';
|
||||
import TablePermissions from '../../components/Permissions/TablePermissions';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import axios from 'axios';
|
||||
import Link from 'next/link';
|
||||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||||
import { useAppSelector } from '../../stores/hooks';
|
||||
import CardBoxModal from '../../components/CardBoxModal';
|
||||
import DragDropFilePicker from '../../components/DragDropFilePicker';
|
||||
import {
|
||||
setRefetch,
|
||||
uploadCsv,
|
||||
} from '../../stores/permissions/permissionsSlice';
|
||||
|
||||
downloadPermissionsCsv,
|
||||
useUploadPermissionsCsvMutation,
|
||||
} from '../../hooks/queries';
|
||||
import { hasPermission } from '../../helpers/userPermissions';
|
||||
import type { Filter, FilterItem } from '../../types/filters';
|
||||
|
||||
const PermissionsTablesPage = () => {
|
||||
const [filterItems, setFilterItems] = useState([]);
|
||||
const [filterItems, setFilterItems] = useState<FilterItem[]>([]);
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [isModalActive, setIsModalActive] = useState(false);
|
||||
const [showTableView, setShowTableView] = useState(false);
|
||||
|
||||
const { currentUser } = useAppSelector((state) => state.auth);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [filters] = useState([{ label: 'Name', title: 'name' }]);
|
||||
const uploadCsv = useUploadPermissionsCsvMutation();
|
||||
const [filters] = useState<Filter[]>([{ label: 'Name', title: 'name' }]);
|
||||
|
||||
const hasCreatePermission =
|
||||
currentUser && hasPermission(currentUser, 'CREATE_PERMISSIONS');
|
||||
@ -50,24 +45,9 @@ const PermissionsTablesPage = () => {
|
||||
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 () => {
|
||||
if (!csvFile) return;
|
||||
await dispatch(uploadCsv(csvFile));
|
||||
dispatch(setRefetch(true));
|
||||
await uploadCsv.mutateAsync(csvFile);
|
||||
setCsvFile(null);
|
||||
setIsModalActive(false);
|
||||
};
|
||||
@ -110,7 +90,7 @@ const PermissionsTablesPage = () => {
|
||||
className={'mr-3'}
|
||||
color='info'
|
||||
label='Download CSV'
|
||||
onClick={getPermissionsCSV}
|
||||
onClick={downloadPermissionsCsv}
|
||||
/>
|
||||
|
||||
{hasCreatePermission && (
|
||||
|
||||
@ -1,14 +1,6 @@
|
||||
import React, { ReactElement, useEffect } from 'react';
|
||||
import React, { ReactElement } from 'react';
|
||||
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 { 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 { getPageTitle } from '../../config';
|
||||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||||
@ -17,30 +9,18 @@ import CardBox from '../../components/CardBox';
|
||||
import BaseButton from '../../components/BaseButton';
|
||||
import BaseDivider from '../../components/BaseDivider';
|
||||
import { mdiChartTimelineVariant } from '@mdi/js';
|
||||
import { SwitchField } from '../../components/SwitchField';
|
||||
import FormField from '../../components/FormField';
|
||||
import type { PermissionEntity } from '../../types/entities';
|
||||
import { usePermissionQuery } from '../../hooks/queries';
|
||||
|
||||
const PermissionsView = () => {
|
||||
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 idStr = Array.isArray(id) ? id[0] : id;
|
||||
const { data: permission } = usePermissionQuery(idStr);
|
||||
|
||||
function removeLastCharacter(str: string) {
|
||||
return str.slice(0, -1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (idStr) {
|
||||
dispatch(fetch({ id: idStr }));
|
||||
}
|
||||
}, [dispatch, idStr]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 rolesSlice from './roles/rolesSlice';
|
||||
import permissionsSlice from './permissions/permissionsSlice';
|
||||
import projectsSlice from './projects/projectsSlice';
|
||||
import project_membershipsSlice from './project_memberships/project_membershipsSlice';
|
||||
import assetsSlice from './assets/assetsSlice';
|
||||
@ -31,7 +30,6 @@ export const store = configureStore({
|
||||
|
||||
users: usersSlice,
|
||||
roles: rolesSlice,
|
||||
permissions: permissionsSlice,
|
||||
projects: projectsSlice,
|
||||
project_memberships: project_membershipsSlice,
|
||||
assets: assetsSlice,
|
||||
|
||||
@ -26,6 +26,12 @@ const permissions = [
|
||||
'READ_ACCESS_LOGS',
|
||||
].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 = {
|
||||
id: 'user-playwright-admin',
|
||||
email: 'admin@example.test',
|
||||
@ -236,6 +242,37 @@ export async function mockFrontendApi(page: Page) {
|
||||
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') {
|
||||
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