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
1 change: 1 addition & 0 deletions docs/guidelines/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ features/[nombre]/
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `components/EditProductModal/` | Modal de edición: datos básicos, medidas y asignación de stock. **Presentacional** — recibe `product` y `warehouses` ya resueltos y devuelve en `onSubmit` el cuerpo de `PUT /api/v1/products/:id`. Quien lo monta decide de dónde salen los datos |
| `components/CreateProductModal/` | Modal de alta, mismo contrato: devuelve el cuerpo de `POST /api/v1/products`. Valida depósitos repetidos en el schema y resalta el campo `sku` cuando la API rechaza el alta por SKU duplicado |
| `components/CategoryField/` | El select de categoría de los dos modales. Controlado (vía `Controller`) porque el de edición se rellena con `reset` al abrir. Las opciones salen de `GET /products/categories` (`useCategories`); `''` es «sin categoría» y viaja como `null` |
| `api.ts` | Frontera con Rails y único lugar que conoce el `snake_case`. También documenta que el index envuelve en `{ data, meta }` y usa `ProductListSerializer` (sin `stocks`), mientras `show`/`update` devuelven el objeto pelado |
| `utils/dimensions.ts` | `products.dimensions` es un único `string` en la API y el diseño lo edita en tres ejes. Define el formato canónico (`"45x30x30"`, cm) y es el único lugar que lo conoce |
| `utils/payload.ts` | Traduce el formulario al cuerpo de la API. Funciones puras, aparte del componente, porque concentran las reglas no obvias (ver abajo) y así se pueden verificar solas |
Expand Down
11 changes: 11 additions & 0 deletions src/features/inventory/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,17 @@ export async function deleteProduct(id: number): Promise<void> {
await client.delete(`/products/${id}`)
}

/**
* El vocabulario de categorías (`Product::CATEGORIES`). Sale del backend y no
* de una lista en el front: sumar una categoría es una línea en el modelo, y
* los selects de los modales la muestran sin tocar esta app.
*/
export async function fetchCategories(): Promise<string[]> {
const { data } = await client.get<{ data: string[] }>('/products/categories')

return data.data
}

