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:
262
src/web/src/features/rubros/components/MoveRubroDialog.tsx
Normal file
262
src/web/src/features/rubros/components/MoveRubroDialog.tsx
Normal 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 “{rubro?.nombre ?? ''}”.
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { AlertCircle } from 'lucide-react'
|
|||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
@@ -101,6 +102,11 @@ export function RubroFormDialog({
|
|||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{isEdit ? 'Editar rubro' : 'Nuevo rubro'}</DialogTitle>
|
<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>
|
</DialogHeader>
|
||||||
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { CanPerform } from '@/components/auth/CanPerform'
|
|||||||
import { CategoryTree } from '../components/CategoryTree'
|
import { CategoryTree } from '../components/CategoryTree'
|
||||||
import { RubroFormDialog } from '../components/RubroFormDialog'
|
import { RubroFormDialog } from '../components/RubroFormDialog'
|
||||||
import { DeleteRubroDialog } from '../components/DeleteRubroDialog'
|
import { DeleteRubroDialog } from '../components/DeleteRubroDialog'
|
||||||
|
import { MoveRubroDialog } from '../components/MoveRubroDialog'
|
||||||
import { useRubrosTree } from '../hooks/useRubrosTree'
|
import { useRubrosTree } from '../hooks/useRubrosTree'
|
||||||
import { useCreateRubro } from '../hooks/useCreateRubro'
|
import { useCreateRubro } from '../hooks/useCreateRubro'
|
||||||
import { useUpdateRubro } from '../hooks/useUpdateRubro'
|
import { useUpdateRubro } from '../hooks/useUpdateRubro'
|
||||||
@@ -28,6 +29,7 @@ export function RubrosPage() {
|
|||||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
const [deletingRubro, setDeletingRubro] = useState<Rubro | null>(null)
|
const [deletingRubro, setDeletingRubro] = useState<Rubro | null>(null)
|
||||||
const [formError, setFormError] = useState<unknown>(null)
|
const [formError, setFormError] = useState<unknown>(null)
|
||||||
|
const [moveTarget, setMoveTarget] = useState<Rubro | null>(null)
|
||||||
|
|
||||||
const { data: tree, isLoading, isError } = useRubrosTree(incluirInactivos)
|
const { data: tree, isLoading, isError } = useRubrosTree(incluirInactivos)
|
||||||
const { mutateAsync: createRubro, isPending: creating } = useCreateRubro()
|
const { mutateAsync: createRubro, isPending: creating } = useCreateRubro()
|
||||||
@@ -153,7 +155,7 @@ export function RubrosPage() {
|
|||||||
onEdit={handleEdit}
|
onEdit={handleEdit}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
onAddChild={handleAddChild}
|
onAddChild={handleAddChild}
|
||||||
onMove={() => {}}
|
onMove={(rubro) => setMoveTarget(rubro)}
|
||||||
canEdit={true}
|
canEdit={true}
|
||||||
/>
|
/>
|
||||||
</CanPerform>
|
</CanPerform>
|
||||||
@@ -180,6 +182,14 @@ export function RubrosPage() {
|
|||||||
onConfirm={handleDeleteConfirm}
|
onConfirm={handleDeleteConfirm}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Move dialog */}
|
||||||
|
<MoveRubroDialog
|
||||||
|
open={!!moveTarget}
|
||||||
|
onOpenChange={(o) => !o && setMoveTarget(null)}
|
||||||
|
rubro={moveTarget}
|
||||||
|
tree={tree ?? []}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
374
src/web/src/tests/features/rubros/MoveRubroDialog.test.tsx
Normal file
374
src/web/src/tests/features/rubros/MoveRubroDialog.test.tsx
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user