test(frontend): ProductsPage pagination + filter tests (PRD-002 W5)
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { AlertCircle, Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { CanPerform } from '@/components/auth/CanPerform'
|
||||
@@ -11,6 +12,8 @@ import { ProductFormDialog } from '../components/ProductFormDialog'
|
||||
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
|
||||
import type { ProductListItem, ProductDetail } from '../types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
export function ProductsPage() {
|
||||
// ── Create dialog state ──────────────────────────────────────────────────
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
@@ -23,7 +26,16 @@ export function ProductsPage() {
|
||||
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
|
||||
|
||||
const { data: paged, isLoading, isError } = useProducts({ activo: true })
|
||||
// ── Pagination & filter state ────────────────────────────────────────────
|
||||
const [page, setPage] = useState(1)
|
||||
const [medioIdFilter, setMedioIdFilter] = useState<number | undefined>(undefined)
|
||||
|
||||
const { data: paged, isLoading, isError } = useProducts({
|
||||
activo: true,
|
||||
page,
|
||||
pageSize: PAGE_SIZE,
|
||||
medioId: medioIdFilter,
|
||||
})
|
||||
const { mutateAsync: deactivateProduct } = useDeactivateProduct()
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||
@@ -52,6 +64,14 @@ export function ProductsPage() {
|
||||
toast.success('Producto desactivado')
|
||||
}
|
||||
|
||||
function handleMedioFilterChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const val = e.target.value
|
||||
setPage(1)
|
||||
setMedioIdFilter(val ? Number(val) : undefined)
|
||||
}
|
||||
|
||||
const totalPages = paged ? Math.ceil(paged.total / PAGE_SIZE) : 1
|
||||
|
||||
// ── Loading / Error ───────────────────────────────────────────────────────
|
||||
|
||||
if (isLoading) {
|
||||
@@ -86,6 +106,18 @@ export function ProductsPage() {
|
||||
</CanPerform>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
placeholder="Filtrar por ID de Medio"
|
||||
aria-label="Filtrar por ID de Medio"
|
||||
className="w-52"
|
||||
onChange={handleMedioFilterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
|
||||
<p>No hay productos.</p>
|
||||
@@ -148,6 +180,33 @@ export function ProductsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
aria-label="Página anterior"
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Página {page} de {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
aria-label="Página siguiente"
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create dialog */}
|
||||
<ProductFormDialog
|
||||
open={createOpen}
|
||||
|
||||
@@ -185,3 +185,99 @@ describe('ProductsPage — deactivate dialog', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Pagination ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — pagination', () => {
|
||||
it('shows pagination controls when total > pageSize', async () => {
|
||||
// 21 total items but only 1 in this page → totalPages=2 → controls visible
|
||||
const pagedWith21Total: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, () => HttpResponse.json(pagedWith21Total)),
|
||||
)
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to page 2 when Siguiente is clicked and sends page=2 to API', async () => {
|
||||
const capturedRequests: URL[] = []
|
||||
|
||||
const pagedPage1: PagedResult<ProductListItem> = {
|
||||
items: [mockItem],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
const pagedPage2: PagedResult<ProductListItem> = {
|
||||
items: [{ ...mockItem, id: 2, nombre: 'Producto Página 2' }],
|
||||
page: 2,
|
||||
pageSize: 20,
|
||||
total: 21,
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||
capturedRequests.push(new URL(request.url))
|
||||
const url = new URL(request.url)
|
||||
const page = Number(url.searchParams.get('page') ?? '1')
|
||||
return HttpResponse.json(page === 2 ? pagedPage2 : pagedPage1)
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage(adminUser)
|
||||
await waitFor(() => expect(screen.getByText('Clasificado Estándar')).toBeInTheDocument())
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /siguiente/i }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Producto Página 2')).toBeInTheDocument()
|
||||
})
|
||||
// Verify that at least one request was made with page=2
|
||||
const page2Requests = capturedRequests.filter((u) => u.searchParams.get('page') === '2')
|
||||
expect(page2Requests.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Filter by Medio ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('ProductsPage — filter by Medio', () => {
|
||||
it('re-fetches with medioId when Medio filter is changed', async () => {
|
||||
const capturedRequests: URL[] = []
|
||||
|
||||
const filteredPaged: PagedResult<ProductListItem> = {
|
||||
items: [{ ...mockItem, medioId: 5, nombre: 'Producto Medio 5' }],
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 1,
|
||||
}
|
||||
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/products`, ({ request }) => {
|
||||
capturedRequests.push(new URL(request.url))
|
||||
const url = new URL(request.url)
|
||||
const medioId = url.searchParams.get('medioId')
|
||||
return HttpResponse.json(medioId === '5' ? filteredPaged : emptyPaged)
|
||||
}),
|
||||
)
|
||||
|
||||
renderPage(adminUser)
|
||||
// Wait for initial empty state
|
||||
await waitFor(() => expect(screen.getByText(/no hay productos/i)).toBeInTheDocument())
|
||||
|
||||
const filterInput = screen.getByLabelText(/filtrar por id de medio/i)
|
||||
await userEvent.type(filterInput, '5')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Producto Medio 5')).toBeInTheDocument()
|
||||
})
|
||||
const filteredRequests = capturedRequests.filter((u) => u.searchParams.get('medioId') === '5')
|
||||
expect(filteredRequests.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user