fix: openEdit fetch ProductTypeDetail antes de abrir dialog (closes #37) #42

Merged
dmolinari merged 1 commits from fix/issue-37-openedit-fetch-detail into main 2026-04-19 19:53:47 +00:00
2 changed files with 97 additions and 13 deletions

View File

@@ -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<ProductTypeDetail | null>(null)
const [editLoadingId, setEditLoadingId] = useState<number | null>(null)
// ── Deactivate dialog state ──────────────────────────────────────────────
const [deactivateOpen, setDeactivateOpen] = useState(false)
const [deactivatingProductType, setDeactivatingProductType] = useState<ProductTypeListItem | null>(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)
}
}
function openDeactivate(pt: ProductTypeListItem) {
@@ -136,6 +141,7 @@ export function ProductTypesPage() {
variant="ghost"
size="sm"
onClick={() => openEdit(pt)}
disabled={editLoadingId === pt.id}
>
Editar
</Button>

View File

@@ -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<ProductTypeListItem> = {
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 ───────────────────────────────────────────────────────