interface ApiProductMapping {
id: number
company_integration_id: number
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { MenuItem, TextField } from '@mui/material'
import { LabeledField } from 'shared/components'

import type { CategoryFieldProps } from './CategoryField.types'

/**
* El select de categoría de los dos modales de producto (Regla de Dos).
*
* Controlado y no registrado: el modal de edición se rellena con `reset` al
* abrir, y un select no controlado seguiría mostrando lo que tenía antes.
*
* Las opciones salen de `GET /products/categories`. Si la API todavía no
* respondió, o el producto trae una categoría que ya no está en el
* vocabulario, ese valor se ofrece igual: sin eso el select lo mostraría vacío
* y guardar lo borraría sin que nadie lo haya pedido.
*/
export function CategoryField({
value,
onChange,
categories,
label,
helperText,
noneLabel,
}: CategoryFieldProps) {
const options = value !== '' && !categories.includes(value) ? [value, ...categories] : categories

return (
<LabeledField label={label} helperText={helperText}>
<TextField
select
value={value}
onChange={(event) => onChange(event.target.value)}
fullWidth
// El `<label>` de LabeledField no nombra al combobox: el Select de MUI
// lo dibuja en un `div`, que no es un control etiquetable.
slotProps={{ select: { SelectDisplayProps: { 'aria-label': label }, displayEmpty: true } }}
>
<MenuItem value="">{noneLabel}</MenuItem>
{options.map((category) => (
<MenuItem key={category} value={category}>
{category}
</MenuItem>
))}
</TextField>
</LabeledField>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface CategoryFieldProps {
/** Valor actual (`''` = sin categoría). Controlado por el formulario. */
value: string
onChange: (value: string) => void
/** El vocabulario de la API. */
categories: string[]
label: string
helperText?: string
/** Rótulo de la opción vacía. */
noneLabel: string
}
2 changes: 2 additions & 0 deletions src/features/inventory/components/CategoryField/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { CategoryField } from './CategoryField'
export type { CategoryFieldProps } from './CategoryField.types'
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ const stockRowSchema = z.object({
/**
* Fuente única de verdad del alta: de acá salen la validación y el tipo.
*
* La categoría queda fuera a propósito — `products` no tiene esa columna, así
* que el campo se pinta pero no se edita ni se envía.
* La categoría es un string y no un enum: el vocabulario llega de la API
* (`GET /products/categories`) y el select sólo ofrece esos valores. `''` es
* «sin categoría», que es válido: la columna es opcional.
*
* `description` tampoco está: el frame del alta no tiene ese campo, así que no
* hay nada que validar. Viaja igual en el payload, en `null` explícito y no por
Expand All @@ -32,6 +33,7 @@ const stockRowSchema = z.object({
export const createProductSchema = z.object({
name: z.string().trim().min(1, validation.nameRequired),
sku: z.string().trim().min(1, validation.skuRequired),
category: z.string(),
weight: nonNegativeNumber,
length: nonNegativeNumber,
width: nonNegativeNumber,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined'
import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined'
import { Alert, Button, Divider, MenuItem, TextField, Typography } from '@mui/material'
import { useEffect } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import { Controller, useFieldArray, useForm } from 'react-hook-form'
import {
LabeledField,
ModalBody,
Expand All @@ -17,6 +17,7 @@ import {

import { inventoryCopy } from '../../content'
import { buildCreatePayload } from '../../utils/payload'
import { CategoryField } from '../CategoryField'

import { createProductSchema } from './CreateProductModal.schema'
import type { CreateProductFormData } from './CreateProductModal.schema'
Expand Down Expand Up @@ -51,6 +52,7 @@ function isSkuConflict(message: string) {
const EMPTY_FORM: CreateProductFormData = {
name: '',
sku: '',
category: '',
weight: 0,
length: 0,
width: 0,
Expand All @@ -68,6 +70,7 @@ const EMPTY_FORM: CreateProductFormData = {
export function CreateProductModal({
open,
warehouses,
categories = [],
onSubmit,
onClose,
submitting = false,
Expand Down Expand Up @@ -154,9 +157,20 @@ export function CreateProductModal({
/>
</LabeledField>

<LabeledField label={copy.fields.category} helperText={copy.categoryHelper}>
<TextField value="—" fullWidth disabled />
</LabeledField>
<Controller
name="category"
control={control}
render={({ field }) => (
<CategoryField
value={field.value}
onChange={field.onChange}
categories={categories}
label={copy.fields.category}
helperText={copy.categoryHelper}
noneLabel={copy.noCategory}
/>
)}
/>
</BasicGrid>
</SectionRoot>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ export interface CreateProductModalProps {
open: boolean
/** Depósitos de la empresa — alimentan los selectores de cada fila. */
warehouses: Warehouse[]
/** Vocabulario de categorías (`GET /products/categories`). */
categories?: string[]
/** Recibe el cuerpo ya armado para `POST /api/v1/products`. */
onSubmit: (payload: CreateProductPayload) => void
onClose: () => void
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ const stockFieldSchema = z.object({
* Fuente única de verdad del formulario: de acá sale la validación y también el
* tipo (`EditProductFormData`). Nunca declarar la interfaz por separado (ADR-006).
*
* SKU y categoría quedan fuera del schema a propósito: el SKU es de solo lectura
* por diseño y la categoría todavía no existe en el backend. Son campos que se
* pintan pero no se editan ni se envían, así que no tienen nada que validar.
* El SKU queda fuera del schema a propósito: es de solo lectura por diseño, se
* pinta pero no se edita ni se envía. La categoría sí se edita: `''` es «sin
* categoría» (ver `CreateProductModal.schema.ts`).
*/
export const editProductSchema = z.object({
name: z.string().trim().min(1, validation.nameRequired),
category: z.string(),
weight: nonNegativeNumber,
length: nonNegativeNumber,
width: nonNegativeNumber,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function renderModal(initial: Product) {
const props = {
open: true,
warehouses: [],
categories: ['Electronics', 'Cabling', 'Power'],
onSubmit: vi.fn(),
onClose: vi.fn(),
}
Expand Down Expand Up @@ -184,4 +185,53 @@ describe('EditProductModal', () => {
expect(screen.getAllByRole('button', { name: /guardar/i })).toHaveLength(1)
})
})

describe('the category', () => {
const categorySelect = () => screen.getByRole('combobox', { name: 'Categoría' })

async function choose(option: string) {
fireEvent.mouseDown(categorySelect())
fireEvent.click(await screen.findByRole('option', { name: option }))
}

async function submittedCategory(props: { onSubmit: ReturnType<typeof vi.fn> }) {
fireEvent.click(screen.getByRole('button', { name: 'Guardar cambios' }))
await waitFor(() => expect(props.onSubmit).toHaveBeenCalled())
return (props.onSubmit.mock.calls[0]?.[0] as { product: { category: string | null } }).product
.category
}

it('opens with the category of the product', () => {
renderModal(product({ category: 'Cabling' }))

expect(categorySelect()).toHaveTextContent('Cabling')
})

it('offers the vocabulary of the API plus no category', () => {
renderModal(product())

fireEvent.mouseDown(categorySelect())

expect(screen.getAllByRole('option').map((option) => option.textContent)).toEqual([
'Sin categoría',
'Electronics',
'Cabling',
'Power',
])
})

it('sends the category the user chose', async () => {
const { props } = renderModal(product({ category: 'Cabling' }))
await choose('Power')

expect(await submittedCategory(props)).toBe('Power')
})

it('lets the user clear it', async () => {
const { props } = renderModal(product({ category: 'Cabling' }))
await choose('Sin categoría')

expect(await submittedCategory(props)).toBeNull()
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import AddIcon from '@mui/icons-material/Add'
import { Alert, AlertTitle, Button, Menu, MenuItem, TextField, Typography } from '@mui/material'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useFieldArray, useForm } from 'react-hook-form'
import { Controller, useFieldArray, useForm } from 'react-hook-form'
import {
LabeledField,
ModalBody,
Expand All @@ -18,6 +18,7 @@ import { inventoryCopy } from '../../content'
import type { Product } from '../../types'
import { parseDimensions } from '../../utils/dimensions'
import { buildUpdatePayload } from '../../utils/payload'
import { CategoryField } from '../CategoryField'

import { editProductSchema } from './EditProductModal.schema'
import type { EditProductFormData } from './EditProductModal.schema'
Expand All @@ -42,6 +43,7 @@ function buildDefaults(product: Product): EditProductFormData {

return {
name: product.name,
category: product.category ?? '',
weight: product.weight,
length,
width,
Expand All @@ -66,6 +68,7 @@ export function EditProductModal({
open,
product,
warehouses,
categories = [],
onSubmit,
onClose,
submitting = false,
Expand Down Expand Up @@ -159,9 +162,20 @@ export function EditProductModal({
<TextField value={product.sku} fullWidth disabled />
</LabeledField>

<LabeledField label={modal.fields.category} helperText={modal.categoryHelper}>
<TextField value="—" fullWidth disabled />
</LabeledField>
<Controller
name="category"
control={control}
render={({ field }) => (
<CategoryField
value={field.value}
onChange={field.onChange}
categories={categories}
label={modal.fields.category}
helperText={modal.categoryHelper}
noneLabel={modal.noCategory}
/>
)}
/>
</BasicGrid>
</SectionRoot>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface EditProductModalProps {
product: Product
/** Depósitos de la empresa — alimentan el botón "Agregar depósito". */
warehouses: Warehouse[]
/** Vocabulario de categorías (`GET /products/categories`). */
categories?: string[]
/** Recibe el cuerpo ya armado para `PUT /api/v1/products/:id`. */
onSubmit: (payload: UpdateProductPayload) => void
onClose: () => void
Expand Down
6 changes: 4 additions & 2 deletions src/features/inventory/content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,8 @@ export const inventoryCopy = {
available: 'Disponible',
},
skuHelper: 'El SKU identifica al producto y no se edita.',
categoryHelper: 'Pendiente de backend: el producto todavía no tiene categoría.',
categoryHelper: 'Se usa para agrupar y filtrar el catálogo.',
noCategory: 'Sin categoría',
addWarehouse: 'Agregar depósito',
removeWarehouse: (warehouseName: string) => `Quitar ${warehouseName}`,
noWarehouses: 'Este producto no tiene stock asignado en ningún depósito.',
Expand Down Expand Up @@ -252,7 +253,8 @@ export const inventoryCopy = {
width: 'A',
height: 'H',
},
categoryHelper: 'Pendiente de backend: el producto todavía no tiene categoría.',
categoryHelper: 'Se usa para agrupar y filtrar el catálogo.',
noCategory: 'Sin categoría',
addWarehouse: 'Agregar depósito',
removeRow: (position: number) => `Quitar la fila ${position}`,
noWarehouses: 'La empresa todavía no tiene depósitos cargados.',
Expand Down
13 changes: 13 additions & 0 deletions src/features/inventory/hooks/useInventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { ApiRequestError } from 'shared/api/types'

import {
createProduct,
fetchCategories,
fetchProduct,
deleteProduct,
fetchProductCount,
Expand Down Expand Up @@ -85,6 +86,18 @@ export function useProduct(id: number | undefined) {
})
}

/**
* Las categorías del catálogo, para los selects de alta y edición. No cambian
* en la vida de una sesión: se piden una vez.
*/
export function useCategories() {
return useQuery<string[]>({
queryKey: inventoryKeys.categories(),
queryFn: fetchCategories,
staleTime: Number.POSITIVE_INFINITY,
})
}

/** Depósitos de la empresa — alimentan "Agregar depósito" en el modal. */
export function useWarehouses() {
return useQuery<Warehouse[]>({
Expand Down
3 changes: 3 additions & 0 deletions src/features/inventory/pages/InventoryPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithTheme } from '../../../test/renderWithTheme'
import type * as inventoryHooks from '../hooks/useInventory'
import {
useCategories,
useCreateProduct,
useDeleteProduct,
useProductCounts,
Expand All @@ -21,6 +22,7 @@ vi.mock('../hooks/useInventory', async (importOriginal) => ({
useProductPage: vi.fn(),
useProductCounts: vi.fn(),
useWarehouses: vi.fn(),
useCategories: vi.fn(),
useCreateProduct: vi.fn(),
useDeleteProduct: vi.fn(),
}))
Expand Down Expand Up @@ -53,6 +55,7 @@ beforeEach(() => {
isError: false,
} as never)
vi.mocked(useProductCounts).mockReturnValue([0, 0, 0, 0] as never)
vi.mocked(useCategories).mockReturnValue({ data: ['Electronics', 'Power'] } as never)
vi.mocked(useWarehouses).mockReturnValue({
data: [],
isPending: false,
Expand Down
Loading
Loading