feat(frontend): wire dialogs en ProductTypesPage (PRD-001 W3)

Conecta ProductTypeFormDialog (create/edit) y DeactivateProductTypeDialog
en ProductTypesPage: botón "Nuevo Tipo", acción Editar por fila, acción
Desactivar por fila, empty state CTA "Crear primer tipo".

9 tests nuevos de page integration. Total: 390.
This commit is contained in:
2026-04-19 12:10:09 -03:00
parent 9cb1e84ec0
commit 230405e056
2 changed files with 335 additions and 86 deletions

View File

@@ -1,50 +1,64 @@
import { useState } from 'react'
import { AlertCircle, Plus } from 'lucide-react'
import { isAxiosError } from 'axios'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/ui/skeleton'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { CanPerform } from '@/components/auth/CanPerform'
import { useProductTypes } from '../hooks/useProductTypes'
import { useCreateProductType } from '../hooks/useCreateProductType'
import { useUpdateProductType } from '../hooks/useUpdateProductType'
import { useDeactivateProductType } from '../hooks/useDeactivateProductType'
import type { ProductTypeListItem } from '../types'
import type { CreateProductTypeRequest } from '../types'
import { ProductTypeFormDialog } from '../components/ProductTypeFormDialog'
import { DeactivateProductTypeDialog } from '../components/DeactivateProductTypeDialog'
import type { ProductTypeListItem, ProductTypeDetail } from '../types'
export function ProductTypesPage() {
const [formError, setFormError] = useState<unknown>(null)
// ── Create dialog state ──────────────────────────────────────────────────
const [createOpen, setCreateOpen] = useState(false)
// ── Edit dialog state ────────────────────────────────────────────────────
const [editOpen, setEditOpen] = useState(false)
const [editingProductType, setEditingProductType] = useState<ProductTypeDetail | null>(null)
// ── Deactivate dialog state ──────────────────────────────────────────────
const [deactivateOpen, setDeactivateOpen] = useState(false)
const [deactivatingProductType, setDeactivatingProductType] = useState<ProductTypeListItem | null>(null)
const { data: paged, isLoading, isError } = useProductTypes({ activo: true })
const { mutateAsync: createProductType, isPending: creating } = useCreateProductType()
const { mutateAsync: updateProductType, isPending: updating } = useUpdateProductType()
const { mutateAsync: deactivateProductType } = useDeactivateProductType()
async function handleCreate(payload: CreateProductTypeRequest) {
try {
setFormError(null)
await createProductType(payload)
toast.success('Tipo de producto creado')
} catch (err) {
setFormError(err)
if (isAxiosError(err) && err.response?.status === 409) {
toast.error('Ya existe un tipo de producto con ese nombre')
} else {
toast.error('Error al crear tipo de producto')
// ── Handlers ─────────────────────────────────────────────────────────────
function openCreate() {
setCreateOpen(true)
}
function openEdit(pt: ProductTypeListItem) {
// Map list item to detail shape for pre-filling (multimedia nulls are fine for list item)
const detail: ProductTypeDetail = {
...pt,
maxImages: null,
maxImageSizeMB: null,
maxImageWidth: null,
maxImageHeight: null,
fechaCreacion: '',
fechaModificacion: null,
}
setEditingProductType(detail)
setEditOpen(true)
}
function openDeactivate(pt: ProductTypeListItem) {
setDeactivatingProductType(pt)
setDeactivateOpen(true)
}
async function handleDeactivate(id: number) {
try {
await deactivateProductType(id)
toast.success('Tipo de producto desactivado')
} catch {
toast.error('Error al desactivar tipo de producto')
}
}
// ── Loading / Error ───────────────────────────────────────────────────────
if (isLoading) {
return (
<div className="space-y-4 p-4">
@@ -63,27 +77,30 @@ export function ProductTypesPage() {
)
}
const isEmpty = !paged?.items.length
return (
<div className="space-y-4 p-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Tipos de Producto</h1>
<CanPerform permission="catalogo:tipos:gestionar">
<Button size="sm">
<Button size="sm" onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
Nuevo Tipo
</Button>
</CanPerform>
</div>
{formError && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>
{isAxiosError(formError) ? formError.message : 'Error inesperado'}
</AlertDescription>
</Alert>
)}
{isEmpty ? (
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
<p>No hay tipos de producto.</p>
<CanPerform permission="catalogo:tipos:gestionar">
<Button variant="outline" onClick={openCreate}>
Crear primer tipo
</Button>
</CanPerform>
</div>
) : (
<div className="rounded-md border">
<table className="w-full text-sm">
<thead>
@@ -99,7 +116,7 @@ export function ProductTypesPage() {
</tr>
</thead>
<tbody>
{paged?.items.map((pt: ProductTypeListItem) => (
{paged.items.map((pt: ProductTypeListItem) => (
<tr key={pt.id} className="border-b last:border-0 hover:bg-muted/25">
<td className="px-4 py-2 font-medium">{pt.nombre}</td>
<td className="px-4 py-2">{pt.hasDuration ? 'Sí' : 'No'}</td>
@@ -114,28 +131,56 @@ export function ProductTypesPage() {
</td>
<td className="px-4 py-2">
<CanPerform permission="catalogo:tipos:gestionar">
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleDeactivate(pt.id)}
onClick={() => openEdit(pt)}
>
Editar
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => openDeactivate(pt)}
disabled={!pt.isActive}
>
Desactivar
</Button>
</div>
</CanPerform>
</td>
</tr>
))}
{paged?.items.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-muted-foreground">
No hay tipos de producto.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Create dialog */}
<ProductTypeFormDialog
open={createOpen}
onOpenChange={setCreateOpen}
/>
{/* Edit dialog */}
{editingProductType && (
<ProductTypeFormDialog
open={editOpen}
onOpenChange={setEditOpen}
productType={editingProductType}
/>
)}
{/* Deactivate confirmation dialog */}
{deactivatingProductType && (
<DeactivateProductTypeDialog
open={deactivateOpen}
onOpenChange={setDeactivateOpen}
productType={deactivatingProductType}
onConfirm={handleDeactivate}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,204 @@
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } 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, Routes, Route } from 'react-router-dom'
import React from 'react'
import { ProductTypesPage } from '../../../features/product-types/pages/ProductTypesPage'
import { useAuthStore } from '../../../stores/authStore'
import type { ProductTypeListItem, PagedResult } from '../../../features/product-types/types'
const API_URL = 'http://localhost:5000'
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
const adminUser = {
id: 1,
username: 'admin',
nombre: 'Admin',
rol: 'admin',
permisos: ['catalogo:tipos:gestionar'],
mustChangePassword: false,
}
const regularUser = {
id: 2,
username: 'viewer',
nombre: 'Viewer',
rol: 'viewer',
permisos: [],
mustChangePassword: false,
}
const mockItem: ProductTypeListItem = {
id: 1,
nombre: 'Clasificados',
hasDuration: true,
requiresText: true,
requiresCategory: false,
isBundle: false,
allowImages: false,
isActive: true,
}
const mockPaged: PagedResult<ProductTypeListItem> = {
items: [mockItem],
page: 1,
pageSize: 20,
total: 1,
}
const emptyPaged: PagedResult<ProductTypeListItem> = {
items: [],
page: 1,
pageSize: 20,
total: 0,
}
const server = setupServer()
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => {
server.resetHandlers()
useAuthStore.getState().clearAuth()
vi.clearAllMocks()
})
afterAll(() => server.close())
function renderPage(user = adminUser) {
useAuthStore.setState({ user })
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={['/admin/product-types']}>
<Routes>
<Route path="/admin/product-types" element={<ProductTypesPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
)
}
// ─── Loading / Error / Data states ──────────────────────────────────────────
describe('ProductTypesPage — loading and error states', () => {
it('renders loading skeleton while fetching', () => {
server.use(
http.get(`${API_URL}/api/v1/product-types`, async () => {
await new Promise(() => {})
return HttpResponse.json(emptyPaged)
}),
)
renderPage()
// During loading, skeletons are shown — verify they are rendered
const skeletons = document.querySelectorAll('[class*="skeleton"], .animate-pulse')
expect(skeletons.length).toBeGreaterThan(0)
})
it('renders data when loaded', async () => {
server.use(
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
)
renderPage()
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
})
it('shows error state on fetch failure', async () => {
server.use(
http.get(`${API_URL}/api/v1/product-types`, () =>
HttpResponse.json({ error: 'server_error' }, { status: 500 }),
),
)
renderPage()
await waitFor(() =>
expect(screen.getByText(/error al cargar tipos de producto/i)).toBeInTheDocument(),
)
})
it('shows empty state when no product types', async () => {
server.use(
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(emptyPaged)),
)
renderPage()
await waitFor(() =>
expect(screen.getByText(/no hay tipos de producto/i)).toBeInTheDocument(),
)
})
})
// ─── Create dialog ───────────────────────────────────────────────────────────
describe('ProductTypesPage — create dialog', () => {
it('opens create dialog when "Nuevo Tipo" button is clicked', async () => {
server.use(
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByRole('button', { name: /nuevo tipo/i })).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /nuevo tipo/i }))
await waitFor(() =>
expect(screen.getByRole('heading', { name: /nuevo tipo de producto/i })).toBeInTheDocument(),
)
})
it('opens create dialog from empty state CTA', async () => {
server.use(
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(emptyPaged)),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByRole('button', { name: /crear primer tipo/i })).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /crear primer tipo/i }))
await waitFor(() =>
expect(screen.getByRole('heading', { name: /nuevo tipo de producto/i })).toBeInTheDocument(),
)
})
it('hides "Nuevo Tipo" button when user lacks permission', async () => {
server.use(
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
)
renderPage(regularUser)
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
expect(screen.queryByRole('button', { name: /nuevo tipo/i })).not.toBeInTheDocument()
})
})
// ─── Edit dialog ─────────────────────────────────────────────────────────────
describe('ProductTypesPage — edit dialog', () => {
it('opens edit dialog pre-filled when Edit button is clicked', async () => {
server.use(
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /editar/i }))
await waitFor(() =>
expect(screen.getByRole('heading', { name: /editar tipo/i })).toBeInTheDocument(),
)
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
expect(input.value).toBe('Clasificados')
})
})
// ─── Deactivate dialog ───────────────────────────────────────────────────────
describe('ProductTypesPage — deactivate dialog', () => {
it('opens deactivate confirmation dialog when Desactivar is clicked', async () => {
server.use(
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
)
renderPage(adminUser)
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
await waitFor(() =>
expect(screen.getByRole('heading', { name: /desactivar tipo de producto/i })).toBeInTheDocument(),
)
})
})