feat: CAT-001 Árbol N-ario de Rubros #30
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 {
|
||||
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}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
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