feat(frontend): ProductForm reactivo a flags ProductType (PRD-002 W2)

This commit is contained in:
2026-04-19 13:33:53 -03:00
parent d262454b28
commit 2b79b6f769
3 changed files with 262 additions and 43 deletions

View File

@@ -12,6 +12,7 @@ import {
FormLabel,
FormMessage,
} from '@/components/ui/form'
import type { ProductTypeListItem } from '@/features/product-types/types'
// ─── Schema ───────────────────────────────────────────────────────────────────
@@ -76,6 +77,7 @@ export interface ProductFormDefaultValues {
}
interface ProductFormProps {
productTypes: ProductTypeListItem[]
defaultValues?: ProductFormDefaultValues
onSubmit: (values: ProductFormOutput) => void
onCancel: () => void
@@ -86,6 +88,7 @@ interface ProductFormProps {
// ─── Component ────────────────────────────────────────────────────────────────
export function ProductForm({
productTypes,
defaultValues,
onSubmit,
onCancel,
@@ -117,8 +120,21 @@ export function ProductForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId])
// Derive selected ProductType flags
const productTypeIdStr = form.watch('productTypeId')
const selectedProductTypeId = productTypeIdStr ? Number(productTypeIdStr) : null
const selectedProductType = productTypes.find((pt) => pt.id === selectedProductTypeId) ?? null
const requiresCategory = selectedProductType?.requiresCategory ?? false
const hasDuration = selectedProductType?.hasDuration ?? false
function handleSubmit(data: ProductFormOutput) {
onSubmit(data)
// Normalize conditional fields to null when not applicable
const normalized: ProductFormOutput = {
...data,
rubroId: requiresCategory ? data.rubroId : null,
priceDurationDays: hasDuration ? data.priceDurationDays : null,
}
onSubmit(normalized)
}
return (
@@ -194,27 +210,29 @@ export function ProductForm({
)}
/>
{/* Rubro ID (optional) */}
<FormField
control={form.control}
name="rubroId"
render={({ field }) => (
<FormItem>
<FormLabel>ID de Rubro (opcional)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending}
placeholder="Sin rubro"
aria-label="ID de Rubro"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Rubro ID — only shown when requiresCategory=true */}
{requiresCategory && (
<FormField
control={form.control}
name="rubroId"
render={({ field }) => (
<FormItem>
<FormLabel>ID de Rubro</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending}
placeholder="ID del rubro"
aria-label="ID de Rubro"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
{/* Base Price */}
<FormField
@@ -239,27 +257,29 @@ export function ProductForm({
)}
/>
{/* Price Duration Days (optional) */}
<FormField
control={form.control}
name="priceDurationDays"
render={({ field }) => (
<FormItem>
<FormLabel>Días de duración del precio (opcional)</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending}
placeholder="Sin límite"
aria-label="Días de duración del precio"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Price Duration Days — only shown when hasDuration=true */}
{hasDuration && (
<FormField
control={form.control}
name="priceDurationDays"
render={({ field }) => (
<FormItem>
<FormLabel>Días de duración del precio</FormLabel>
<FormControl>
<Input
{...field}
type="number"
min={1}
disabled={isPending}
placeholder="Días"
aria-label="Días de duración del precio"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex justify-end gap-2 pt-2">
<Button

View File

@@ -15,6 +15,7 @@ import type { ProductFormOutput } from './ProductForm'
import { useCreateProduct } from '../hooks/useCreateProduct'
import { useUpdateProduct } from '../hooks/useUpdateProduct'
import type { ProductDetail } from '../types'
import { useProductTypes } from '@/features/product-types/hooks/useProductTypes'
// ─── Error resolver ────────────────────────────────────────────────────────────
@@ -55,6 +56,10 @@ export function ProductFormDialog({
const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct()
const isPending = creating || updating
// Fetch active product types so ProductForm can derive conditional fields
const { data: productTypesPaged } = useProductTypes({ activo: true })
const productTypes = productTypesPaged?.items ?? []
async function handleSubmit(values: ProductFormOutput) {
setBackendError(null)
try {
@@ -114,6 +119,7 @@ export function ProductFormDialog({
)}
<ProductForm
productTypes={productTypes}
defaultValues={product}
onSubmit={handleSubmit}
onCancel={() => onOpenChange(false)}

View File

@@ -0,0 +1,193 @@
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 { ProductForm } from '../../../features/products/components/ProductForm'
import type { ProductTypeListItem } from '../../../features/product-types/types'
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
const ptRequiresCategory: ProductTypeListItem = {
id: 1,
nombre: 'Con categoría',
hasDuration: false,
requiresText: false,
requiresCategory: true,
isBundle: false,
allowImages: false,
isActive: true,
}
const ptHasDuration: ProductTypeListItem = {
id: 2,
nombre: 'Con duración',
hasDuration: true,
requiresText: false,
requiresCategory: false,
isBundle: false,
allowImages: false,
isActive: true,
}
const ptSimple: ProductTypeListItem = {
id: 3,
nombre: 'Simple',
hasDuration: false,
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>,
)
}
// ─── ProductForm — no ProductType selected ────────────────────────────────
describe('ProductForm — no ProductType selected', () => {
it('hides RubroId and PriceDurationDays fields when no ProductType is selected', () => {
wrap(
<ProductForm
productTypes={[ptRequiresCategory, ptHasDuration, ptSimple]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
/>,
)
expect(screen.queryByLabelText(/rubro/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/días de duración/i)).not.toBeInTheDocument()
})
})
// ─── ProductForm — requiresCategory flag ─────────────────────────────────
describe('ProductForm — requiresCategory flag', () => {
it('shows RubroId field when ProductType has requiresCategory=true', async () => {
wrap(
<ProductForm
productTypes={[ptRequiresCategory, ptSimple]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 1 }}
/>,
)
await waitFor(() =>
expect(screen.getByLabelText(/id de rubro/i)).toBeInTheDocument(),
)
})
it('hides RubroId field when ProductType has requiresCategory=false', async () => {
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 3 }}
/>,
)
await waitFor(() =>
expect(screen.queryByLabelText(/id de rubro/i)).not.toBeInTheDocument(),
)
})
})
// ─── ProductForm — hasDuration flag ──────────────────────────────────────
describe('ProductForm — hasDuration flag', () => {
it('shows PriceDurationDays when ProductType has hasDuration=true', async () => {
wrap(
<ProductForm
productTypes={[ptHasDuration]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 2 }}
/>,
)
await waitFor(() =>
expect(screen.getByLabelText(/días de duración/i)).toBeInTheDocument(),
)
})
it('hides PriceDurationDays when ProductType has hasDuration=false', async () => {
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={vi.fn()}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 3 }}
/>,
)
await waitFor(() =>
expect(screen.queryByLabelText(/días de duración/i)).not.toBeInTheDocument(),
)
})
})
// ─── ProductForm — submit normalization ──────────────────────────────────
describe('ProductForm — submit normalization', () => {
it('nulls out rubroId when requiresCategory=false on submit', async () => {
const onSubmit = vi.fn()
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={onSubmit}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 3 }}
/>,
)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test')
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
const payload = onSubmit.mock.calls[0][0]
expect(payload.rubroId).toBeNull()
})
})
it('nulls out priceDurationDays when hasDuration=false on submit', async () => {
const onSubmit = vi.fn()
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={onSubmit}
onCancel={vi.fn()}
defaultValues={{ productTypeId: 3 }}
/>,
)
await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test2')
await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
await userEvent.type(screen.getByLabelText(/precio base/i), '100')
await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
await waitFor(() => {
expect(onSubmit).toHaveBeenCalled()
const payload = onSubmit.mock.calls[0][0]
expect(payload.priceDurationDays).toBeNull()
})
})
it('calls onCancel when cancel button is clicked', async () => {
const onCancel = vi.fn()
wrap(
<ProductForm
productTypes={[ptSimple]}
onSubmit={vi.fn()}
onCancel={onCancel}
/>,
)
await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
expect(onCancel).toHaveBeenCalled()
})
})