From 34b07a1d5580fd0f354ae26edf0a8b7b7dfd9054 Mon Sep 17 00:00:00 2001 From: dmolinari Date: Sun, 19 Apr 2026 19:52:45 -0300 Subject: [PATCH] feat(frontend): pagination UI on product prices history (refs #47) --- .../features/products/api/getProductPrices.ts | 13 +- .../components/ProductPriceHistory.tsx | 96 ++++++++---- .../products/hooks/useAddProductPrice.ts | 2 +- .../products/hooks/useProductPrices.ts | 9 +- .../products/ProductPriceHistory.test.tsx | 139 ++++++++++++++++-- .../products/productPrices.hooks.test.ts | 79 ++++++++-- 6 files changed, 282 insertions(+), 56 deletions(-) diff --git a/src/web/src/features/products/api/getProductPrices.ts b/src/web/src/features/products/api/getProductPrices.ts index 1f9c533..b169db7 100644 --- a/src/web/src/features/products/api/getProductPrices.ts +++ b/src/web/src/features/products/api/getProductPrices.ts @@ -1,7 +1,14 @@ import { axiosClient } from '@/api/axiosClient' -import type { ProductPrice } from '../types' +import type { PagedResult, ProductPrice } from '../types' -export async function getProductPrices(productId: number): Promise { - const res = await axiosClient.get(`/api/v1/products/${productId}/prices`) +export async function getProductPrices( + productId: number, + page: number = 1, + pageSize: number = 20, +): Promise> { + const res = await axiosClient.get>( + `/api/v1/products/${productId}/prices`, + { params: { page, pageSize } }, + ) return res.data } diff --git a/src/web/src/features/products/components/ProductPriceHistory.tsx b/src/web/src/features/products/components/ProductPriceHistory.tsx index 306f602..b02d520 100644 --- a/src/web/src/features/products/components/ProductPriceHistory.tsx +++ b/src/web/src/features/products/components/ProductPriceHistory.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import { AlertCircle, Plus } from 'lucide-react' +import { AlertCircle, ChevronLeft, ChevronRight, Plus } from 'lucide-react' import { Alert, AlertDescription } from '@/components/ui/alert' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -17,6 +17,10 @@ import { formatCivilDate, formatCurrency } from '@/lib/formatters' import { useProductPrices } from '../hooks/useProductPrices' import { AddProductPriceDialog } from './AddProductPriceDialog' +// ─── Constants ──────────────────────────────────────────────────────────────── + +const PAGE_SIZE = 20 + // ─── Props ──────────────────────────────────────────────────────────────────── interface ProductPriceHistoryProps { @@ -27,7 +31,9 @@ interface ProductPriceHistoryProps { export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) { const [addOpen, setAddOpen] = useState(false) - const { data: prices, isLoading, isError } = useProductPrices(productId) + const [currentPage, setCurrentPage] = useState(1) + + const { data: prices, isLoading, isError } = useProductPrices(productId, currentPage, PAGE_SIZE) if (isLoading) { return ( @@ -48,7 +54,9 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) { ) } - const isEmpty = !prices?.length + const total = prices?.total ?? 0 + const totalPages = Math.ceil(total / PAGE_SIZE) + const isEmpty = total === 0 return (
@@ -73,34 +81,62 @@ export function ProductPriceHistory({ productId }: ProductPriceHistoryProps) {
) : ( -
- - - - Desde - Hasta - Precio - Estado - - - - {prices.map((p) => ( - - {formatCivilDate(p.priceValidFrom)} - - {p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'} - - {formatCurrency(p.price)} - - {p.isActive ? ( - Vigente - ) : null} - + <> +
+
+ + + Desde + Hasta + Precio + Estado - ))} - -
-
+ + + {prices?.items.map((p) => ( + + {formatCivilDate(p.priceValidFrom)} + + {p.priceValidTo ? formatCivilDate(p.priceValidTo) : '—'} + + {formatCurrency(p.price)} + + {p.isActive ? ( + Vigente + ) : null} + + + ))} + + + + +
+ + Página {currentPage} de {totalPages || 1} + +
+ + +
+
+ )} addProductPrice(productId, payload), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['products', productId, 'prices'] }) + queryClient.invalidateQueries({ queryKey: ['product-prices', productId] }) }, }) } diff --git a/src/web/src/features/products/hooks/useProductPrices.ts b/src/web/src/features/products/hooks/useProductPrices.ts index 0453bf4..b1f137e 100644 --- a/src/web/src/features/products/hooks/useProductPrices.ts +++ b/src/web/src/features/products/hooks/useProductPrices.ts @@ -1,11 +1,12 @@ -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { getProductPrices } from '../api/getProductPrices' -export function useProductPrices(productId: number) { +export function useProductPrices(productId: number, page: number = 1, pageSize: number = 20) { return useQuery({ - queryKey: ['products', productId, 'prices'], - queryFn: () => getProductPrices(productId), + queryKey: ['product-prices', productId, page, pageSize], + queryFn: () => getProductPrices(productId, page, pageSize), enabled: productId > 0, staleTime: 30_000, + placeholderData: keepPreviousData, }) } diff --git a/src/web/src/tests/features/products/ProductPriceHistory.test.tsx b/src/web/src/tests/features/products/ProductPriceHistory.test.tsx index 5ff48ee..9dbf2fc 100644 --- a/src/web/src/tests/features/products/ProductPriceHistory.test.tsx +++ b/src/web/src/tests/features/products/ProductPriceHistory.test.tsx @@ -7,7 +7,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { ProductPriceHistory } from '../../../features/products/components/ProductPriceHistory' import { useAuthStore } from '../../../stores/authStore' -import type { ProductPrice } from '../../../features/products/types' +import type { ProductPrice, PagedResult } from '../../../features/products/types' const API_URL = 'http://localhost:5000' @@ -53,6 +53,17 @@ const regularUser = { mustChangePassword: false, } +// ─── PagedResult helpers ────────────────────────────────────────────────────── + +function makePagedResult(items: ProductPrice[], opts: { page?: number; pageSize?: number; total?: number } = {}): PagedResult { + return { + items, + page: opts.page ?? 1, + pageSize: opts.pageSize ?? 20, + total: opts.total ?? items.length, + } +} + // ─── Server ─────────────────────────────────────────────────────────────────── const server = setupServer() @@ -86,7 +97,7 @@ describe('ProductPriceHistory — loading state', () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, async () => { await new Promise(() => {}) - return HttpResponse.json([]) + return HttpResponse.json(makePagedResult([])) }), ) renderHistory() @@ -111,9 +122,11 @@ describe('ProductPriceHistory — error state', () => { }) describe('ProductPriceHistory — empty state', () => { - it('shows CTA when no prices exist', async () => { + it('shows CTA when no prices exist (total=0)', async () => { server.use( - http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([])), + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json(makePagedResult([], { total: 0 })), + ), ) renderHistory() await waitFor(() => @@ -130,7 +143,7 @@ describe('ProductPriceHistory — data rendering', () => { it('renders price list with formatted dates and prices', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice, mockClosedPrice]), + HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])), ), ) renderHistory() @@ -146,7 +159,7 @@ describe('ProductPriceHistory — data rendering', () => { it('shows Badge "Vigente" for active price row (priceValidTo=null)', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice, mockClosedPrice]), + HttpResponse.json(makePagedResult([mockActivePrice, mockClosedPrice])), ), ) renderHistory() @@ -156,7 +169,7 @@ describe('ProductPriceHistory — data rendering', () => { it('shows formatted currency for prices', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice]), + HttpResponse.json(makePagedResult([mockActivePrice])), ), ) renderHistory() @@ -173,7 +186,7 @@ describe('ProductPriceHistory — dialog integration', () => { it('opens AddProductPriceDialog when "Programar nuevo precio" is clicked', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice]), + HttpResponse.json(makePagedResult([mockActivePrice])), ), ) renderHistory() @@ -189,7 +202,7 @@ describe('ProductPriceHistory — dialog integration', () => { it('hides "Programar nuevo precio" button when user lacks permission', async () => { server.use( http.get(`${API_URL}/api/v1/products/1/prices`, () => - HttpResponse.json([mockActivePrice]), + HttpResponse.json(makePagedResult([mockActivePrice])), ), ) renderHistory(1, regularUser) @@ -197,3 +210,111 @@ describe('ProductPriceHistory — dialog integration', () => { expect(screen.queryByRole('button', { name: /programar nuevo precio/i })).not.toBeInTheDocument() }) }) + +// ─── §P.9 — §P.12: Pagination controls ─────────────────────────────────────── + +describe('ProductPriceHistory — pagination controls (§P.9–§P.12)', () => { + // §P.9: page 1 of many — Next enabled, Previous disabled + it('§P.9 — page 1: Next enabled and Previous disabled when total > pageSize', async () => { + // 30 total, pageSize=20 → 2 pages + const page1Items = Array.from({ length: 20 }, (_, i) => ({ + ...mockActivePrice, + id: i + 1, + isActive: i === 0, + })) + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json(makePagedResult(page1Items, { page: 1, pageSize: 20, total: 30 })), + ), + ) + renderHistory() + await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument()) + + const prevBtn = screen.getByRole('button', { name: /anterior/i }) + const nextBtn = screen.getByRole('button', { name: /siguiente/i }) + + expect(prevBtn).toBeDisabled() + expect(nextBtn).not.toBeDisabled() + }) + + // §P.10: click Next → refetch with page=2, renders new items + it('§P.10 — click Next → refetches page=2 and shows new items', async () => { + const price1 = { ...mockActivePrice, id: 1, price: 100, priceValidFrom: '2026-04-01', isActive: true } + const price2 = { ...mockActivePrice, id: 21, price: 200, priceValidFrom: '2026-01-01', priceValidTo: '2026-03-31', isActive: false } + + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => { + const url = new URL(request.url) + const page = url.searchParams.get('page') ?? '1' + if (page === '2') { + return HttpResponse.json(makePagedResult([price2], { page: 2, pageSize: 20, total: 21 })) + } + return HttpResponse.json(makePagedResult([price1], { page: 1, pageSize: 20, total: 21 })) + }), + ) + renderHistory() + + // Wait for page 1 to load — price1 has priceValidFrom 2026-04-01 + await waitFor(() => expect(screen.getByText('01/04/2026')).toBeInTheDocument()) + + const nextBtn = screen.getByRole('button', { name: /siguiente/i }) + await userEvent.click(nextBtn) + + // Page 2 shows price2 — priceValidFrom 2026-01-01 + await waitFor(() => expect(screen.getByText('01/01/2026')).toBeInTheDocument()) + }) + + // §P.11: first page always has Previous disabled + it('§P.11 — primera página: botón Anterior siempre deshabilitado', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 1 })), + ), + ) + renderHistory() + await waitFor(() => expect(screen.getByRole('button', { name: /anterior/i })).toBeInTheDocument()) + expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled() + }) + + // §P.12: last page has Next disabled (page * pageSize >= total) + it('§P.12 — última página: botón Siguiente deshabilitado', async () => { + // page=2, pageSize=20, total=21 → 2 pages, page 2 is last + const lastPageItem = { ...mockClosedPrice, id: 21 } + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => { + const url = new URL(request.url) + const page = url.searchParams.get('page') ?? '1' + if (page === '2') { + return HttpResponse.json(makePagedResult([lastPageItem], { page: 2, pageSize: 20, total: 21 })) + } + // Page 1: 20 items + const items = Array.from({ length: 20 }, (_, i) => ({ ...mockClosedPrice, id: i + 1 })) + return HttpResponse.json(makePagedResult(items, { page: 1, pageSize: 20, total: 21 })) + }), + ) + renderHistory() + + // Navigate to page 2 + await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeInTheDocument()) + const nextBtn = screen.getByRole('button', { name: /siguiente/i }) + expect(nextBtn).not.toBeDisabled() + + await userEvent.click(nextBtn) + + // On page 2, Next should be disabled + await waitFor(() => expect(screen.getByRole('button', { name: /siguiente/i })).toBeDisabled()) + expect(screen.getByRole('button', { name: /anterior/i })).not.toBeDisabled() + }) + + it('shows page info text', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => + HttpResponse.json(makePagedResult([mockActivePrice], { page: 1, pageSize: 20, total: 30 })), + ), + ) + renderHistory() + await waitFor(() => + expect(screen.getByText(/página 1 de 2/i)).toBeInTheDocument(), + ) + }) +}) diff --git a/src/web/src/tests/features/products/productPrices.hooks.test.ts b/src/web/src/tests/features/products/productPrices.hooks.test.ts index 5eea52e..dda690c 100644 --- a/src/web/src/tests/features/products/productPrices.hooks.test.ts +++ b/src/web/src/tests/features/products/productPrices.hooks.test.ts @@ -6,7 +6,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import React from 'react' import { useProductPrices } from '../../../features/products/hooks/useProductPrices' import { useAddProductPrice } from '../../../features/products/hooks/useAddProductPrice' -import type { ProductPrice, AddProductPriceResponse } from '../../../features/products/types' +import type { ProductPrice, AddProductPriceResponse, PagedResult } from '../../../features/products/types' const API_URL = 'http://localhost:5000' @@ -23,6 +23,13 @@ const mockPrice: ProductPrice = { isActive: true, } +const mockPagedResult: PagedResult = { + items: [mockPrice], + page: 1, + pageSize: 20, + total: 1, +} + const mockResponse: AddProductPriceResponse = { created: { id: 2, productId: 1, price: 700, priceValidFrom: '2026-05-01', priceValidTo: null, isActive: true }, closed: { id: 1, productId: 1, price: 500, priceValidFrom: '2026-04-01', priceValidTo: '2026-04-30', isActive: false }, @@ -46,16 +53,55 @@ function makeWrapper() { } describe('useProductPrices', () => { - it('fetches prices for productId and returns data', async () => { + it('fetches prices for productId and returns PagedResult data', async () => { server.use( - http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])), + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)), ) - const { qc, wrapper } = makeWrapper() + const { wrapper } = makeWrapper() const { result } = renderHook(() => useProductPrices(1), { wrapper }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(result.current.data).toEqual([mockPrice]) - // Verify caching: queryKey should be ['products', 1, 'prices'] - expect(qc.getQueryState(['products', 1, 'prices'])).toBeDefined() + expect(result.current.data).toEqual(mockPagedResult) + expect(result.current.data?.items).toEqual([mockPrice]) + }) + + it('includes page and pageSize in queryKey for correct caching', async () => { + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)), + ) + const { qc, wrapper } = makeWrapper() + const { result } = renderHook(() => useProductPrices(1, 1, 20), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + // queryKey must include page and pageSize + expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined() + }) + + it('uses different cache entry for different pages', async () => { + const page2Result: PagedResult = { + items: [{ ...mockPrice, id: 2 }], + page: 2, + pageSize: 20, + total: 21, + } + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => { + const url = new URL(request.url) + const page = url.searchParams.get('page') + return page === '2' ? HttpResponse.json(page2Result) : HttpResponse.json(mockPagedResult) + }), + ) + const { qc, wrapper } = makeWrapper() + + const { result: r1 } = renderHook(() => useProductPrices(1, 1, 20), { wrapper }) + await waitFor(() => expect(r1.current.isSuccess).toBe(true)) + + const { result: r2 } = renderHook(() => useProductPrices(1, 2, 20), { wrapper }) + await waitFor(() => expect(r2.current.isSuccess).toBe(true)) + + // Each page is cached separately + expect(qc.getQueryState(['product-prices', 1, 1, 20])).toBeDefined() + expect(qc.getQueryState(['product-prices', 1, 2, 20])).toBeDefined() + expect(r1.current.data?.page).toBe(1) + expect(r2.current.data?.page).toBe(2) }) it('is disabled when productId is 0', async () => { @@ -66,12 +112,27 @@ describe('useProductPrices', () => { expect(result.current.isFetching).toBe(false) expect(result.current.data).toBeUndefined() }) + + it('sends page and pageSize as query params in the URL', async () => { + let capturedUrl: string | null = null + server.use( + http.get(`${API_URL}/api/v1/products/1/prices`, ({ request }) => { + capturedUrl = request.url + return HttpResponse.json(mockPagedResult) + }), + ) + const { wrapper } = makeWrapper() + const { result } = renderHook(() => useProductPrices(1, 2, 10), { wrapper }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(capturedUrl).toContain('page=2') + expect(capturedUrl).toContain('pageSize=10') + }) }) describe('useAddProductPrice', () => { it('calls POST and invalidates product prices queries on success', async () => { server.use( - http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json([mockPrice])), + http.get(`${API_URL}/api/v1/products/1/prices`, () => HttpResponse.json(mockPagedResult)), http.post(`${API_URL}/api/v1/admin/products/1/prices`, () => HttpResponse.json(mockResponse, { status: 201 }), ), @@ -85,7 +146,7 @@ describe('useAddProductPrice', () => { result.current.mutate({ price: 700, priceValidFrom: '2026-05-01' }) }) await waitFor(() => expect(result.current.isSuccess).toBe(true)) - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['products', 1, 'prices'] }) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['product-prices', 1] }) }) it('returns error state on 409', async () => {