Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions src/features/shipments/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { AxiosResponse } from 'axios'
import { client } from 'shared/api/client'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { fetchShipmentCount, fetchShipmentPage } from './api'

// Sólo el `data` importa: la frontera no lee headers ni status de estas respuestas.
function respond(data: unknown): AxiosResponse {
return { data, headers: {} } as AxiosResponse
}

/** Los query params con los que se llamó al endpoint. */
function capture() {
const sent: Record<string, unknown>[] = []
vi.spyOn(client, 'get').mockImplementation((_url: string, config?: unknown) => {
sent.push((config as { params: Record<string, unknown> }).params)

return Promise.resolve(respond({ data: [], meta: { page: 1, per_page: 20, total: 0 } }))
})

return sent
}

beforeEach(() => {
vi.restoreAllMocks()
})

// El filtro lo hace el backend (TESIS-164): lo que esta capa tiene que
// garantizar es que el término llegue, y que el vacío no.
describe('fetchShipmentPage', () => {
it('sends the term as the search parameter', async () => {
const sent = capture()
await fetchShipmentPage({ page: 1, perPage: 20, search: 'AND-9920' })

expect(sent[0].search).toBe('AND-9920')
})

// Un `search=` en blanco ensucia la URL y la clave de caché sin cambiar la
// respuesta: el backend corta antes de armar la condición.
it('omits search when there is no term', async () => {
const sent = capture()
await fetchShipmentPage({ page: 1, perPage: 20, search: '' })

expect(sent[0]).not.toHaveProperty('search')
})

it('keeps the status filter alongside the term', async () => {
const sent = capture()
await fetchShipmentPage({ page: 1, perPage: 20, status: 'pending', search: 'AND' })

expect(sent[0]).toMatchObject({ status: 'pending', search: 'AND' })
})
})

describe('fetchShipmentCount', () => {
it('counts with the term applied', async () => {
const sent = capture()
await fetchShipmentCount('pending', 'AND')

expect(sent[0]).toMatchObject({ status: 'pending', search: 'AND', per_page: 1 })
})

it('omits search when there is no term', async () => {
const sent = capture()
await fetchShipmentCount('pending')

expect(sent[0]).not.toHaveProperty('search')
})
})
14 changes: 10 additions & 4 deletions src/features/shipments/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,16 @@ function toShipment(shipment: ApiShipmentSummary): ShipmentSummary {
}

