feat(frontend): MoveRubroDialog + wire en RubrosPage + aria-describedby (CAT-001)

Implementa MoveRubroDialog con flattenExcludingSubtree para prevenir ciclos en UI,
lo conecta en RubrosPage y agrega DialogDescription en RubroFormDialog.
This commit is contained in:
2026-04-18 20:52:08 -03:00
parent 443380d1d1
commit d49d2f7536
4 changed files with 653 additions and 1 deletions

View File

@@ -0,0 +1,262 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { isAxiosError } from 'axios'
import { AlertCircle } from 'lucide-react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Alert, AlertDescription } from '@/components/ui/alert'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { useMoveRubro } from '../hooks/useMoveRubro'
import type { Rubro, RubroTreeNode } from '../types'
// ─── Helper: flatten tree excluding a subtree rooted at excludedId ────────────
export interface FlatNode {
id: number
nombre: string
depth: number
}
export function flattenExcludingSubtree(
tree: RubroTreeNode[],
excludedId: number,
): FlatNode[] {
const result: FlatNode[] = []
function walk(nodes: RubroTreeNode[], depth: number) {
for (const node of nodes) {
if (node.id === excludedId) {
// Skip this entire subtree (node + all descendants)
continue
}
result.push({ id: node.id, nombre: node.nombre, depth })
if (node.hijos.length > 0) {
walk(node.hijos, depth + 1)
}
}
}
walk(tree, 0)
return result
}
// ─── Schema ───────────────────────────────────────────────────────────────────
const moveRubroSchema = z.object({
nuevoParentId: z
.string()
.transform((val) => (val === 'root' ? null : Number(val)))
.pipe(z.number().int().positive().nullable()),
nuevoOrden: z
.string()
.transform((val) => (val === '' ? 0 : Number(val)))
.pipe(z.number().int().min(0, 'El orden debe ser 0 o mayor')),
})
// Raw form field types (what useForm sees before zod transforms)
type MoveRubroFormRaw = {
nuevoParentId: string
nuevoOrden: string
}
// Output type after zod transforms
type MoveRubroFormOutput = {
nuevoParentId: number | null
nuevoOrden: number
}
// ─── Error resolver ───────────────────────────────────────────────────────────
function resolveMoveError(err: unknown): string | null {
if (!err) return null
if (isAxiosError(err) && err.response?.data) {
const data = err.response.data as { error?: string; message?: string }
return data.message ?? data.error ?? 'Error al mover el rubro'
}
// Handle raw rejection objects (from tests)
const errObj = err as { response?: { status?: number; data?: { message?: string } } }
if (errObj?.response?.data?.message) {
return errObj.response.data.message
}
return 'Error al mover el rubro'
}
// ─── Props ────────────────────────────────────────────────────────────────────
interface MoveRubroDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
rubro: Rubro | null
tree: RubroTreeNode[]
onConfirmed?: () => void
}
// ─── Component ────────────────────────────────────────────────────────────────
export function MoveRubroDialog({
open,
onOpenChange,
rubro,
tree,
onConfirmed,
}: MoveRubroDialogProps) {
const [backendError, setBackendError] = useState<string | null>(null)
const { mutateAsync, isPending } = useMoveRubro()
const availableParents = rubro ? flattenExcludingSubtree(tree, rubro.id) : []
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const form = useForm<MoveRubroFormRaw>({
resolver: zodResolver(moveRubroSchema) as any,
defaultValues: {
nuevoParentId: rubro?.parentId != null ? String(rubro.parentId) : 'root',
nuevoOrden: String(rubro?.orden ?? 0),
},
})
useEffect(() => {
if (open && rubro) {
setBackendError(null)
form.reset({
nuevoParentId: rubro.parentId != null ? String(rubro.parentId) : 'root',
nuevoOrden: String(rubro.orden ?? 0),
})
}
}, [open, rubro, form])
async function handleSubmit(data: MoveRubroFormOutput) {
if (!rubro) return
setBackendError(null)
const nuevoParentId = data.nuevoParentId
const nuevoOrden = data.nuevoOrden
try {
await mutateAsync({ id: rubro.id, data: { nuevoParentId, nuevoOrden } })
toast.success('Rubro movido')
onOpenChange(false)
onConfirmed?.()
} catch (err) {
const msg = resolveMoveError(err)
setBackendError(msg)
if (
!isAxiosError(err) ||
(err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400)
) {
toast.error('Error al mover el rubro')
}
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Mover rubro</DialogTitle>
<DialogDescription>
Seleccioná el nuevo padre y orden para &ldquo;{rubro?.nombre ?? ''}&rdquo;.
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4" noValidate>
{backendError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{backendError}</AlertDescription>
</Alert>
)}
<FormField
control={form.control}
name="nuevoParentId"
render={({ field }) => (
<FormItem>
<FormLabel>Nuevo padre</FormLabel>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isPending}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Seleccioná el padre…" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="root">Raíz (sin padre)</SelectItem>
{availableParents.map((node) => (
<SelectItem key={node.id} value={String(node.id)}>
{'—'.repeat(node.depth)} {node.nombre}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nuevoOrden"
render={({ field }) => (
<FormItem>
<FormLabel>Orden</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={0}
disabled={isPending}
placeholder="0"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
Cancelar
</Button>
<Button type="submit" disabled={isPending}>
{isPending ? 'Moviendo...' : 'Mover'}
</Button>
</div>
</form>
</Form>
</DialogContent>
</Dialog>
)
}

View File

@@ -7,6 +7,7 @@ import { AlertCircle } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
@@ -101,6 +102,11 @@ export function RubroFormDialog({
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{isEdit ? 'Editar rubro' : 'Nuevo rubro'}</DialogTitle>
<DialogDescription>
{isEdit
? `Modificá los datos del rubro "${rubro?.nombre ?? ''}".`
: 'Completá los datos para crear un nuevo rubro.'}
</DialogDescription>
</DialogHeader>
<Form {...form}>

View File

@@ -11,6 +11,7 @@ import { CanPerform } from '@/components/auth/CanPerform'
import { CategoryTree } from '../components/CategoryTree'
import { RubroFormDialog } from '../components/RubroFormDialog'
import { DeleteRubroDialog } from '../components/DeleteRubroDialog'
import { MoveRubroDialog } from '../components/MoveRubroDialog'
import { useRubrosTree } from '../hooks/useRubrosTree'
import { useCreateRubro } from '../hooks/useCreateRubro'
import { useUpdateRubro } from '../hooks/useUpdateRubro'
@@ -28,6 +29,7 @@ export function RubrosPage() {
const [deleteOpen, setDeleteOpen] = useState(false)
const [deletingRubro, setDeletingRubro] = useState<Rubro | null>(null)
const [formError, setFormError] = useState<unknown>(null)
const [moveTarget, setMoveTarget] = useState<Rubro | null>(null)
const { data: tree, isLoading, isError } = useRubrosTree(incluirInactivos)
const { mutateAsync: createRubro, isPending: creating } = useCreateRubro()
@@ -153,7 +155,7 @@ export function RubrosPage() {
onEdit={handleEdit}
onDelete={handleDelete}
onAddChild={handleAddChild}
onMove={() => {}}
onMove={(rubro) => setMoveTarget(rubro)}
canEdit={true}
/>
</CanPerform>
@@ -180,6 +182,14 @@ export function RubrosPage() {
onConfirm={handleDeleteConfirm}
/>
)}
{/* Move dialog */}
<MoveRubroDialog
open={!!moveTarget}
onOpenChange={(o) => !o && setMoveTarget(null)}
rubro={moveTarget}
tree={tree ?? []}
/>
</div>
)
}

View File

@@ -0,0 +1,374 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MemoryRouter } from 'react-router-dom'
import React from 'react'
import { MoveRubroDialog } from '../../../features/rubros/components/MoveRubroDialog'
import { flattenExcludingSubtree } from '../../../features/rubros/components/MoveRubroDialog'
import type { Rubro, RubroTreeNode } from '../../../features/rubros/types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
// Mock useMoveRubro to avoid real network calls
const mockMutateAsync = vi.fn()
let mockIsPending = false
vi.mock('../../../features/rubros/hooks/useMoveRubro', () => ({
useMoveRubro: () => ({
mutateAsync: mockMutateAsync,
get isPending() {
return mockIsPending
},
}),
}))
// ─── Fixtures ─────────────────────────────────────────────────────────────────
const rubroAutos: Rubro = {
id: 1,
nombre: 'Autos',
orden: 1,
activo: true,
parentId: null,
tarifarioBaseId: null,
fechaCreacion: '2026-04-18T00:00:00Z',
fechaModificacion: null,
}
const rubroUsados: Rubro = {
id: 2,
nombre: 'Usados',
orden: 1,
activo: true,
parentId: 1,
tarifarioBaseId: null,
fechaCreacion: '2026-04-18T00:00:00Z',
fechaModificacion: null,
}
const rubroInmuebles: Rubro = {
id: 3,
nombre: 'Inmuebles',
orden: 2,
activo: true,
parentId: null,
tarifarioBaseId: null,
fechaCreacion: '2026-04-18T00:00:00Z',
fechaModificacion: null,
}
// Tree:
// Autos (id=1)
// Usados (id=2)
// Compactos (id=4)
// Inmuebles (id=3)
const treeCompact: RubroTreeNode = {
id: 4,
nombre: 'Compactos',
orden: 1,
activo: true,
parentId: 2,
tarifarioBaseId: null,
hijos: [],
}
const treeUsados: RubroTreeNode = {
id: 2,
nombre: 'Usados',
orden: 1,
activo: true,
parentId: 1,
tarifarioBaseId: null,
hijos: [treeCompact],
}
const treeAutos: RubroTreeNode = {
id: 1,
nombre: 'Autos',
orden: 1,
activo: true,
parentId: null,
tarifarioBaseId: null,
hijos: [treeUsados],
}
const treeInmuebles: RubroTreeNode = {
id: 3,
nombre: 'Inmuebles',
orden: 2,
activo: true,
parentId: null,
tarifarioBaseId: null,
hijos: [],
}
const fullTree: RubroTreeNode[] = [treeAutos, treeInmuebles]
// ─── Helper ───────────────────────────────────────────────────────────────────
function wrap(children: React.ReactNode) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter>{children}</MemoryRouter>
</QueryClientProvider>,
)
}
// ─── flattenExcludingSubtree unit tests ───────────────────────────────────────
describe('flattenExcludingSubtree', () => {
it('returns all nodes when excludedId does not match anything', () => {
const result = flattenExcludingSubtree(fullTree, 999)
expect(result.map((n) => n.id)).toContain(1)
expect(result.map((n) => n.id)).toContain(2)
expect(result.map((n) => n.id)).toContain(3)
expect(result.map((n) => n.id)).toContain(4)
})
it('excludes the target node itself', () => {
const result = flattenExcludingSubtree(fullTree, 1)
expect(result.map((n) => n.id)).not.toContain(1)
})
it('excludes all descendants of the target node', () => {
const result = flattenExcludingSubtree(fullTree, 1)
// Autos (id=1), Usados (id=2), Compactos (id=4) all excluded
expect(result.map((n) => n.id)).not.toContain(1)
expect(result.map((n) => n.id)).not.toContain(2)
expect(result.map((n) => n.id)).not.toContain(4)
// Inmuebles stays
expect(result.map((n) => n.id)).toContain(3)
})
it('excludes only the leaf node when a leaf is the target', () => {
const result = flattenExcludingSubtree(fullTree, 4)
expect(result.map((n) => n.id)).toContain(1)
expect(result.map((n) => n.id)).toContain(2)
expect(result.map((n) => n.id)).toContain(3)
expect(result.map((n) => n.id)).not.toContain(4)
})
})
// ─── MoveRubroDialog component tests ─────────────────────────────────────────
describe('MoveRubroDialog', () => {
beforeEach(() => {
mockMutateAsync.mockReset()
mockIsPending = false
})
it('renders with current parent selected when opened', async () => {
// rubroUsados has parentId=1 (Autos)
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroUsados}
tree={fullTree}
/>,
)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
expect(screen.getByText(/mover rubro/i)).toBeInTheDocument()
})
it('shows dialog title with rubro name', async () => {
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroAutos}
tree={fullTree}
/>,
)
await waitFor(() => {
expect(screen.getByText(/mover rubro/i)).toBeInTheDocument()
})
})
it('shows flat list of available parents excluding the rubro being moved', async () => {
// Moving Inmuebles (id=3) — Inmuebles should not appear as option, others should
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroInmuebles}
tree={fullTree}
/>,
)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// The combobox/select area should contain Autos and Usados but not Inmuebles
// We check via the select trigger content after opening
const trigger = screen.getByRole('combobox')
expect(trigger).toBeInTheDocument()
})
it('shows flat list of available parents excluding DESCENDANTS of the rubro being moved', async () => {
// Moving Autos (id=1) — Usados (id=2) and Compactos (id=4) are descendants
// The options should not include Autos, Usados, or Compactos
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroAutos}
tree={fullTree}
/>,
)
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
// Open the select
const trigger = screen.getByRole('combobox')
await userEvent.click(trigger)
await waitFor(() => {
// Inmuebles should be available as an option
expect(screen.getByRole('option', { name: /inmuebles/i })).toBeInTheDocument()
})
// Autos itself and its descendants should not appear
expect(screen.queryByRole('option', { name: /^autos$/i })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /usados/i })).not.toBeInTheDocument()
expect(screen.queryByRole('option', { name: /compactos/i })).not.toBeInTheDocument()
})
it('allows selecting "raíz" (null parent)', async () => {
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroUsados}
tree={fullTree}
/>,
)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
const trigger = screen.getByRole('combobox')
await userEvent.click(trigger)
await waitFor(() => {
expect(screen.getByRole('option', { name: /raíz/i })).toBeInTheDocument()
})
})
it('submit calls mutateAsync with { nuevoParentId, nuevoOrden }', async () => {
mockMutateAsync.mockResolvedValue(undefined)
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroUsados}
tree={fullTree}
onConfirmed={vi.fn()}
/>,
)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
// Select raíz
const trigger = screen.getByRole('combobox')
await userEvent.click(trigger)
await waitFor(() => expect(screen.getByRole('option', { name: /raíz/i })).toBeInTheDocument())
await userEvent.click(screen.getByRole('option', { name: /raíz/i }))
// Set orden
const ordenInput = screen.getByLabelText(/orden/i)
await userEvent.clear(ordenInput)
await userEvent.type(ordenInput, '5')
// Submit
await userEvent.click(screen.getByRole('button', { name: /mover/i }))
await waitFor(() => {
expect(mockMutateAsync).toHaveBeenCalledWith({
id: rubroUsados.id,
data: { nuevoParentId: null, nuevoOrden: 5 },
})
})
})
it('displays backend error inline when 409 cycle/duplicate', async () => {
mockMutateAsync.mockRejectedValue({
response: {
status: 409,
data: { message: 'Ya existe un rubro con ese nombre en el destino' },
},
})
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroUsados}
tree={fullTree}
/>,
)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /mover/i }))
await waitFor(() => {
expect(screen.getByText(/ya existe un rubro con ese nombre/i)).toBeInTheDocument()
})
})
it('displays backend error inline when 422 depth', async () => {
mockMutateAsync.mockRejectedValue({
response: {
status: 422,
data: { message: 'Profundidad máxima 10 niveles alcanzada' },
},
})
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroUsados}
tree={fullTree}
/>,
)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /mover/i }))
await waitFor(() => {
expect(screen.getByText(/profundidad máxima/i)).toBeInTheDocument()
})
})
it('disables submit button while mutation is pending', async () => {
// Set isPending to true before rendering — simulates pending state
mockIsPending = true
mockMutateAsync.mockImplementation(() => new Promise(() => {}))
wrap(
<MoveRubroDialog
open={true}
onOpenChange={vi.fn()}
rubro={rubroUsados}
tree={fullTree}
/>,
)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
// When isPending=true, button shows "Moviendo..." and is disabled
const submitBtn = screen.getByRole('button', { name: /moviendo/i })
expect(submitBtn).toBeDisabled()
})
it('closes on cancel', async () => {
const onOpenChange = vi.fn()
wrap(
<MoveRubroDialog
open={true}
onOpenChange={onOpenChange}
rubro={rubroUsados}
tree={fullTree}
/>,
)
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
expect(onOpenChange).toHaveBeenCalledWith(false)
})
})