3 Commits

Author SHA1 Message Date
henryor
0beebb04c0 refactor(admin): migrate users page to UI kit 2026-05-15 20:11:59 -05:00
henryor
44d3b2cf26 feat(admin): add admin UI compositions 2026-05-15 20:11:59 -05:00
henryor
21bb2bbea8 feat(admin): add reusable UI primitives 2026-05-15 20:11:58 -05:00
13 changed files with 1068 additions and 158 deletions

View File

@@ -0,0 +1,230 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Button } from '../ui/button'
import { ConfirmDialog } from './confirm-dialog'
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeader,
DataTableRow,
} from './data-table'
import { EmptyState } from './empty-state'
import { FilterBar } from './filter-bar'
import { PageHeader } from './page-header'
describe('admin UI compositions', () => {
let container: HTMLDivElement | null
let root: ReturnType<typeof createRoot> | null
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
if (root) {
await act(async () => {
root.unmount()
})
}
container?.remove()
container = null
root = null
document.body.innerHTML = ''
})
it('renders page header identity, metadata, status, and actions in named regions', async () => {
await act(async () => {
root?.render(
<PageHeader
title="Users"
description="Manage platform operators."
meta={<span>24 active</span>}
status={<span>Synced</span>}
actions={<Button>Invite user</Button>}
/>
)
})
const header = container?.querySelector('header')
const actions = container?.querySelector('[aria-label="Page actions"]')
expect(header?.querySelector('h1')?.textContent).toBe('Users')
expect(header?.textContent).toContain('Manage platform operators.')
expect(header?.textContent).toContain('24 active')
expect(header?.textContent).toContain('Synced')
expect(actions?.textContent).toBe('Invite user')
})
it('keeps filter controls grouped with a useful accessible name and optional summary', async () => {
await act(async () => {
root?.render(
<FilterBar title="User filters" summary="2 filters active">
<label htmlFor="search-users">Search</label>
<input id="search-users" defaultValue="admin" />
<Button variant="outline">Reset</Button>
</FilterBar>
)
})
const group = container?.querySelector('[role="search"][aria-label="User filters"]')
const input = container?.querySelector('#search-users') as HTMLInputElement
expect(group?.textContent).toContain('User filters')
expect(group?.textContent).toContain('2 filters active')
expect(group?.textContent).toContain('Reset')
expect(input.value).toBe('admin')
})
it('renders empty states with perceivable icon, message, and recovery action', async () => {
await act(async () => {
root?.render(
<EmptyState
icon={<span aria-hidden="true"></span>}
title="No users found"
description="Adjust filters or invite a new operator."
action={<Button>Invite user</Button>}
/>
)
})
const status = container?.querySelector('[role="status"]')
expect(status?.querySelector('[aria-hidden="true"]')?.textContent).toBe('∅')
expect(status?.querySelector('h2')?.textContent).toBe('No users found')
expect(status?.textContent).toContain('Adjust filters or invite a new operator.')
expect(status?.textContent).toContain('Invite user')
})
it('keeps data table helpers semantic and provides loading and empty regions', async () => {
await act(async () => {
root?.render(
<DataTable aria-label="Users table">
<DataTableHeader>
<DataTableRow>
<DataTableHead>Name</DataTableHead>
<DataTableHead>Status</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
<DataTableRow>
<DataTableCell>Ada Lovelace</DataTableCell>
<DataTableCell>Active</DataTableCell>
</DataTableRow>
</DataTableBody>
</DataTable>
)
})
const table = container?.querySelector('table[aria-label="Users table"]')
const headers = Array.from(container?.querySelectorAll('th') ?? [])
const cells = Array.from(container?.querySelectorAll('td') ?? [])
expect(table).not.toBeNull()
expect(headers.map((header) => header.textContent)).toEqual(['Name', 'Status'])
expect(cells.map((cell) => cell.textContent)).toEqual(['Ada Lovelace', 'Active'])
await act(async () => {
root?.render(<DataTable aria-label="Users table" isLoading loadingLabel="Loading users" />)
})
expect(container?.querySelector('[role="status"]')?.textContent).toBe('Loading users')
await act(async () => {
root?.render(<DataTable aria-label="Users table" emptyState={<EmptyState title="No rows" />} />)
})
expect(container?.textContent).toContain('No rows')
})
it('keeps narrow viewport header, filters, table, and actions in reachable named regions', async () => {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 })
window.dispatchEvent(new Event('resize'))
await act(async () => {
root?.render(
<div>
<PageHeader
title="Users"
description="Manage platform operators from a phone."
actions={<Button>Invite user</Button>}
/>
<FilterBar title="User filters" summary="1 filter active">
<label htmlFor="narrow-search">Search users</label>
<input id="narrow-search" defaultValue="ada" />
</FilterBar>
<DataTable aria-label="Users table">
<DataTableHeader>
<DataTableRow>
<DataTableHead>Name</DataTableHead>
<DataTableHead>Action</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
<DataTableRow>
<DataTableCell>Ada Lovelace</DataTableCell>
<DataTableCell>
<Button>Reset password</Button>
</DataTableCell>
</DataTableRow>
</DataTableBody>
</DataTable>
</div>
)
})
expect(container?.querySelector('header')?.textContent).toContain('Invite user')
expect(container?.querySelector('[role="search"][aria-label="User filters"]')?.textContent).toContain('Search users')
expect((container?.querySelector('#narrow-search') as HTMLInputElement).value).toBe('ada')
const tableScrollRegion = container?.querySelector('[role="region"][aria-label="Users table scroll area"]')
expect(tableScrollRegion).not.toBeNull()
expect(tableScrollRegion?.textContent).toContain('Reset password')
})
it('confirms destructive actions through a controlled dialog and disables pending confirmation', async () => {
const onConfirm = vi.fn()
await act(async () => {
root?.render(
<ConfirmDialog
open
onOpenChange={() => undefined}
title="Delete user"
description="This action cannot be undone."
confirmLabel="Delete"
variant="destructive"
onConfirm={onConfirm}
/>
)
})
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain('Delete user')
await act(async () => {
;(document.body.querySelector('button[data-action="confirm"]') as HTMLButtonElement).click()
})
expect(onConfirm).toHaveBeenCalledTimes(1)
await act(async () => {
root?.render(
<ConfirmDialog
open
onOpenChange={() => undefined}
title="Delete user"
confirmLabel="Deleting"
isPending
onConfirm={onConfirm}
/>
)
})
expect((document.body.querySelector('button[data-action="confirm"]') as HTMLButtonElement).disabled).toBe(true)
})
})

