diff --git a/src/web/src/features/product-types/pages/ProductTypesPage.tsx b/src/web/src/features/product-types/pages/ProductTypesPage.tsx index 3f6c68e..3606f88 100644 --- a/src/web/src/features/product-types/pages/ProductTypesPage.tsx +++ b/src/web/src/features/product-types/pages/ProductTypesPage.tsx @@ -1,12 +1,14 @@ import { useState } from 'react' import { AlertCircle, Plus } from 'lucide-react' import { toast } from 'sonner' +import { useQueryClient } from '@tanstack/react-query' 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 { useDeactivateProductType } from '../hooks/useDeactivateProductType' +import { getProductTypeById } from '../api/getProductTypeById' import { ProductTypeFormDialog } from '../components/ProductTypeFormDialog' import { DeactivateProductTypeDialog } from '../components/DeactivateProductTypeDialog' import type { ProductTypeListItem, ProductTypeDetail } from '../types' @@ -18,11 +20,13 @@ export function ProductTypesPage() { // ── Edit dialog state ──────────────────────────────────────────────────── const [editOpen, setEditOpen] = useState(false) const [editingProductType, setEditingProductType] = useState(null) + const [editLoadingId, setEditLoadingId] = useState(null) // ── Deactivate dialog state ────────────────────────────────────────────── const [deactivateOpen, setDeactivateOpen] = useState(false) const [deactivatingProductType, setDeactivatingProductType] = useState(null) + const queryClient = useQueryClient() const { data: paged, isLoading, isError } = useProductTypes({ activo: true }) const { mutateAsync: deactivateProductType } = useDeactivateProductType() @@ -32,19 +36,20 @@ export function ProductTypesPage() { 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, + async function openEdit(pt: ProductTypeListItem) { + setEditLoadingId(pt.id) + try { + const detail = await queryClient.fetchQuery({ + queryKey: ['product-type', pt.id], + queryFn: () => getProductTypeById(pt.id), + }) + setEditingProductType(detail) + setEditOpen(true) + } catch { + toast.error('Error al cargar el tipo de producto') + } finally { + setEditLoadingId(null) } - setEditingProductType(detail) - setEditOpen(true) } function openDeactivate(pt: ProductTypeListItem) { @@ -136,6 +141,7 @@ export function ProductTypesPage() { variant="ghost" size="sm" onClick={() => openEdit(pt)} + disabled={editLoadingId === pt.id} > Editar diff --git a/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx b/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx index b96fe1a..274949b 100644 --- a/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx +++ b/src/web/src/tests/features/product-types/ProductTypesPage.test.tsx @@ -8,7 +8,7 @@ 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' +import type { ProductTypeListItem, ProductTypeDetail, PagedResult } from '../../../features/product-types/types' const API_URL = 'http://localhost:5000' @@ -45,6 +45,23 @@ const mockItem: ProductTypeListItem = { isActive: true, } +const mockDetail: ProductTypeDetail = { + id: 1, + nombre: 'Clasificados', + hasDuration: true, + requiresText: true, + requiresCategory: false, + isBundle: false, + allowImages: true, + maxImages: 7, + maxImageSizeMB: 5, + maxImageWidth: 800, + maxImageHeight: 600, + isActive: true, + fechaCreacion: '2024-01-01T00:00:00Z', + fechaModificacion: null, +} + const mockPaged: PagedResult = { items: [mockItem], page: 1, @@ -175,6 +192,7 @@ 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)), + http.get(`${API_URL}/api/v1/product-types/1`, () => HttpResponse.json(mockDetail)), ) renderPage(adminUser) await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument()) @@ -185,6 +203,66 @@ describe('ProductTypesPage — edit dialog', () => { const input = screen.getByLabelText(/nombre/i) as HTMLInputElement expect(input.value).toBe('Clasificados') }) + + it('fetches full detail and populates all multimedia fields in edit dialog', async () => { + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)), + http.get(`${API_URL}/api/v1/product-types/1`, () => HttpResponse.json(mockDetail)), + ) + 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(), + ) + // Multimedia fields must be populated from the fetched detail (not null) + const maxImages = screen.getByLabelText(/máx\. imágenes/i) as HTMLInputElement + const maxSizeMB = screen.getByLabelText(/máx\. tamaño/i) as HTMLInputElement + const maxWidth = screen.getByLabelText(/ancho máx/i) as HTMLInputElement + const maxHeight = screen.getByLabelText(/alto máx/i) as HTMLInputElement + expect(maxImages.value).toBe('7') + expect(maxSizeMB.value).toBe('5') + expect(maxWidth.value).toBe('800') + expect(maxHeight.value).toBe('600') + }) + + it('disables edit button while fetch is in flight', async () => { + let resolveDetail!: (value: unknown) => void + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)), + http.get(`${API_URL}/api/v1/product-types/1`, () => + new Promise((resolve) => { + resolveDetail = () => resolve(HttpResponse.json(mockDetail)) + }), + ), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument()) + const editBtn = screen.getByRole('button', { name: /editar/i }) + await userEvent.click(editBtn) + // Button should be disabled while fetch is pending + await waitFor(() => expect(editBtn).toBeDisabled()) + // Resolve the fetch so the dialog opens and no unhandled promise remains + resolveDetail(undefined) + await waitFor(() => + expect(screen.getByRole('heading', { name: /editar tipo/i })).toBeInTheDocument(), + ) + }) + + it('shows toast error and does not open dialog when fetch fails', async () => { + const { toast } = await import('sonner') + server.use( + http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)), + http.get(`${API_URL}/api/v1/product-types/1`, () => + HttpResponse.json({ error: 'not_found' }, { status: 404 }), + ), + ) + renderPage(adminUser) + await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument()) + await userEvent.click(screen.getByRole('button', { name: /editar/i })) + await waitFor(() => expect(toast.error).toHaveBeenCalled()) + expect(screen.queryByRole('heading', { name: /editar tipo/i })).not.toBeInTheDocument() + }) }) // ─── Deactivate dialog ───────────────────────────────────────────────────────