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 { AlertCircle, Plus } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
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'
|
||||||
@@ -11,6 +12,8 @@ import { ProductFormDialog } from '../components/ProductFormDialog'
|
|||||||
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
|
import { DeactivateProductDialog } from '../components/DeactivateProductDialog'
|
||||||
import type { ProductListItem, ProductDetail } from '../types'
|
import type { ProductListItem, ProductDetail } from '../types'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
export function ProductsPage() {
|
export function ProductsPage() {
|
||||||
// ── Create dialog state ──────────────────────────────────────────────────
|
// ── Create dialog state ──────────────────────────────────────────────────
|
||||||
const [createOpen, setCreateOpen] = useState(false)
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
@@ -23,7 +26,16 @@ export function ProductsPage() {
|
|||||||
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
const [deactivateOpen, setDeactivateOpen] = useState(false)
|
||||||
const [deactivatingProduct, setDeactivatingProduct] = useState<ProductListItem | null>(null)
|
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()
|
const { mutateAsync: deactivateProduct } = useDeactivateProduct()
|
||||||
|
|
||||||
// ── Handlers ─────────────────────────────────────────────────────────────
|
// ── Handlers ─────────────────────────────────────────────────────────────
|
||||||
@@ -52,6 +64,14 @@ export function ProductsPage() {
|
|||||||
toast.success('Producto desactivado')
|
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 ───────────────────────────────────────────────────────
|
// ── Loading / Error ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -86,6 +106,18 @@ export function ProductsPage() {
|
|||||||
</CanPerform>
|
</CanPerform>
|
||||||
</div>
|
</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 ? (
|
{isEmpty ? (
|
||||||
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
|
<div className="flex flex-col items-center gap-4 py-16 text-center text-muted-foreground">
|
||||||
<p>No hay productos.</p>
|
<p>No hay productos.</p>
|
||||||
@@ -148,6 +180,33 @@ export function ProductsPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Create dialog */}
|
||||||
<ProductFormDialog
|
<ProductFormDialog
|
||||||
open={createOpen}
|
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