From 11f34d87e6a403b78a9ffa7948762a0e9c646bc1 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 25 Sep 2026 00:03:48 -0300 Subject: [PATCH 1/5] feat: [TESIS-68] add the shared modal frame and move the product modals onto it ModalFrame is the ModalFrame of the design: header with title, subtitle, optional icon and close, and below it whatever the modal brings (body and footer, or a form wrapping them). It takes the size (sm 480, md 672, lg 880), keeps only the body scrolling, ties the title to aria-labelledby and, with `busy`, cannot be closed from anywhere. It replaces ProductModalShell, which lived in the inventory feature. The create modal can no longer be closed while it is saving, as the edit modal already did with its close button. Co-Authored-By: Claude Opus 5.5 --- .../CreateProductModal.styles.ts | 4 +- .../CreateProductModal/CreateProductModal.tsx | 53 ++++----- .../EditProductModal.styles.ts | 5 +- .../EditProductModal/EditProductModal.tsx | 66 ++++------- .../ProductModalShell.styles.ts | 80 -------------- .../components/ProductModalShell/index.ts | 9 -- .../ModalFrame/ModalFrame.styles.ts | 104 ++++++++++++++++++ .../components/ModalFrame/ModalFrame.test.tsx | 84 ++++++++++++++ .../components/ModalFrame/ModalFrame.tsx | 63 +++++++++++ .../components/ModalFrame/ModalFrame.types.ts | 28 +++++ src/shared/components/ModalFrame/index.ts | 9 ++ src/shared/components/index.ts | 9 ++ 12 files changed, 346 insertions(+), 168 deletions(-) delete mode 100644 src/features/inventory/components/ProductModalShell/ProductModalShell.styles.ts delete mode 100644 src/features/inventory/components/ProductModalShell/index.ts create mode 100644 src/shared/components/ModalFrame/ModalFrame.styles.ts create mode 100644 src/shared/components/ModalFrame/ModalFrame.test.tsx create mode 100644 src/shared/components/ModalFrame/ModalFrame.tsx create mode 100644 src/shared/components/ModalFrame/ModalFrame.types.ts create mode 100644 src/shared/components/ModalFrame/index.ts diff --git a/src/features/inventory/components/CreateProductModal/CreateProductModal.styles.ts b/src/features/inventory/components/CreateProductModal/CreateProductModal.styles.ts index 6408342..7e0d879 100644 --- a/src/features/inventory/components/CreateProductModal/CreateProductModal.styles.ts +++ b/src/features/inventory/components/CreateProductModal/CreateProductModal.styles.ts @@ -1,8 +1,8 @@ import { Box, Button } from '@mui/material' import { styled } from '@mui/material/styles' -// El shell (root, header, form, body, footer, campos) viene de -// `ProductModalShell`. Acá quedan las piezas propias del alta. +// El marco (cabecera, form, cuerpo y pie) es el `ModalFrame` de +// `shared/components`. Acá quedan las piezas propias del alta. // // El encabezado de sección de este frame es distinto al de la edición: ícono en // un recuadro tintado en vez de la barra de acento. Es la versión v2 del diff --git a/src/features/inventory/components/CreateProductModal/CreateProductModal.tsx b/src/features/inventory/components/CreateProductModal/CreateProductModal.tsx index 58b2eb6..b781b5f 100644 --- a/src/features/inventory/components/CreateProductModal/CreateProductModal.tsx +++ b/src/features/inventory/components/CreateProductModal/CreateProductModal.tsx @@ -1,24 +1,22 @@ import { zodResolver } from '@hookform/resolvers/zod' import AddIcon from '@mui/icons-material/Add' -import CloseIcon from '@mui/icons-material/Close' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' import StraightenOutlinedIcon from '@mui/icons-material/StraightenOutlined' import WarehouseOutlinedIcon from '@mui/icons-material/WarehouseOutlined' -import { Alert, Button, Divider, IconButton, MenuItem, TextField, Typography } from '@mui/material' -import { useEffect, useId } from 'react' +import { Alert, Button, Divider, MenuItem, TextField, Typography } from '@mui/material' +import { useEffect } from 'react' import { useFieldArray, useForm } from 'react-hook-form' -import { LabeledField } from 'shared/components' - -import { inventoryCopy } from '../../content' -import { buildCreatePayload } from '../../utils/payload' import { - FooterActions, + LabeledField, ModalBody, ModalFooter, + ModalFooterActions, ModalForm, - ModalHeader, - ModalRoot, -} from '../ProductModalShell' + ModalFrame, +} from 'shared/components' + +import { inventoryCopy } from '../../content' +import { buildCreatePayload } from '../../utils/payload' import { createProductSchema } from './CreateProductModal.schema' import type { CreateProductFormData } from './CreateProductModal.schema' @@ -75,8 +73,6 @@ export function CreateProductModal({ submitting = false, submitError, }: CreateProductModalProps) { - const titleId = useId() - const { control, register, @@ -121,21 +117,14 @@ export function CreateProductModal({ const submit = handleSubmit((data) => onSubmit(buildCreatePayload(data))) return ( - - -
- - {copy.title} - - - {copy.subtitle} - -
- - - -
- + {generalError === undefined ? null : ( @@ -306,17 +295,17 @@ export function CreateProductModal({ - - + + - + -
+ ) } diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.styles.ts b/src/features/inventory/components/EditProductModal/EditProductModal.styles.ts index 2658837..e6ed750 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.styles.ts +++ b/src/features/inventory/components/EditProductModal/EditProductModal.styles.ts @@ -1,9 +1,8 @@ import { Box, Button, Typography } from '@mui/material' import { styled } from '@mui/material/styles' -// El shell del modal (root, header, form, body, footer y campos) vive en -// `ProductModalShell`, compartido con el alta. Acá quedan sólo las piezas -// propias de la edición. +// El marco (cabecera, form, cuerpo y pie) es el `ModalFrame` de +// `shared/components`. Acá quedan sólo las piezas propias de la edición. // Radios (px). No se leen del tema ni de `tokens.ts`: `shape.borderRadius` es // el radio base (8) de los controles, y las reglas de capa prohíben que una diff --git a/src/features/inventory/components/EditProductModal/EditProductModal.tsx b/src/features/inventory/components/EditProductModal/EditProductModal.tsx index e229527..60752d1 100644 --- a/src/features/inventory/components/EditProductModal/EditProductModal.tsx +++ b/src/features/inventory/components/EditProductModal/EditProductModal.tsx @@ -1,33 +1,23 @@ import { zodResolver } from '@hookform/resolvers/zod' import AddIcon from '@mui/icons-material/Add' -import CloseIcon from '@mui/icons-material/Close' -import { - Alert, - AlertTitle, - Button, - IconButton, - Menu, - MenuItem, - TextField, - Typography, -} from '@mui/material' -import { useEffect, useId, useMemo, useRef, useState } from 'react' +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 { LabeledField } from 'shared/components' +import { + LabeledField, + ModalBody, + ModalFooter, + ModalFooterActions, + ModalFooterNote, + ModalForm, + ModalFrame, +} from 'shared/components' import { formatRelativeTime } from 'shared/utils' import { inventoryCopy } from '../../content' import type { Product } from '../../types' import { parseDimensions } from '../../utils/dimensions' import { buildUpdatePayload } from '../../utils/payload' -import { - FooterActions, - ModalBody, - ModalFooter, - ModalForm, - ModalHeader, - ModalRoot, -} from '../ProductModalShell' import { editProductSchema } from './EditProductModal.schema' import type { EditProductFormData } from './EditProductModal.schema' @@ -81,7 +71,6 @@ export function EditProductModal({ submitting = false, conflict, }: EditProductModalProps) { - const titleId = useId() const [menuAnchor, setMenuAnchor] = useState(null) const { @@ -131,21 +120,14 @@ export function EditProductModal({ const submit = handleSubmit((data) => onSubmit(buildUpdatePayload(product, data))) return ( - - -
- - {modal.title(product.name)} - - - {modal.subtitle(product.sku)} - -
- - - -
- + {/* El conflicto va arriba del formulario y no reemplaza nada: lo que @@ -273,10 +255,10 @@ export function EditProductModal({ - + {lastUpdated === null ? '' : modal.lastUpdated(lastUpdated)} - - + + @@ -292,7 +274,7 @@ export function EditProductModal({ > {conflict === undefined ? modal.submit : modal.conflict.overwrite} - + @@ -314,6 +296,6 @@ export function EditProductModal({ ))} -
+ ) } diff --git a/src/features/inventory/components/ProductModalShell/ProductModalShell.styles.ts b/src/features/inventory/components/ProductModalShell/ProductModalShell.styles.ts deleted file mode 100644 index d7111f1..0000000 --- a/src/features/inventory/components/ProductModalShell/ProductModalShell.styles.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Box, Dialog } from '@mui/material' -import { styled } from '@mui/material/styles' - -// Estructura compartida por los modales de producto (alta y edición). Se extrajo -// acá cuando apareció el segundo consumidor — la "Regla de Dos" de -// feature-structure.md §6 — en vez de duplicar el shell en cada modal. -// -// Lo que NO vive acá es el encabezado de sección: cada frame de Figma tiene el -// suyo (barra de acento en el de edición, ícono en recuadro en el de alta), así -// que cada modal aporta el propio. - -export const MODAL_MAX_WIDTH = 672 - -export const ModalRoot = styled(Dialog)(({ theme }) => ({ - '& .MuiDialog-paper': { - width: '100%', - maxWidth: MODAL_MAX_WIDTH, - maxHeight: `calc(100% - ${theme.spacing(8)})`, - margin: theme.spacing(2), - // El paper no scrollea (MUI le pone `overflow-y: auto` por defecto): el - // único que scrollea es el cuerpo, así el título y las acciones quedan - // siempre a la vista, como en el diseño. - overflow: 'hidden', - }, -})) - -// El `
` envuelve cuerpo y pie, así que tiene que ser la columna flex que -// reparte el alto. Si queda como bloque suelto, el `overflow` del cuerpo no -// tiene contra qué medirse y termina scrolleando el modal entero. -export const ModalForm = styled('form')({ - display: 'flex', - flexDirection: 'column', - flex: '1 1 auto', - minHeight: 0, -}) - -export const ModalHeader = styled(Box)(({ theme }) => ({ - display: 'flex', - alignItems: 'flex-start', - justifyContent: 'space-between', - gap: theme.spacing(2), - padding: theme.spacing(3), - borderBottom: `1px solid ${theme.palette.divider}`, - flexShrink: 0, -})) - -export const ModalBody = styled(Box)(({ theme }) => ({ - display: 'flex', - flexDirection: 'column', - gap: theme.spacing(4), - padding: theme.spacing(3), - // `minHeight: 0` es load-bearing: sin eso un ítem flex no baja de su tamaño - // de contenido y el scroll nunca se activa. - flex: '1 1 auto', - minHeight: 0, - overflowY: 'auto', -})) - -export const ModalFooter = styled(Box)(({ theme }) => ({ - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - gap: theme.spacing(2), - padding: theme.spacing(3), - borderTop: `1px solid ${theme.palette.divider}`, - backgroundColor: theme.palette.background.default, - flexShrink: 0, - // En pantallas chicas el pie apila la leyenda sobre los botones. - [theme.breakpoints.down('sm')]: { - flexDirection: 'column', - alignItems: 'stretch', - }, -})) - -export const FooterActions = styled(Box)(({ theme }) => ({ - display: 'flex', - gap: theme.spacing(1.5), - flexShrink: 0, - [theme.breakpoints.down('sm')]: { justifyContent: 'flex-end' }, -})) diff --git a/src/features/inventory/components/ProductModalShell/index.ts b/src/features/inventory/components/ProductModalShell/index.ts deleted file mode 100644 index 728c799..0000000 --- a/src/features/inventory/components/ProductModalShell/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { - FooterActions, - MODAL_MAX_WIDTH, - ModalBody, - ModalFooter, - ModalForm, - ModalHeader, - ModalRoot, -} from './ProductModalShell.styles' diff --git a/src/shared/components/ModalFrame/ModalFrame.styles.ts b/src/shared/components/ModalFrame/ModalFrame.styles.ts new file mode 100644 index 0000000..3320de0 --- /dev/null +++ b/src/shared/components/ModalFrame/ModalFrame.styles.ts @@ -0,0 +1,104 @@ +import { Box, Dialog, Typography } from '@mui/material' +import { styled } from '@mui/material/styles' + +import type { ModalSize } from './ModalFrame.types' + +// `md` es el ancho del ModalFrame del diseño (672), el de los formularios. `sm` +// es para una pregunta: una confirmación de dos líneas a 672 queda en un renglón +// larguísimo, con los botones lejos de lo que confirman. `lg` es para cuando el +// cuerpo trae una tabla o un resumen que necesita columnas. +const MODAL_WIDTHS: Record = { + sm: 480, + md: 672, + lg: 880, +} + +export const ModalRoot = styled(Dialog, { + shouldForwardProp: (prop) => prop !== 'modalSize', +})<{ modalSize: ModalSize }>(({ theme, modalSize }) => ({ + '& .MuiDialog-paper': { + width: '100%', + maxWidth: MODAL_WIDTHS[modalSize], + maxHeight: `calc(100% - ${theme.spacing(8)})`, + margin: theme.spacing(2), + // El paper no scrollea (MUI le pone `overflow-y: auto` por defecto): el + // único que scrollea es el cuerpo, así el título y las acciones quedan + // siempre a la vista, como en el diseño. + overflow: 'hidden', + }, +})) + +export const ModalHeader = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(2), + padding: theme.spacing(3, 3, 2), + borderBottom: `1px solid ${theme.palette.divider}`, + flexShrink: 0, +})) + +export const ModalHeading = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + gap: theme.spacing(1.5), + flex: 1, + minWidth: 0, +})) + +// Cuando el modal es un formulario, el `` envuelve cuerpo y pie, así que +// tiene que ser la columna flex que reparte el alto. Si queda como bloque +// suelto, el `overflow` del cuerpo no tiene contra qué medirse y termina +// scrolleando el modal entero. +export const ModalForm = styled('form')({ + display: 'flex', + flexDirection: 'column', + flex: '1 1 auto', + minHeight: 0, +}) + +export const ModalBody = styled(Box)(({ theme }) => ({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(4), + padding: theme.spacing(3), + // `minHeight: 0` es load-bearing: sin eso un ítem flex no baja de su tamaño + // de contenido y el scroll nunca se activa. + flex: '1 1 auto', + minHeight: 0, + overflowY: 'auto', +})) + +export const ModalFooter = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: theme.spacing(2), + padding: theme.spacing(2, 3), + borderTop: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.default, + flexShrink: 0, + // En pantallas chicas el pie apila la nota sobre los botones. + [theme.breakpoints.down('sm')]: { + flexDirection: 'column', + alignItems: 'stretch', + }, +})) + +// Texto del pie a la izquierda de las acciones ("Actualizado hace 2 horas"). +export const ModalFooterNote = styled(Typography)(({ theme }) => ({ + marginRight: 'auto', + color: theme.palette.text.secondary, + [theme.breakpoints.down('sm')]: { marginRight: 0 }, +})) + +export const ModalFooterActions = styled(Box)(({ theme }) => ({ + display: 'flex', + gap: theme.spacing(2), + flexShrink: 0, + // En pantallas chicas los botones van a lo ancho, con la acción principal + // arriba, como apila Material las acciones de un diálogo. + [theme.breakpoints.down('sm')]: { + flexDirection: 'column-reverse', + alignItems: 'stretch', + }, +})) diff --git a/src/shared/components/ModalFrame/ModalFrame.test.tsx b/src/shared/components/ModalFrame/ModalFrame.test.tsx new file mode 100644 index 0000000..d84852a --- /dev/null +++ b/src/shared/components/ModalFrame/ModalFrame.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { renderWithTheme } from '../../../test/renderWithTheme' + +import { ModalFrame } from './ModalFrame' +import { ModalBody } from './ModalFrame.styles' +import type { ModalFrameProps } from './ModalFrame.types' + +function renderFrame(overrides: Partial = {}) { + const props: ModalFrameProps = { + open: true, + title: 'Nuevo producto', + closeLabel: 'Cerrar', + onClose: vi.fn(), + children: Cuerpo del modal, + ...overrides, + } + + renderWithTheme() + + return props +} + +describe('ModalFrame', () => { + it('stays closed until it is opened', () => { + renderFrame({ open: false }) + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('is named by its title, with the subtitle and the body inside', () => { + renderFrame({ subtitle: 'Se agrega una entrada al catálogo maestro.' }) + + const dialog = screen.getByRole('dialog', { name: 'Nuevo producto' }) + + expect(dialog).toHaveTextContent('Se agrega una entrada al catálogo maestro.') + expect(dialog).toHaveTextContent('Cuerpo del modal') + }) + + it('closes from the header and with Escape', () => { + const { onClose } = renderFrame() + + fireEvent.click(screen.getByRole('button', { name: 'Cerrar' })) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + + expect(onClose).toHaveBeenCalledTimes(2) + }) + + // Con algo en vuelo el resultado tiene que llegar a un modal que siga a la + // vista: no se cierra por ningún lado. + it('cannot be closed while something is in flight', () => { + const { onClose } = renderFrame({ busy: true }) + + fireEvent.click(screen.getByRole('button', { name: 'Cerrar' })) + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + + expect(onClose).not.toHaveBeenCalled() + }) + + it('takes the ModalFrame width of the design unless told otherwise', () => { + renderFrame() + + expect(screen.getByRole('dialog')).toHaveStyle({ maxWidth: '672px' }) + }) + + it('narrows or widens with its size', () => { + renderFrame({ size: 'lg' }) + + expect(screen.getByRole('dialog')).toHaveStyle({ maxWidth: '880px' }) + }) + + it('can present itself as an alert dialog described by its body', () => { + renderFrame({ + role: 'alertdialog', + describedBy: 'body', + children: Esta acción no se puede deshacer., + }) + + expect(screen.getByRole('alertdialog', { name: 'Nuevo producto' })).toHaveAccessibleDescription( + 'Esta acción no se puede deshacer.', + ) + }) +}) diff --git a/src/shared/components/ModalFrame/ModalFrame.tsx b/src/shared/components/ModalFrame/ModalFrame.tsx new file mode 100644 index 0000000..263e068 --- /dev/null +++ b/src/shared/components/ModalFrame/ModalFrame.tsx @@ -0,0 +1,63 @@ +import CloseIcon from '@mui/icons-material/Close' +import { IconButton, Typography } from '@mui/material' +import { useId } from 'react' + +import { ModalHeader, ModalHeading, ModalRoot } from './ModalFrame.styles' +import type { ModalFrameProps } from './ModalFrame.types' + +/** + * Marco de todos los modales de la app, el ModalFrame del diseño: cabecera con + * título y cierre, y abajo lo que traiga cada modal —`ModalBody` y + * `ModalFooter`, o un `ModalForm` que los envuelva—. + * + * El marco resuelve lo que ningún modal debería repetir: el ancho, que sólo + * scrollee el cuerpo, el título atado al `aria-labelledby` y no dejar cerrar + * mientras hay algo en vuelo. + */ +export function ModalFrame({ + open, + title, + subtitle, + icon, + closeLabel, + onClose, + size = 'md', + busy = false, + role = 'dialog', + describedBy, + children, +}: ModalFrameProps) { + const titleId = useId() + + return ( + + + + {icon} +
+ + {title} + + {subtitle === undefined ? null : ( + + {subtitle} + + )} +
+
+ + + +
+ + {children} +
+ ) +} diff --git a/src/shared/components/ModalFrame/ModalFrame.types.ts b/src/shared/components/ModalFrame/ModalFrame.types.ts new file mode 100644 index 0000000..46f8623 --- /dev/null +++ b/src/shared/components/ModalFrame/ModalFrame.types.ts @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' + +/** Ancho máximo del modal: sm 480 · md 672 (el ModalFrame del diseño) · lg 880. */ +export type ModalSize = 'sm' | 'md' | 'lg' + +export interface ModalFrameProps { + open: boolean + title: string + subtitle?: ReactNode + /** Glifo a la izquierda del título, ya con su recuadro (p. ej. la advertencia de una baja). */ + icon?: ReactNode + /** Nombre accesible de la X de la cabecera. */ + closeLabel: string + onClose: () => void + /** `md` por defecto: el ancho del ModalFrame del diseño. */ + size?: ModalSize + /** + * Hay una acción en vuelo: el modal no se cierra ni con la X, ni con Esc, ni + * con un clic afuera, así el resultado llega a algo que sigue a la vista. + */ + busy?: boolean + /** `alertdialog` para lo que interrumpe pidiendo una decisión (una confirmación). */ + role?: 'dialog' | 'alertdialog' + /** Id del nodo que describe el modal, para que el lector de pantalla lo lea al abrir. */ + describedBy?: string + /** Cuerpo y pie: `ModalBody` + `ModalFooter`, o un `ModalForm` que los envuelva. */ + children: ReactNode +} diff --git a/src/shared/components/ModalFrame/index.ts b/src/shared/components/ModalFrame/index.ts new file mode 100644 index 0000000..faa6eec --- /dev/null +++ b/src/shared/components/ModalFrame/index.ts @@ -0,0 +1,9 @@ +export { ModalFrame } from './ModalFrame' +export { + ModalBody, + ModalFooter, + ModalFooterActions, + ModalFooterNote, + ModalForm, +} from './ModalFrame.styles' +export type { ModalFrameProps, ModalSize } from './ModalFrame.types' diff --git a/src/shared/components/index.ts b/src/shared/components/index.ts index 53e9d32..7796f21 100644 --- a/src/shared/components/index.ts +++ b/src/shared/components/index.ts @@ -47,3 +47,12 @@ export type { } from './DataTable' export { FieldLabel, FieldRoot, FullRow, LabeledField } from './LabeledField' export type { LabeledFieldProps } from './LabeledField' +export { + ModalBody, + ModalFooter, + ModalFooterActions, + ModalFooterNote, + ModalForm, + ModalFrame, +} from './ModalFrame' +export type { ModalFrameProps, ModalSize } from './ModalFrame' From 90a4f61004071c9ad42b634fe7792740b7f416b4 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 25 Sep 2026 00:03:57 -0300 Subject: [PATCH 2/5] feat: [TESIS-68] add the shared confirmation dialog ConfirmDialog asks before running an action, on top of ModalFrame (sm by default). The destructive tone adds the red warning, paints the confirm button red and starts with the focus on cancel, so a hurried Enter cannot delete anything. `canConfirm={false}` leaves only the way out, for when the backend already refused the action, and `children` carries the reason. DeleteProductDialog now wraps it, and keeps the last product while the dialog fades out: the text used to vanish and the box shrank on its way out. Co-Authored-By: Claude Opus 5.5 --- .../DeleteProductDialog.test.tsx | 18 +++- .../DeleteProductDialog.tsx | 60 +++++------ src/features/inventory/content.ts | 1 + .../ConfirmDialog/ConfirmDialog.styles.ts | 16 +++ .../ConfirmDialog/ConfirmDialog.test.tsx | 101 ++++++++++++++++++ .../ConfirmDialog/ConfirmDialog.tsx | 85 +++++++++++++++ .../ConfirmDialog/ConfirmDialog.types.ts | 34 ++++++ src/shared/components/ConfirmDialog/index.ts | 2 + src/shared/components/index.ts | 2 + 9 files changed, 282 insertions(+), 37 deletions(-) create mode 100644 src/shared/components/ConfirmDialog/ConfirmDialog.styles.ts create mode 100644 src/shared/components/ConfirmDialog/ConfirmDialog.test.tsx create mode 100644 src/shared/components/ConfirmDialog/ConfirmDialog.tsx create mode 100644 src/shared/components/ConfirmDialog/ConfirmDialog.types.ts create mode 100644 src/shared/components/ConfirmDialog/index.ts diff --git a/src/features/inventory/components/DeleteProductDialog/DeleteProductDialog.test.tsx b/src/features/inventory/components/DeleteProductDialog/DeleteProductDialog.test.tsx index 0b54a02..8bc4ea2 100644 --- a/src/features/inventory/components/DeleteProductDialog/DeleteProductDialog.test.tsx +++ b/src/features/inventory/components/DeleteProductDialog/DeleteProductDialog.test.tsx @@ -26,16 +26,16 @@ function renderDialog(overrides: Partial[ ...overrides, } - renderWithTheme() + const view = renderWithTheme() - return props + return { ...props, view } } describe('DeleteProductDialog', () => { it('stays closed without a product', () => { renderDialog({ product: undefined }) - expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() }) // Nombre y SKU: en un catálogo con productos parecidos, el nombre solo no @@ -46,6 +46,18 @@ describe('DeleteProductDialog', () => { expect(screen.getByText(/Cable UTP Cat6 \(CAB-6-305\)/)).toBeInTheDocument() }) + // Al cerrar, `product` pasa a `undefined` mientras el diálogo todavía se está + // desvaneciendo: el texto tiene que irse con él, no antes. + it('keeps naming the product while it fades out', () => { + const { view, onConfirm, onClose } = renderDialog() + + view.rerender( + , + ) + + expect(screen.getByText(/Cable UTP Cat6 \(CAB-6-305\)/)).toBeInTheDocument() + }) + it('confirms only when the person asks for it', () => { const { onConfirm } = renderDialog() diff --git a/src/features/inventory/components/DeleteProductDialog/DeleteProductDialog.tsx b/src/features/inventory/components/DeleteProductDialog/DeleteProductDialog.tsx index 3d10dfa..329abca 100644 --- a/src/features/inventory/components/DeleteProductDialog/DeleteProductDialog.tsx +++ b/src/features/inventory/components/DeleteProductDialog/DeleteProductDialog.tsx @@ -1,12 +1,6 @@ -import { - Alert, - Button, - Dialog, - DialogActions, - DialogContent, - DialogContentText, - DialogTitle, -} from '@mui/material' +import { Alert } from '@mui/material' +import { useState } from 'react' +import { ConfirmDialog } from 'shared/components' import { inventoryCopy } from '../../content' @@ -29,31 +23,29 @@ export function DeleteProductDialog({ onConfirm, onClose, }: DeleteProductDialogProps) { + // El diálogo se cierra con un fundido, y durante ese fundido `product` ya es + // `undefined`: sin guardar el último, el texto desaparecía y la caja se + // encogía mientras se iba. Se actualiza en el render, sin efecto. + const [shown, setShown] = useState(product) + if (product !== undefined && product !== shown) setShown(product) + return ( - - {remove.title} - - {product === undefined ? null : ( - {remove.body(product.name, product.sku)} - )} - {blocked ? ( - - {remove.blocked} - - ) : null} - - - - {/* Con el borrado ya rechazado el botón desaparece: reintentar lo mismo - va a fallar igual, y dejarlo invita a insistir. */} - {blocked ? null : ( - - )} - - + + {blocked ? {remove.blocked} : null} + ) } diff --git a/src/features/inventory/content.ts b/src/features/inventory/content.ts index a65a0e1..9319f40 100644 --- a/src/features/inventory/content.ts +++ b/src/features/inventory/content.ts @@ -58,6 +58,7 @@ export const inventoryCopy = { 'Borrarlo haría desaparecer esos registros.', cancel: 'Cancelar', confirm: 'Eliminar', + close: 'Cerrar', }, pagination: { previous: 'Página anterior', diff --git a/src/shared/components/ConfirmDialog/ConfirmDialog.styles.ts b/src/shared/components/ConfirmDialog/ConfirmDialog.styles.ts new file mode 100644 index 0000000..974d577 --- /dev/null +++ b/src/shared/components/ConfirmDialog/ConfirmDialog.styles.ts @@ -0,0 +1,16 @@ +import { Box } from '@mui/material' +import { styled } from '@mui/material/styles' + +// Ícono de advertencia de las acciones destructivas, en recuadro tonal: el mismo +// par container/main que usan los badges para la intención de error. +export const DangerIcon = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + width: theme.spacing(5), + height: theme.spacing(5), + borderRadius: theme.shape.borderRadius, + backgroundColor: theme.palette.error.container, + color: theme.palette.error.main, +})) diff --git a/src/shared/components/ConfirmDialog/ConfirmDialog.test.tsx b/src/shared/components/ConfirmDialog/ConfirmDialog.test.tsx new file mode 100644 index 0000000..fab1f28 --- /dev/null +++ b/src/shared/components/ConfirmDialog/ConfirmDialog.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' + +import { renderWithTheme } from '../../../test/renderWithTheme' + +import { ConfirmDialog } from './ConfirmDialog' +import type { ConfirmDialogProps } from './ConfirmDialog.types' + +function renderDialog(overrides: Partial = {}) { + const props: ConfirmDialogProps = { + open: true, + title: '¿Eliminar la orden?', + description: 'Se va a eliminar la orden #ORD-1042.', + confirmLabel: 'Eliminar', + cancelLabel: 'Cancelar', + closeLabel: 'Cerrar', + onConfirm: vi.fn(), + onClose: vi.fn(), + ...overrides, + } + + renderWithTheme() + + return props +} + +describe('ConfirmDialog', () => { + it('stays closed until it is opened', () => { + renderDialog({ open: false }) + + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + }) + + // Un lector de pantalla anuncia título y descripción al abrir: la persona + // tiene que saber qué está confirmando sin recorrer el diálogo. + it('announces itself with its title and description', () => { + renderDialog() + + const dialog = screen.getByRole('alertdialog', { name: '¿Eliminar la orden?' }) + + expect(dialog).toHaveAccessibleDescription('Se va a eliminar la orden #ORD-1042.') + }) + + it('confirms only when the person asks for it', () => { + const { onConfirm, onClose } = renderDialog() + + fireEvent.click(screen.getByRole('button', { name: 'Eliminar' })) + + expect(onConfirm).toHaveBeenCalledTimes(1) + expect(onClose).not.toHaveBeenCalled() + }) + + it('closes from cancel, from the header and with Escape', () => { + const { onConfirm, onClose } = renderDialog() + + fireEvent.click(screen.getByRole('button', { name: 'Cancelar' })) + fireEvent.click(screen.getByRole('button', { name: 'Cerrar' })) + fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }) + + expect(onClose).toHaveBeenCalledTimes(3) + expect(onConfirm).not.toHaveBeenCalled() + }) + + it('does not let anything fire while the action is in flight', () => { + const { onConfirm, onClose } = renderDialog({ busy: true }) + + fireEvent.click(screen.getByRole('button', { name: 'Eliminar' })) + fireEvent.click(screen.getByRole('button', { name: 'Cancelar' })) + fireEvent.click(screen.getByRole('button', { name: 'Cerrar' })) + fireEvent.keyDown(screen.getByRole('alertdialog'), { key: 'Escape' }) + + expect(onConfirm).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) + + it('leaves only the way out when confirming is no longer possible', () => { + renderDialog({ canConfirm: false }) + + expect(screen.queryByRole('button', { name: 'Eliminar' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancelar' })).toBeInTheDocument() + }) + + it('shows the extra content below the description', () => { + renderDialog({ children:

La orden ya fue despachada.

}) + + expect(screen.getByText('La orden ya fue despachada.')).toBeInTheDocument() + }) + + it('is as wide as a question unless asked for more room', () => { + renderDialog() + + expect(screen.getByRole('alertdialog')).toHaveStyle({ maxWidth: '480px' }) + }) + + // En una destructiva un Enter apurado no tiene que poder borrar nada. + it('starts a destructive confirmation focused on cancel', () => { + renderDialog({ tone: 'destructive' }) + + expect(screen.getByRole('button', { name: 'Cancelar' })).toHaveFocus() + }) +}) diff --git a/src/shared/components/ConfirmDialog/ConfirmDialog.tsx b/src/shared/components/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 0000000..6665504 --- /dev/null +++ b/src/shared/components/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,85 @@ +import WarningAmberRoundedIcon from '@mui/icons-material/WarningAmberRounded' +import { Button, Typography } from '@mui/material' +import { useId } from 'react' + +import { ModalBody, ModalFooter, ModalFooterActions, ModalFrame } from '../ModalFrame' + +import { DangerIcon } from './ConfirmDialog.styles' +import type { ConfirmDialogProps } from './ConfirmDialog.types' + +/** + * Confirmación de una acción antes de ejecutarla, sobre el `ModalFrame`. + * + * Es presentacional: no ejecuta nada. Quien lo monta hace la llamada en + * `onConfirm`, marca `busy` mientras está en vuelo y decide si cerrarlo o + * dejarlo abierto con el error en `children`. + */ +export function ConfirmDialog({ + open, + title, + description, + children, + confirmLabel, + cancelLabel, + closeLabel, + tone = 'default', + size = 'sm', + busy = false, + canConfirm = true, + onConfirm, + onClose, +}: ConfirmDialogProps) { + const descriptionId = useId() + const destructive = tone === 'destructive' + + return ( + + + + ) : undefined + } + closeLabel={closeLabel} + onClose={onClose} + size={size} + busy={busy} + role="alertdialog" + describedBy={description === undefined ? undefined : descriptionId} + > + {/* Una pregunta no se reparte en secciones: el cuerpo va más apretado que + el de un formulario. */} + + {description === undefined ? null : ( + + {description} + + )} + {children} + + + + + {/* En una destructiva el foco arranca en cancelar: un Enter apurado no + tiene que poder borrar nada. */} + + {canConfirm ? ( + + ) : null} + + + + ) +} diff --git a/src/shared/components/ConfirmDialog/ConfirmDialog.types.ts b/src/shared/components/ConfirmDialog/ConfirmDialog.types.ts new file mode 100644 index 0000000..dfb4d36 --- /dev/null +++ b/src/shared/components/ConfirmDialog/ConfirmDialog.types.ts @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react' + +import type { ModalSize } from '../ModalFrame' + +/** + * `destructive` es para lo que no se puede deshacer (una baja, una cancelación): + * suma el ícono de advertencia y pinta la confirmación en rojo. + */ +export type ConfirmDialogTone = 'default' | 'destructive' + +export interface ConfirmDialogProps { + open: boolean + title: string + /** Qué va a pasar, con el dato que identifica al objeto (un ID, un SKU). */ + description?: ReactNode + /** Contenido extra bajo la descripción: típicamente un `Alert` con un error. */ + children?: ReactNode + confirmLabel: string + cancelLabel: string + /** Nombre accesible de la X de la cabecera. */ + closeLabel: string + tone?: ConfirmDialogTone + /** `sm` por defecto: el tamaño de una pregunta, no de un formulario. */ + size?: ModalSize + /** La acción está en vuelo: nada se puede tocar ni cerrar hasta que vuelva. */ + busy?: boolean + /** + * En `false` la confirmación desaparece y queda sólo cancelar. Es para cuando + * la acción ya fue rechazada y reintentarla va a fallar igual. + */ + canConfirm?: boolean + onConfirm: () => void + onClose: () => void +} diff --git a/src/shared/components/ConfirmDialog/index.ts b/src/shared/components/ConfirmDialog/index.ts new file mode 100644 index 0000000..e7e5ddc --- /dev/null +++ b/src/shared/components/ConfirmDialog/index.ts @@ -0,0 +1,2 @@ +export { ConfirmDialog } from './ConfirmDialog' +export type { ConfirmDialogProps, ConfirmDialogTone } from './ConfirmDialog.types' diff --git a/src/shared/components/index.ts b/src/shared/components/index.ts index 7796f21..9d84220 100644 --- a/src/shared/components/index.ts +++ b/src/shared/components/index.ts @@ -47,6 +47,8 @@ export type { } from './DataTable' export { FieldLabel, FieldRoot, FullRow, LabeledField } from './LabeledField' export type { LabeledFieldProps } from './LabeledField' +export { ConfirmDialog } from './ConfirmDialog' +export type { ConfirmDialogProps, ConfirmDialogTone } from './ConfirmDialog' export { ModalBody, ModalFooter, From 78926958f001681b2b33c02de0b8e4291d66bc9c Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 25 Sep 2026 00:04:05 -0300 Subject: [PATCH 3/5] feat: [TESIS-68] style alerts after the design system and show notifications as toasts The Alert theme override matches Alert.dc.html: tonal background, border in the intent colour, icon and text in the onContainer tone. It applies to the standard and outlined variants, which the app used interchangeably. Only the close glyph goes grey, so actions like "Reintentar" keep the intent colour. The close button is labelled in Spanish. NotificationHost renders each notification as a toast: that same alert, floating, with an opaque background and the level 2 shadow. It no longer closes on a click anywhere on the page; it goes away on its own, with Escape or with its close button. The inventory and product detail pages dropped their own Snackbars and notify through the shared host, like the rest of the app. Co-Authored-By: Claude Opus 5.5 --- src/app/theme/components/alert.ts | 44 +++++++++++++ src/app/theme/components/index.ts | 2 + .../inventory/pages/InventoryPage.test.tsx | 50 +++++++++++++- .../inventory/pages/InventoryPage.tsx | 15 ++--- .../inventory/pages/ProductDetailPage.tsx | 13 +--- src/shared/components/NotificationHost.tsx | 37 ----------- .../NotificationHost.styles.ts | 22 +++++++ .../NotificationHost.test.tsx | 65 +++++++++++++++++++ .../NotificationHost/NotificationHost.tsx | 43 ++++++++++++ .../components/NotificationHost/index.ts | 1 + 10 files changed, 232 insertions(+), 60 deletions(-) create mode 100644 src/app/theme/components/alert.ts delete mode 100644 src/shared/components/NotificationHost.tsx create mode 100644 src/shared/components/NotificationHost/NotificationHost.styles.ts create mode 100644 src/shared/components/NotificationHost/NotificationHost.test.tsx create mode 100644 src/shared/components/NotificationHost/NotificationHost.tsx create mode 100644 src/shared/components/NotificationHost/index.ts diff --git a/src/app/theme/components/alert.ts b/src/app/theme/components/alert.ts new file mode 100644 index 0000000..fa97672 --- /dev/null +++ b/src/app/theme/components/alert.ts @@ -0,0 +1,44 @@ +import type { Components, Theme } from '@mui/material/styles' + +import { radius } from '../tokens' + +// El aviso del sistema del diseño (Alert.dc.html): fondo tonal de la intención, +// borde de su color base, ícono y texto en el tono `onContainer`, radio 8 y +// 12 × 16 de relleno. +// +// Aplica a `standard` y `outlined`, que en la app se usaban indistintamente +// para lo mismo: el diseño tiene un solo aviso, así que las dos variantes pintan +// igual. `filled` queda como la trae MUI; nada la usa. +export function muiAlert(): Components['MuiAlert'] { + return { + // MUI rotula la X en inglés ("Close") y la app no carga su locale: sin esto, + // el lector de pantalla anuncia en inglés el cierre de cada toast. + defaultProps: { closeText: 'Cerrar' }, + styleOverrides: { + root: ({ theme, ownerState }) => { + if (ownerState.variant === 'filled') return {} + + // Mismo orden que MUI: `color` pisa a `severity`, y sin ninguno es `success`. + const color = theme.palette[ownerState.color ?? ownerState.severity ?? 'success'] + + return { + ...theme.typography.bodyMd, + // 4 + los 8 que MUI ya le pone al mensaje = los 12 del diseño. + padding: theme.spacing(0.5, 2), + borderRadius: radius.base, + border: `1px solid ${color.main}`, + backgroundColor: color.container, + color: color.onContainer, + '& .MuiAlert-icon': { color: color.onContainer, opacity: 1 }, + // Sólo la X va en gris, como en el diseño. La zona de acción también + // aloja botones como "Reintentar" (`color="inherit"`), y esos tienen + // que conservar el tono de la intención: son la salida del aviso. + '& .MuiAlert-action > .MuiIconButton-root': { color: theme.palette.text.secondary }, + // Con título, el título lleva el tono y el cuerpo baja a secundario. + '& .MuiAlertTitle-root': { fontWeight: 600, color: color.onContainer }, + '&:has(.MuiAlertTitle-root) .MuiAlert-message': { color: theme.palette.text.secondary }, + } + }, + }, + } +} diff --git a/src/app/theme/components/index.ts b/src/app/theme/components/index.ts index 4277cac..f5013b1 100644 --- a/src/app/theme/components/index.ts +++ b/src/app/theme/components/index.ts @@ -2,6 +2,7 @@ import type { ThemeOptions } from '@mui/material/styles' import type { ThemeMode } from '../tokens' +import { muiAlert } from './alert' import { muiAppBar } from './appBar' import { muiButton } from './button' import { muiCard } from './card' @@ -27,6 +28,7 @@ export function buildComponents(mode: ThemeMode, accent?: string): ThemeOptions[ MuiOutlinedInput: muiOutlinedInput(mode, accent), MuiTextField: muiTextField(), MuiCard: muiCard(), + MuiAlert: muiAlert(), MuiDialog: muiDialog(), MuiBackdrop: muiBackdrop(), MuiTypography: muiTypography(), diff --git a/src/features/inventory/pages/InventoryPage.test.tsx b/src/features/inventory/pages/InventoryPage.test.tsx index 188443a..3b72d72 100644 --- a/src/features/inventory/pages/InventoryPage.test.tsx +++ b/src/features/inventory/pages/InventoryPage.test.tsx @@ -1,6 +1,7 @@ -import { fireEvent, screen } from '@testing-library/react' +import { act, fireEvent, screen } from '@testing-library/react' import { MemoryRouter, Route, Routes } from 'react-router-dom' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { useNotificationStore } from 'shared/store' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { renderWithTheme } from '../../../test/renderWithTheme' import type * as inventoryHooks from '../hooks/useInventory' @@ -11,6 +12,7 @@ import { useProductPage, useWarehouses, } from '../hooks/useInventory' +import type { ProductSummary } from '../types' import { InventoryPage } from './InventoryPage' @@ -104,3 +106,47 @@ describe('InventoryPage · the tab in the URL', () => { expect(lastRequestedStatus()).toBeUndefined() }) }) + +describe('InventoryPage · the delete notice', () => { + const PRODUCT: ProductSummary = { + id: 8842, + sku: 'SKU-8842-PL', + name: 'Arreglo de sensores serie G', + category: 'Electronics', + totalStock: 2710, + stockStatus: 'available', + inTransitQuantity: 0, + primaryWarehouse: { id: 1, name: 'CD Ezeiza', quantity: 2710 }, + warehouseCount: 1, + } + + afterEach(() => { + act(() => useNotificationStore.setState({ notifications: [] })) + }) + + // El aviso sale por el `NotificationHost` como el del resto de la app, no por + // un Snackbar propio de la pantalla con otro aspecto y otra duración. + it('announces the deletion through the app notifications', () => { + vi.mocked(useProductPage).mockReturnValue({ + data: { products: [PRODUCT], total: 1 }, + isPending: false, + isError: false, + } as never) + vi.mocked(useDeleteProduct).mockReturnValue({ + ...IDLE_MUTATION, + mutate: vi.fn((_id: number, options: { onSuccess: () => void }) => options.onSuccess()), + } as never) + renderCatalog('/inventory') + + fireEvent.click(screen.getByRole('button', { name: 'Acciones del producto SKU-8842-PL' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Eliminar' })) + fireEvent.click(screen.getByRole('button', { name: 'Eliminar' })) + + expect(useNotificationStore.getState().notifications).toEqual([ + expect.objectContaining({ + message: 'Arreglo de sensores serie G eliminado.', + severity: 'success', + }), + ]) + }) +}) diff --git a/src/features/inventory/pages/InventoryPage.tsx b/src/features/inventory/pages/InventoryPage.tsx index c8c8776..786d7e9 100644 --- a/src/features/inventory/pages/InventoryPage.tsx +++ b/src/features/inventory/pages/InventoryPage.tsx @@ -1,11 +1,12 @@ import AddIcon from '@mui/icons-material/Add' import SearchIcon from '@mui/icons-material/Search' -import { Box, Button, InputAdornment, Snackbar, Stack, TextField, Typography } from '@mui/material' +import { Box, Button, InputAdornment, Stack, TextField, Typography } from '@mui/material' import { useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components' import type { DataTableTab } from 'shared/components' import { useDebouncedValue } from 'shared/hooks/useDebouncedValue' +import { notify } from 'shared/store' import { CreateProductModal } from '../components/CreateProductModal' import { DeleteProductDialog } from '../components/DeleteProductDialog' @@ -66,7 +67,6 @@ export function InventoryPage() { const [search, setSearch] = useState('') const [creating, setCreating] = useState(false) const [removing, setRemoving] = useState(undefined) - const [notice, setNotice] = useState(null) // Lo que se tipea actualiza el campo en el acto; lo que viaja a la API espera // a que la persona deje de escribir. @@ -128,7 +128,7 @@ export function InventoryPage() { deleteMutation.mutate(removing.id, { onSuccess: () => { - setNotice(page.deleted(name)) + notify(page.deleted(name), 'success') setRemoving(undefined) if (eraLaUltimaDeLaPagina) setPageNumber((current) => Math.max(current - 1, 1)) }, @@ -237,7 +237,7 @@ export function InventoryPage() { onSubmit={(payload) => { createMutation.mutate(payload, { onSuccess: (created) => { - setNotice(page.saved(created.name)) + notify(page.saved(created.name), 'success') setCreating(false) }, }) @@ -251,13 +251,6 @@ export function InventoryPage() { onConfirm={confirmRemove} onClose={() => setRemoving(undefined)} /> - - setNotice(null)} - message={notice ?? undefined} - /> ) } diff --git a/src/features/inventory/pages/ProductDetailPage.tsx b/src/features/inventory/pages/ProductDetailPage.tsx index c1ef7c4..fb3b4f2 100644 --- a/src/features/inventory/pages/ProductDetailPage.tsx +++ b/src/features/inventory/pages/ProductDetailPage.tsx @@ -1,10 +1,11 @@ import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined' import LockOutlinedIcon from '@mui/icons-material/LockOutlined' -import { Box, Button, Snackbar, Stack, Typography } from '@mui/material' +import { Box, Button, Stack, Typography } from '@mui/material' import { useEffect, useState } from 'react' import { Link, useLocation, useNavigate, useParams } from 'react-router-dom' import { ErrorFallback, LoadingSpinner, PageWrapper } from 'shared/components' +import { notify } from 'shared/store' import { EditProductModal } from '../components/EditProductModal' import { MasterStockCard } from '../components/MasterStockCard' @@ -172,7 +173,6 @@ export function ProductDetailPage() { void navigate(pathname, { replace: true, state: null }) }, [abrirEdicion, navigate, pathname]) - const [savedName, setSavedName] = useState(null) // Estado del producto cuando el modal lo abrió. Se guarda para poder decir // QUÉ cambió si la API rechaza el guardado por versión vieja (TESIS-101). const [baseline, setBaseline] = useState(undefined) @@ -207,7 +207,7 @@ export function ProductDetailPage() { const name = product.data?.name ?? '' updateMutation.mutate(payload, { onSuccess: () => { - setSavedName(name) + notify(page.saved(name), 'success') setEditing(false) setBaseline(undefined) }, @@ -304,13 +304,6 @@ export function ProductDetailPage() { }} onSubmit={save} /> - - setSavedName(null)} - message={savedName === null ? undefined : page.saved(savedName)} - /> ) } diff --git a/src/shared/components/NotificationHost.tsx b/src/shared/components/NotificationHost.tsx deleted file mode 100644 index 16f9d8c..0000000 --- a/src/shared/components/NotificationHost.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Alert, Snackbar } from '@mui/material' - -import { useNotificationStore } from '../store/notificationStore' - -const AUTO_HIDE_MS = 6000 - -/** - * Único punto de render de las notificaciones de la app. Se monta una vez en los - * providers; cualquier capa (incluido el interceptor HTTP) notifica escribiendo - * en el store. - * - * Provisional a propósito: usa `Snackbar` + `Alert` de MUI. Cuando TESIS-68 - * defina el componente Toast del design system, se cambia acá adentro y ningún - * consumidor se entera. - */ -export function NotificationHost() { - const notifications = useNotificationStore((state) => state.notifications) - const dismiss = useNotificationStore((state) => state.dismiss) - - // Se muestra una a la vez: apiladas se tapan entre sí y ninguna se lee. - const current = notifications[0] - if (!current) return null - - return ( - dismiss(current.id)} - anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} - > - dismiss(current.id)}> - {current.message} - - - ) -} diff --git a/src/shared/components/NotificationHost/NotificationHost.styles.ts b/src/shared/components/NotificationHost/NotificationHost.styles.ts new file mode 100644 index 0000000..378a852 --- /dev/null +++ b/src/shared/components/NotificationHost/NotificationHost.styles.ts @@ -0,0 +1,22 @@ +import { Alert } from '@mui/material' +import { styled } from '@mui/material/styles' + +// Ancho del aviso del diseño (Alert.dc.html): suficiente para una oración sin +// que el toast cruce la pantalla de lado a lado en escritorio. +const TOAST_MAX_WIDTH = 520 + +// El toast es el aviso del sistema del tema, flotando. Dos ajustes para que +// flote: en dark el fondo tonal es translúcido (la intención al 12%) y dejaría +// ver la pantalla de atrás, así que el tinte se pinta encima del `paper` opaco; +// y lleva la sombra del nivel 2, la de lo que se despliega por encima del resto. +export const Toast = styled(Alert)(({ theme, severity = 'info' }) => { + const tint = theme.palette[severity].container + + return { + width: '100%', + maxWidth: TOAST_MAX_WIDTH, + backgroundColor: theme.palette.background.paper, + backgroundImage: `linear-gradient(${tint}, ${tint})`, + boxShadow: theme.elevation[2].boxShadow, + } +}) diff --git a/src/shared/components/NotificationHost/NotificationHost.test.tsx b/src/shared/components/NotificationHost/NotificationHost.test.tsx new file mode 100644 index 0000000..7209fad --- /dev/null +++ b/src/shared/components/NotificationHost/NotificationHost.test.tsx @@ -0,0 +1,65 @@ +import { act, fireEvent, screen } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { renderWithTheme } from '../../../test/renderWithTheme' +import { notify, useNotificationStore } from '../../store/notificationStore' + +import { NotificationHost } from './NotificationHost' + +afterEach(() => { + act(() => useNotificationStore.setState({ notifications: [] })) +}) + +describe('NotificationHost', () => { + it('renders nothing while there is nothing to say', () => { + renderWithTheme() + + expect(screen.queryByRole('alert')).not.toBeInTheDocument() + }) + + it('shows a notification pushed from outside React', () => { + renderWithTheme() + + act(() => notify('Producto guardado.', 'success')) + + expect(screen.getByRole('alert')).toHaveTextContent('Producto guardado.') + }) + + // Apiladas se tapan entre sí y ninguna se lee: la segunda espera su turno. + it('shows one notification at a time, in order', () => { + renderWithTheme() + + act(() => { + notify('Primera.') + notify('Segunda.') + }) + + expect(screen.getByRole('alert')).toHaveTextContent('Primera.') + expect(screen.queryByText('Segunda.')).not.toBeInTheDocument() + }) + + // Un clic en cualquier otro lado no es "ya lo leí": el toast se va por + // tiempo o por su X, no porque la persona siguió trabajando. + it('stays up when the person clicks elsewhere on the page', async () => { + renderWithTheme() + + act(() => notify('Producto guardado.', 'success')) + // El Snackbar arma su escucha de clics afuera en el tick siguiente. + await act(() => new Promise((resolve) => setTimeout(resolve, 0))) + fireEvent.click(document.body) + + expect(screen.getByRole('alert')).toHaveTextContent('Producto guardado.') + }) + + it('moves on to the next one when the current is closed', () => { + renderWithTheme() + + act(() => { + notify('Primera.') + notify('Segunda.') + }) + fireEvent.click(screen.getByRole('button', { name: 'Cerrar' })) + + expect(screen.getByRole('alert')).toHaveTextContent('Segunda.') + }) +}) diff --git a/src/shared/components/NotificationHost/NotificationHost.tsx b/src/shared/components/NotificationHost/NotificationHost.tsx new file mode 100644 index 0000000..489e645 --- /dev/null +++ b/src/shared/components/NotificationHost/NotificationHost.tsx @@ -0,0 +1,43 @@ +import { Snackbar } from '@mui/material' + +import { useNotificationStore } from '../../store/notificationStore' + +import { Toast } from './NotificationHost.styles' + +const AUTO_HIDE_MS = 6000 + +/** + * Único punto de render de las notificaciones de la app. Se monta una vez en los + * providers; cualquier capa (incluido el interceptor HTTP) notifica con + * `notify()`, que escribe en el store. + * + * Cada notificación es un toast: el aviso del sistema del diseño (el `Alert` + * del tema) con su ícono de intención y el botón de cierre. + */ +export function NotificationHost() { + const notifications = useNotificationStore((state) => state.notifications) + const dismiss = useNotificationStore((state) => state.dismiss) + + // Se muestra una a la vez: apiladas se tapan entre sí y ninguna se lee. + const current = notifications[0] + if (!current) return null + + return ( + { + if (reason !== 'clickaway') dismiss(current.id) + }} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} + > + dismiss(current.id)}> + {current.message} + + + ) +} diff --git a/src/shared/components/NotificationHost/index.ts b/src/shared/components/NotificationHost/index.ts new file mode 100644 index 0000000..40f8fed --- /dev/null +++ b/src/shared/components/NotificationHost/index.ts @@ -0,0 +1 @@ +export { NotificationHost } from './NotificationHost' From 16063b9895ad615732b8692847f5489416efb463 Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 25 Sep 2026 00:04:11 -0300 Subject: [PATCH 4/5] feat: [TESIS-68] show the alerts, toasts and confirmation dialog in the design system page Alerts in their four intents, with a title and with an action, plus buttons that fire the real toast through notify(). The confirmation dialog shows its standard, destructive and rejected variants in the three sizes. Co-Authored-By: Claude Opus 5.5 --- src/features/design-system/content.ts | 60 ++++++++ .../design-system/pages/DesignSystemPage.tsx | 141 +++++++++++++++++- 2 files changed, 200 insertions(+), 1 deletion(-) diff --git a/src/features/design-system/content.ts b/src/features/design-system/content.ts index b4c65c0..0a222c6 100644 --- a/src/features/design-system/content.ts +++ b/src/features/design-system/content.ts @@ -62,6 +62,15 @@ export const dsCopy = { title: 'Tabla de datos estándar', subtitle: 'Pestañas, selección, badges de estado, acciones por fila y paginación', }, + alerts: { + title: 'Avisos del sistema y toasts', + subtitle: + 'Alert — en línea dentro de la pantalla o flotando como toast, en sus cuatro intenciones', + }, + confirm: { + title: 'Modal de confirmación', + subtitle: 'ModalFrame — estándar, destructiva y rechazada; tamaños sm · md · lg', + }, }, progressGroups: { load: 'Columna Load — canal neutro', @@ -421,3 +430,54 @@ export const dataTableCopy = { page: (page: number) => `Ir a la página ${page}`, selectedCount: (count: number) => `${count} seleccionadas`, } as const + +// Las tres variantes del modal de confirmación. La "rechazada" es la destructiva +// después de que el backend dijo que no: queda el motivo y sólo la salida. +export const confirmDialogSamples = { + default: { + trigger: 'Estándar', + title: '¿Confirmar el despacho?', + description: 'Se va a emitir la etiqueta de la orden #ORD-7721-X con Andreani.', + confirm: 'Confirmar despacho', + }, + destructive: { + trigger: 'Destructiva', + title: '¿Eliminar la orden?', + description: 'Se va a eliminar la orden #ORD-7721-X. Esta acción no se puede deshacer.', + confirm: 'Eliminar orden', + }, + rejected: { + trigger: 'Rechazada', + title: '¿Eliminar la orden?', + description: 'Se va a eliminar la orden #ORD-7721-X. Esta acción no se puede deshacer.', + confirm: 'Eliminar orden', + error: 'No se puede eliminar: la orden ya fue despachada.', + }, +} as const + +export type ConfirmSampleKey = keyof typeof confirmDialogSamples + +export const confirmDialogCopy = { + cancel: 'Cancelar', + close: 'Cerrar', + sizeLabel: 'Tamaño del diálogo', +} as const + +// Avisos del sistema (Alert.dc.html). Los mismos cuatro textos sirven en línea y +// como toast: el componente es uno solo, lo que cambia es dónde se monta. +export const alertSamples = [ + { severity: 'success', label: 'Éxito', message: 'Orden #ORD-7721-X procesada.' }, + { severity: 'info', label: 'Información', message: 'Envío pendiente de despacho en CD Ezeiza.' }, + { severity: 'warning', label: 'Advertencia', message: 'Stock bajo en 3 productos del catálogo.' }, + { severity: 'error', label: 'Error', message: 'Falló la sincronización con Tiendanube.' }, +] as const + +export const alertCopy = { + inline: 'En línea', + toast: 'Toast — se dispara el real, por notify()', + withTitle: { + title: 'La orden cambió mientras la editabas', + body: 'Revisá los cambios antes de guardar: guardar ahora pisa lo que cambió la otra persona.', + }, + withAction: { message: 'No pudimos cargar el panel.', action: 'Reintentar' }, +} as const diff --git a/src/features/design-system/pages/DesignSystemPage.tsx b/src/features/design-system/pages/DesignSystemPage.tsx index cee86b5..0002ed9 100644 --- a/src/features/design-system/pages/DesignSystemPage.tsx +++ b/src/features/design-system/pages/DesignSystemPage.tsx @@ -18,6 +18,8 @@ import ViewColumnOutlinedIcon from '@mui/icons-material/ViewColumnOutlined' import VisibilityOutlinedIcon from '@mui/icons-material/VisibilityOutlined' import WarningAmberOutlinedIcon from '@mui/icons-material/WarningAmberOutlined' import { + Alert, + AlertTitle, Box, Button, Card, @@ -25,6 +27,8 @@ import { IconButton, Stack, TextField, + ToggleButton, + ToggleButtonGroup, Typography, useTheme, } from '@mui/material' @@ -32,6 +36,7 @@ import { useState } from 'react' import type { ReactElement, ReactNode } from 'react' import { CompactStatCard, + ConfirmDialog, DataTable, Logo, PageWrapper, @@ -44,19 +49,25 @@ import { StepsProgress, TopNavBar, type DataTableColumn, + type ModalSize, type StatusVariant, type TopNavThemeMode, } from 'shared/components' +import { notify } from 'shared/store' import { FulfillmentPanel } from '../components/FulfillmentPanel' import { OperationalStatusCard } from '../components/OperationalStatusCard' import { + alertCopy, + alertSamples, badgeSamples, badgeSizes, buttonHierarchies, buttonIntents, compactGroups, compactStatSamples, + confirmDialogCopy, + confirmDialogSamples, dataTableCopy, dataTableOrders, dataTableTabs, @@ -77,7 +88,7 @@ import { topNavDemoUser, typeSpecs, } from '../content' -import type { CompactSampleKey, DemoOrder, StatSampleKey } from '../content' +import type { CompactSampleKey, ConfirmSampleKey, DemoOrder, StatSampleKey } from '../content' // Grillas del catálogo: colapsan a una columna en mobile para que las tarjetas // no se compriman. @@ -278,6 +289,122 @@ function DataTableDemo() { ) } +// Los toasts de la vitrina son los reales: `notify()` escribe en el store y los +// dibuja el `NotificationHost` de los providers, como en cualquier pantalla. +function AlertsDemo() { + return ( + + + + {alertCopy.inline} + + {alertSamples.map((sample) => ( + {}}> + {sample.message} + + ))} + + {alertCopy.withTitle.title} + {alertCopy.withTitle.body} + + + {alertCopy.withAction.action} + + } + > + {alertCopy.withAction.message} + + + + + {alertCopy.toast} + + + {alertSamples.map((sample) => ( + + ))} + + + + ) +} + +const CONFIRM_SAMPLE_KEYS = Object.keys(confirmDialogSamples) as ConfirmSampleKey[] +const CONFIRM_SIZES: ModalSize[] = ['sm', 'md', 'lg'] + +// Inerte como el resto del catálogo: confirmar sólo cierra el diálogo. +function ConfirmDialogDemo() { + // La muestra y el estado abierto van separados: al cerrar, el diálogo se + // desvanece con la muestra que tenía, en vez de saltar a otra a mitad del + // fundido. + const [sampleKey, setSampleKey] = useState('default') + const [open, setOpen] = useState(false) + const [size, setSize] = useState('sm') + const sample = confirmDialogSamples[sampleKey] + const close = () => setOpen(false) + + return ( + <> + + { + // Un toggle exclusivo devuelve `null` al reclickear el activo: se + // ignora para que siempre haya un tamaño elegido. + if (next !== null) setSize(next) + }} + aria-label={confirmDialogCopy.sizeLabel} + > + {CONFIRM_SIZES.map((option) => ( + + {option} + + ))} + + {CONFIRM_SAMPLE_KEYS.map((key) => ( + + ))} + + + {'error' in sample ? {sample.error} : null} + + + ) +} + export function DesignSystemPage() { // Demo autocontenido: estado local, no toca el uiStore real. Un catálogo // debe ser inerte — antes clickear la hamburguesa del ejemplo colapsaba el @@ -806,6 +933,18 @@ export function DesignSystemPage() { > + + + +
+ +
+ + + +
+ +
) From 6559d7a81881d185a8f59cd7469ddaf89f1b7ffe Mon Sep 17 00:00:00 2001 From: LauAubert Date: Fri, 25 Sep 2026 00:04:12 -0300 Subject: [PATCH 5/5] docs: [TESIS-68] document the modal frame, the confirmation dialog and the toast Co-Authored-By: Claude Opus 5.5 --- docs/guidelines/architecture.md | 53 +++++++++++++------------- docs/guidelines/component-structure.md | 1 + 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/docs/guidelines/architecture.md b/docs/guidelines/architecture.md index 891ef80..abad108 100644 --- a/docs/guidelines/architecture.md +++ b/docs/guidelines/architecture.md @@ -229,24 +229,26 @@ interface PaginationParams { **Componentes compartidos:** -| Componente | Props | Descripción | -| ------------------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `LoadingSpinner` | `fullScreen?: boolean` | CircularProgress centrado. `fullScreen`: 100vh × 100% | -| `ErrorBoundary` | `children` | Class component que captura errores de render y muestra `ErrorFallback`. Cableado en `AppLayout` alrededor del `` y en cada ruta `bare` | -| `ErrorFallback` | `error?: Error`, `onRetry?: () => void` | Pantalla de error con botón Reintentar (presentacional) | -| `PageWrapper` | `children`, `...BoxProps` | `
` con `p: {xs:2, md:3}`, `maxWidth: 1200`, `mx: auto` | -| `NotificationHost` | — | Render único de las notificaciones del `notificationStore`. Montado en los providers; una a la vez. Usa `Snackbar` + `Alert` de MUI hasta que TESIS-68 defina el Toast del DS | -| `StatCard` / `CompactStatCard` | Ver `StatCard.types.ts` | Tarjeta de KPI: ícono + chip de tendencia o etiqueta, valor destacado, aclaración opcional (`note`) y footer comparativo. `tone: 'error'` suma borde y halo de acento. `loading` reemplaza el valor por un skeleton de la misma altura. La variante condensada es una sola fila. Presentacionales: el valor llega ya formateado | -| `ProgressIndicator` | Ver `ProgressIndicator.types.ts` | Barra lineal con tono semántico. `size` thin/medium/large, `layout` stacked/inline, `indeterminate` para progreso desconocido. El ancho sale del porcentaje | -| `StepsProgress` | `total`, `completed`, `tone?`, `label?`, `caption?` | Progreso por etapas discretas, para procesos con pasos nombrados | -| `ProgressSkeleton` | `label?`, `avatar?`, `lines?` | Placeholder de carga con la silueta del contenido que reemplaza | -| `TopNavBar` | Ver `TopNavBar.types.ts` | Shell de navegación global (brand + búsqueda + acciones + usuario). Presentacional — sin datos, sin `uiStore`; único acople: el `Link` de Router (brand/engranaje). Fixed/z-index intrínsecos vía `MuiAppBar` en el tema. Cableado real en `app/layout/Header.tsx`. | -| `StatusFeed` | Ver `StatusFeed.types.ts` | Bitácora de eventos de alta densidad: barra fina como eje temporal, título y metadato en monoespaciada. `current` marca la entrada vigente; el resto se atenúa. Renderiza `
    ` porque el orden es información | -| `Logo` | `brand`, `tagline` | Lockup de marca (isotipo + wordmark). Las reglas del manual viajan con el componente: área de respeto como padding propio y mínimo de 140px como `minWidth` | -| `LogoMark` | `size?: number` | Isotipo suelto, inline y con `currentColor` para que herede el color del contenedor | -| `DataTable` | Ver `DataTable.types.ts` | Tabla genérica del DS: pestañas, selección, acciones por fila, paginación, `title` y `footer`. `density: 'compact'` baja la fila a 52px para las tablas que viven dentro de un formulario (líneas del alta manual). Presentacional: el formato de cada celda lo resuelve el `render` de su columna | -| `LabeledField` | `label`, `children`, `error?`, `helperText?`, `fullWidth?` | Campo con el rótulo arriba del input, como pide el DS (MUI lo flota en el borde). Renderiza `