feat(frontend): ProductTypeForm + Dialog + DeactivateDialog con TDD (PRD-001 W3)
Implementa los 3 componentes de UI faltantes con enfoque Red→Green: - ProductTypeForm: zod schema con transforms para multimedia numérica, lógica condicional (multimedia deshabilitada cuando allowImages=false), normalización en submit. - ProductTypeFormDialog: mode create/edit, inline error 409, aria-describedby (NFR8). - DeactivateProductTypeDialog: AlertDialog confirmar soft-delete, inline error 409 EnUso. 18 tests nuevos (8 form + 6 dialog + 4 deactivate). Total: 381.
This commit is contained in:
@@ -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> | void
|
||||
}
|
||||
|
||||
// ─── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function DeactivateProductTypeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
productType,
|
||||
onConfirm,
|
||||
}: DeactivateProductTypeDialogProps) {
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Desactivar tipo de producto</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
¿Desactivar el tipo “{productType.nombre}”? Los productos asociados conservan
|
||||
la referencia pero el tipo no aparecerá en listados activos.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm} disabled={isPending}>
|
||||
{isPending ? 'Procesando...' : 'Desactivar'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -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<ProductTypeFormRaw>({
|
||||
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 (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(
|
||||
handleSubmit as unknown as Parameters<typeof form.handleSubmit>[0],
|
||||
)}
|
||||
className="space-y-4"
|
||||
noValidate
|
||||
>
|
||||
{/* Nombre */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="nombre"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nombre</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="text"
|
||||
disabled={isPending}
|
||||
placeholder="Nombre del tipo de producto"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Flags */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hasDuration"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<input
|
||||
id="hasDuration"
|
||||
type="checkbox"
|
||||
checked={field.value as boolean}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
disabled={isPending}
|
||||
aria-label="Tiene duración"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel htmlFor="hasDuration" className="font-normal cursor-pointer">
|
||||
Tiene duración
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requiresText"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<input
|
||||
id="requiresText"
|
||||
type="checkbox"
|
||||
checked={field.value as boolean}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
disabled={isPending}
|
||||
aria-label="Requiere texto"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel htmlFor="requiresText" className="font-normal cursor-pointer">
|
||||
Requiere texto
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="requiresCategory"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<input
|
||||
id="requiresCategory"
|
||||
type="checkbox"
|
||||
checked={field.value as boolean}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
disabled={isPending}
|
||||
aria-label="Requiere categoría"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel htmlFor="requiresCategory" className="font-normal cursor-pointer">
|
||||
Requiere categoría
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="isBundle"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<input
|
||||
id="isBundle"
|
||||
type="checkbox"
|
||||
checked={field.value as boolean}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
disabled={isPending}
|
||||
aria-label="Es bundle"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel htmlFor="isBundle" className="font-normal cursor-pointer">
|
||||
Es bundle
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Allow Images toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="allowImages"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-2 space-y-0">
|
||||
<FormControl>
|
||||
<input
|
||||
id="allowImages"
|
||||
type="checkbox"
|
||||
checked={field.value as boolean}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
disabled={isPending}
|
||||
aria-label="Permite imágenes"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel htmlFor="allowImages" className="font-normal cursor-pointer">
|
||||
Permite imágenes
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Multimedia fields — always rendered, disabled when allowImages=false */}
|
||||
<div className="grid grid-cols-2 gap-3 border rounded-md p-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxImages"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Máx. imágenes</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || !allowImages}
|
||||
placeholder="Sin límite"
|
||||
aria-label="Máx. imágenes"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxImageSizeMB"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Máx. tamaño (MB)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={0.1}
|
||||
step={0.1}
|
||||
disabled={isPending || !allowImages}
|
||||
placeholder="Sin límite"
|
||||
aria-label="Máx. tamaño (MB)"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxImageWidth"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ancho máx. (px)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || !allowImages}
|
||||
placeholder="Sin límite"
|
||||
aria-label="Ancho máx. (px)"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="maxImageHeight"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Alto máx. (px)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending || !allowImages}
|
||||
placeholder="Sin límite"
|
||||
aria-label="Alto máx. (px)"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onCancel}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending}>
|
||||
{isPending ? 'Guardando...' : isEdit ? 'Guardar cambios' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isEdit ? 'Editar tipo de producto' : 'Nuevo tipo de producto'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit
|
||||
? `Modificá los datos del tipo "${productType?.nombre ?? ''}".`
|
||||
: 'Completá los datos para crear un nuevo tipo de producto.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{backendError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>{backendError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ProductTypeForm
|
||||
defaultValues={productType}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => onOpenChange(false)}
|
||||
isPending={isPending}
|
||||
isEdit={isEdit}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('DeactivateProductTypeDialog', () => {
|
||||
it('renders confirmation message with product type name', () => {
|
||||
wrap(
|
||||
<DeactivateProductTypeDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
productType={sampleProductType}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<DeactivateProductTypeDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
productType={sampleProductType}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<DeactivateProductTypeDialog
|
||||
open={true}
|
||||
onOpenChange={vi.fn()}
|
||||
productType={sampleProductType}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
)
|
||||
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(
|
||||
<DeactivateProductTypeDialog
|
||||
open={true}
|
||||
onOpenChange={onOpenChange}
|
||||
productType={sampleProductType}
|
||||
onConfirm={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||
await waitFor(() => expect(onOpenChange).toHaveBeenCalled())
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ProductTypeForm — field rendering ─────────────────────────────────────
|
||||
|
||||
describe('ProductTypeForm — field rendering', () => {
|
||||
it('renders nombre field and all flag checkboxes', () => {
|
||||
wrap(
|
||||
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: true }} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeForm onSubmit={vi.fn()} onCancel={vi.fn()} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeForm onSubmit={onSubmit} onCancel={vi.fn()} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeForm onSubmit={onSubmit} onCancel={vi.fn()} defaultValues={{ allowImages: false }} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeForm onSubmit={vi.fn()} onCancel={onCancel} />,
|
||||
)
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
|
||||
expect(onCancel).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter>{children}</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── ProductTypeFormDialog — create mode ──────────────────────────────────
|
||||
|
||||
describe('ProductTypeFormDialog — create mode', () => {
|
||||
it('renders create dialog title when no productType prop', () => {
|
||||
wrap(
|
||||
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
|
||||
)
|
||||
expect(screen.getByRole('heading', { name: /nuevo tipo/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has aria-describedby on DialogDescription (NFR8)', () => {
|
||||
wrap(
|
||||
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeFormDialog open={true} onOpenChange={onOpenChange} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeFormDialog open={true} onOpenChange={vi.fn()} productType={mockDetail} />,
|
||||
)
|
||||
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(
|
||||
<ProductTypeFormDialog open={true} onOpenChange={onOpenChange} productType={mockDetail} />,
|
||||
)
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user