View File

@@ -0,0 +1,72 @@
import * as React from 'react'
import { Button } from '../ui/button'
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '../ui/dialog'
type ConfirmDialogProps = {
open: boolean
onOpenChange: (open: boolean) => void
title: React.ReactNode
description?: React.ReactNode
confirmLabel: React.ReactNode
cancelLabel?: React.ReactNode
variant?: 'default' | 'destructive'
isPending?: boolean
onConfirm: () => void
}
function ConfirmDialog({
open,
onOpenChange,
title,
description,
confirmLabel,
cancelLabel = 'Cancel',
variant = 'default',
isPending = false,
onConfirm,
}: ConfirmDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent role="alertdialog">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{typeof description === 'string' ? (
<DialogDescription>{description}</DialogDescription>
) : description ? (
<DialogDescription asChild>
<div className="text-sm text-muted-foreground">{description}</div>
</DialogDescription>
) : null}
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="outline" disabled={isPending}>
{cancelLabel}
</Button>
</DialogClose>
<Button
type="button"
data-action="confirm"
variant={variant === 'destructive' ? 'destructive' : 'default'}
disabled={isPending}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export { ConfirmDialog }
export type { ConfirmDialogProps }

View File

@@ -0,0 +1,62 @@
import * as React from 'react'
import { cn } from '@/lib/utils/cn'
type DataTableProps = React.ComponentProps<'table'> & {
isLoading?: boolean
loadingLabel?: string
emptyState?: React.ReactNode
}
function DataTable({ isLoading, loadingLabel = 'Loading', emptyState, className, children, ...props }: DataTableProps) {
const tableLabel = props['aria-label']
const scrollRegionLabel = typeof tableLabel === 'string' ? `${tableLabel} scroll area` : undefined
if (isLoading) {
return (
<div data-slot="data-table-loading" role="status" className="rounded-2xl border border-white/10 p-6 text-sm text-muted-foreground">
{loadingLabel}
</div>
)
}
if (emptyState) {
return <div data-slot="data-table-empty">{emptyState}</div>
}
return (
<div
data-slot="data-table-wrapper"
role={scrollRegionLabel ? 'region' : undefined}
aria-label={scrollRegionLabel}
className="overflow-x-auto rounded-2xl border border-white/10 bg-card/80"
>
<table className={cn('w-full caption-bottom text-sm', className)} {...props}>
{children}
</table>
</div>
)
}
function DataTableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
return <thead className={cn('border-b border-white/10', className)} {...props} />
}
function DataTableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
return <tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} />
}
function DataTableRow({ className, ...props }: React.ComponentProps<'tr'>) {
return <tr className={cn('border-b border-white/10 transition-colors hover:bg-primary/5', className)} {...props} />
}
function DataTableHead({ className, ...props }: React.ComponentProps<'th'>) {
return <th className={cn('h-10 px-4 text-left align-middle font-medium text-muted-foreground', className)} {...props} />
}
function DataTableCell({ className, ...props }: React.ComponentProps<'td'>) {
return <td className={cn('px-4 py-3 align-middle text-foreground', className)} {...props} />
}
export { DataTable, DataTableBody, DataTableCell, DataTableHead, DataTableHeader, DataTableRow }
export type { DataTableProps }

