From 2d63d2db6d6edf3a6d033af9cb630d81dcecbe99 Mon Sep 17 00:00:00 2001 From: Dmitri Date: Mon, 6 Jul 2026 16:12:49 +0200 Subject: [PATCH] devided state separation between redux and react query --- documentation/deployment-vm.md | 23 +- documentation/project-improvement-todo.ru.md | 25 +- documentation/rbac-system.md | 16 +- frontend/docs/stores-module.md | 65 +++- .../Permissions/TablePermissions.tsx | 325 +++++++++++++++++- frontend/src/hooks/queries/index.ts | 13 +- .../src/hooks/queries/usePermissionsQuery.ts | 283 ++++++++++++++- .../hooks/usePermissionsQuery.helpers.test.ts | 54 +++ frontend/src/lib/queryClient.ts | 5 +- .../src/pages/permissions/[permissionsId].tsx | 64 +--- .../pages/permissions/permissions-edit.tsx | 26 +- .../pages/permissions/permissions-list.tsx | 153 ++++++++- .../src/pages/permissions/permissions-new.tsx | 7 +- .../pages/permissions/permissions-table.tsx | 40 +-- .../pages/permissions/permissions-view.tsx | 26 +- .../stores/permissions/permissionsSlice.ts | 25 -- frontend/src/stores/store.ts | 2 - frontend/tests/e2e/fixtures.ts | 37 ++ frontend/tests/e2e/permissions.spec.ts | 72 ++++ 19 files changed, 1017 insertions(+), 244 deletions(-) create mode 100644 frontend/src/hooks/usePermissionsQuery.helpers.test.ts delete mode 100644 frontend/src/stores/permissions/permissionsSlice.ts create mode 100644 frontend/tests/e2e/permissions.spec.ts diff --git a/documentation/deployment-vm.md b/documentation/deployment-vm.md index 31623f4..862b84c 100644 --- a/documentation/deployment-vm.md +++ b/documentation/deployment-vm.md @@ -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 diff --git a/documentation/project-improvement-todo.ru.md b/documentation/project-improvement-todo.ru.md index 2965e73..3f350b9 100644 --- a/documentation/project-improvement-todo.ru.md +++ b/documentation/project-improvement-todo.ru.md @@ -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 diff --git a/documentation/rbac-system.md b/documentation/rbac-system.md index 4dc582e..447f594 100644 --- a/documentation/rbac-system.md +++ b/documentation/rbac-system.md @@ -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 diff --git a/frontend/docs/stores-module.md b/frontend/docs/stores-module.md index a5a36ae..d732662 100644 --- a/frontend/docs/stores-module.md +++ b/frontend/docs/stores-module.md @@ -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 diff --git a/frontend/src/components/Permissions/TablePermissions.tsx b/frontend/src/components/Permissions/TablePermissions.tsx index 4f014c4..f6b3e0d 100644 --- a/frontend/src/components/Permissions/TablePermissions.tsx +++ b/frontend/src/components/Permissions/TablePermissions.tsx @@ -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({ - 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([]); + const [rowSelectionModel, setRowSelectionModel] = + useState([]); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 10, + }); + const [sortModel, setSortModel] = useState([]); + + 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 => { + 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) => { + 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 && ( + + undefined}> +
+ {filterItems.map((filterItem) => ( +
+
+
+ Filter +
+ + + {filters.map((filter) => ( + + ))} + +
+ +
+
+ Contains +
+ +
+ +
+
+ Action +
+ deleteFilter(filterItem.id)} + /> +
+
+ ))} +
+ +
+
+
+
+ )} + + {rowSelectionModel.length > 0 && ( +
+ +
+ )} + + +
+ row.id} + sx={{ + border: 'none', + '& .MuiDataGrid-cell': { + borderColor: 'rgba(0,0,0,0.1)', + }, + '& .MuiDataGrid-columnHeaders': { + backgroundColor: 'rgba(0,0,0,0.02)', + }, + }} + /> +
+
+ + + + ); +}; export default TablePermissions; diff --git a/frontend/src/hooks/queries/index.ts b/frontend/src/hooks/queries/index.ts index 103806d..2138071 100644 --- a/frontend/src/hooks/queries/index.ts +++ b/frontend/src/hooks/queries/index.ts @@ -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 { diff --git a/frontend/src/hooks/queries/usePermissionsQuery.ts b/frontend/src/hooks/queries/usePermissionsQuery.ts index 4f0d40f..bdd013d 100644 --- a/frontend/src/hooks/queries/usePermissionsQuery.ts +++ b/frontend/src/hooks/queries/usePermissionsQuery.ts @@ -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; } 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, + 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 = {}; + + 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 => { - const response = await axios.get('permissions'); - return response.data.rows; + queryKey: queryKeys.permissions.list(params), + queryFn: async (): Promise => { + const response = await axios.get( + `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 => { + const response = await axios.get( + `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 => { + const response = await axios.get( + `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, + ): Promise => { + const response = await axios.post('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; + }): Promise => { + const response = await axios.put(`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 => { + const response = await axios.delete(`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 => { + const response = await axios.post('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 => { + const formData = new FormData(); + formData.append('file', file); + formData.append('filename', file.name); + + const response = await axios.post( + '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; diff --git a/frontend/src/hooks/usePermissionsQuery.helpers.test.ts b/frontend/src/hooks/usePermissionsQuery.helpers.test.ts new file mode 100644 index 0000000..3a88783 --- /dev/null +++ b/frontend/src/hooks/usePermissionsQuery.helpers.test.ts @@ -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'], + }); +}); diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts index a197d5a..6882205 100644 --- a/frontend/src/lib/queryClient.ts +++ b/frontend/src/lib/queryClient.ts @@ -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 diff --git a/frontend/src/pages/permissions/[permissionsId].tsx b/frontend/src/pages/permissions/[permissionsId].tsx index ab58527..688b9a0 100644 --- a/frontend/src/pages/permissions/[permissionsId].tsx +++ b/frontend/src/pages/permissions/[permissionsId].tsx @@ -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)[el] = ( - permission as unknown as Record - )[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, - }), - ); + await updatePermission.mutateAsync({ id: idStr, data }); await router.push('/permissions/permissions-list'); } }; diff --git a/frontend/src/pages/permissions/permissions-edit.tsx b/frontend/src/pages/permissions/permissions-edit.tsx index ff9a19c..954a273 100644 --- a/frontend/src/pages/permissions/permissions-edit.tsx +++ b/frontend/src/pages/permissions/permissions-edit.tsx @@ -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'); } }; diff --git a/frontend/src/pages/permissions/permissions-list.tsx b/frontend/src/pages/permissions/permissions-list.tsx index 53c0e32..e61d6e2 100644 --- a/frontend/src/pages/permissions/permissions-list.tsx +++ b/frontend/src/pages/permissions/permissions-list.tsx @@ -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([]); + const [csvFile, setCsvFile] = useState(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 ( + <> + + {getPageTitle('Permissions')} + + + + {''} + + + {hasCreatePermission && ( + + )} + + + {hasCreatePermission && ( + setIsModalActive(true)} + /> + )} +
+
+
+
+ + + +
+ + + + + ); +}; + +PermissionsListPage.getLayout = function getLayout(page: ReactElement) { + return ( + + {page} + + ); +}; + +export default PermissionsListPage; diff --git a/frontend/src/pages/permissions/permissions-new.tsx b/frontend/src/pages/permissions/permissions-new.tsx index 0d125f4..2a248df 100644 --- a/frontend/src/pages/permissions/permissions-new.tsx +++ b/frontend/src/pages/permissions/permissions-new.tsx @@ -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'); }; diff --git a/frontend/src/pages/permissions/permissions-table.tsx b/frontend/src/pages/permissions/permissions-table.tsx index 858911b..0fba68d 100644 --- a/frontend/src/pages/permissions/permissions-table.tsx +++ b/frontend/src/pages/permissions/permissions-table.tsx @@ -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([]); const [csvFile, setCsvFile] = useState(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([{ 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 && ( diff --git a/frontend/src/pages/permissions/permissions-view.tsx b/frontend/src/pages/permissions/permissions-view.tsx index 8b41650..4f55757 100644 --- a/frontend/src/pages/permissions/permissions-view.tsx +++ b/frontend/src/pages/permissions/permissions-view.tsx @@ -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 ( <> diff --git a/frontend/src/stores/permissions/permissionsSlice.ts b/frontend/src/stores/permissions/permissionsSlice.ts deleted file mode 100644 index 3a6065d..0000000 --- a/frontend/src/stores/permissions/permissionsSlice.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Permissions Redux Slice - */ - -import { createEntitySlice } from '../createEntitySlice'; -import type { PermissionEntity } from '../../types/entities'; - -const { slice, actions, reducer } = createEntitySlice({ - name: 'permissions', - endpoint: 'permissions', - singularName: 'Permission', -}); - -export const { - fetch, - create, - update, - deleteItem, - deleteItemsByIds, - uploadCsv, - setRefetch, -} = actions; -export const permissionsSlice = slice; - -export default reducer; diff --git a/frontend/src/stores/store.ts b/frontend/src/stores/store.ts index 19503da..1ad0e2e 100644 --- a/frontend/src/stores/store.ts +++ b/frontend/src/stores/store.ts @@ -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, diff --git a/frontend/tests/e2e/fixtures.ts b/frontend/tests/e2e/fixtures.ts index 203767c..3a66d6d 100644 --- a/frontend/tests/e2e/fixtures.ts +++ b/frontend/tests/e2e/fixtures.ts @@ -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([])); } diff --git a/frontend/tests/e2e/permissions.spec.ts b/frontend/tests/e2e/permissions.spec.ts new file mode 100644 index 0000000..df38176 --- /dev/null +++ b/frontend/tests/e2e/permissions.spec.ts @@ -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(); +});