feat: PRD-002 Product CRUD #40
@@ -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,20 +210,21 @@ export function ProductForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Rubro ID (optional) */}
|
||||
{/* Rubro ID — only shown when requiresCategory=true */}
|
||||
{requiresCategory && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="rubroId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>ID de Rubro (opcional)</FormLabel>
|
||||
<FormLabel>ID de Rubro</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending}
|
||||
placeholder="Sin rubro"
|
||||
placeholder="ID del rubro"
|
||||
aria-label="ID de Rubro"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -215,6 +232,7 @@ export function ProductForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Base Price */}
|
||||
<FormField
|
||||
@@ -239,20 +257,21 @@ export function ProductForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Price Duration Days (optional) */}
|
||||
{/* 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 (opcional)</FormLabel>
|
||||
<FormLabel>Días de duración del precio</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type="number"
|
||||
min={1}
|
||||
disabled={isPending}
|
||||
placeholder="Sin límite"
|
||||
placeholder="Días"
|
||||
aria-label="Días de duración del precio"
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -260,6 +279,7 @@ export function ProductForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
|
||||
@@ -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)}
|
||||
|
||||
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