View File

@@ -0,0 +1,39 @@
import * as React from 'react'
import { cn } from '@/lib/utils/cn'
type EmptyStateProps = React.ComponentProps<'div'> & {
icon?: React.ReactNode
title: React.ReactNode
description?: React.ReactNode
action?: React.ReactNode
}
function EmptyState({ icon, title, description, action, className, ...props }: EmptyStateProps) {
const titleId = React.useId()
return (
<div
role="status"
aria-labelledby={titleId}
data-slot="empty-state"
className={cn(
'flex min-h-40 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-white/10 bg-background/40 p-8 text-center',
className
)}
{...props}
>
{icon && <div className="text-primary/80">{icon}</div>}
<div className="space-y-1">
<h2 id={titleId} className="text-base font-semibold text-foreground">
{title}
</h2>
{description && <p className="max-w-md text-sm text-muted-foreground">{description}</p>}
</div>
{action && <div className="pt-2">{action}</div>}
</div>
)
}
export { EmptyState }
export type { EmptyStateProps }

View File

@@ -0,0 +1,34 @@
import * as React from 'react'
import { cn } from '@/lib/utils/cn'
type FilterBarProps = React.ComponentProps<'section'> & {
title: React.ReactNode
summary?: React.ReactNode
}
function FilterBar({ title, summary, className, children, ...props }: FilterBarProps) {
const titleText = typeof title === 'string' ? title : undefined
return (
<section
role="search"
aria-label={props['aria-label'] ?? titleText}
data-slot="filter-bar"
className={cn(
'rounded-2xl border border-white/10 bg-card/80 p-4 shadow-xl shadow-black/10 backdrop-blur-sm',
className
)}
{...props}
>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-medium text-foreground">{title}</h2>
{summary && <p className="text-xs text-muted-foreground">{summary}</p>}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">{children}</div>
</section>
)
}
export { FilterBar }
export type { FilterBarProps }

View File

