diff --git a/src/web/src/features/product-types/components/DeactivateProductTypeDialog.tsx b/src/web/src/features/product-types/components/DeactivateProductTypeDialog.tsx new file mode 100644 index 0000000..717b2e3 --- /dev/null +++ b/src/web/src/features/product-types/components/DeactivateProductTypeDialog.tsx @@ -0,0 +1,87 @@ +import { useState } from 'react' +import { AlertCircle } from 'lucide-react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { Alert, AlertDescription } from '@/components/ui/alert' +import type { ProductTypeListItem } from '../types' + +// ─── Error resolver ──────────────────────────────────────────────────────────── + +function resolveDeactivateError(err: unknown): string | null { + if (!err) return null + const errObj = err as { response?: { status?: number; data?: { message?: string } } } + if (errObj?.response?.data?.message) { + return errObj.response.data.message + } + return 'Error al desactivar el tipo de producto' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface DeactivateProductTypeDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + productType: ProductTypeListItem + onConfirm: (id: number) => Promise | void +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function DeactivateProductTypeDialog({ + open, + onOpenChange, + productType, + onConfirm, +}: DeactivateProductTypeDialogProps) { + const [error, setError] = useState(null) + const [isPending, setIsPending] = useState(false) + + async function handleConfirm() { + setError(null) + setIsPending(true) + try { + await onConfirm(productType.id) + onOpenChange(false) + } catch (err) { + setError(resolveDeactivateError(err)) + } finally { + setIsPending(false) + } + } + + return ( + + + + Desactivar tipo de producto + + ¿Desactivar el tipo “{productType.nombre}”? Los productos asociados conservan + la referencia pero el tipo no aparecerá en listados activos. + + + + {error && ( + + + {error} + + )} + + + Cancelar + + {isPending ? 'Procesando...' : 'Desactivar'} + + + + + ) +} diff --git a/src/web/src/features/product-types/components/ProductTypeForm.tsx b/src/web/src/features/product-types/components/ProductTypeForm.tsx new file mode 100644 index 0000000..c592d9e --- /dev/null +++ b/src/web/src/features/product-types/components/ProductTypeForm.tsx @@ -0,0 +1,408 @@ +import { useEffect } from 'react' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form' + +// ─── Schema ─────────────────────────────────────────────────────────────────── + +function nullablePositiveInt() { + return z + .string() + .optional() + .transform((v) => (v === '' || v == null ? null : Number(v))) + .pipe(z.number().int().positive().nullable()) +} + +function nullablePositiveDecimal() { + return z + .string() + .optional() + .transform((v) => (v === '' || v == null ? null : Number(v))) + .pipe(z.number().positive().nullable()) +} + +const productTypeFormSchema = z.object({ + nombre: z.string().trim().min(1, 'Nombre requerido').max(100, 'Máximo 100 caracteres'), + hasDuration: z.boolean(), + requiresText: z.boolean(), + requiresCategory: z.boolean(), + isBundle: z.boolean(), + allowImages: z.boolean(), + maxImages: nullablePositiveInt(), + maxImageSizeMB: nullablePositiveDecimal(), + maxImageWidth: nullablePositiveInt(), + maxImageHeight: nullablePositiveInt(), +}) + +// Raw form field types (strings before zod transforms) +type ProductTypeFormRaw = { + nombre: string + hasDuration: boolean + requiresText: boolean + requiresCategory: boolean + isBundle: boolean + allowImages: boolean + maxImages: string + maxImageSizeMB: string + maxImageWidth: string + maxImageHeight: string +} + +// Output type after zod transforms (what onSubmit receives at runtime) +export type ProductTypeFormOutput = { + nombre: string + hasDuration: boolean + requiresText: boolean + requiresCategory: boolean + isBundle: boolean + allowImages: boolean + maxImages: number | null + maxImageSizeMB: number | null + maxImageWidth: number | null + maxImageHeight: number | null +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +export interface ProductTypeFormDefaultValues { + nombre?: string + hasDuration?: boolean + requiresText?: boolean + requiresCategory?: boolean + isBundle?: boolean + allowImages?: boolean + maxImages?: number | null + maxImageSizeMB?: number | null + maxImageWidth?: number | null + maxImageHeight?: number | null +} + +interface ProductTypeFormProps { + defaultValues?: ProductTypeFormDefaultValues + onSubmit: (values: ProductTypeFormOutput) => void + onCancel: () => void + isPending?: boolean + isEdit?: boolean +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ProductTypeForm({ + defaultValues, + onSubmit, + onCancel, + isPending = false, + isEdit = false, +}: ProductTypeFormProps) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const form = useForm({ + resolver: zodResolver(productTypeFormSchema) as any, + defaultValues: { + nombre: defaultValues?.nombre ?? '', + hasDuration: defaultValues?.hasDuration ?? false, + requiresText: defaultValues?.requiresText ?? false, + requiresCategory: defaultValues?.requiresCategory ?? false, + isBundle: defaultValues?.isBundle ?? false, + allowImages: defaultValues?.allowImages ?? false, + maxImages: defaultValues?.maxImages != null ? String(defaultValues.maxImages) : '', + maxImageSizeMB: defaultValues?.maxImageSizeMB != null ? String(defaultValues.maxImageSizeMB) : '', + maxImageWidth: defaultValues?.maxImageWidth != null ? String(defaultValues.maxImageWidth) : '', + maxImageHeight: defaultValues?.maxImageHeight != null ? String(defaultValues.maxImageHeight) : '', + }, + }) + + const allowImages = form.watch('allowImages') + + useEffect(() => { + form.reset({ + nombre: defaultValues?.nombre ?? '', + hasDuration: defaultValues?.hasDuration ?? false, + requiresText: defaultValues?.requiresText ?? false, + requiresCategory: defaultValues?.requiresCategory ?? false, + isBundle: defaultValues?.isBundle ?? false, + allowImages: defaultValues?.allowImages ?? false, + maxImages: defaultValues?.maxImages != null ? String(defaultValues.maxImages) : '', + maxImageSizeMB: defaultValues?.maxImageSizeMB != null ? String(defaultValues.maxImageSizeMB) : '', + maxImageWidth: defaultValues?.maxImageWidth != null ? String(defaultValues.maxImageWidth) : '', + maxImageHeight: defaultValues?.maxImageHeight != null ? String(defaultValues.maxImageHeight) : '', + }) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [defaultValues?.nombre, defaultValues?.allowImages]) + + function handleSubmit(data: ProductTypeFormOutput) { + // Normalize multimedia to null when allowImages=false + if (!data.allowImages) { + data = { + ...data, + maxImages: null, + maxImageSizeMB: null, + maxImageWidth: null, + maxImageHeight: null, + } + } + onSubmit(data) + } + + return ( +
+ [0], + )} + className="space-y-4" + noValidate + > + {/* Nombre */} + ( + + Nombre + + + + + + )} + /> + + {/* Flags */} +
+ ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Tiene duración" + className="h-4 w-4 cursor-pointer" + /> + + + Tiene duración + + + )} + /> + + ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Requiere texto" + className="h-4 w-4 cursor-pointer" + /> + + + Requiere texto + + + )} + /> + + ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Requiere categoría" + className="h-4 w-4 cursor-pointer" + /> + + + Requiere categoría + + + )} + /> + + ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Es bundle" + className="h-4 w-4 cursor-pointer" + /> + + + Es bundle + + + )} + /> +
+ + {/* Allow Images toggle */} + ( + + + field.onChange(e.target.checked)} + disabled={isPending} + aria-label="Permite imágenes" + className="h-4 w-4 cursor-pointer" + /> + + + Permite imágenes + + + )} + /> + + {/* Multimedia fields — always rendered, disabled when allowImages=false */} +
+ ( + + Máx. imágenes + + + + + + )} + /> + + ( + + Máx. tamaño (MB) + + + + + + )} + /> + + ( + + Ancho máx. (px) + + + + + + )} + /> + + ( + + Alto máx. (px) + + + + + + )} + /> +
+ +
+ + +
+ + + ) +} diff --git a/src/web/src/features/product-types/components/ProductTypeFormDialog.tsx b/src/web/src/features/product-types/components/ProductTypeFormDialog.tsx new file mode 100644 index 0000000..4a5c757 --- /dev/null +++ b/src/web/src/features/product-types/components/ProductTypeFormDialog.tsx @@ -0,0 +1,111 @@ +import { useState } from 'react' +import { isAxiosError } from 'axios' +import { AlertCircle } from 'lucide-react' +import { toast } from 'sonner' +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { Alert, AlertDescription } from '@/components/ui/alert' +import { ProductTypeForm } from './ProductTypeForm' +import type { ProductTypeFormOutput } from './ProductTypeForm' +import { useCreateProductType } from '../hooks/useCreateProductType' +import { useUpdateProductType } from '../hooks/useUpdateProductType' +import type { ProductTypeDetail } from '../types' + +// ─── Error resolver ──────────────────────────────────────────────────────────── + +function resolveBackendError(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 guardar el tipo de producto' + } + const errObj = err as { response?: { data?: { message?: string } } } + if (errObj?.response?.data?.message) { + return errObj.response.data.message + } + return 'Error al guardar el tipo de producto' +} + +// ─── Props ──────────────────────────────────────────────────────────────────── + +interface ProductTypeFormDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + productType?: ProductTypeDetail + onSuccess?: () => void +} + +// ─── Component ──────────────────────────────────────────────────────────────── + +export function ProductTypeFormDialog({ + open, + onOpenChange, + productType, + onSuccess, +}: ProductTypeFormDialogProps) { + const [backendError, setBackendError] = useState(null) + + const isEdit = !!productType + const { mutateAsync: createProductType, isPending: creating } = useCreateProductType() + const { mutateAsync: updateProductType, isPending: updating } = useUpdateProductType() + const isPending = creating || updating + + async function handleSubmit(values: ProductTypeFormOutput) { + setBackendError(null) + try { + if (isEdit) { + await updateProductType({ id: productType.id, data: values }) + toast.success('Tipo de producto actualizado') + } else { + await createProductType(values) + toast.success('Tipo de producto creado') + } + onOpenChange(false) + onSuccess?.() + } catch (err) { + const msg = resolveBackendError(err) + setBackendError(msg) + if ( + !isAxiosError(err) || + (err.response?.status !== 409 && err.response?.status !== 422 && err.response?.status !== 400) + ) { + toast.error(isEdit ? 'Error al actualizar tipo de producto' : 'Error al crear tipo de producto') + } + } + } + + return ( + + + + {isEdit ? 'Editar tipo de producto' : 'Nuevo tipo de producto'} + + {isEdit + ? `Modificá los datos del tipo "${productType?.nombre ?? ''}".` + : 'Completá los datos para crear un nuevo tipo de producto.'} + + + + {backendError && ( + + + {backendError} + + )} + + onOpenChange(false)} + isPending={isPending} + isEdit={isEdit} + /> + + + ) +} diff --git a/src/web/src/tests/features/product-types/DeactivateProductTypeDialog.test.tsx b/src/web/src/tests/features/product-types/DeactivateProductTypeDialog.test.tsx new file mode 100644 index 0000000..aed12d4 --- /dev/null +++ b/src/web/src/tests/features/product-types/DeactivateProductTypeDialog.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi } 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 { DeactivateProductTypeDialog } from '../../../features/product-types/components/DeactivateProductTypeDialog' +import type { ProductTypeListItem } from '../../../features/product-types/types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const sampleProductType: ProductTypeListItem = { + id: 1, + nombre: 'Clasificados', + hasDuration: true, + requiresText: false, + requiresCategory: false, + isBundle: false, + allowImages: false, + isActive: true, +} + +function wrap(children: React.ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + {children} + , + ) +} + +describe('DeactivateProductTypeDialog', () => { + it('renders confirmation message with product type name', () => { + wrap( + , + ) + expect(screen.getByText(/Clasificados/i)).toBeInTheDocument() + expect(screen.getByRole('heading', { name: /desactivar tipo/i })).toBeInTheDocument() + }) + + it('calls onConfirm with product type id when user confirms', async () => { + const onConfirm = vi.fn().mockResolvedValue(undefined) + wrap( + , + ) + const buttons = screen.getAllByRole('button', { name: /desactivar/i }) + const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')! + await userEvent.click(confirmBtn) + await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(sampleProductType.id)) + }) + + it('shows inline error when backend returns 409 EnUso', async () => { + const onConfirm = vi.fn(() => + Promise.reject({ + response: { status: 409, data: { message: 'No se puede desactivar: el tipo está en uso por Products.' } }, + }), + ) + wrap( + , + ) + const buttons = screen.getAllByRole('button', { name: /desactivar/i }) + const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')! + await userEvent.click(confirmBtn) + await waitFor(() => { + expect(screen.getByText(/en uso/i)).toBeInTheDocument() + }) + }) + + it('closes dialog when cancel is clicked', async () => { + const onOpenChange = vi.fn() + wrap( + , + ) + await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) + await waitFor(() => expect(onOpenChange).toHaveBeenCalled()) + }) +}) diff --git a/src/web/src/tests/features/product-types/ProductTypeForm.test.tsx b/src/web/src/tests/features/product-types/ProductTypeForm.test.tsx new file mode 100644 index 0000000..4b7db5a --- /dev/null +++ b/src/web/src/tests/features/product-types/ProductTypeForm.test.tsx @@ -0,0 +1,129 @@ +import { describe, it, expect, vi } 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 { ProductTypeForm } from '../../../features/product-types/components/ProductTypeForm' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +function wrap(children: React.ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + {children} + , + ) +} + +// ─── ProductTypeForm — field rendering ───────────────────────────────────── + +describe('ProductTypeForm — field rendering', () => { + it('renders nombre field and all flag checkboxes', () => { + wrap( + , + ) + expect(screen.getByLabelText(/nombre/i)).toBeInTheDocument() + expect(screen.getByLabelText('Tiene duración')).toBeInTheDocument() + expect(screen.getByLabelText('Requiere texto')).toBeInTheDocument() + expect(screen.getByLabelText('Requiere categoría')).toBeInTheDocument() + expect(screen.getByLabelText('Es bundle')).toBeInTheDocument() + expect(screen.getByLabelText('Permite imágenes')).toBeInTheDocument() + }) + + it('renders multimedia fields when allowImages is true', async () => { + wrap( + , + ) + expect(screen.getByLabelText('Máx. imágenes')).toBeInTheDocument() + expect(screen.getByLabelText('Máx. tamaño (MB)')).toBeInTheDocument() + expect(screen.getByLabelText('Ancho máx. (px)')).toBeInTheDocument() + expect(screen.getByLabelText('Alto máx. (px)')).toBeInTheDocument() + }) + + it('disables multimedia fields when allowImages is false', () => { + wrap( + , + ) + expect(screen.getByLabelText('Máx. imágenes')).toBeDisabled() + expect(screen.getByLabelText('Máx. tamaño (MB)')).toBeDisabled() + expect(screen.getByLabelText('Ancho máx. (px)')).toBeDisabled() + expect(screen.getByLabelText('Alto máx. (px)')).toBeDisabled() + }) +}) + +// ─── ProductTypeForm — allowImages toggle ────────────────────────────────── + +describe('ProductTypeForm — allowImages toggle', () => { + it('enables multimedia fields after toggling allowImages on', async () => { + wrap( + , + ) + const allowImagesCheckbox = screen.getByLabelText('Permite imágenes') + await userEvent.click(allowImagesCheckbox) + await waitFor(() => { + expect(screen.getByLabelText('Máx. imágenes')).not.toBeDisabled() + }) + }) +}) + +// ─── ProductTypeForm — validation ───────────────────────────────────────── + +describe('ProductTypeForm — zod validation', () => { + it('shows validation error when nombre is empty', async () => { + wrap( + , + ) + await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i })) + await waitFor(() => { + expect(screen.getByText(/nombre.*requerido/i)).toBeInTheDocument() + }) + }) + + it('calls onSubmit with correct payload when form is valid', async () => { + const onSubmit = vi.fn() + wrap( + , + ) + await userEvent.type(screen.getByLabelText(/nombre/i), 'Clasificados') + await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i })) + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const payload = onSubmit.mock.calls[0][0] + expect(payload).toMatchObject({ nombre: 'Clasificados' }) + }) + }) + + it('normalizes multimedia fields to null when allowImages is false on submit', async () => { + const onSubmit = vi.fn() + wrap( + , + ) + await userEvent.type(screen.getByLabelText(/nombre/i), 'Sin imágenes') + await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i })) + await waitFor(() => { + expect(onSubmit).toHaveBeenCalled() + const payload = onSubmit.mock.calls[0][0] + expect(payload.maxImages).toBeNull() + expect(payload.maxImageSizeMB).toBeNull() + expect(payload.maxImageWidth).toBeNull() + expect(payload.maxImageHeight).toBeNull() + }) + }) +}) + +// ─── ProductTypeForm — cancel ────────────────────────────────────────────── + +describe('ProductTypeForm — cancel', () => { + it('calls onCancel when cancel button is clicked', async () => { + const onCancel = vi.fn() + wrap( + , + ) + await userEvent.click(screen.getByRole('button', { name: /cancelar/i })) + expect(onCancel).toHaveBeenCalled() + }) +}) diff --git a/src/web/src/tests/features/product-types/ProductTypeFormDialog.test.tsx b/src/web/src/tests/features/product-types/ProductTypeFormDialog.test.tsx new file mode 100644 index 0000000..aa4b3fe --- /dev/null +++ b/src/web/src/tests/features/product-types/ProductTypeFormDialog.test.tsx @@ -0,0 +1,138 @@ +import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { setupServer } from 'msw/node' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { MemoryRouter } from 'react-router-dom' +import React from 'react' +import { ProductTypeFormDialog } from '../../../features/product-types/components/ProductTypeFormDialog' +import type { ProductTypeDetail } from '../../../features/product-types/types' + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +const API_URL = 'http://localhost:5000' + +const mockDetail: ProductTypeDetail = { + id: 1, + nombre: 'Clasificados', + hasDuration: true, + requiresText: true, + requiresCategory: false, + isBundle: false, + allowImages: false, + maxImages: null, + maxImageSizeMB: null, + maxImageWidth: null, + maxImageHeight: null, + isActive: true, + fechaCreacion: '2026-04-19T00:00:00Z', + fechaModificacion: null, +} + +const server = setupServer() + +beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) +afterEach(() => { + server.resetHandlers() + vi.clearAllMocks() +}) +afterAll(() => server.close()) + +function wrap(children: React.ReactNode) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + return render( + + {children} + , + ) +} + +// ─── ProductTypeFormDialog — create mode ────────────────────────────────── + +describe('ProductTypeFormDialog — create mode', () => { + it('renders create dialog title when no productType prop', () => { + wrap( + , + ) + expect(screen.getByRole('heading', { name: /nuevo tipo/i })).toBeInTheDocument() + }) + + it('has aria-describedby on DialogDescription (NFR8)', () => { + wrap( + , + ) + const desc = screen.getByText(/completá los datos/i) + expect(desc).toBeInTheDocument() + }) + + it('calls create mutation and closes dialog on success', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/product-types`, () => + HttpResponse.json({ id: 5, nombre: 'Nuevo Tipo' }, { status: 201 }), + ), + ) + const onOpenChange = vi.fn() + wrap( + , + ) + await userEvent.type(screen.getByLabelText(/nombre/i), 'Nuevo Tipo') + await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i })) + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + }) + + it('shows inline error when backend returns 409 NombreDuplicado', async () => { + server.use( + http.post(`${API_URL}/api/v1/admin/product-types`, () => + HttpResponse.json( + { error: 'producto_tipo_nombre_duplicado', message: 'Ya existe un tipo con ese nombre' }, + { status: 409 }, + ), + ), + ) + wrap( + , + ) + await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado') + await userEvent.click(screen.getByRole('button', { name: /guardar|crear/i })) + await waitFor(() => { + expect(screen.getByText(/ya existe un tipo con ese nombre/i)).toBeInTheDocument() + }) + }) +}) + +// ─── ProductTypeFormDialog — edit mode ──────────────────────────────────── + +describe('ProductTypeFormDialog — edit mode', () => { + it('renders edit dialog title and pre-fills nombre', () => { + wrap( + , + ) + expect(screen.getByRole('heading', { name: /editar tipo/i })).toBeInTheDocument() + const input = screen.getByLabelText(/nombre/i) as HTMLInputElement + expect(input.value).toBe('Clasificados') + }) + + it('calls update mutation and closes dialog on success', async () => { + server.use( + http.put(`${API_URL}/api/v1/admin/product-types/1`, () => + HttpResponse.json({ ...mockDetail, nombre: 'Modificado' }), + ), + ) + const onOpenChange = vi.fn() + wrap( + , + ) + const input = screen.getByLabelText(/nombre/i) as HTMLInputElement + await userEvent.clear(input) + await userEvent.type(input, 'Modificado') + await userEvent.click(screen.getByRole('button', { name: /guardar/i })) + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + }) +})