Compare commits
2 Commits
1730b0623e
...
c5a8cd9edd
| Author | SHA1 | Date | |
|---|---|---|---|
| c5a8cd9edd | |||
| 616f6432d1 |
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AlertCircle, Plus } from 'lucide-react'
|
import { AlertCircle, Plus } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||||
import { CanPerform } from '@/components/auth/CanPerform'
|
import { CanPerform } from '@/components/auth/CanPerform'
|
||||||
import { useProductTypes } from '../hooks/useProductTypes'
|
import { useProductTypes } from '../hooks/useProductTypes'
|
||||||
import { useDeactivateProductType } from '../hooks/useDeactivateProductType'
|
import { useDeactivateProductType } from '../hooks/useDeactivateProductType'
|
||||||
|
import { getProductTypeById } from '../api/getProductTypeById'
|
||||||
import { ProductTypeFormDialog } from '../components/ProductTypeFormDialog'
|
import { ProductTypeFormDialog } from '../components/ProductTypeFormDialog'
|
||||||
import { DeactivateProductTypeDialog } from '../components/DeactivateProductTypeDialog'
|
import { DeactivateProductTypeDialog } from '../components/DeactivateProductTypeDialog'
|
||||||
import type { ProductTypeListItem, ProductTypeDetail } from '../types'
|
import type { ProductTypeListItem, ProductTypeDetail } from '../types'
|
||||||
@@ -18,11 +20,13 @@ export function ProductTypesPage() {
|
|||||||
// ── Edit dialog state ────────────────────────────────────────────────────
|
// ── Edit dialog state ────────────────────────────────────────────────────
|
||||||
const [editOpen, setEditOpen] = useState(false)
|
const [editOpen, setEditOpen] = useState(false)
|
||||||
const [editingProductType, setEditingProductType] = useState<ProductTypeDetail | null>(null)
|
const [editingProductType, setEditingProductType] = useState<ProductTypeDetail | null>(null)
|
||||||
|
const [editLoadingId, setEditLoadingId] = useState<number | null>(null)
|
||||||
|
|
||||||
// ── Deactivate dialog state ──────────────────────────────────────────────
|
// ── Deactivate dialog state ──────────────────────────────────────────────
|
||||||
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||||
const [deactivatingProductType, setDeactivatingProductType] = useState<ProductTypeListItem | null>(null)
|
const [deactivatingProductType, setDeactivatingProductType] = useState<ProductTypeListItem | null>(null)
|
||||||
|
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const { data: paged, isLoading, isError } = useProductTypes({ activo: true })
|
const { data: paged, isLoading, isError } = useProductTypes({ activo: true })
|
||||||
const { mutateAsync: deactivateProductType } = useDeactivateProductType()
|
const { mutateAsync: deactivateProductType } = useDeactivateProductType()
|
||||||
|
|
||||||
@@ -32,19 +36,20 @@ export function ProductTypesPage() {
|
|||||||
setCreateOpen(true)
|
setCreateOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(pt: ProductTypeListItem) {
|
async function openEdit(pt: ProductTypeListItem) {
|
||||||
// Map list item to detail shape for pre-filling (multimedia nulls are fine for list item)
|
setEditLoadingId(pt.id)
|
||||||
const detail: ProductTypeDetail = {
|
try {
|
||||||
...pt,
|
const detail = await queryClient.fetchQuery({
|
||||||
maxImages: null,
|
queryKey: ['product-type', pt.id],
|
||||||
maxImageSizeMB: null,
|
queryFn: () => getProductTypeById(pt.id),
|
||||||
maxImageWidth: null,
|
})
|
||||||
maxImageHeight: null,
|
|
||||||
fechaCreacion: '',
|
|
||||||
fechaModificacion: null,
|
|
||||||
}
|
|
||||||
setEditingProductType(detail)
|
setEditingProductType(detail)
|
||||||
setEditOpen(true)
|
setEditOpen(true)
|
||||||
|
} catch {
|
||||||
|
toast.error('Error al cargar el tipo de producto')
|
||||||
|
} finally {
|
||||||
|
setEditLoadingId(null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDeactivate(pt: ProductTypeListItem) {
|
function openDeactivate(pt: ProductTypeListItem) {
|
||||||
@@ -136,6 +141,7 @@ export function ProductTypesPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openEdit(pt)}
|
onClick={() => openEdit(pt)}
|
||||||
|
disabled={editLoadingId === pt.id}
|
||||||
>
|
>
|
||||||
Editar
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { MemoryRouter, Routes, Route } from 'react-router-dom'
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { ProductTypesPage } from '../../../features/product-types/pages/ProductTypesPage'
|
import { ProductTypesPage } from '../../../features/product-types/pages/ProductTypesPage'
|
||||||
import { useAuthStore } from '../../../stores/authStore'
|
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'
|
const API_URL = 'http://localhost:5000'
|
||||||
|
|
||||||
@@ -45,6 +45,23 @@ const mockItem: ProductTypeListItem = {
|
|||||||
isActive: true,
|
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> = {
|
const mockPaged: PagedResult<ProductTypeListItem> = {
|
||||||
items: [mockItem],
|
items: [mockItem],
|
||||||
page: 1,
|
page: 1,
|
||||||
@@ -175,6 +192,7 @@ describe('ProductTypesPage — edit dialog', () => {
|
|||||||
it('opens edit dialog pre-filled when Edit button is clicked', async () => {
|
it('opens edit dialog pre-filled when Edit button is clicked', async () => {
|
||||||
server.use(
|
server.use(
|
||||||
http.get(`${API_URL}/api/v1/product-types`, () => HttpResponse.json(mockPaged)),
|
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)
|
renderPage(adminUser)
|
||||||
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('Clasificados')).toBeInTheDocument())
|
||||||
@@ -185,6 +203,66 @@ describe('ProductTypesPage — edit dialog', () => {
|
|||||||
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
|
||||||
expect(input.value).toBe('Clasificados')
|
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 ───────────────────────────────────────────────────────
|
// ─── Deactivate dialog ───────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user