@@ -0,0 +1,51 @@
import * as React from 'react'
import { cn } from '@/lib/utils/cn'
type PageHeaderProps = React.ComponentProps<'header'> & {
title: React.ReactNode
description?: React.ReactNode
meta?: React.ReactNode
status?: React.ReactNode
actions?: React.ReactNode
}
function PageHeader({
title,
description,
meta,
status,
actions,
className,
...props
}: PageHeaderProps) {
const titleId = React.useId()
return (
<header
aria-labelledby={titleId}
data-slot="page-header"
className={cn('flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between', className)}
{...props}
>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-3">
<h1 id={titleId} className="text-2xl font-semibold tracking-tight text-foreground">
{title}
</h1>
{status && <div data-slot="page-header-status">{status}</div>}
</div>
{description && <p className="max-w-3xl text-sm text-muted-foreground">{description}</p>}
{meta && <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">{meta}</div>}
</div>
{actions && (
<div aria-label="Page actions" className="flex shrink-0 flex-wrap items-center gap-2">
{actions}
</div>
)}
</header>
)
}
export { PageHeader }
export type { PageHeaderProps }

View File

@@ -0,0 +1,124 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { Field } from './field'
import { IconButton } from './icon-button'
import { Input } from './input'
import { StatusBadge } from './status-badge'
describe('admin UI primitive foundations', () => {
let container: HTMLDivElement | null
let root: ReturnType<typeof createRoot> | null
beforeEach(() => {
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
if (root) {
await act(async () => {
root.unmount()
})
}
container?.remove()
container = null
root = null
})
it('associates field labels, helper text, and errors with the input control', async () => {
await act(async () => {
root?.render(
<Field
label="Project name"
htmlFor="project-name"
description="Use the public project name."
error="Project name is required."
required
>
<Input id="project-name" defaultValue="" />
</Field>
)
})
const input = container?.querySelector('input') as HTMLInputElement
const describedBy = input.getAttribute('aria-describedby')?.split(' ') ?? []
expect(container?.querySelector('label')?.getAttribute('for')).toBe('project-name')
expect(input.getAttribute('aria-invalid')).toBe('true')
expect(describedBy).toHaveLength(2)
expect(describedBy.map((id) => document.getElementById(id)?.textContent)).toEqual([
'Use the public project name.',
'Project name is required.',
])
expect(container?.textContent).toContain('Project name')
expect(container?.textContent).toContain('*')
})
it('preserves input semantics while rendering icon and action slots', async () => {
await act(async () => {
root?.render(
<Input
aria-label="Search wells"
defaultValue="Well A"
leftIcon={<span aria-hidden="true"></span>}
rightSlot={<button type="button">Clear search</button>}
/>
)
})
const input = container?.querySelector('input[aria-label="Search wells"]') as HTMLInputElement
const clearButton = container?.querySelector('button') as HTMLButtonElement
expect(input.value).toBe('Well A')
expect(input.disabled).toBe(false)
expect(container?.querySelector('[aria-hidden="true"]')?.textContent).toBe('⌕')
expect(clearButton.textContent).toBe('Clear search')
})
it('keeps icon-only actions keyboard-operable and accessible by name', async () => {
await act(async () => {
root?.render(
<IconButton aria-label="Reset filters" variant="outline">
<span aria-hidden="true"></span>
</IconButton>
)
})
const button = container?.querySelector('button[aria-label="Reset filters"]') as HTMLButtonElement
expect(button.type).toBe('button')
expect(button.disabled).toBe(false)
expect(button.textContent).toBe('↺')
})
it('maps admin statuses to stable semantic labels and badge variants', async () => {
await act(async () => {
root?.render(
<div>
<StatusBadge status="success">Online</StatusBadge>
<StatusBadge status="warning">Pending</StatusBadge>
<StatusBadge status="destructive">Offline</StatusBadge>
<StatusBadge status="muted">Archived</StatusBadge>
</div>
)
})
const badges = Array.from(container?.querySelectorAll('[data-slot="status-badge"]') ?? [])
expect(badges.map((badge) => badge.getAttribute('aria-label'))).toEqual([
'Status: Online',
'Status: Pending',
'Status: Offline',
'Status: Archived',
])
expect(badges.map((badge) => badge.getAttribute('data-status'))).toEqual([
'success',
'warning',
'destructive',
'muted',
])
})
})

View File

@@ -0,0 +1,72 @@
import * as React from 'react'
import { cn } from '@/lib/utils/cn'
type FieldContextValue = {
controlId?: string
descriptionId?: string
errorId?: string
invalid: boolean
}
const FieldContext = React.createContext<FieldContextValue | null>(null)
type FieldProps = React.ComponentProps<'div'> & {
label: React.ReactNode
htmlFor?: string
description?: React.ReactNode
error?: React.ReactNode
required?: boolean
}
function Field({
label,
htmlFor,
description,
error,
required = false,
className,
children,
...props
}: FieldProps) {
const generatedId = React.useId()
const controlId = htmlFor ?? generatedId
const descriptionId = description ? `${controlId}-description` : undefined
const errorId = error ? `${controlId}-error` : undefined
return (
<FieldContext.Provider
value={{
controlId,
descriptionId,
errorId,
invalid: Boolean(error),
}}
>
<div data-slot="field" className={cn('grid gap-2', className)} {...props}>
<label htmlFor={controlId} className="text-sm font-medium text-foreground">
{label}
{required && <span aria-hidden="true" className="ml-1 text-primary">*</span>}
</label>
{children}
{description && (
<p id={descriptionId} className="text-xs text-muted-foreground">
{description}
</p>
)}
{error && (
<p id={errorId} className="text-xs font-medium text-destructive" role="alert">
{error}
</p>
)}
</div>
</FieldContext.Provider>
)
}
function useFieldContext() {
return React.useContext(FieldContext)
}
export { Field, useFieldContext }
export type { FieldProps }

View File

@@ -0,0 +1,15 @@
import * as React from 'react'
import { Button } from './button'
type IconButtonProps = Omit<React.ComponentProps<typeof Button>, 'size' | 'aria-label'> & {
'aria-label': string
size?: 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
}
function IconButton({ size = 'icon-sm', type = 'button', ...props }: IconButtonProps) {
return <Button data-slot="icon-button" size={size} type={type} {...props} />
}
export { IconButton }
export type { IconButtonProps }

View File

@@ -0,0 +1,47 @@
import * as React from 'react'
import { cn } from '@/lib/utils/cn'
import { useFieldContext } from './field'
type InputProps = React.ComponentProps<'input'> & {
leftIcon?: React.ReactNode
rightSlot?: React.ReactNode
invalid?: boolean
}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, leftIcon, rightSlot, invalid, id, 'aria-describedby': ariaDescribedBy, ...props }, ref) => {
const field = useFieldContext()
const controlId = id ?? field?.controlId
const describedBy = [ariaDescribedBy, field?.descriptionId, field?.errorId]
.filter(Boolean)
.join(' ') || undefined
const isInvalid = invalid ?? field?.invalid ?? false
return (
<div
data-slot="input-wrapper"
data-invalid={isInvalid || undefined}
className={cn(
'flex min-h-9 w-full items-center gap-2 rounded-xl border border-primary/20 bg-background/75 px-3 text-sm text-foreground shadow-sm shadow-orange-500/10 transition-colors focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/30 has-disabled:cursor-not-allowed has-disabled:opacity-50 has-aria-invalid:border-destructive has-aria-invalid:ring-destructive/20',
className
)}
>
{leftIcon && <span className="flex shrink-0 text-primary/80">{leftIcon}</span>}
<input
ref={ref}
id={controlId}
aria-describedby={describedBy}
aria-invalid={isInvalid || undefined}
className="min-w-0 flex-1 bg-transparent py-2 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
{...props}
/>
{rightSlot && <span className="flex shrink-0 items-center">{rightSlot}</span>}
</div>
)
}
)
Input.displayName = 'Input'
export { Input }
export type { InputProps }

