feat(frontend): ProductForm reactivo a flags ProductType (PRD-002 W2)
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form'
|
} from '@/components/ui/form'
|
||||||
|
import type { ProductTypeListItem } from '@/features/product-types/types'
|
||||||
|
|
||||||
// ─── Schema ───────────────────────────────────────────────────────────────────
|
// ─── Schema ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -76,6 +77,7 @@ export interface ProductFormDefaultValues {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ProductFormProps {
|
interface ProductFormProps {
|
||||||
|
productTypes: ProductTypeListItem[]
|
||||||
defaultValues?: ProductFormDefaultValues
|
defaultValues?: ProductFormDefaultValues
|
||||||
onSubmit: (values: ProductFormOutput) => void
|
onSubmit: (values: ProductFormOutput) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
@@ -86,6 +88,7 @@ interface ProductFormProps {
|
|||||||
// ─── Component ────────────────────────────────────────────────────────────────
|
// ─── Component ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function ProductForm({
|
export function ProductForm({
|
||||||
|
productTypes,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
@@ -117,8 +120,21 @@ export function ProductForm({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [defaultValues?.nombre, defaultValues?.medioId, defaultValues?.productTypeId])
|
}, [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) {
|
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 (
|
return (
|
||||||
@@ -194,27 +210,29 @@ export function ProductForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Rubro ID (optional) */}
|
{/* Rubro ID — only shown when requiresCategory=true */}
|
||||||
<FormField
|
{requiresCategory && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name="rubroId"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="rubroId"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>ID de Rubro (opcional)</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>ID de Rubro</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
{...field}
|
<Input
|
||||||
type="number"
|
{...field}
|
||||||
min={1}
|
type="number"
|
||||||
disabled={isPending}
|
min={1}
|
||||||
placeholder="Sin rubro"
|
disabled={isPending}
|
||||||
aria-label="ID de Rubro"
|
placeholder="ID del rubro"
|
||||||
/>
|
aria-label="ID de Rubro"
|
||||||
</FormControl>
|
/>
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Base Price */}
|
{/* Base Price */}
|
||||||
<FormField
|
<FormField
|
||||||
@@ -239,27 +257,29 @@ export function ProductForm({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Price Duration Days (optional) */}
|
{/* Price Duration Days — only shown when hasDuration=true */}
|
||||||
<FormField
|
{hasDuration && (
|
||||||
control={form.control}
|
<FormField
|
||||||
name="priceDurationDays"
|
control={form.control}
|
||||||
render={({ field }) => (
|
name="priceDurationDays"
|
||||||
<FormItem>
|
render={({ field }) => (
|
||||||
<FormLabel>Días de duración del precio (opcional)</FormLabel>
|
<FormItem>
|
||||||
<FormControl>
|
<FormLabel>Días de duración del precio</FormLabel>
|
||||||
<Input
|
<FormControl>
|
||||||
{...field}
|
<Input
|
||||||
type="number"
|
{...field}
|
||||||
min={1}
|
type="number"
|
||||||
disabled={isPending}
|
min={1}
|
||||||
placeholder="Sin límite"
|
disabled={isPending}
|
||||||
aria-label="Días de duración del precio"
|
placeholder="Días"
|
||||||
/>
|
aria-label="Días de duración del precio"
|
||||||
</FormControl>
|
/>
|
||||||
<FormMessage />
|
</FormControl>
|
||||||
</FormItem>
|
<FormMessage />
|
||||||
)}
|
</FormItem>
|
||||||
/>
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { ProductFormOutput } from './ProductForm'
|
|||||||
import { useCreateProduct } from '../hooks/useCreateProduct'
|
import { useCreateProduct } from '../hooks/useCreateProduct'
|
||||||
import { useUpdateProduct } from '../hooks/useUpdateProduct'
|
import { useUpdateProduct } from '../hooks/useUpdateProduct'
|
||||||
import type { ProductDetail } from '../types'
|
import type { ProductDetail } from '../types'
|
||||||
|
import { useProductTypes } from '@/features/product-types/hooks/useProductTypes'
|
||||||
|
|
||||||
// ─── Error resolver ────────────────────────────────────────────────────────────
|
// ─── Error resolver ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -55,6 +56,10 @@ export function ProductFormDialog({
|
|||||||
const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct()
|
const { mutateAsync: updateProduct, isPending: updating } = useUpdateProduct()
|
||||||
const isPending = creating || updating
|
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) {
|
async function handleSubmit(values: ProductFormOutput) {
|
||||||
setBackendError(null)
|
setBackendError(null)
|
||||||
try {
|
try {
|
||||||
@@ -114,6 +119,7 @@ export function ProductFormDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ProductForm
|
<ProductForm
|
||||||
|
productTypes={productTypes}
|
||||||
defaultValues={product}
|
defaultValues={product}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={() => onOpenChange(false)}
|
onCancel={() => onOpenChange(false)}
|
||||||
|
|||||||
193
src/web/src/tests/features/products/ProductForm.test.tsx
Normal file
193
src/web/src/tests/features/products/ProductForm.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user