feat(web): tabs.tsx + PermisosEditor tri-state [UDT-009]
This commit is contained in:
87
src/web/package-lock.json
generated
87
src/web/package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
"axios": "1.7",
|
"axios": "1.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -2636,6 +2637,92 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs": {
|
||||||
|
"version": "1.1.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
|
||||||
|
"integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.3",
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-direction": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.1",
|
||||||
|
"@radix-ui/react-presence": "1.1.5",
|
||||||
|
"@radix-ui/react-primitive": "2.1.3",
|
||||||
|
"@radix-ui/react-roving-focus": "1.1.11",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.2.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-use-callback-ref": {
|
"node_modules/@radix-ui/react-use-callback-ref": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@radix-ui/react-navigation-menu": "^1.2.14",
|
"@radix-ui/react-navigation-menu": "^1.2.14",
|
||||||
"@radix-ui/react-separator": "^1.1.8",
|
"@radix-ui/react-separator": "^1.1.8",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
|
"@radix-ui/react-tabs": "^1.1.13",
|
||||||
"@tanstack/react-query": "^5.99.0",
|
"@tanstack/react-query": "^5.99.0",
|
||||||
"axios": "1.7",
|
"axios": "1.7",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
|
|||||||
53
src/web/src/components/ui/tabs.tsx
Normal file
53
src/web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Tabs = TabsPrimitive.Root
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex h-9 items-center justify-start rounded-lg bg-muted p-1 text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsList.displayName = TabsPrimitive.List.displayName
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<TabsPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||||
197
src/web/src/features/users/components/PermisosEditor.tsx
Normal file
197
src/web/src/features/users/components/PermisosEditor.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { AlertCircle } from 'lucide-react'
|
||||||
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useUserPermisos } from '../hooks/useUserPermisos'
|
||||||
|
import { useUpdateUserPermisosOverrides } from '../hooks/useUpdateUserPermisosOverrides'
|
||||||
|
import { usePermisos } from '@/features/permisos/hooks/usePermisos'
|
||||||
|
import type { PermisoOverrideState } from '../types'
|
||||||
|
import type { PermisoDto } from '@/features/permisos/api/types'
|
||||||
|
|
||||||
|
interface PermisosEditorProps {
|
||||||
|
userId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByModulo(permisos: PermisoDto[]): Map<string, PermisoDto[]> {
|
||||||
|
const map = new Map<string, PermisoDto[]>()
|
||||||
|
for (const p of permisos) {
|
||||||
|
const modulo = p.codigo.split(':')[0] ?? p.modulo
|
||||||
|
if (!map.has(modulo)) map.set(modulo, [])
|
||||||
|
map.get(modulo)!.push(p)
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveErrorMessage(err: unknown): string {
|
||||||
|
if (isAxiosError(err) && err.response?.data) {
|
||||||
|
const data = err.response.data as { title?: string; invalidCodes?: string[]; overlap?: string[] }
|
||||||
|
if (data.title === 'invalid-permiso-codes') {
|
||||||
|
const codes = data.invalidCodes?.join(', ') ?? ''
|
||||||
|
return `Códigos de permiso inválidos: ${codes}`
|
||||||
|
}
|
||||||
|
if (data.title === 'grant-deny-overlap') {
|
||||||
|
const codes = data.overlap?.join(', ') ?? ''
|
||||||
|
return `Los siguientes permisos están en grant y deny al mismo tiempo: ${codes}`
|
||||||
|
}
|
||||||
|
return 'No se pudieron guardar los cambios.'
|
||||||
|
}
|
||||||
|
return 'No se pudieron guardar los cambios.'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PermisosEditor({ userId }: PermisosEditorProps) {
|
||||||
|
const { data: permisoData, isLoading: loadingPermisos } = useUserPermisos(userId)
|
||||||
|
const { data: catalogo, isLoading: loadingCatalogo } = usePermisos()
|
||||||
|
const mutation = useUpdateUserPermisosOverrides(userId)
|
||||||
|
|
||||||
|
// Map<codigopermiso, PermisoOverrideState>
|
||||||
|
const [states, setStates] = useState<Map<string, PermisoOverrideState>>(new Map())
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Initialize state from loaded data
|
||||||
|
useEffect(() => {
|
||||||
|
if (!permisoData) return
|
||||||
|
const map = new Map<string, PermisoOverrideState>()
|
||||||
|
// Start all known codes as 'heredado'
|
||||||
|
for (const c of permisoData.rolPermisos) map.set(c, 'heredado')
|
||||||
|
// Apply grant overrides
|
||||||
|
for (const c of permisoData.grant) map.set(c, 'concedido')
|
||||||
|
// Apply deny overrides
|
||||||
|
for (const c of permisoData.deny) map.set(c, 'denegado')
|
||||||
|
setStates(map)
|
||||||
|
setSaveError(null)
|
||||||
|
}, [permisoData])
|
||||||
|
|
||||||
|
if (loadingPermisos || loadingCatalogo) {
|
||||||
|
return <p className="text-sm text-muted-foreground">Cargando permisos...</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!permisoData || !catalogo) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>No se pudieron cargar los permisos.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build complete set of all relevant permission codes from catalog
|
||||||
|
// Filter catalog to only show permisos that appear in rolPermisos, grant, deny, or all catalog
|
||||||
|
const allCodes = new Set([
|
||||||
|
...permisoData.rolPermisos,
|
||||||
|
...permisoData.grant,
|
||||||
|
...permisoData.deny,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Use catalog for grouping and names, showing all permisos known plus any from overrides
|
||||||
|
const relevantPermisos = catalogo.filter(
|
||||||
|
(p) => allCodes.has(p.codigo) || catalogo.length <= 30,
|
||||||
|
)
|
||||||
|
|
||||||
|
const grupos = groupByModulo(relevantPermisos)
|
||||||
|
|
||||||
|
function getState(codigo: string): PermisoOverrideState {
|
||||||
|
return states.get(codigo) ?? 'heredado'
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(codigo: string, state: PermisoOverrideState) {
|
||||||
|
setSaveError(null)
|
||||||
|
setStates((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(codigo, state)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const grant: string[] = []
|
||||||
|
const deny: string[] = []
|
||||||
|
|
||||||
|
for (const [codigo, state] of states.entries()) {
|
||||||
|
if (state === 'concedido') grant.push(codigo)
|
||||||
|
else if (state === 'denegado') deny.push(codigo)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(
|
||||||
|
{ grant, deny },
|
||||||
|
{
|
||||||
|
onError: (err) => {
|
||||||
|
setSaveError(resolveErrorMessage(err))
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setSaveError(null)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{saveError && (
|
||||||
|
<Alert variant="destructive" role="alert">
|
||||||
|
<AlertCircle className="h-4 w-4" />
|
||||||
|
<AlertDescription>{saveError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Array.from(grupos.entries()).map(([modulo, permisos]) => (
|
||||||
|
<section key={modulo}>
|
||||||
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-3 capitalize">
|
||||||
|
{modulo}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{permisos.map((p) => {
|
||||||
|
const currentState = getState(p.codigo)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={p.codigo}
|
||||||
|
data-testid={`permiso-row-${p.codigo}`}
|
||||||
|
className="flex items-center justify-between rounded-md border border-border px-3 py-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col min-w-0 mr-4">
|
||||||
|
<span className="text-sm font-medium">{p.nombre}</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground/70">{p.codigo}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{(['heredado', 'concedido', 'denegado'] as PermisoOverrideState[]).map(
|
||||||
|
(state) => (
|
||||||
|
<button
|
||||||
|
key={state}
|
||||||
|
type="button"
|
||||||
|
role="button"
|
||||||
|
aria-label={state.charAt(0).toUpperCase() + state.slice(1)}
|
||||||
|
aria-pressed={currentState === state}
|
||||||
|
onClick={() => setState(p.codigo, state)}
|
||||||
|
className={`
|
||||||
|
px-2 py-1 rounded text-xs font-medium transition-colors capitalize
|
||||||
|
${
|
||||||
|
currentState === state
|
||||||
|
? state === 'heredado'
|
||||||
|
? 'bg-secondary text-secondary-foreground'
|
||||||
|
: state === 'concedido'
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100'
|
||||||
|
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-100'
|
||||||
|
: 'bg-transparent text-muted-foreground hover:bg-accent'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{state}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-2">
|
||||||
|
<Button onClick={handleSave} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Guardando...' : 'Guardar cambios'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
197
src/web/src/tests/features/users/PermisosEditor.test.tsx
Normal file
197
src/web/src/tests/features/users/PermisosEditor.test.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { PermisosEditor } from '../../../features/users/components/PermisosEditor'
|
||||||
|
|
||||||
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
|
// Catalog of ALL known permissions (from /api/v1/permisos)
|
||||||
|
const catalogoPermisos = [
|
||||||
|
{ id: 1, codigo: 'ventas:contado:crear', nombre: 'Crear venta contado', descripcion: null, modulo: 'ventas' },
|
||||||
|
{ id: 2, codigo: 'ventas:contado:cobrar', nombre: 'Cobrar venta contado', descripcion: null, modulo: 'ventas' },
|
||||||
|
{ id: 3, codigo: 'textos:editar', nombre: 'Editar textos', descripcion: null, modulo: 'textos' },
|
||||||
|
]
|
||||||
|
|
||||||
|
// User permisos — from /api/v1/users/42/permisos
|
||||||
|
const mockUsuarioPermisos = {
|
||||||
|
usuarioId: 42,
|
||||||
|
rol: 'cajero',
|
||||||
|
rolPermisos: ['ventas:contado:crear', 'ventas:contado:cobrar'],
|
||||||
|
grant: ['textos:editar'],
|
||||||
|
deny: ['ventas:contado:cobrar'],
|
||||||
|
effective: ['ventas:contado:crear', 'textos:editar'],
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = setupServer()
|
||||||
|
|
||||||
|
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|
||||||
|
function renderEditor(userId = 42) {
|
||||||
|
const qc = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
})
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<MemoryRouter>
|
||||||
|
<PermisosEditor userId={userId} />
|
||||||
|
</MemoryRouter>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupHandlers() {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||||
|
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PermisosEditor', () => {
|
||||||
|
it('calls GET /api/v1/users/:id/permisos on mount', async () => {
|
||||||
|
let getPermisosCallCount = 0
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||||
|
http.get(`${API_URL}/api/v1/users/42/permisos`, () => {
|
||||||
|
getPermisosCallCount++
|
||||||
|
return HttpResponse.json(mockUsuarioPermisos)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderEditor(42)
|
||||||
|
|
||||||
|
await waitFor(() => expect(getPermisosCallCount).toBe(1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders permissions grouped by module', async () => {
|
||||||
|
setupHandlers()
|
||||||
|
renderEditor()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('ventas')).toBeInTheDocument())
|
||||||
|
|
||||||
|
expect(screen.getByText('textos')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Crear venta contado')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Editar textos')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Heredado state for permissions in role but not in grant or deny', async () => {
|
||||||
|
setupHandlers()
|
||||||
|
renderEditor()
|
||||||
|
|
||||||
|
// ventas:contado:crear is in rolPermisos, not in grant, not in deny
|
||||||
|
// expect a button/element indicating "Heredado" is active for that permission
|
||||||
|
await waitFor(() => expect(screen.getByText('Crear venta contado')).toBeInTheDocument())
|
||||||
|
|
||||||
|
// Should have a "Heredado" indicator active for ventas:contado:crear
|
||||||
|
// We look for the specific row container and check the selected state
|
||||||
|
const crearRow = screen.getByTestId('permiso-row-ventas:contado:crear')
|
||||||
|
expect(within(crearRow).getByRole('button', { name: /heredado/i })).toHaveAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Concedido state for permissions in grant', async () => {
|
||||||
|
setupHandlers()
|
||||||
|
renderEditor()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Editar textos')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const editarRow = screen.getByTestId('permiso-row-textos:editar')
|
||||||
|
expect(within(editarRow).getByRole('button', { name: /concedido/i })).toHaveAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows Denegado state for permissions in deny', async () => {
|
||||||
|
setupHandlers()
|
||||||
|
renderEditor()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Cobrar venta contado')).toBeInTheDocument())
|
||||||
|
|
||||||
|
const cobrarRow = screen.getByTestId('permiso-row-ventas:contado:cobrar')
|
||||||
|
expect(within(cobrarRow).getByRole('button', { name: /denegado/i })).toHaveAttribute(
|
||||||
|
'aria-pressed',
|
||||||
|
'true',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('Guardar button calls PUT with correct { grant, deny } body', async () => {
|
||||||
|
let capturedBody: unknown = null
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||||
|
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
|
||||||
|
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, async ({ request }) => {
|
||||||
|
capturedBody = await request.json()
|
||||||
|
return HttpResponse.json(mockUsuarioPermisos)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const u = userEvent.setup()
|
||||||
|
renderEditor()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
|
||||||
|
|
||||||
|
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
|
||||||
|
|
||||||
|
await waitFor(() => expect(capturedBody).not.toBeNull())
|
||||||
|
// Initial state: grant=['textos:editar'], deny=['ventas:contado:cobrar']
|
||||||
|
expect(capturedBody).toMatchObject({
|
||||||
|
grant: expect.arrayContaining(['textos:editar']),
|
||||||
|
deny: expect.arrayContaining(['ventas:contado:cobrar']),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows alert on 400 invalid-permiso-codes error', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||||
|
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
|
||||||
|
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ title: 'invalid-permiso-codes', status: 400, invalidCodes: ['fake:codigo'] },
|
||||||
|
{ status: 400 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const u = userEvent.setup()
|
||||||
|
renderEditor()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
|
||||||
|
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows alert on 400 grant-deny-overlap error', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get(`${API_URL}/api/v1/permisos`, () => HttpResponse.json(catalogoPermisos)),
|
||||||
|
http.get(`${API_URL}/api/v1/users/42/permisos`, () => HttpResponse.json(mockUsuarioPermisos)),
|
||||||
|
http.put(`${API_URL}/api/v1/users/42/permisos/overrides`, () =>
|
||||||
|
HttpResponse.json(
|
||||||
|
{ title: 'grant-deny-overlap', status: 400, overlap: ['textos:editar'] },
|
||||||
|
{ status: 400 },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const u = userEvent.setup()
|
||||||
|
renderEditor()
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText('Guardar cambios')).toBeInTheDocument())
|
||||||
|
await u.click(screen.getByRole('button', { name: /guardar cambios/i }))
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user