View File

@@ -0,0 +1,33 @@
import * as React from 'react'
import { Badge } from './badge'
type StatusBadgeStatus = 'success' | 'warning' | 'destructive' | 'muted'
type StatusBadgeProps = Omit<React.ComponentProps<typeof Badge>, 'variant'> & {
status: StatusBadgeStatus
}
const statusVariantByStatus: Record<StatusBadgeStatus, React.ComponentProps<typeof Badge>['variant']> = {
success: 'default',
warning: 'secondary',
destructive: 'destructive',
muted: 'outline',
}
function StatusBadge({ status, children, 'aria-label': ariaLabel, ...props }: StatusBadgeProps) {
return (
<Badge
data-slot="status-badge"
data-status={status}
variant={statusVariantByStatus[status]}
aria-label={ariaLabel ?? `Status: ${children}`}
{...props}
>
{children}
</Badge>
)
}
export { StatusBadge }
export type { StatusBadgeProps, StatusBadgeStatus }

View File

@@ -0,0 +1,145 @@
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import UsersPage from './UsersPage'
import { getRoles, getUsers, resetPassword } from '../api/users-api'
vi.mock('../api/users-api', () => ({
getUsers: vi.fn(),
getRoles: vi.fn(),
updateUser: vi.fn(),
resetPassword: vi.fn(),
}))
vi.mock('../components/UserFormModal', () => ({
default: ({ onClose }: { onClose: () => void }) => (
<div role="dialog" aria-label="Formulario de usuario">
<button type="button" onClick={onClose}>Cerrar</button>
</div>
),
}))
const users = [
{
id: 'user-1',
email: 'ada@example.com',
full_name: 'Ada Lovelace',
role_name: 'admin',
is_active: true,
project_count: 3,
},
{
id: 'user-2',
email: 'grace@example.com',
full_name: 'Grace Hopper',
role_name: 'operator',
is_active: false,
project_count: 1,
},
]
const roles = [
{ id: 1, name: 'admin', description: 'Administradores' },
{ id: 2, name: 'operator', description: 'Operadores' },
]
async function flushEffects() {
await act(async () => {
await Promise.resolve()
await Promise.resolve()
})
}
describe('UsersPage admin UI migration', () => {
let container: HTMLDivElement
let root: ReturnType<typeof createRoot>
beforeEach(() => {
vi.mocked(getUsers).mockResolvedValue(users)
vi.mocked(getRoles).mockResolvedValue(roles)
vi.mocked(resetPassword).mockResolvedValue(undefined)
vi.spyOn(window, 'alert').mockImplementation(() => undefined)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})
afterEach(async () => {
await act(async () => {
root.unmount()
})
container.remove()
document.body.innerHTML = ''
vi.restoreAllMocks()
})
it('renders users with shared page header, filters, status badges, and accessible row actions', async () => {
await act(async () => {
root.render(<UsersPage />)
})
await flushEffects()
expect(container.querySelector('header')?.textContent).toContain('Usuarios')
expect(container.querySelector('[role="search"]')?.textContent).toContain('Filtros de usuarios')
expect(container.querySelector('table[aria-label="Usuarios del sistema"]')).not.toBeNull()
expect(Array.from(container.querySelectorAll('th')).map((header) => header.textContent)).toEqual([
'Nombre',
'Email',
'Rol',
'Estado',
'Proyectos',
'Acciones',
])
expect(container.textContent).toContain('Ada Lovelace')
expect(container.textContent).toContain('Grace Hopper')
expect(Array.from(container.querySelectorAll('[data-slot="status-badge"]')).map((badge) => badge.getAttribute('aria-label'))).toEqual([
'Status: Activo',
'Status: Inactivo',
])
expect(container.querySelector('button[aria-label="Restablecer contrasena de Ada Lovelace"]')).not.toBeNull()
})
it('filters visible table rows from the shared search input without refetching users', async () => {
await act(async () => {
root.render(<UsersPage />)
})
await flushEffects()
const searchInput = container.querySelector('input[aria-label="Buscar usuarios"]') as HTMLInputElement
await act(async () => {
searchInput.value = 'grace'
searchInput.dispatchEvent(new Event('input', { bubbles: true }))
})
expect(container.textContent).not.toContain('Ada Lovelace')
expect(container.textContent).toContain('Grace Hopper')
expect(getUsers).toHaveBeenCalledTimes(1)
})
it('opens the migrated reset password confirmation and submits the new password when valid', async () => {
await act(async () => {
root.render(<UsersPage />)
})
await flushEffects()
await act(async () => {
;(container.querySelector('button[aria-label="Restablecer contrasena de Ada Lovelace"]') as HTMLButtonElement).click()
})
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain('Restablecer Contrasena')
const passwordInput = document.body.querySelector('input[aria-label="Nueva contrasena"]') as HTMLInputElement
await act(async () => {
passwordInput.value = 'nueva123'
passwordInput.dispatchEvent(new Event('input', { bubbles: true }))
})
await act(async () => {
;(document.body.querySelector('button[data-action="confirm"]') as HTMLButtonElement).click()
})
expect(resetPassword).toHaveBeenCalledWith('user-1', { new_password: 'nueva123' })
expect(window.alert).toHaveBeenCalledWith('Contrasena restablecida exitosamente')
})
})

