From 4691960f211c5bd1cb52fd8383e6ccd71c719eee Mon Sep 17 00:00:00 2001 From: Tomas Martin Date: Tue, 22 Sep 2026 23:38:04 -0300 Subject: [PATCH 1/4] feat: [TESIS-125] search the catalog against the backend The picker pulled one page of a hundred products and filtered it in memory, because when it was written `GET /products` only paginated. Past that cut a product simply never appeared, and the operator had no way to tell that apart from "it does not exist". TESIS-62 added `search`, so the filter moves to where the whole catalog is. `filterOptions` now returns the options untouched. Without that MUI filters the server's answer a second time by the visible label, which would hide matches the backend did find. What the term does is debounced, so the three keystrokes of "sen" are one request, and it travels in the query key, so going back to a term already typed comes from the cache. `keepPreviousData` keeps the list from blinking empty between searches. An empty term does not send `search` at all: the backend would read it as a filter by empty string. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp --- docs/guidelines/architecture.md | 2 +- src/features/orders/api.test.ts | 41 ++++++++++++++++- src/features/orders/api.ts | 29 +++++++----- .../ProductPicker/ProductPicker.tsx | 22 ++++++--- .../ProductPicker/ProductPicker.types.ts | 10 ++++- .../orders/hooks/useCatalogProducts.ts | 22 ++++++--- .../orders/pages/NewOrderPage.test.tsx | 45 ++++++++++++++++++- src/features/orders/pages/NewOrderPage.tsx | 12 ++++- src/features/orders/queryKeys.ts | 4 +- src/features/orders/utils/draft.test.ts | 45 +------------------ src/features/orders/utils/draft.ts | 17 ------- 11 files changed, 156 insertions(+), 93 deletions(-) diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 4308c62..dac638f 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -311,7 +311,7 @@ features/[nombre]/ | `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | | `home` | Landing de la app (`/`) | | `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | -| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío (`/orders/:orderId`) y el paso 1 del alta manual (`/orders/new`: cliente y productos; los pasos 2 y 3 son TESIS-58 y TESIS-59) | +| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío (`/orders/:orderId`) y el paso 1 del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; los pasos 2 y 3 son TESIS-58 y TESIS-59) | | `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) | **`orders` — piezas y por qué:** diff --git a/src/features/orders/api.test.ts b/src/features/orders/api.test.ts index 353307e..5f5cc83 100644 --- a/src/features/orders/api.test.ts +++ b/src/features/orders/api.test.ts @@ -2,7 +2,7 @@ import type { AxiosResponse } from 'axios' import { client } from 'shared/api/client' import { describe, expect, it, vi } from 'vitest' -import { fetchOrder, fetchOrderShipment } from './api' +import { CATALOG_MATCHES, fetchCatalogProducts, fetchOrder, fetchOrderShipment } from './api' // Sólo el `data` importa: la frontera no lee headers ni status de estas respuestas. function respond(data: unknown): AxiosResponse { @@ -127,3 +127,42 @@ describe('fetchOrder', () => { ]) }) }) + +describe('fetchCatalogProducts', () => { + function capture() { + const sent: { params?: Record }[] = [] + vi.spyOn(client, 'get').mockImplementation((_url: string, config?: unknown) => { + sent.push(config as { params?: Record }) + return Promise.resolve(respond({ data: [], meta: { page: 1, per_page: 20, total: 0 } })) + }) + + return sent + } + + // El filtro lo hace el backend desde TESIS-125: lo que esta capa tiene que + // garantizar es que el término llegue. + it('sends the term as the search parameter', async () => { + const sent = capture() + + await fetchCatalogProducts('cable') + + expect(sent[0].params).toEqual({ page: 1, per_page: CATALOG_MATCHES, search: 'cable' }) + }) + + it('trims the term before sending it', async () => { + const sent = capture() + + await fetchCatalogProducts(' cable ') + + expect(sent[0].params).toMatchObject({ search: 'cable' }) + }) + + // Un `search` vacío haría que el backend filtre por cadena vacía: no viaja. + it('omits the parameter when nothing was typed', async () => { + const sent = capture() + + await fetchCatalogProducts(' ') + + expect(sent[0].params).toEqual({ page: 1, per_page: CATALOG_MATCHES }) + }) +}) diff --git a/src/features/orders/api.ts b/src/features/orders/api.ts index 99d0112..021264a 100644 --- a/src/features/orders/api.ts +++ b/src/features/orders/api.ts @@ -242,21 +242,28 @@ export function fetchOrderCount(status?: OrderStatus, search?: string): Promise< } /** - * Cuántos productos trae el buscador del alta manual. Es el máximo que la API - * permite por página (`per_page.clamp(1, 100)`). + * Cuántas coincidencias muestra el buscador del alta manual. * - * `GET /products` no tiene parámetro de búsqueda —el index sólo pagina—, así - * que el filtro por SKU o nombre corre del lado del cliente sobre esta página. - * Un catálogo de más de cien productos deja los últimos fuera del buscador; - * cuando eso pase, la salida es un `search` en el backend, no una segunda - * página acá. + * Ya no es «el catálogo entero»: desde TESIS-62 el backend filtra, así que lo + * que llega son los productos que matchean lo tipeado, y veinte alcanzan de + * sobra para elegir uno. Antes se pedían cien y se filtraba en memoria, y un + * catálogo más grande dejaba productos que el buscador no encontraba nunca, + * sin que el operador pudiera distinguirlo de «no existe». */ -export const CATALOG_PAGE_SIZE = 100 +export const CATALOG_MATCHES = 20 -/** El catálogo de la empresa, para el buscador del paso 1 del alta manual. */ -export async function fetchCatalogProducts(): Promise { +/** + * Los productos que matchean el término, para el buscador del alta manual. + * + * Con el término vacío devuelve la primera página: el buscador recién abierto + * muestra algo en vez de nada, y el operador acota tipeando. + */ +export async function fetchCatalogProducts(search: string): Promise { + const term = search.trim() const { data } = await client.get<{ data: ApiCatalogProduct[]; meta: ApiListMeta }>('/products', { - params: { page: 1, per_page: CATALOG_PAGE_SIZE }, + // Un `search` vacío no viaja: el backend lo trataría como un filtro por + // cadena vacía. Mismo criterio que `toFilters` del listado de órdenes. + params: { page: 1, per_page: CATALOG_MATCHES, ...(term === '' ? {} : { search: term }) }, }) return data.data.map((product) => ({ diff --git a/src/features/orders/components/ProductPicker/ProductPicker.tsx b/src/features/orders/components/ProductPicker/ProductPicker.tsx index f1b818f..ea966c5 100644 --- a/src/features/orders/components/ProductPicker/ProductPicker.tsx +++ b/src/features/orders/components/ProductPicker/ProductPicker.tsx @@ -4,7 +4,7 @@ import { useState } from 'react' import { formatCount, ordersCopy } from '../../content' import type { CatalogProduct } from '../../types' -import { filterCatalog, isDraftItemValid } from '../../utils/draft' +import { isDraftItemValid } from '../../utils/draft' import { OptionBody, PickerForm, PriceSlot, QuantitySlot, SearchSlot } from './ProductPicker.styles' import type { ProductPickerProps } from './ProductPicker.types' @@ -29,7 +29,14 @@ const MONO = { typography: 'dataMono' } * misma que valida las filas ya cargadas, y así vive en un solo lugar en vez * de repetirse en un schema. */ -export function ProductPicker({ products, loading = false, addedIds, onAdd }: ProductPickerProps) { +export function ProductPicker({ + products, + loading = false, + addedIds, + search, + onSearchChange, + onAdd, +}: ProductPickerProps) { const [product, setProduct] = useState(null) const [quantity, setQuantity] = useState(INITIAL_QUANTITY) const [unitPrice, setUnitPrice] = useState(INITIAL_PRICE) @@ -55,6 +62,7 @@ export function ProductPicker({ products, loading = false, addedIds, onAdd }: Pr setProduct(null) setQuantity(INITIAL_QUANTITY) setUnitPrice(INITIAL_PRICE) + onSearchChange('') } return ( @@ -69,9 +77,13 @@ export function ProductPicker({ products, loading = false, addedIds, onAdd }: Pr options={products} value={product} onChange={(_event, value) => setProduct(value)} - // El filtro es el mismo que se prueba en `utils/draft.ts`, no el de - // MUI: así busca en el SKU y en el nombre con la misma regla. - filterOptions={(options, state) => filterCatalog(options, state.inputValue)} + inputValue={search} + onInputChange={(_event, value) => onSearchChange(value)} + // Las opciones se muestran tal como llegaron: ya vienen filtradas por + // el backend. Sin esto MUI las vuelve a filtrar por su etiqueta, y + // esconde coincidencias que el servidor sí encontró —por ejemplo, un + // producto que matchea por su descripción y no por el texto visible. + filterOptions={(options) => options} getOptionLabel={(option) => `${option.sku} · ${option.name}`} getOptionKey={(option) => option.id} isOptionEqualToValue={(option, value) => option.id === value.id} diff --git a/src/features/orders/components/ProductPicker/ProductPicker.types.ts b/src/features/orders/components/ProductPicker/ProductPicker.types.ts index 1f8380c..57e00a2 100644 --- a/src/features/orders/components/ProductPicker/ProductPicker.types.ts +++ b/src/features/orders/components/ProductPicker/ProductPicker.types.ts @@ -3,12 +3,18 @@ import type { OrderDraftItem } from 'shared/store' import type { CatalogProduct } from '../../types' export interface ProductPickerProps { - /** El catálogo completo; el filtro por SKU o nombre corre acá adentro. */ + /** Las coincidencias que devolvió el backend para `search`. */ products: CatalogProduct[] - /** Mientras el catálogo viaja el buscador avisa en vez de decir "sin resultados". */ + /** Mientras la búsqueda viaja el buscador avisa en vez de decir "sin resultados". */ loading?: boolean /** Productos que ya tienen fila: se ven pero no se pueden volver a elegir. */ addedIds: ReadonlySet + /** + * Lo tipeado en el buscador. Controlado desde afuera porque de eso sale la + * consulta: el componente no decide cuándo se busca ni con qué demora. + */ + search: string + onSearchChange: (search: string) => void /** La línea armada con producto, cantidad y precio unitario. */ onAdd: (item: OrderDraftItem) => void } diff --git a/src/features/orders/hooks/useCatalogProducts.ts b/src/features/orders/hooks/useCatalogProducts.ts index 3420f29..e6bd8cc 100644 --- a/src/features/orders/hooks/useCatalogProducts.ts +++ b/src/features/orders/hooks/useCatalogProducts.ts @@ -1,17 +1,25 @@ -import { useQuery } from '@tanstack/react-query' +import { keepPreviousData, useQuery } from '@tanstack/react-query' import { fetchCatalogProducts } from '../api' import { catalogKeys } from '../queryKeys' import type { CatalogProduct } from '../types' /** - * El catálogo completo para el buscador del alta manual. Se pide una vez y el - * filtro por SKU o nombre corre en memoria (ver `filterCatalog`): la API no - * busca, así que un request por tecla no tendría nada que devolver distinto. + * Los productos que matchean lo que se tipeó en el buscador del alta manual. + * + * Filtra el backend (`search` de `GET /products`, TESIS-62) y no el cliente: + * traerse una página y filtrarla en memoria dejaba fuera del buscador a todo + * producto más allá del corte de la API, y el operador no tenía cómo + * distinguir «no existe» de «está más allá de la primera página». + * + * `keepPreviousData` es lo que evita que la lista parpadee vacía entre + * pulsaciones: mientras llega la búsqueda nueva se siguen mostrando las + * coincidencias de la anterior. */ -export function useCatalogProducts() { +export function useCatalogProducts(search: string) { return useQuery({ - queryKey: catalogKeys.products(), - queryFn: fetchCatalogProducts, + queryKey: catalogKeys.products(search), + queryFn: () => fetchCatalogProducts(search), + placeholderData: keepPreviousData, }) } diff --git a/src/features/orders/pages/NewOrderPage.test.tsx b/src/features/orders/pages/NewOrderPage.test.tsx index 38e61ab..52367bb 100644 --- a/src/features/orders/pages/NewOrderPage.test.tsx +++ b/src/features/orders/pages/NewOrderPage.test.tsx @@ -30,15 +30,28 @@ const CATALOG: CatalogProduct[] = [ }, ] -function mockCatalog(state: { data?: CatalogProduct[]; isPending?: boolean; isError?: boolean }) { +function mockCatalog(state: { + data?: CatalogProduct[] + isPending?: boolean + isFetching?: boolean + isError?: boolean +}) { vi.mocked(useCatalogProducts).mockReturnValue({ data: state.data, isPending: state.isPending ?? false, + isFetching: state.isFetching ?? false, isError: state.isError ?? false, refetch: vi.fn(), } as never) } +/** Con qué término se pidió el catálogo la última vez. */ +function lastSearch(): string { + const calls = vi.mocked(useCatalogProducts).mock.calls + + return calls[calls.length - 1][0] +} + function renderPage() { return renderWithTheme( @@ -125,7 +138,35 @@ describe('NewOrderPage', () => { await waitFor(() => expect(nextButton()).toBeEnabled()) }) - it('finds a product by its name as well as by its sku', async () => { + // El filtro es del backend (TESIS-125): lo que la pantalla tiene que hacer es + // mandarle lo tipeado. Buscar en memoria dejaba fuera del buscador todo + // producto más allá del corte de la API, sin que se distinguiera de «no + // existe». + it('asks the backend for what was typed', async () => { + renderPage() + + type(search(), 'sensor') + + await waitFor(() => expect(lastSearch()).toBe('sensor')) + }) + + it('does not go to the backend on every keystroke', async () => { + renderPage() + + type(search(), 's') + type(search(), 'se') + type(search(), 'sen') + + // Las tres pulsaciones colapsan en una sola búsqueda. + await waitFor(() => expect(lastSearch()).toBe('sen')) + expect( + vi.mocked(useCatalogProducts).mock.calls.filter((call) => call[0] === 'se'), + ).toHaveLength(0) + }) + + // Lo que llega ya viene filtrado: la pantalla no vuelve a decidir qué mostrar. + it('lists what the backend returned without filtering it again', async () => { + mockCatalog({ data: [CATALOG[1]] }) renderPage() type(search(), 'sensor') diff --git a/src/features/orders/pages/NewOrderPage.tsx b/src/features/orders/pages/NewOrderPage.tsx index 94e104a..4a5d778 100644 --- a/src/features/orders/pages/NewOrderPage.tsx +++ b/src/features/orders/pages/NewOrderPage.tsx @@ -2,9 +2,11 @@ import { zodResolver } from '@hookform/resolvers/zod' import ArrowForwardIcon from '@mui/icons-material/ArrowForward' import CloseIcon from '@mui/icons-material/Close' import { Alert, Button, Stack } from '@mui/material' +import { useState } from 'react' import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router-dom' import { PageWrapper } from 'shared/components' +import { useDebouncedValue } from 'shared/hooks/useDebouncedValue' import { useOrderDraftStore } from 'shared/store' import { CustomerFieldsCard, customerSchema } from '../components/CustomerFieldsCard' @@ -54,7 +56,11 @@ export function NewOrderPage() { const removeItem = useOrderDraftStore((state) => state.removeItem) const clearDraft = useOrderDraftStore((state) => state.clearDraft) - const catalog = useCatalogProducts() + // Lo tipeado actualiza el campo en el acto; lo que viaja a la API espera a + // que la persona deje de escribir. Sin esto el buscador dispara un request + // por pulsación y descarta casi todos. + const [search, setSearch] = useState('') + const catalog = useCatalogProducts(useDebouncedValue(search)) const { register, @@ -109,8 +115,10 @@ export function NewOrderPage() { toolbar={ } diff --git a/src/features/orders/queryKeys.ts b/src/features/orders/queryKeys.ts index e6767e2..bc48ea8 100644 --- a/src/features/orders/queryKeys.ts +++ b/src/features/orders/queryKeys.ts @@ -31,5 +31,7 @@ export const orderKeys = { * otra. */ export const catalogKeys = { - products: () => ['inventory', 'products', 'catalog'] as const, + // El término entra en la clave: cada búsqueda es una consulta distinta, y así + // volver a un término ya tipeado sale de la caché en vez de la red. + products: (search: string) => ['inventory', 'products', 'catalog', search] as const, } diff --git a/src/features/orders/utils/draft.test.ts b/src/features/orders/utils/draft.test.ts index 5bf3f3d..9ba5585 100644 --- a/src/features/orders/utils/draft.test.ts +++ b/src/features/orders/utils/draft.test.ts @@ -1,36 +1,7 @@ import type { OrderDraftItem } from 'shared/store' import { describe, expect, it } from 'vitest' -import type { CatalogProduct } from '../types' - -import { canProceed, draftSubtotal, draftWeight, filterCatalog, isDraftItemValid } from './draft' - -const CATALOG: CatalogProduct[] = [ - { - id: 1, - sku: 'PX-9021-LRG', - name: 'Router industrial', - category: 'Electronics', - weight: 1.2, - totalStock: 40, - }, - { - id: 2, - sku: 'PX-1185-MED', - name: 'Sensor de presión X4', - category: 'Machinery', - weight: 0.4, - totalStock: 0, - }, - { - id: 3, - sku: 'CB-4420-XT', - name: 'Precinto de carga', - category: null, - weight: 2.5, - totalStock: 12, - }, -] +import { canProceed, draftSubtotal, draftWeight, isDraftItemValid } from './draft' function item(overrides: Partial = {}): OrderDraftItem { return { @@ -45,20 +16,6 @@ function item(overrides: Partial = {}): OrderDraftItem { } } -describe('filterCatalog', () => { - it('matches the sku ignoring case', () => { - expect(filterCatalog(CATALOG, 'px-11').map((p) => p.id)).toEqual([2]) - }) - - it('matches the name ignoring case', () => { - expect(filterCatalog(CATALOG, 'PRECINTO').map((p) => p.id)).toEqual([3]) - }) - - it('returns the whole catalog for a blank term', () => { - expect(filterCatalog(CATALOG, ' ')).toHaveLength(3) - }) -}) - describe('isDraftItemValid', () => { it('accepts a whole quantity with a positive price', () => { expect(isDraftItemValid({ quantity: 1, unitPrice: 0.01 })).toBe(true) diff --git a/src/features/orders/utils/draft.ts b/src/features/orders/utils/draft.ts index e599a11..13181f2 100644 --- a/src/features/orders/utils/draft.ts +++ b/src/features/orders/utils/draft.ts @@ -1,27 +1,10 @@ import type { OrderDraftItem } from 'shared/store' -import type { CatalogProduct } from '../types' - import { lineSubtotal } from './payment' // Las reglas del paso 1 del alta manual, fuera de los componentes para que se // puedan probar sin montar la pantalla. -/** - * Los productos cuyo SKU o nombre contienen el término, sin distinguir - * mayúsculas. Con el término vacío devuelve el catálogo entero: el buscador - * recién abierto muestra todo, no nada. - */ -export function filterCatalog(products: CatalogProduct[], term: string): CatalogProduct[] { - const needle = term.trim().toLowerCase() - if (needle === '') return products - - return products.filter( - (product) => - product.sku.toLowerCase().includes(needle) || product.name.toLowerCase().includes(needle), - ) -} - /** * Una línea válida para enviar: al menos una unidad entera y un precio mayor a * cero. Es lo que `Orders::CreateOrder` exige por ítem (`quantity` y From c9b93215fb141324e393b7e4e77cebace9e37b72 Mon Sep 17 00:00:00 2001 From: Tomas Martin Date: Thu, 24 Sep 2026 15:24:59 -0300 Subject: [PATCH 2/4] fix: [TESIS-125] search only for what the operator types Picking an option made MUI write its label into the field, and the controlled input sent that label to the backend as the search term. `search_catalog` matches on sku and name, so it found nothing and the list came back empty for the next time the picker was opened. MUI owns the field now; only `reason: 'input'` and an empty value reach the search, which also covers the clear button and the reset that follows adding a line. Three more things the review found: - The test for "do not filter twice" typed a term that was in the label, so it passed with MUI's own filter too. It now types one that is not, and fails if `filterOptions` is removed. - The comment justified skipping MUI's filter with a match "by description" that does not exist. The real effect is that with keepPreviousData the previous matches stay visible while the new search travels. - The query key used the untrimmed term, so "cab" and "cab " were two cache entries and two identical requests. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp --- .../ProductPicker/ProductPicker.tsx | 27 ++++-- .../ProductPicker/ProductPicker.types.ts | 7 +- src/features/orders/content.ts | 2 +- .../orders/hooks/useCatalogProducts.test.tsx | 88 +++++++++++++++++++ .../orders/hooks/useCatalogProducts.ts | 9 +- .../orders/pages/NewOrderPage.test.tsx | 59 ++++++++++++- src/features/orders/pages/NewOrderPage.tsx | 1 - 7 files changed, 177 insertions(+), 16 deletions(-) create mode 100644 src/features/orders/hooks/useCatalogProducts.test.tsx diff --git a/src/features/orders/components/ProductPicker/ProductPicker.tsx b/src/features/orders/components/ProductPicker/ProductPicker.tsx index ea966c5..f1d18e5 100644 --- a/src/features/orders/components/ProductPicker/ProductPicker.tsx +++ b/src/features/orders/components/ProductPicker/ProductPicker.tsx @@ -33,7 +33,6 @@ export function ProductPicker({ products, loading = false, addedIds, - search, onSearchChange, onAdd, }: ProductPickerProps) { @@ -59,10 +58,11 @@ export function ProductPicker({ }) // Lista para el siguiente SKU: el foco queda en el buscador porque el // usuario va a tipear otro. + // El buscador se vacía solo: al volver `product` a null, MUI limpia el + // campo y avisa con un valor vacío, que `onInputChange` sí propaga. setProduct(null) setQuantity(INITIAL_QUANTITY) setUnitPrice(INITIAL_PRICE) - onSearchChange('') } return ( @@ -77,12 +77,25 @@ export function ProductPicker({ options={products} value={product} onChange={(_event, value) => setProduct(value)} - inputValue={search} - onInputChange={(_event, value) => onSearchChange(value)} + // Sólo lo que la persona tipea es una búsqueda. Al elegir una opción + // MUI escribe su etiqueta en el campo y avisa con `reason: 'reset'`: + // si eso viajara al backend —y viajaba— se pediría el catálogo por + // «PX-9021-LRG · Router industrial…», que no matchea ni por SKU ni + // por nombre, y la lista quedaría vacía la próxima vez que se abra el + // buscador. + // + // `value === ''` sí se propaga: es la cruz de borrar y el reinicio + // que hace MUI cuando la línea se agrega y el producto vuelve a null. + onInputChange={(_event, value, reason) => { + if (reason === 'input' || value === '') onSearchChange(value) + }} // Las opciones se muestran tal como llegaron: ya vienen filtradas por - // el backend. Sin esto MUI las vuelve a filtrar por su etiqueta, y - // esconde coincidencias que el servidor sí encontró —por ejemplo, un - // producto que matchea por su descripción y no por el texto visible. + // el backend, que busca por SKU y por nombre. Filtrar de nuevo acá no + // agregaría nada y sí quitaría: con `keepPreviousData` la lista sigue + // mostrando las coincidencias de la búsqueda anterior mientras llega + // la nueva, y el filtro de MUI —que compara contra lo ya tipeado— las + // escondería tras un «Ningún producto coincide.» que dura lo que + // tarda el request. filterOptions={(options) => options} getOptionLabel={(option) => `${option.sku} · ${option.name}`} getOptionKey={(option) => option.id} diff --git a/src/features/orders/components/ProductPicker/ProductPicker.types.ts b/src/features/orders/components/ProductPicker/ProductPicker.types.ts index 57e00a2..5d45e6c 100644 --- a/src/features/orders/components/ProductPicker/ProductPicker.types.ts +++ b/src/features/orders/components/ProductPicker/ProductPicker.types.ts @@ -10,10 +10,11 @@ export interface ProductPickerProps { /** Productos que ya tienen fila: se ven pero no se pueden volver a elegir. */ addedIds: ReadonlySet /** - * Lo tipeado en el buscador. Controlado desde afuera porque de eso sale la - * consulta: el componente no decide cuándo se busca ni con qué demora. + * Lo que se tipeó, hacia afuera: de eso sale la consulta, y el componente no + * decide cuándo se busca ni con qué demora. El texto del campo lo maneja MUI + * —controlarlo desde afuera hacía que la etiqueta de la opción elegida se + * mandara como término—. */ - search: string onSearchChange: (search: string) => void /** La línea armada con producto, cantidad y precio unitario. */ onAdd: (item: OrderDraftItem) => void diff --git a/src/features/orders/content.ts b/src/features/orders/content.ts index 384294d..6b34436 100644 --- a/src/features/orders/content.ts +++ b/src/features/orders/content.ts @@ -195,7 +195,7 @@ export const ordersCopy = { searchLabel: 'Buscar por SKU o nombre', searchPlaceholder: 'SKU o nombre del producto', noMatches: 'Ningún producto coincide.', - loadingCatalog: 'Cargando el catálogo…', + loadingCatalog: 'Buscando…', catalogError: 'No pudimos cargar el catálogo de productos.', retry: 'Reintentar', /** El producto ya tiene su fila: se edita ahí, no se agrega dos veces. */ diff --git a/src/features/orders/hooks/useCatalogProducts.test.tsx b/src/features/orders/hooks/useCatalogProducts.test.tsx new file mode 100644 index 0000000..099cf7c --- /dev/null +++ b/src/features/orders/hooks/useCatalogProducts.test.tsx @@ -0,0 +1,88 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import * as api from '../api' +import { catalogKeys } from '../queryKeys' + +import { useCatalogProducts } from './useCatalogProducts' + +// Un cliente por ejemplo: la caché no puede filtrarse de un test al siguiente. +function makeWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +function newClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +describe('useCatalogProducts', () => { + it('asks the backend for the term it was given', async () => { + const fetchCatalog = vi.spyOn(api, 'fetchCatalogProducts').mockResolvedValue([]) + + const { result } = renderHook(() => useCatalogProducts('cable'), { + wrapper: makeWrapper(newClient()), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(fetchCatalog).toHaveBeenCalledWith('cable') + }) + + // «cab» y «cab » son la misma búsqueda. Si el término entrara sin recortar en + // la clave serían dos entradas de caché y dos requests con la misma respuesta. + it('treats a term with surrounding spaces as the same search', async () => { + const fetchCatalog = vi.spyOn(api, 'fetchCatalogProducts').mockResolvedValue([]) + const queryClient = newClient() + + const { result } = renderHook(() => useCatalogProducts(' cable '), { + wrapper: makeWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(fetchCatalog).toHaveBeenCalledWith('cable') + }) + + it('files it in the cache under the trimmed term', async () => { + vi.spyOn(api, 'fetchCatalogProducts').mockResolvedValue([]) + const queryClient = newClient() + + const { result } = renderHook(() => useCatalogProducts('cable '), { + wrapper: makeWrapper(queryClient), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(queryClient.getQueryData(catalogKeys.products('cable'))).toEqual([]) + }) + + // Mientras llega la búsqueda nueva se siguen mostrando las coincidencias de + // la anterior: es lo que evita que la lista parpadee vacía entre pulsaciones. + it('keeps the previous matches while the new search travels', async () => { + const product = { + id: 1, + sku: 'CAB-1', + name: 'Cable', + category: 'Electronics', + weight: 0.2, + totalStock: 5, + } + vi.spyOn(api, 'fetchCatalogProducts').mockResolvedValue([product]) + const queryClient = newClient() + + const { result, rerender } = renderHook((term: string) => useCatalogProducts(term), { + wrapper: makeWrapper(queryClient), + initialProps: 'cab', + }) + await waitFor(() => expect(result.current.data).toEqual([product])) + + rerender('cable') + + expect(result.current.data).toEqual([product]) + }) +}) diff --git a/src/features/orders/hooks/useCatalogProducts.ts b/src/features/orders/hooks/useCatalogProducts.ts index e6bd8cc..c502c6f 100644 --- a/src/features/orders/hooks/useCatalogProducts.ts +++ b/src/features/orders/hooks/useCatalogProducts.ts @@ -17,9 +17,14 @@ import type { CatalogProduct } from '../types' * coincidencias de la anterior. */ export function useCatalogProducts(search: string) { + // Recortado acá y no sólo al armar el request: la clave de caché se arma con + // el mismo término que viaja, así que «cab» y «cab » son una sola entrada y + // un solo pedido, en vez de dos que devuelven lo mismo. + const term = search.trim() + return useQuery({ - queryKey: catalogKeys.products(search), - queryFn: () => fetchCatalogProducts(search), + queryKey: catalogKeys.products(term), + queryFn: () => fetchCatalogProducts(term), placeholderData: keepPreviousData, }) } diff --git a/src/features/orders/pages/NewOrderPage.test.tsx b/src/features/orders/pages/NewOrderPage.test.tsx index 52367bb..d098707 100644 --- a/src/features/orders/pages/NewOrderPage.test.tsx +++ b/src/features/orders/pages/NewOrderPage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router-dom' import { useOrderDraftStore } from 'shared/store' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -52,6 +52,20 @@ function lastSearch(): string { return calls[calls.length - 1][0] } +/** Todos los términos con los que se pidió el catálogo. */ +function searchedTerms(): string[] { + return vi.mocked(useCatalogProducts).mock.calls.map((call) => call[0]) +} + +// Lo tipeado viaja con un debounce de 300 ms (`useDebouncedValue`). Cuando lo +// que se quiere afirmar es que algo NO se buscó, hay que dejarlo vencer: si no, +// el ejemplo pasa por llegar antes y no por el comportamiento. +const SETTLE_MS = 400 + +function settle() { + return act(() => new Promise((resolve) => setTimeout(resolve, SETTLE_MS))) +} + function renderPage() { return renderWithTheme( @@ -165,16 +179,57 @@ describe('NewOrderPage', () => { }) // Lo que llega ya viene filtrado: la pantalla no vuelve a decidir qué mostrar. + // + // El término no aparece en ninguna etiqueta a propósito: con uno que sí + // apareciera —«sensor»— la opción se vería igual con el filtro que MUI trae + // de fábrica, y el ejemplo pasaría aunque se quitara `filterOptions`. it('lists what the backend returned without filtering it again', async () => { mockCatalog({ data: [CATALOG[1]] }) renderPage() - type(search(), 'sensor') + type(search(), 'zzz') expect(await screen.findByRole('option', { name: /PX-1185-MED/ })).toBeInTheDocument() expect(screen.queryByRole('option', { name: /PX-9021-LRG/ })).not.toBeInTheDocument() }) + // Al elegir una opción, MUI escribe su etiqueta en el campo. Eso no es una + // búsqueda: mandarla al backend pedía el catálogo por «PX-9021-LRG · Router + // industrial…», que no matchea ni por SKU ni por nombre, y dejaba la lista + // vacía para la próxima vez que se abriera el buscador. + // + // El `settle()` no es decorativo: lo que viaja pasa por un debounce, así que + // sin esperar a que venza el ejemplo pasa aunque la etiqueta sí se mande. + it('does not search for the label of the option that was picked', async () => { + renderPage() + + type(search(), 'PX-9021') + await waitFor(() => expect(lastSearch()).toBe('PX-9021')) + fireEvent.click(await screen.findByRole('option', { name: /PX-9021/i })) + await settle() + + expect(searchedTerms().some((term) => term.includes('Router industrial'))).toBe(false) + }) + + it('keeps searching for what was typed after picking an option', async () => { + renderPage() + + type(search(), 'PX-9021') + await waitFor(() => expect(lastSearch()).toBe('PX-9021')) + fireEvent.click(await screen.findByRole('option', { name: /PX-9021/i })) + await settle() + + expect(lastSearch()).toBe('PX-9021') + }) + + it('empties the search once the line is added', async () => { + renderPage() + + await addProduct('PX-9021', '2', '1000') + + await waitFor(() => expect(lastSearch()).toBe('')) + }) + it('will not add a line until quantity and price are valid', async () => { renderPage() diff --git a/src/features/orders/pages/NewOrderPage.tsx b/src/features/orders/pages/NewOrderPage.tsx index 4a5d778..409cac3 100644 --- a/src/features/orders/pages/NewOrderPage.tsx +++ b/src/features/orders/pages/NewOrderPage.tsx @@ -117,7 +117,6 @@ export function NewOrderPage() { products={catalog.data ?? []} loading={catalog.isPending || catalog.isFetching} addedIds={addedIds} - search={search} onSearchChange={setSearch} onAdd={addItem} /> From 9d2167d7bbaccd4953c3218543453ad7000d600f Mon Sep 17 00:00:00 2001 From: Tomas Martin Date: Thu, 24 Sep 2026 15:52:13 -0300 Subject: [PATCH 3/4] feat: [TESIS-125] search the backend from the order edit screen too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge of TESIS-61 brought a second user of ProductPicker: the "add line" toolbar of the edit screen. It called the catalog with no term, which the type checker caught — the tests did not, because vitest does not type check. It now holds its own debounced term and hands it to the same hook, the way step 1 of the wizard does, with an example that types in that search and asserts what travels. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp --- docs/guidelines/architecture.md | 2 +- .../orders/components/NewLineToolbar.tsx | 4 ++++ .../OrderEditForm/OrderEditForm.tsx | 8 +++++++- .../orders/pages/OrderEditPage.test.tsx | 20 +++++++++++++++++++ 4 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 7be624d..f43602b 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -311,7 +311,7 @@ features/[nombre]/ | `design-system` | Catálogo de tokens y componentes del DS (`/design-system`). No tiene datos ni hooks | | `home` | Landing de la app (`/`) | | `inventory` | Catálogo de productos y stock por depósito (`/inventory`). Hoy el alta y la edición de producto (`CreateProductModal`, `EditProductModal`) sobre la API real; la vista real del catálogo la construye TESIS-62, que reemplaza el cuerpo de `InventoryPage` | -| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los pasos 1 y 2 del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; el paso 3 es TESIS-59) | +| `orders` | Listado global de órdenes (`/orders`), el detalle de una orden con el ciclo de vida de su envío (`/orders/:orderId`), su modificación (`/orders/edit/:orderId`) y los pasos 1 y 2 del alta manual (`/orders/new`: cliente y productos, con el buscador filtrando contra el `search` del backend; `/orders/new/shipping`: origen y destino; el paso 3 es TESIS-59) | | `reports` | Reportes — analítica de la operación (`/reports`, S14): métricas del período, curva de despacho, nivel de servicio por operador y anomalías recientes. **Sin endpoint de agregados todavía**: la pantalla muestra el dataset de muestra del diseño y lo dice junto al título (ver `api.ts`) | **`orders` — piezas y por qué:** diff --git a/src/features/orders/components/NewLineToolbar.tsx b/src/features/orders/components/NewLineToolbar.tsx index bb41306..067f5e4 100644 --- a/src/features/orders/components/NewLineToolbar.tsx +++ b/src/features/orders/components/NewLineToolbar.tsx @@ -17,6 +17,8 @@ export interface NewLineToolbarProps { warehouseId: number | null onWarehouseChange: (warehouseId: number) => void onAdd: (item: OrderDraftItem) => void + /** Lo que se tipeó en el buscador: de eso sale la consulta al catálogo. */ + onSearchChange: (search: string) => void disabled: boolean } @@ -37,6 +39,7 @@ export function NewLineToolbar({ warehouseId, onWarehouseChange, onAdd, + onSearchChange, disabled, }: NewLineToolbarProps) { if (disabled) return null @@ -74,6 +77,7 @@ export function NewLineToolbar({ products={products} loading={productsLoading} addedIds={addedIds} + onSearchChange={onSearchChange} onAdd={onAdd} /> )} diff --git a/src/features/orders/components/OrderEditForm/OrderEditForm.tsx b/src/features/orders/components/OrderEditForm/OrderEditForm.tsx index 0bda9bc..f9fd61f 100644 --- a/src/features/orders/components/OrderEditForm/OrderEditForm.tsx +++ b/src/features/orders/components/OrderEditForm/OrderEditForm.tsx @@ -4,6 +4,7 @@ import { Alert, Box, Button, Stack } from '@mui/material' import { useMemo, useState } from 'react' import { useForm, useWatch } from 'react-hook-form' import { useNavigate } from 'react-router-dom' +import { useDebouncedValue } from 'shared/hooks/useDebouncedValue' import { notify } from 'shared/store' import type { OrderDraftItem } from 'shared/store' @@ -92,7 +93,11 @@ export function OrderEditForm({ const [newLineWarehouse, setNewLineWarehouse] = useState(() => mostUsedWarehouse(original)) const warehouses = useOriginWarehouses() - const catalog = useCatalogProducts() + // El catálogo lo filtra el backend (TESIS-125), igual que en el paso 1 del + // alta: lo tipeado actualiza el campo en el acto y viaja recién cuando la + // persona deja de escribir. + const [productSearch, setProductSearch] = useState('') + const catalog = useCatalogProducts(useDebouncedValue(productSearch)) const provinces = useProvinces() const stocks = useProductStocks([ ...new Set([...original, ...lines].map((line) => line.productId)), @@ -234,6 +239,7 @@ export function OrderEditForm({ warehouseId={newLineWarehouse} onWarehouseChange={setNewLineWarehouse} onAdd={addLine} + onSearchChange={setProductSearch} disabled={readOnly} /> } diff --git a/src/features/orders/pages/OrderEditPage.test.tsx b/src/features/orders/pages/OrderEditPage.test.tsx index c22a80a..b9511dd 100644 --- a/src/features/orders/pages/OrderEditPage.test.tsx +++ b/src/features/orders/pages/OrderEditPage.test.tsx @@ -345,6 +345,26 @@ describe('OrderEditPage', { timeout: 15_000 }, () => { expect(vi.mocked(useUpdateOrder)).toHaveBeenLastCalledWith(8829, '"v1"') }) + // La pantalla de edición comparte el buscador con el paso 1 del alta, así que + // también busca contra el backend (TESIS-125). El depósito se elige primero: + // sin él no se muestra el buscador. + it('asks the backend for what was typed in the line search', async () => { + renderPage() + + fireEvent.mouseDown(screen.getByRole('combobox', { name: 'Depósito de la línea nueva' })) + fireEvent.click(await screen.findByRole('option', { name: /CD Ezeiza/ })) + + const productSearch = screen.getByRole('combobox', { name: 'Buscar por SKU o nombre' }) + fireEvent.focus(productSearch) + fireEvent.change(productSearch, { target: { value: 'sensor' } }) + + await waitFor(() => { + const calls = vi.mocked(useCatalogProducts).mock.calls + + expect(calls[calls.length - 1][0]).toBe('sensor') + }) + }) + it('goes back to the detail without saving on Discard', async () => { renderPage() From f6bee4b3cf4857600b4c7f586dcfffc7a33f4c9b Mon Sep 17 00:00:00 2001 From: Tomas Martin Date: Thu, 24 Sep 2026 21:28:24 -0300 Subject: [PATCH 4/4] fix: [TESIS-125] show the same loading state on both searches The edit screen passed only isPending, so a search that came back empty kept saying "no product matches" while the next one was still in flight. Step 1 already used isPending || isFetching. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012xAtddb53LRNVLuyTXyELp --- .../orders/components/OrderEditForm/OrderEditForm.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/orders/components/OrderEditForm/OrderEditForm.tsx b/src/features/orders/components/OrderEditForm/OrderEditForm.tsx index f9fd61f..379c1d8 100644 --- a/src/features/orders/components/OrderEditForm/OrderEditForm.tsx +++ b/src/features/orders/components/OrderEditForm/OrderEditForm.tsx @@ -233,7 +233,11 @@ export function OrderEditForm({ toolbar={ line.productId))} warehouses={warehouses.data ?? []} warehouseId={newLineWarehouse}