// Un filtro vacío no viaja: mandar `status=` en blanco haría que el backend
// filtre por el string vacío y devuelva cero filas.
function toParams({ page, perPage, status, orderId }: ShipmentFilters) {
// filtre por el string vacío y devuelva cero filas. Con `search` el backend sí
// tolera el vacío —corta antes de armar la condición—, pero mandarlo igual
// ensucia la URL y la clave de caché, así que se omite por el mismo criterio.
function toParams({ page, perPage, status, orderId, search }: ShipmentFilters) {
return {
page,
per_page: perPage,
...(status === undefined ? {} : { status }),
...(orderId === undefined ? {} : { order_id: orderId }),
...(search ? { search } : {}),
}
}

Expand All @@ -80,6 +83,9 @@ export async function fetchShipmentPage(filters: ShipmentFilters): Promise<Shipm
* que el `meta.total`, que el backend cuenta sobre el scope ya filtrado. Es el
* mismo `fetchCount` que usan el panel y el catálogo.
*/
export function fetchShipmentCount(status?: ShipmentStatus): Promise<number> {
return fetchCount('/shipments', status === undefined ? {} : { status })
export function fetchShipmentCount(status?: ShipmentStatus, search = ''): Promise<number> {
return fetchCount('/shipments', {
...(status === undefined ? {} : { status }),
...(search ? { search } : {}),
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export function ShipmentsTable({
activeTabId,
onTabChange,
pagination,
emptyMessage,
onView,
}: ShipmentsTableProps) {
return (
Expand All @@ -161,7 +162,7 @@ export function ShipmentsTable({
tabs={tabs}
activeTabId={activeTabId}
onTabChange={onTabChange}
emptyMessage={table.empty}
emptyMessage={emptyMessage}
pagination={pagination}
paginationLabels={{
previousLabel: shipmentsCopy.pagination.previous,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export interface ShipmentsTableProps {
activeTabId: string
onTabChange: (tabId: string) => void
pagination: DataTablePagination
/**
* Qué decir con la tabla vacía. Lo elige la pantalla porque depende de si se
* buscó algo: una pestaña sin envíos y una búsqueda sin resultados no son lo
* mismo para quien está mirando.
*/
emptyMessage: string
/** Abre la orden del envío: su detalle vive ahí, no en una pantalla propia. */
onView: (shipment: ShipmentSummary) => void
}
5 changes: 5 additions & 0 deletions src/features/shipments/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ export const shipmentsCopy = {
title: 'Envíos',
subtitle: 'Todos los envíos de la empresa y en qué anda cada uno.',
error: 'No pudimos cargar los envíos.',
searchLabel: 'Buscar',
/** Los dos códigos que el operador tiene en la mano cuando lo llaman. */
searchPlaceholder: 'Seguimiento u orden del canal',
},
tabs: {
all: 'Todos',
Expand Down Expand Up @@ -51,6 +54,8 @@ export const shipmentsCopy = {
table: {
label: 'Envíos de la empresa',
empty: 'No hay envíos para este filtro.',
/** Buscar y no encontrar no es lo mismo que una pestaña vacía. */
emptySearch: 'Ningún envío coincide con la búsqueda.',
/** El detalle del envío vive en la orden: no hay pantalla propia. */
view: 'Ver la orden',
/** El ojito se repite en cada fila: el nombre accesible dice cuál es. */
Expand Down
6 changes: 3 additions & 3 deletions src/features/shipments/hooks/useShipments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,11 @@ export function useShipmentPage(filters: ShipmentFilters) {
* Que uno falle no voltea la pantalla: la tabla se ve igual y esa pestaña queda
* sin número.
*/
export function useShipmentCounts() {
export function useShipmentCounts(search = '') {
return useQueries({
queries: SHIPMENT_TABS.map(({ status }) => ({
queryKey: shipmentKeys.count(status),
queryFn: () => fetchShipmentCount(status),
queryKey: shipmentKeys.count(status, search),
queryFn: () => fetchShipmentCount(status, search),
})),
combine: (results) => results.map((result) => result.data),
})
Expand Down
83 changes: 82 additions & 1 deletion src/features/shipments/pages/ShipmentsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fireEvent, screen, within } from '@testing-library/react'
import { act, fireEvent, screen, within } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'

Expand Down Expand Up @@ -47,6 +47,21 @@ function mockPage(rows: ShipmentSummary[], overrides: Partial<ShipmentPage> = {}
} as never)
}

// Lo tipeado viaja con un debounce de 300 ms (`useDebouncedValue`): hay que
// dejarlo vencer para afirmar sobre lo que se le pidió a la API.
const SETTLE_MS = 400

function settle() {
return act(() => new Promise((resolve) => setTimeout(resolve, SETTLE_MS)))
}

/** Los filtros del último pedido del listado. */
function lastFilters() {
const calls = vi.mocked(useShipmentPage).mock.calls

return calls[calls.length - 1][0]
}

function renderPage() {
renderWithTheme(
<MemoryRouter initialEntries={['/shipments']}>
Expand Down Expand Up @@ -148,6 +163,72 @@ describe('ShipmentsPage', () => {
expect(screen.getByText('Mostrando 0 a 0 de 0 envíos')).toBeInTheDocument()
})

// El buscador del listado (TESIS-164).
describe('search', () => {
function type(term: string) {
fireEvent.change(screen.getByRole('textbox', { name: 'Buscar' }), {
target: { value: term },
})

return settle()
}

it('sends the typed term to the API', async () => {
renderPage()
await type('AND-9920')

expect(lastFilters().search).toBe('AND-9920')
})

// Sin el debounce cada tecla es un request: escribir el seguimiento entero
// son dieciséis, de los que quince se descartan.
it('does not send anything until the typing settles', () => {
renderPage()
fireEvent.change(screen.getByRole('textbox', { name: 'Buscar' }), {
target: { value: 'AND' },
})

expect(lastFilters().search).toBe('')
})

// Buscar parado en la página 3 devolvía una página vacía: el resultado de
// la búsqueda entra en una sola.
it('goes back to the first page when the term changes', async () => {
mockPage([DISPATCHED, PENDING], { total: 45 })
renderPage()
fireEvent.click(screen.getByRole('button', { name: 'Página siguiente' }))
expect(lastFilters().page).toBe(2)

await type('AND')

expect(lastFilters().page).toBe(1)
})

// Las pestañas tienen que contar lo mismo que muestra la tabla.
it('counts the tabs with the term applied', async () => {
renderPage()
await type('AND')

expect(vi.mocked(useShipmentCounts)).toHaveBeenLastCalledWith('AND')
})

// Buscar y no encontrar no es lo mismo que una pestaña vacía.
it('says nothing matched instead of showing the empty tab message', async () => {
mockPage([], { total: 0 })
renderPage()
await type('nada')

expect(screen.getByText('Ningún envío coincide con la búsqueda.')).toBeInTheDocument()
})

it('keeps the empty tab message when nobody searched', () => {
mockPage([], { total: 0 })
renderPage()

expect(screen.getByText('No hay envíos para este filtro.')).toBeInTheDocument()
})
})

it('offers to retry when the listing fails', () => {
const refetch = vi.fn()
vi.mocked(useShipmentPage).mockReturnValue({
Expand Down
76 changes: 62 additions & 14 deletions src/features/shipments/pages/ShipmentsPage.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { Box, Stack, Typography } from '@mui/material'
import SearchIcon from '@mui/icons-material/Search'
import { Box, InputAdornment, Stack, TextField, Typography } from '@mui/material'
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components'
import type { DataTableTab } from 'shared/components'
import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'

import { ShipmentsTable } from '../components/ShipmentsTable'
import { formatCount, shipmentsCopy } from '../content'
import { SHIPMENT_TABS, useShipmentCounts, useShipmentPage } from '../hooks/useShipments'
import type { ShipmentTabId } from '../hooks/useShipments'
import type { ShipmentSummary } from '../types'

const { page: pageCopy, tabs: tabCopy, pagination } = shipmentsCopy
const { page: pageCopy, tabs: tabCopy, table, pagination } = shipmentsCopy

const PER_PAGE = 20

Expand Down Expand Up @@ -42,18 +44,36 @@ export function ShipmentsPage() {
const navigate = useNavigate()
const [tabId, setTabId] = useState<ShipmentTabId>('all')
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')

// Lo que se tipea actualiza el campo en el acto; lo que viaja a la API espera
// a que la persona deje de escribir.
const debouncedSearch = useDebouncedValue(search)

const status = SHIPMENT_TABS.find((tab) => tab.id === tabId)?.status
const shipments = useShipmentPage({ page, perPage: PER_PAGE, status })
const counts = useShipmentCounts()
const shipments = useShipmentPage({
page,
perPage: PER_PAGE,
status,
search: debouncedSearch,
})
// El término también va a los contadores: si no, las pestañas seguirían
// diciendo cuántos envíos tiene la empresa mientras la tabla muestra tres.
const counts = useShipmentCounts(debouncedSearch)

// Cambiar de pestaña vuelve a la primera página: quedarse en la 7 de un
// filtro que ahora tiene 2 páginas deja la tabla vacía sin motivo visible.
// Cambiar de pestaña o de búsqueda vuelve a la primera página: quedarse en la
// 7 de un filtro que ahora tiene 2 páginas deja la tabla vacía sin motivo
// visible.
function changeTab(nextTabId: string) {
setTabId(nextTabId as ShipmentTabId)
setPage(1)
}

function changeSearch(value: string) {
setSearch(value)
setPage(1)
}

const tabs: DataTableTab[] = SHIPMENT_TABS.map(({ id }, index) => ({
id,
label: TAB_LABELS[id],
Expand Down Expand Up @@ -85,21 +105,49 @@ export function ShipmentsPage() {
return (
<PageWrapper sx={{ maxWidth: 1400 }}>
<Stack spacing={3}>
<Box>
<Typography variant="h1" component="h1">
{pageCopy.title}
</Typography>
<Typography variant="bodyLg" sx={{ color: 'text.secondary' }}>
{pageCopy.subtitle}
</Typography>
</Box>
{/* `useFlexGap`: sin él `spacing` separa con `margin-left` y pisa el
`ml: 'auto'` que manda el buscador a la derecha (TESIS-132). */}
<Stack
direction={{ xs: 'column', md: 'row' }}
spacing={2}
useFlexGap
sx={{ alignItems: { md: 'flex-start' } }}
>
<Box>
<Typography variant="h1" component="h1">
{pageCopy.title}
</Typography>
<Typography variant="bodyLg" sx={{ color: 'text.secondary' }}>
{pageCopy.subtitle}
</Typography>
</Box>

<TextField
size="small"
value={search}
onChange={(event) => changeSearch(event.target.value)}
label={pageCopy.searchLabel}
placeholder={pageCopy.searchPlaceholder}
sx={{ ml: { md: 'auto' }, width: { xs: '100%', sm: 300 }, flexShrink: 0 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
/>
</Stack>

<ShipmentsTable
shipments={rows}
tabs={tabs}
activeTabId={tabId}
onTabChange={changeTab}
onView={openOrder}
emptyMessage={debouncedSearch ? table.emptySearch : table.empty}
pagination={{
page,
pageCount,
Expand Down
7 changes: 5 additions & 2 deletions src/features/shipments/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export const shipmentKeys = {
counts: () => [...shipmentKeys.all, 'count'] as const,
// `status ?? 'all'`: sin esto, la clave de «todos» y la de un estado
// indefinido serían la misma sólo por casualidad de serialización.
count: (status: ShipmentStatus | undefined) =>
[...shipmentKeys.counts(), status ?? 'all'] as const,
// El término entra en la clave del contador igual que en la del listado: si
// no, las pestañas seguirían contando la empresa entera mientras la tabla
// muestra lo buscado.
count: (status: ShipmentStatus | undefined, search: string) =>
[...shipmentKeys.counts(), status ?? 'all', search] as const,
}
Loading