View File

@@ -1,9 +1,24 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { Plus, Loader2, Search, KeyRound } from 'lucide-react' import { KeyRound, Loader2, Plus, Search } from 'lucide-react'
import { ConfirmDialog } from '@/components/admin-ui/confirm-dialog'
import {
DataTable,
DataTableBody,
DataTableCell,
DataTableHead,
DataTableHeader,
DataTableRow,
} from '@/components/admin-ui/data-table'
import { EmptyState } from '@/components/admin-ui/empty-state'
import { FilterBar } from '@/components/admin-ui/filter-bar'
import { PageHeader } from '@/components/admin-ui/page-header'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Field } from '@/components/ui/field'
import { IconButton } from '@/components/ui/icon-button'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { StatusBadge } from '@/components/ui/status-badge'
import { import {
getUsers, getUsers,
getRoles, getRoles,
@@ -91,32 +106,28 @@ export default function UsersPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} <PageHeader
<div className="flex items-center justify-between"> title="Usuarios"
<div> description="Gestiona los usuarios del sistema y su acceso a proyectos"
<h1 className="text-2xl font-bold text-foreground">Usuarios</h1> meta={`${filteredUsers.length} usuarios visibles`}
<p className="text-sm text-muted-foreground mt-1"> actions={(
Gestiona los usuarios del sistema y su acceso a proyectos
</p>
</div>
<Button onClick={() => { setEditUserId(null); setModalOpen(true) }} className="gap-2"> <Button onClick={() => { setEditUserId(null); setModalOpen(true) }} className="gap-2">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Nuevo Usuario Nuevo Usuario
</Button> </Button>
</div> )}
/>
{/* Filters */} <FilterBar title="Filtros de usuarios" summary={`${filteredUsers.length} resultados`} className="relative z-40 overflow-visible">
<Card className="glass-card relative z-40 overflow-visible border-border/40"> <div className="min-w-[200px] flex-1">
<CardContent className="p-4"> <Input
<div className="flex flex-wrap gap-4 items-center"> aria-label="Buscar usuarios"
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
type="text" type="text"
placeholder="Buscar por nombre o email..." placeholder="Buscar por nombre o email..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 rounded-lg bg-background/60 border border-border/50 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" onInput={(e) => setSearch(e.currentTarget.value)}
leftIcon={<Search className="h-4 w-4" />}
/> />
</div> </div>
<Select <Select
@@ -135,59 +146,39 @@ export default function UsersPage() {
options={activeOptions} options={activeOptions}
triggerClassName="rounded-lg border-primary/30 bg-card/80" triggerClassName="rounded-lg border-primary/30 bg-card/80"
/> />
</div> </FilterBar>
</CardContent>
</Card>
{/* Table */} <DataTable
{loading ? ( aria-label="Usuarios del sistema"
<div className="flex items-center justify-center h-64"> isLoading={loading}
<Loader2 className="h-8 w-8 animate-spin text-primary" /> loadingLabel="Cargando usuarios"
</div> emptyState={filteredUsers.length === 0 ? <EmptyState title="No se encontraron usuarios" /> : undefined}
) : (
<Card className="glass-card relative z-0 border-border/40 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/40">
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Nombre</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Email</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Rol</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Estado</th>
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Proyectos</th>
<th className="text-right px-4 py-3 text-muted-foreground font-medium">Acciones</th>
</tr>
</thead>
<tbody>
{filteredUsers.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-12 text-muted-foreground">
No se encontraron usuarios
</td>
</tr>
) : (
filteredUsers.map((user) => (
<tr key={user.id} className="border-b border-border/20 hover:bg-secondary/20 transition-colors">
<td className="px-4 py-3 font-medium text-foreground">{user.full_name}</td>
<td className="px-4 py-3 text-muted-foreground">{user.email}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="capitalize">
{user.role_name}
</Badge>
</td>
<td className="px-4 py-3">
<Badge
className={
user.is_active
? 'bg-success/10 text-success border-0'
: 'bg-destructive/10 text-destructive border-0'
}
> >
<DataTableHeader>
<DataTableRow>
<DataTableHead>Nombre</DataTableHead>
<DataTableHead>Email</DataTableHead>
<DataTableHead>Rol</DataTableHead>
<DataTableHead>Estado</DataTableHead>
<DataTableHead>Proyectos</DataTableHead>
<DataTableHead className="text-right">Acciones</DataTableHead>
</DataTableRow>
</DataTableHeader>
<DataTableBody>
{filteredUsers.map((user) => (
<DataTableRow key={user.id}>
<DataTableCell className="font-medium">{user.full_name}</DataTableCell>
<DataTableCell className="text-muted-foreground">{user.email}</DataTableCell>
<DataTableCell>
<Badge variant="outline" className="capitalize">{user.role_name}</Badge>
</DataTableCell>
<DataTableCell>
<StatusBadge status={user.is_active ? 'success' : 'destructive'}>
{user.is_active ? 'Activo' : 'Inactivo'} {user.is_active ? 'Activo' : 'Inactivo'}
</Badge> </StatusBadge>
</td> </DataTableCell>
<td className="px-4 py-3 text-muted-foreground">{user.project_count}</td> <DataTableCell className="text-muted-foreground">{user.project_count}</DataTableCell>
<td className="px-4 py-3 text-right"> <DataTableCell>
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button <Button
variant="ghost" variant="ghost"
@@ -196,14 +187,13 @@ export default function UsersPage() {
> >
Editar Editar
</Button> </Button>
<Button <IconButton
aria-label={`Restablecer contrasena de ${user.full_name}`}
variant="ghost" variant="ghost"
size="sm"
onClick={() => setResetPwUserId(user.id)} onClick={() => setResetPwUserId(user.id)}
title="Restablecer contrasena"
> >
<KeyRound className="h-4 w-4" /> <KeyRound className="h-4 w-4" />
</Button> </IconButton>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
@@ -213,42 +203,38 @@ export default function UsersPage() {
{user.is_active ? 'Desactivar' : 'Activar'} {user.is_active ? 'Desactivar' : 'Activar'}
</Button> </Button>
</div> </div>
</td> </DataTableCell>
</tr> </DataTableRow>
)) ))}
)} </DataTableBody>
</tbody> </DataTable>
</table>
</div>
</Card>
)}
{/* Reset Password Dialog */} <ConfirmDialog
{resetPwUserId && ( open={Boolean(resetPwUserId)}
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"> onOpenChange={(open) => {
<Card className="w-full max-w-md glass-card border-border/40"> if (!open) {
<CardContent className="p-6 space-y-4"> setResetPwUserId(null)
<h3 className="text-lg font-semibold text-foreground">Restablecer Contrasena</h3> setNewPassword('')
<input }
}}
title="Restablecer Contrasena"
description={(
<Field label="Nueva contrasena" description="Mínimo 6 caracteres">
<Input
aria-label="Nueva contrasena"
type="password" type="password"
placeholder="Nueva contrasena (min. 6 caracteres)" placeholder="Nueva contrasena (min. 6 caracteres)"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 rounded-lg bg-background/60 border border-border/50 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" onInput={(e) => setNewPassword(e.currentTarget.value)}
/> />
<div className="flex justify-end gap-2"> </Field>
<Button variant="ghost" onClick={() => { setResetPwUserId(null); setNewPassword('') }}>
Cancelar
</Button>
<Button onClick={handleResetPassword} disabled={newPassword.length < 6 || resetLoading}>
{resetLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Restablecer
</Button>
</div>
</CardContent>
</Card>
</div>
)} )}
cancelLabel="Cancelar"
confirmLabel={resetLoading ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Restablecer</> : 'Restablecer'}
isPending={newPassword.length < 6 || resetLoading}
onConfirm={handleResetPassword}
/>
{/* Create/Edit Modal */} {/* Create/Edit Modal */}
{modalOpen && ( {modalOpen && (