feat: add entity list state management and UI components

- Implemented asset and resource list query state helpers to manage loading, error, and availability states.
- Created EntityListStatus component for displaying status messages and retry options based on entity list state.
- Added EntityTableSkeleton component for rendering loading placeholders in tables.
- Defined EntityListState type and entityListState function for consistent state management across components.
- Developed comprehensive UI tests for list states using Playwright, covering various scenarios including loading, error handling, and responsiveness.
This commit is contained in:
David Arranz 2026-10-22 11:16:59 +02:00
parent ba034794c6
commit 9fc7d3a2e0
31 changed files with 1226 additions and 164 deletions

View File

@ -213,8 +213,8 @@ En diálogos anidados, mantener borrador padre y foco de retorno; cancelar selec
| Situación | Convención vigente / limitación conocida |
| --- | --- |
| Carga inicial | Mensaje de estado sin fingir vacío; reservar estructura cuando la integración lo permita. Activos aún desmonta controles, V12. |
| Actualización en segundo plano | TanStack Query gestiona estado remoto; conservar datos y controles disponibles. No duplicar la caché en estado local. |
| Carga inicial | Conservar herramientas, cabeceras conocidas y paginación pendiente; skeletons decorativos sin registros/totales ficticios. Activos y Recursos aplican el estado explícito de [L02.3](docs/estados-listados-l02-3.md); otros consumidores mantienen su contrato. |
| Actualización en segundo plano | TanStack Query gestiona estado remoto; conservar instancia, datos, controles, selección y foco. Indicación discreta en la banda del listado, sin anuncios/toasts repetidos. Si falla, identificar los datos anteriores y ofrecer reintento dirigido. No duplicar la caché en estado local. |
| Sin registros / sin resultados | Distinguir primer registro de filtros sin coincidencias. Mantener CTA de alta o limpieza cuando proceda; EmptyState para incorporación de elementos en pasos. |
| Éxito | Confirmar solo tras respuesta satisfactoria. No equiparar color primario con éxito: FormFeedback mantiene una inconsistencia señalada en la auditoría, sin estado de éxito visual validado. |
| Error | Mensaje recuperable, contexto del campo/formulario/consulta y reintento pertinente; sin SQL, stacks ni secretos. |
@ -276,7 +276,7 @@ V02, V03, V06 y V09 están **resueltos en los escenarios verificados del sprint
| V09 | Resuelto: etiquetas/i18n de seis listados | Cabeceras breves y motivos conservados; tests es/en y navegador |
| V10 | Jerarquía de ficha | Diseñar resumen de vacíos y grupos sin ocultar relaciones/acciones; validar fixture poblada |
| V11 | Renderizadores y semántica de ausencia | Aplicar pauta de «—» distinguiendo estado de negocio |
| V12 | Carga inicial de implementación | Reservar estructura y mensajes precisos; probar recuperación y actualización |
| V12 | Resuelto en los escenarios verificados de L02.3 | Estructura inicial estable en Activos/Recursos, refetch con caché, error diferenciado y recuperación accesible; véase [informe](docs/estados-listados-l02-3.md) |
La auditoría usó una compañía y un portátil. Faltan relaciones pobladas, fichas de otras entidades, todos los pasos de puesto/perfil, guardados positivos, rendimiento con volumen y comprobación de accesibilidad ampliada. Los riesgos detectados solo por código conservan esa categoría; no implican fallo visual demostrado.

View File

@ -744,3 +744,28 @@ compañías sintéticas. Las evidencias y los fallos globales previos figuran en
**V08 queda resuelto en los escenarios verificados. V05, V07 y V12 permanecen pendientes.**
No cambia el estado de los demás hallazgos ni se implementa L02.3.
## Seguimiento L02.3 — Estabilidad de estados V12
El 22 de octubre de 2026, [L02.3](estados-listados-l02-3.md) corrige V12 en los escenarios
verificados. Se conservan íntegros el diagnóstico y las medidas de la sección 11 como
línea base. Antes del cambio se reproduce de nuevo el GET retenido a 1440×900 y 320×640:
cero tablas/búsquedas/configuración, solo cabecera y «Cargando catálogo…».
Activos mantiene ahora la instancia de tabla, búsqueda, cabeceras conocidas y paginación
pendiente, con skeletons decorativos. Catálogo y red tienen disponibilidad y recuperación
independientes; los datos accesorios no bloquean filas. Recursos adopta el mismo estado
sin cambiar sus rutas ni selección. Un refetch conserva datos y contexto; su fallo indica
explícitamente que se muestran datos anteriores. Error inicial, vacío válido, cero
coincidencias y permiso denegado se distinguen. Reintentar devuelve el foco a la búsqueda;
el aviso queda fuera del ancho desplazable de la tabla.
Playwright MCP con login real y datos sintéticos comprueba las siete resoluciones,
retención/liberación, 503/403 simulados, recuperación, plantilla/búsqueda, orden/página,
selección, scroll, foco, borrador, movimiento reducido y es/en. La carga mantiene diez
placeholders sin controles ficticios. En refetch se conservan scrolls de 120/150 px y la
misma tabla; los nuevos textos medidos obtienen contraste de 4,83:1 (carga) y 10,01:1 (error).
El informe documenta suites, capturas abiertas, geometría y fallos técnicos preexistentes.
**V12 queda resuelto en ese alcance; V05 y V07 siguen pendientes y V08 conserva su cierre
L02.2.** No se generaliza la adopción a todos los listados ni se implementan L03/L04.

View File

@ -518,3 +518,25 @@ técnicos fallidos sin presentarlos como suites globales correctas.
**V05, V07 y V12 siguen pendientes.** La normalización añadida solo corresponde al buscador
de columnas; no corrige las búsquedas de entidades. No se cambia el estado de otros hallazgos
ni se acredita WCAG completo o pruebas con datos reales.
## Seguimiento L02.3 — Estados de consulta V12
El 22 de octubre de 2026 se completa [L02.3](estados-listados-l02-3.md) en Activos y Recursos.
**V12 pasa a resuelto en los escenarios verificados**, conservando la auditoría original
como línea base. El GET inicial retenido ya no desmonta búsqueda, herramientas ni tabla;
los placeholders no contienen entidades o acciones ficticias. Catálogo y datos de red
no bloquean indiscriminadamente la consulta principal.
La navegación autenticada con Playwright MCP comprueba carga, refetch retenido/correcto,
503 inicial/posterior simulados, reintento, vacío válido, filtros, 403 y dependencias.
Se conserva la instancia DOM, selección, orden/página, columnas, scroll y foco durante
el refetch. Los mensajes de error no dependen del scroll horizontal; Reintentar devuelve
el foco a la búsqueda persistente. También se comprueban borrador de edición, es/en,
movimiento reducido y contraste de los textos nuevos. La matriz inicial cubre siete
resoluciones entre 320×640 y 1920×1080.
Se utiliza una instalación propia desechable con quince compañías y una principal con
50 personas, 51 activos y 66 recursos. El informe enlaza evidencias locales y resultados
técnicos reales, diferenciando errores de red provocados y fallos globales preexistentes.
**V05/V07 continúan pendientes; V08 mantiene su estado L02.2.** No se declara WCAG completo,
rendimiento de producción ni adopción universal del nuevo contrato.

View File

@ -1180,6 +1180,30 @@ almacenamiento contenga una preferencia contraria. Los datos inválidos o el alm
bloqueado no impiden utilizar la tabla. Cambiar de clave carga las preferencias de la
nueva vista sin escribir sobre ellas la selección de la anterior.
### Estados de consulta de listados — L02.3
`EntityListState` y `entityListState` (`@repo/rdx-ui/lib/entity-list-state`) separan
disponibilidad y actividad sin conocer DTO, TanStack Query, endpoints ni permisos.
La unión tiene estados `loading`, `ready`, `refreshing`, `unavailable` y `stale`;
los errores incluyen `retrying`. El módulo adapta su consulta y aporta mensajes es/en.
`EntityListStatus` compone Alert/Button existentes. Recibe `state`, `retryLabel`,
`onRetry` asíncrono y `retryFocus` opcional para un destino persistente; no carga datos.
La carga tiene estado accesible, el refetch normal evita anuncios repetidos y el error
permite recuperación. `EntityTableSkeleton` reserva filas decorativas, sin controles
ni identidades ficticias, y respeta movimiento reducido.
`EntityTable` admite `state`, `onRetry` y `columnsDisabled` opcionales. Los inventarios
de Activos/Recursos adoptan estos estados con layout `content`, conservando una instancia
de tabla, búsqueda, columnas, página y foco. La banda de estado queda fuera del scroll
horizontal. La búsqueda es el destino de foco de Reintentar. Sin el contrato nuevo,
se conservan `loading`/`unavailable` y comportamiento de selectores legacy. Paginación
puede estar deshabilitada mientras el total es desconocido, sin anunciar registros cero.
Los adaptadores de módulo separan consulta principal, catálogo y referencias accesorias;
una denegación explícita no habilita consulta de caché. Véanse estados, límites de adopción,
matriz responsive y evidencias en [L02.3](estados-listados-l02-3.md).
### Configuración avanzada de columnas — L02.2
`EntityColumn` admite `groupId?: string` y `keywords?: readonly string[]`.

View File

@ -0,0 +1,266 @@
# L02.3 — Estabilidad de listados y estados de consulta (V12)
Fecha: 22 de octubre de 2026. Implementación en Activos y adopción equivalente en Recursos.
Este documento complementa [L01](auditoria-listados-l01.md), [L02.1](selectores-accesibilidad-l02-1.md),
[L02.2](configuracion-columnas-l02-2.md) y el [patrón de listados](patron-de-listados-de-entidades.md).
## 1. Resumen de la implementación
El inventario mantiene su instancia de `AssetList`/`EntityTable` desde la primera petición.
Búsqueda, cabeceras conocidas, configuración y espacio de paginación permanecen reconocibles.
La consulta principal, el catálogo y los datos de red tienen estados y reintentos independientes.
Las actualizaciones conservan la caché de TanStack Query y comunican cuándo fallan.
No se modifican backend, rutas, autenticación, permisos, dependencias ni vendor.
Archivos afectados, agrupados por responsabilidad:
| Área | Archivos principales |
| --- | --- |
| rdx-ui, componentes | `entity-list.tsx`, `entity-list-controls.tsx`, `entity-table-configuration.tsx`, `entity-table-content.tsx`; nuevos `entity-list-status.tsx` y `entity-table-skeleton.tsx`, en `packages/rdx-ui/src/components/`. |
| rdx-ui, contrato/pruebas/textos | Nuevo `src/lib/entity-list-state.ts`, `src/entity-list.test.tsx` y `src/locales/{es,en}.json`. |
| Activos | `modules/assets/src/web/assets/list/controllers/use-inventory-page-controller.ts`, nuevo `list/helpers/asset-list-query-state.ts`, `list/ui/inventory-page.tsx`, `list/ui/asset-workstation-action.tsx`, `shared/ui/asset-list.tsx` y `modules/assets/src/common/locales/{es,en}.json`. |
| Recursos | `modules/resources/src/web/resources/list/controllers/use-resources-page-controller.ts`, nuevo `list/helpers/resource-list-query-state.ts`, `list/ui/resources-page.tsx` y `modules/resources/src/common/locales/{es,en}.json`. |
| Pruebas MCP | Nuevo `scripts/list-states-ui.test.py`; actualización de `scripts/picker-ui.test.py` y `scripts/visual-audit-ui.test.py` para contratos vigentes y datos abundantes. |
| Documentación | Este informe, `DESIGN.md`, `docs/patron-de-listados-de-entidades.md`, `docs/componentes-de-interfaz.md` y los seguimientos de `docs/auditoria-listados-l01.md` / `docs/auditoria-ui-ux-visual.md`. |
## 2. Diagnóstico de consultas
| Consulta / clave real | Datos y necesidad | Disponibilidad, actualización y error |
| --- | --- | --- |
| `inventoryKeys.company(companyId)` → `["inventory", companyId]` | GET `/api/assets/:companyId`: activos, instantáneas, puestos y relaciones. Imprescindible para filas y selección. | Inicialmente `data` indefinido; refetch conserva datos. Error inicial impide operar sobre filas; error posterior permite consultar la copia anterior. |
| `catalogKeys.scope(companyId)` → `["asset-catalog", companyId]` | GET `/api/asset-catalog/:companyId`: plantillas y campos vigentes. Necesario para configurar el catálogo completo y editar/crear. | Independiente del inventario. Las instantáneas permiten consultar filas mientras llega; sin catálogo se explica la limitación y se deshabilitan configuración y alta. Si falla refetch se conservan las definiciones anteriores y se indica su antigüedad. |
| `infrastructureKeys.company(companyId)` → `["asset-infrastructure", companyId]` | GET `/api/asset-infrastructure/:companyId`: roles, interfaces, IP y MAC. Accesorio para el inventario. | Pendiente/error no bloquea filas; aviso localizado y reintento propio. Refetch fallido mantiene los últimos datos de red. |
| `inventoryKeys.company(companyId)` de Resources → `["resource-inventory", companyId]` | GET `/api/resources/:companyId`: recursos y referencias. | Misma separación entre carga, disponibilidad, actualización y error; conserva contratos de selección URL y diálogo explícito. |
| `catalogKeys.scope(companyId)` de Resources → `["resource-catalog", companyId]` | GET `/api/resource-catalog/:companyId`: plantillas de recursos. | No bloquea lectura de instantáneas. Configuración y alta requieren referencias disponibles; el error tiene recuperación propia. |
Estas consultas no dependen unas de otras y se habilitan al montar la página con `companyId`.
Sedes/personas llegan mediante `lookups` desde la consulta de compañía del padre; no se añaden
consultas ni se cambia ese requisito de montaje. Los campos dinámicos se derivan del catálogo
y de las instantáneas reales, sin consulta adicional.
`useListQuery` mantiene su intervalo de 60 s en primer plano, refetch al recuperar foco/conexión
y deduplicación por claves. El cliente real configura `retry: false`: los reintentos añadidos
son explícitos y dirigidos a la consulta correspondiente. No cambia la política global.
Antes, `AssetInventoryPage` condicionaba `AssetList` a `query.data` y agrupaba el pending del
inventario, catálogo e infraestructura. El mensaje genérico «Cargando catálogo…» no identificaba
la consulta retenida. En Recursos, el pending/error combinado podía ocultar datos utilizables.
## 3. Modelo de estados adoptado
`EntityListState` es una unión discriminada presentacional: `loading`, `ready`, `refreshing`,
`unavailable` y `stale`. `entityListState` combina disponibilidad, actividad y fallo; no conoce
TanStack Query. Los adaptadores de módulo leen `data`, `isFetching`, `isError` y `error`.
| Datos disponibles | Consulta | Presentación |
| --- | --- | --- |
| No | Pendiente | `loading`, mensaje específico y skeleton |
| Sí, incluidos cero registros | Correcta | `ready`; vacío solo si la respuesta válida está vacía |
| Sí | En curso | `refreshing`, filas anteriores y aviso discreto |
| No | Fallida | `unavailable`, error y reintento recuperable |
| Sí | Fallida | `stale`, copia anterior, aviso explícito y reintento |
Los errores conservan `retrying` para deshabilitar el reintento duplicado e indicar actividad.
No se guardan DTO, marcas temporales ni estados de consulta duplicados en React. Se opta por
explicar «últimos datos disponibles» sin introducir una hora ni duplicar `dataUpdatedAt`.
## 4. Cambios de composición
Activos monta siempre `AssetList`, sin keys dependientes de la consulta. Su controlador deriva
el conjunto legible de la caché; Resources aplica la misma regla. `EntityTable` conserva el hook
de tabla y acepta `state`, `onRetry` y `columnsDisabled` opcionales. Los consumidores legacy,
incluidos PickerList y selectores remotos, mantienen sus contratos existentes.
Las páginas deciden permisos, dependencias y operaciones; rdx-ui solo recibe textos y callbacks.
`useContextualDetail`, presupuestos de contenedor, navegación y `EntityNotFound` se conservan.
Un error transitorio no confirma que haya desaparecido una selección. Una respuesta correcta
que la retire sigue utilizando el contrato de desaparición existente.
## 5. Carga inicial
La búsqueda y ordenación de columnas conocidas funcionan durante la espera; sus valores se
mantienen cuando llegan los datos. Diez placeholders como máximo reservan una página plausible,
con las clases de las columnas reales. Son `aria-hidden`, sin nombres de entidades, selección,
links ni botones de acción. No se anuncian diez registros ni se muestra un total cero inventado.
Paginación/tamaño de página permanecen visibles pero deshabilitados hasta disponer de datos.
El catálogo pendiente tiene un marcador decorativo y un mensaje contextual cuando hay filas.
Las pestañas y columnas solo utilizan definiciones/instantáneas realmente conocidas. La alta
espera a inventario y catálogo válidos; los datos de red pendientes no bloquean el inventario.
## 6. Refetch y datos anteriores
«Actualizando activos…»/«Updating assets…» sustituye el resumen en su banda habitual, sin spinner
modal, skeleton completo ni toast por cada intervalo. Las filas y herramientas no se desmontan.
Se conservan búsqueda, plantilla, orden, página válida, preferencias, selección, scroll y foco
durante la petición. El refetch normal del catálogo/red no añade bandas que muevan el listado.
## 7. Error y recuperación
El error inicial y el posterior aparecen en la banda de estado, fuera del ancho desplazable de
las columnas. Así, el mensaje y Reintentar no requieren scroll horizontal en móvil. La tabla
conserva cabeceras y espacio corporal, sin simular registros.
Un 503 posterior comunica que se muestran datos anteriores; permite búsqueda y consulta.
Alta/edición y operaciones sensibles se restringen cuando su consulta necesaria no es válida;
el borrador ya abierto se conserva ante un error transitorio. Los reintentos de catálogo/red
no invalidan indiscriminadamente otras consultas.
Una denegación `InventoryApiError` con `FORBIDDEN` oculta los datos en caché del inventario y
muestra falta de permiso sin prometer recuperación mediante Reintentar. Se conserva la
navegación a otros destinos. No se altera la autorización del servidor.
## 8. Estados vacíos
La respuesta válida sin activos conserva «Esta compañía todavía no tiene activos.» y su alta
de cabecera, cuando las referencias están listas. Cero coincidencias conserva la búsqueda y
«Limpiar filtros». Un error no utiliza ninguno de esos mensajes. `—` sigue reservado para
valores ausentes/controles sin valor conocido; no sustituye los estados de consulta.
## 9. Componentes compartidos
- `lib/entity-list-state.ts`: unión y derivación pura, incluida compatibilidad legacy.
- `EntityListStatus`: estado, mensaje y recuperación con Alert/Button existentes; destino de
foco opcional `retryFocus`, sin DTO, query keys ni permisos.
- `EntityTableSkeleton`: placeholders decorativos con geometría de columnas conocidas.
- `EntityTable`: instancia estable y estado explícito; `EntityTableConfiguration` permite
deshabilitar la configuración dependiente; `EntityListPagination` distingue total desconocido.
Adopción nueva limitada a los inventarios de Activos/Recursos con layout `content`. No se
convierte ningún selector remoto en tabla local ni se generalizan estados de consultas ajenas.
## 10. Accesibilidad
La carga inicial tiene un único `role=status`; los skeletons no participan en el foco.
La tabla señala `aria-busy` durante carga/refetch normal. El refetch periódico no genera
anuncios live repetidos. Los errores utilizan el Alert existente y texto explícito, además
del color. Los controles pendientes tienen explicación contextual en es/en.
Reintentar funciona con Enter y enfoca la búsqueda persistente antes de iniciar la recuperación;
así su desaparición no deja el foco en `body`. El destino se aporta desde el consumidor.
Las animaciones nuevas respetan `prefers-reduced-motion`. Estas comprobaciones no acreditan
cumplimiento WCAG completo ni sustituyen pruebas con lector de pantalla.
## 11. Resultados responsive
La matriz de carga inicial comprende 1920×1080, 1440×900, 1366×768, 1024×768, 768×1024,
390×844 y 320×640. Se comprueba estructura, búsqueda habilitada, paginación pendiente,
placeholders sin controles y ausencia de overflow horizontal global. La tabla conserva
su scroll horizontal local. Paneles siguen los mínimos V01.3 (1144 px) y V01.4 (984 px).
La línea base retenida antes del cambio tenía **cero tablas y cero controles de búsqueda y
configuración**, tanto a 1440×900 como a 320×640. La corrección conserva una tabla y sus
herramientas. Las medidas completas, incluidas posiciones, se guardan junto a las capturas.
En la prueba de refetch a 1920×1080 se usan página 2, orden descendente, selección contextual,
columnas opcionales y scroll real: horizontal 120 px y vertical 150 px. Durante la espera se
conservan ambos scrolls, foco e identidad DOM; la cabecera de tabla permanece en y=230 px.
El aviso de fallo posterior ocupa espacio adicional (26 px en ese escenario), sin desmontar
filas ni reiniciar scroll/selección. No se promete layout idéntico ante mensajes de error.
## 12. Pruebas
| Comprobación ejecutada | Resultado real |
| --- | --- |
| `pnpm --filter @repo/rdx-ui typecheck`, `@metis/assets typecheck`, `@metis/resources typecheck` | Correctas las tres comprobaciones dirigidas. |
| `pnpm --filter @repo/rdx-ui test` | 11/11 ficheros; el fichero de listado ejecutado directamente obtiene 27/27 casos. |
| `pnpm --filter @metis/assets test` / `@metis/resources test` | 27/27 y 11/11 ficheros. |
| `pnpm exec biome check <archivos TS/TSX/JSON afectados>` / `pnpm exec eslint <TS/TSX afectados>` | Correctos, sin diagnósticos nuevos. |
| `pnpm typecheck` | Falla en `packages/shadcn-ui/src/components/calendar.tsx:165`, TS2375 de `locale` con `exactOptionalPropertyTypes`; mismo fallo vendor previo. La interrupción global no sustituye los typechecks dirigidos. |
| `pnpm lint` | Falla: 15 errores, un warning y cuatro infos preexistentes. |
| `pnpm check` | Falla: 168 errores, un warning y cuatro infos preexistentes. |
| `pnpm test` | Falla: 7/8 tareas correctas; dos expectativas SSR antiguas de Compañías (`Personas (1)` y el mensaje de acceso de persona). Ejecutada contra otra MariaDB temporal `metis_desk_test`, sin utilizar la BD del navegador. |
| `pnpm build` | Correcto, 2/2 tareas; persiste el aviso de chunk mayor de 500 kB. |
| MCP, L02.3 | 7/7 métodos principales + 1/1 adicional de plantilla/contraste/reintento móvil, **ocho escenarios automatizados distintos correctos**. |
| MCP, regresiones seleccionadas | 9/9: navegación móvil/teclado/rutas, capacidad del shell y Activos, contraste, asistentes, foco, capacidad de Recursos y edición anidada. |
| MCP, L02.2 | 8/8 métodos correctos. |
| MCP, L02.1 | 8/8 métodos correctos tras actualizar las expectativas del configurador L02.2. |
Se ejecutan **33 métodos MCP distintos**; las repeticiones de diagnóstico no incrementan ese
total. Las pruebas unitarias nuevas cubren ocho combinaciones de consulta,
compatibilidad legacy, estructura inicial, ausencia de registros ficticios, datos anteriores
y distinción entre error, vacío válido y filtros en es/en.
`scripts/list-states-ui.test.py` intercepta exclusivamente GET del entorno sintético: retención
y liberación inicial/refetch, 503 inicial/posterior, reintento real, catálogo/red independientes,
403, respuesta vacía válida, filtros, edición conservada, movimiento reducido y traducción.
No cambia opciones internas de QueryClient para simular los estados; la reconexión controlada
dispara el mecanismo normal del consumidor.
Ejecutar con la sesión privada activa del preparador, sin escribir su contenido ni guardarla
en Git. El procedimiento de [Playwright MCP](playwright-mcp.md) explica cómo obtenerla:
```bash
METIS_PLAYWRIGHT_STORAGE_STATE=.artifacts/playwright/authenticated-ID/session.json \
python3 scripts/list-states-ui.test.py
```
La suite contiene ya los ocho métodos. La ejecución documentada separó el octavo, incorporado
al final, de los siete primeros. Para las regresiones se seleccionaron los nueve métodos
indicados, no los 28 del fichero entero. La consola final de estas regresiones y la de columnas
no registra errores/warnings. En las simulaciones L02.3 aparecen los 503 deliberados del GET
interceptado; no se clasifican como fallos espontáneos.
## 13. Evidencias
Directorio local ignorado: `.artifacts/playwright/l02-3/`. Contiene baseline `before/`, capturas,
snapshots, geometría, foco, consola y escrituras bloqueadas en `after/`, y logs de validación.
Los errores HTTP 503/403 se provocan deliberadamente y no representan incidencias espontáneas.
El índice distingue ejecuciones intermedias y finales. Las imágenes no se versionan ni se
incluyen sesiones privadas; su entrega fuera de esta instalación requiere compartir esos
PNG/mediciones de forma explícita.
Índice local: `.artifacts/playwright/l02-3/indice-evidencias.md`. Capturas representativas
abiertas: baseline retenido 1440, carga nueva 1440, error inicial 320, error posterior 1920,
catálogo fallido 1440 y reintento visible 320. El contraste real del texto obtiene 4,83:1 en
carga y 10,01:1 en error sobre sus fondos computados; son dos muestras concretas, no una
auditoría integral de contraste.
Se reutiliza `scripts/visual-audit.py`, login real y usuario ordinario: una única instalación
propia desechable, exclusivamente `metis_desk_test`, con **15 compañías**, y una principal con
**50 personas, 51 activos y 66 recursos** mediante el preparador de volumen existente.
La sesión anterior encontrada devolvía 401 y no se reutilizó ni se eliminaron servicios ajenos.
Al terminar se cierra el preparador propio: salida correcta, servicios temporales retirados y
comprobación explícita de que su `session.json` ya no existe. Las capturas se conservan sin sesión.
## 14. Regresiones
Las suites de columnas L02.2, selectores L02.1 y los casos seleccionados de
`visual-audit-ui.test.py` comprueban navegación móvil/portátil, presupuestos de inventario,
detalle de Recursos, contraste de selección, asistentes y retorno de foco. Se preservan
visibilidad persistente, IDs de campos y acciones ancladas. Los tests anteriores deben esperar
datos disponibles explícitamente: la existencia de la tabla ya no implica que acabó el GET.
Las primeras ejecuciones detectaron supuestos obsoletos en pruebas: un único botón Editar,
etiquetas DOM sin la marca Obligatoria, `selection` configurable y todos los destinos visibles
sin scroll con quince compañías. Se actualizan las expectativas al contrato L02.2 y al volumen
real, usando nombre accesible para la identidad protegida y comprobando que el foco hace visible
Inicio en el menú desplazable. No se cambia el shell para hacer pasar esas expectativas.
Los instrumentos nuevos también se corrigieron antes de la ejecución final: esperar promesas
de Locator, limitar Reintentar a su listado (hay otros observadores de la misma consulta) y
esperar las compuertas en el contexto Node del MCP. Se conservan sus logs fallidos intermedios.
## 15. Limitaciones
No se acredita rendimiento de producción, otros motores, lectores de pantalla ni acceso a
datos reales. La sesión y el navegador son efímeros. Las interceptaciones no prueban una caída
real del backend. Compañía/lookups conserva su carga exterior; otros listados legacy no adoptan
automáticamente el nuevo estado. Siguen vigentes los fallos globales preexistentes de calendario
vendor, lint/check y dos expectativas SSR de Compañías; no se corrigen fuera de alcance.
Skills leídas: `ux-tables` principal; `ux-empty-states`, `ux-error-recovery`, `ux-microcopy`,
`shadcn`, `vercel-composition-patterns` y `vercel-react-best-practices` complementan estados,
mensajes, primitivas reales, composición y derivación sin efectos. Prevalecen DESIGN y contratos
de Metis. Los auditores `*-audit` y `docs/orchestration-policy.md` referidos por algunas skills
no están instalados/disponibles; no se instalan ni se afirma haberlos ejecutado.
## 16. Pendientes de L03 y L04
V05 (restauración entre rutas) y V07 (normalización general de búsqueda) permanecen pendientes.
V08 sigue resuelto en los escenarios L02.2, sin reordenación ni densidades nuevas.
L03 conserva su alcance futuro; L04 podrá valorar adopciones de estado en otros consumidores,
sin refactor masivo de consultas ni paginación/virtualización nuevas en esta entrega.
**V12 queda resuelto en los escenarios descritos y ejecutados.** No se cambia el estado de
otros hallazgos ni se declara que todos los listados del monorepo adopten este contrato.

View File

@ -99,6 +99,20 @@ Usar tabla semántica con nombre accesible, th con scope, controles etiquetados,
Carga inicial y errores se presentan dentro del contenido sin afirmar que no hay registros. El vacío distingue ausencia de datos y búsqueda sin resultados. Mantener el mensaje y la recuperación de errores de cada consulta; no inventar resultados ni endpoints. Las recargas conservan datos y controles cuando la integración existente lo permite.
[L02.3](estados-listados-l02-3.md) define el estado explícito de Activos y Recursos:
`loading`, `ready`, `refreshing`, `unavailable` y `stale`. Mantener la instancia de tabla,
búsqueda, cabeceras conocidas y espacio de paginación desde el GET inicial. Skeletons
decorativos sin acciones, nombres ni totales inventados; controles dependientes de datos
pendientes deshabilitados con explicación. Separar errores del inventario, catálogo y
referencias accesorias. Una dependencia de red pendiente no bloquea todas las filas.
En refetch conservar la caché y el contexto sin copiar DTO a estado React. El fallo posterior
debe indicar «últimos datos disponibles» y ofrecer reintento dirigido. Situar error y
Reintentar fuera del ancho desplazable de las columnas; no esconderlos en una celda ancha.
Al reintentar, enfocar la búsqueda persistente para evitar que la desaparición del botón
deje el foco en el documento. Las denegaciones explícitas siguen el contrato de permisos
del módulo y no se representan como vacío ni como una copia consultable sin autorización.
## Inventario de listados y ubicaciones actuales
La aplicación navega por espacios y secciones mediante rutas URL independientes, según [Rutas del cliente](rutas-del-cliente.md). Los componentes siguientes se componen desde CompaniesWorkspace, CompanyDetail o IdentityWorkspace; altas, ediciones e historiales conservan sus modales.

View File

@ -665,5 +665,28 @@
"SMB_RESOURCE_INACTIVE": "The share is inactive.",
"SMB_RESOURCE_NOT_FOUND": "This share is no longer available."
}
},
"listState": {
"forbidden": "You do not have permission to view this company information.",
"retryCatalog": "Retry catalog",
"retryInfrastructure": "Retry infrastructure",
"inventory": {
"loading": "Loading assets…",
"unavailable": "Assets could not be loaded. You can try again.",
"refreshing": "Updating assets…",
"stale": "Assets could not be updated. Showing the last available data."
},
"catalog": {
"loading": "Loading assets templates. Column configuration and creation will be available when loading finishes.",
"unavailable": "The catalog could not be loaded. Snapshot data is shown; column configuration and creation are unavailable.",
"refreshing": "Updating catalog…",
"stale": "The catalog could not be updated. Showing the last available definitions."
},
"infrastructure": {
"loading": "Loading network data; the asset inventory is already available.",
"unavailable": "Network data could not be loaded. The asset inventory remains available.",
"refreshing": "Updating network data…",
"stale": "Network data could not be updated. Showing the last available network data."
}
}
}

View File

@ -665,5 +665,28 @@
"SMB_RESOURCE_INACTIVE": "La carpeta está inactiva.",
"SMB_RESOURCE_NOT_FOUND": "La carpeta ya no está disponible."
}
},
"listState": {
"forbidden": "No tienes permiso para consultar esta información de la compañía.",
"retryCatalog": "Reintentar catálogo",
"retryInfrastructure": "Reintentar infraestructura",
"inventory": {
"loading": "Cargando activos…",
"unavailable": "No se pudieron cargar los activos. Puedes volver a intentarlo.",
"refreshing": "Actualizando activos…",
"stale": "No se pudieron actualizar los activos. Se muestran los últimos datos disponibles."
},
"catalog": {
"loading": "Cargando las plantillas de activos. La configuración de columnas y el alta estarán disponibles al terminar.",
"unavailable": "No se pudo cargar el catálogo. Se muestran los datos de las instantáneas; la configuración de columnas y el alta no están disponibles.",
"refreshing": "Actualizando catálogo…",
"stale": "No se pudo actualizar el catálogo. Se conservan sus últimas definiciones disponibles."
},
"infrastructure": {
"loading": "Cargando los datos de red; el inventario de activos ya está disponible.",
"unavailable": "No se pudieron cargar los datos de red. El inventario de activos sigue disponible.",
"refreshing": "Actualizando datos de red…",
"stale": "No se pudieron actualizar los datos de red. Se muestran los últimos datos de red disponibles."
}
}
}

View File

@ -11,6 +11,7 @@ import {
import { inventoryKeys } from "../../../shared/queries";
import { removeAsset } from "../../shared/api";
import { getAssetInventory } from "../api";
import { assetListQueryDenied } from "../helpers/asset-list-query-state";
import { INVENTORY_DETAIL_BUDGET } from "../helpers/inventory-detail-layout";
import type { AssetInventoryPageProps } from "../types";
@ -99,14 +100,15 @@ export function useAssetInventoryPageController({
setSelectedId(id);
};
const assets = query.data?.assets ?? [];
const denied = assetListQueryDenied(query.error);
const assets = denied ? [] : (query.data?.assets ?? []);
const selected = selectedId ? assets.find((asset) => asset.id === selectedId) : undefined;
const edited = assets.find((asset) => asset.id === editor);
const deleted = assets.find((asset) => asset.id === deleteId);
return {
loading: query.isPending || catalog.isPending || infrastructure.isPending,
hasLoadError: query.isError || catalog.isError || infrastructure.isError,
assets,
denied,
query,
infrastructure,
entityHref,

View File

@ -0,0 +1,33 @@
import type { EntityListState } from "@repo/rdx-ui/lib/entity-list-state";
import { entityListState } from "@repo/rdx-ui/lib/entity-list-state";
import type { UseQueryResult } from "@tanstack/react-query";
import type { TFunction } from "i18next";
import { InventoryApiError } from "../../../shared/api/inventory-api-error";
/** Una denegación explícita no permite seguir mostrando la copia en caché. */
export function assetListQueryDenied(error: Error | null): boolean {
return error instanceof InventoryApiError && error.code === "FORBIDDEN";
}
export function assetListQueryState(
query: Pick<UseQueryResult<unknown>, "data" | "isFetching" | "isError" | "error">,
kind: "inventory" | "catalog" | "infrastructure",
t: TFunction<"assets">
): EntityListState {
const denied = assetListQueryDenied(query.error);
return entityListState(
{
available: query.data !== undefined && !denied,
fetching: query.isFetching,
failed: query.isError,
},
{
loading: t(`listState.${kind}.loading`),
unavailable: denied ? t("listState.forbidden") : t(`listState.${kind}.unavailable`),
refreshing: t(`listState.${kind}.refreshing`),
stale: t(`listState.${kind}.stale`),
}
);
}

View File

@ -12,11 +12,13 @@ export function AssetWorkstationAction({
workstations,
template,
onCreate,
disabled = false,
}: {
asset: AssetDTO;
workstations: readonly WorkstationDTO[];
template: CatalogItemDTO | undefined;
onCreate: (assetId: string) => void;
disabled?: boolean;
}) {
const { t } = useTranslation("assets");
const action = assetWorkstationAction(asset, workstations, template);
@ -41,6 +43,7 @@ export function AssetWorkstationAction({
return action.kind === "create" ? (
<Button
className="h-auto justify-start px-0 py-1 text-left whitespace-normal"
disabled={disabled}
onClick={() => onCreate(asset.id)}
size="sm"
type="button"

View File

@ -4,19 +4,19 @@ import {
EntityListHeader,
EntityRowActions,
} from "@repo/rdx-ui/components/entity-list";
import { EntityListStatus } from "@repo/rdx-ui/components/entity-list-status";
import { contextualDetailColumns } from "@repo/rdx-ui/lib/contextual-detail-layout";
import { BoxIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { CatalogError } from "../../../catalog";
import { InventoryFeedback } from "../../../shared";
import { WorkstationEditorDialog } from "../../../workstations/shared/ui/workstation-editor-dialog";
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
import { AssetList } from "../../shared/ui/asset-list";
import { useAssetInventoryPageController } from "../controllers";
import { assetListQueryState } from "../helpers/asset-list-query-state";
import { INVENTORY_DETAIL_BUDGET } from "../helpers/inventory-detail-layout";
import type { AssetInventoryPageProps } from "../types";
@ -37,8 +37,8 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
query,
catalog,
infrastructure,
loading,
hasLoadError,
assets,
denied,
templateId,
setTemplateId,
clearFilters,
@ -64,6 +64,10 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
} = useAssetInventoryPageController(props);
const { containerRef: inventoryRef } = detail;
const { t, i18n } = useTranslation("assets");
const inventoryState = assetListQueryState(query, "inventory", t);
const catalogState = assetListQueryState(catalog, "catalog", t);
const infrastructureState = assetListQueryState(infrastructure, "infrastructure", t);
const canMutate = query.isSuccess && catalog.isSuccess;
useLayoutEffect(() => {
if (missingView && document.activeElement === document.body) {
@ -142,7 +146,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
</Button>
)}
<Button
disabled={!(catalog.data && query.data)}
disabled={!canMutate}
onClick={() => openEditor("new")}
ref={newAssetButtonRef}
>
@ -157,102 +161,104 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
title={t("inventory")}
/>
{loading && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error} />
<CatalogError error={catalog.error} />
{infrastructure.isError && (
<p className="text-sm text-destructive" role="alert">
{t("assetColumns.infrastructureError")}
</p>
{query.data && !denied && (
<>
<EntityListStatus
onRetry={() => catalog.refetch()}
retryFocus={() =>
inventoryRef.current?.querySelector<HTMLInputElement>("input[data-quick-search]") ??
null
}
retryLabel={t("listState.retryCatalog")}
state={catalogState.status === "refreshing" ? undefined : catalogState}
/>
<EntityListStatus
onRetry={() => infrastructure.refetch()}
retryFocus={() =>
inventoryRef.current?.querySelector<HTMLInputElement>("input[data-quick-search]") ??
null
}
retryLabel={t("listState.retryInfrastructure")}
state={infrastructureState.status === "refreshing" ? undefined : infrastructureState}
/>
</>
)}
{hasLoadError && (
<Button
onClick={() => {
Promise.all([query.refetch(), catalog.refetch(), infrastructure.refetch()]).catch(
() => undefined
);
}}
variant="outline"
>
{t("retry")}
</Button>
)}
{query.data && (
<AssetList
assets={query.data.assets}
catalog={catalog.data?.items ?? []}
columnVisibilityStorageKey="asset-inventory-columns"
extraColumns={[
{
id: "workstation",
label: t("workstationInAsset"),
hideable: false,
render: (asset) => (
<AssetWorkstationAction
asset={asset}
onCreate={setWorkstationAssetId}
template={catalog.data?.items.find(
(item) => item.id === asset.values.templateId
)}
workstations={query.data?.workstations ?? []}
/>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (asset) => (
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => openEditor(asset.id),
disabled: !catalog.data,
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => navigateTo(entityHref(asset.id))}>
{t("viewAsset")}
</EntityAction>
<AssetList
assets={assets}
catalog={catalog.data?.items ?? []}
catalogState={catalogState}
columnVisibilityStorageKey="asset-inventory-columns"
extraColumns={[
{
id: "workstation",
label: t("workstationInAsset"),
hideable: false,
render: (asset) => (
<AssetWorkstationAction
asset={asset}
disabled={!canMutate}
onCreate={setWorkstationAssetId}
template={catalog.data?.items.find((item) => item.id === asset.values.templateId)}
workstations={query.data?.workstations ?? []}
/>
),
},
{
id: "actions",
label: t("actions"),
hideable: false,
render: (asset) => (
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => openEditor(asset.id),
disabled: !canMutate,
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => navigateTo(entityHref(asset.id))}>
{t("viewAsset")}
</EntityAction>
<EntityAction
className="text-destructive data-highlighted:text-destructive"
onClick={() => setDeleteId(asset.id)}
>
{t("deleteAsset")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
infrastructure={infrastructure.data?.items ?? []}
onClearFilters={clearFilters}
onRowClick={(asset) => selectAsset(asset.id)}
onSearchChange={setSearch}
onTemplateChange={setTemplateId}
renderName={(asset) => (
<BrowserLink
aria-pressed={selected?.id === asset.id && detailOpen}
className="cursor-pointer text-left font-semibold wrap-anywhere app-link text-primary hover:text-primary focus-visible:rounded-lg focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
href={entityHref(asset.id)}
>
{asset.values.name}
</BrowserLink>
)}
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
search={search}
sites={lookups.sites}
templateId={templateId}
/>
)}
<EntityAction
className="text-destructive data-highlighted:text-destructive"
onClick={() => setDeleteId(asset.id)}
>
{t("deleteAsset")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),
},
]}
infrastructure={infrastructure.data?.items ?? []}
onClearFilters={clearFilters}
onRetry={denied ? undefined : () => query.refetch()}
onRowClick={(asset) => selectAsset(asset.id)}
onSearchChange={setSearch}
onTemplateChange={setTemplateId}
renderName={(asset) => (
<BrowserLink
aria-pressed={selected?.id === asset.id && detailOpen}
className="cursor-pointer text-left font-semibold wrap-anywhere app-link text-primary hover:text-primary focus-visible:rounded-lg focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-4 focus-visible:outline-ring"
href={entityHref(asset.id)}
>
{asset.values.name}
</BrowserLink>
)}
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
search={search}
sites={lookups.sites}
state={inventoryState}
templateId={templateId}
/>
</div>
<AssetSelectionPanel
asset={selected}
canEdit={catalog.isSuccess}
canEdit={canMutate}
companyId={companyId}
lookups={lookups}
onClose={() => detail.setVisible(false)}
@ -262,7 +268,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
open={detailOpen && detail.mode === "inline"}
template={catalog.data?.items.find((item) => item.id === selected?.values.templateId)}
/>
{editor && catalog.data && (
{editor && !denied && catalog.data && (
<AssetEditorDialog
asset={edited}
companyId={companyId}

View File

@ -1,6 +1,8 @@
import type { EntityColumn } from "@repo/rdx-ui/components/entity-list";
import { EntityStatus, EntityTable } from "@repo/rdx-ui/components/entity-list";
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
import type { EntityListState } from "@repo/rdx-ui/lib/entity-list-state";
import { Skeleton } from "@repo/shadcn-ui/components/skeleton";
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
@ -35,6 +37,9 @@ export function AssetList({
layout = "content",
loading = false,
unavailable = false,
state,
catalogState,
onRetry,
}: {
assets: readonly AssetDTO[];
catalog: readonly CatalogItemDTO[];
@ -54,6 +59,9 @@ export function AssetList({
layout?: "content" | "fill";
loading?: boolean;
unavailable?: boolean;
state?: EntityListState | undefined;
catalogState?: EntityListState;
onRetry?: (() => Promise<unknown>) | undefined;
}) {
const { t, i18n } = useTranslation("assets");
const templates = new Map(
@ -98,6 +106,9 @@ export function AssetList({
locale={i18n.language}
value={templateId ?? "all"}
/>
{catalogState?.status === "loading" && (
<Skeleton aria-hidden="true" className="h-2 w-28 motion-reduce:animate-none" />
)}
<TabsContent
className={`min-w-0 ${layout === "fill" ? "flex min-h-0 flex-col" : ""}`}
keepMounted
@ -210,6 +221,9 @@ export function AssetList({
.filter((column) => column.id === "actions")
.map((column) => ({ ...column, groupId: "general" })),
]}
columnsDisabled={
catalogState?.status === "loading" || catalogState?.status === "unavailable"
}
columnVisibilityStorageKey={columnVisibilityStorageKey}
emptyMessage={t("noAssets")}
entityLabel={
@ -222,11 +236,13 @@ export function AssetList({
loading={loading}
locale={i18n.language}
onClearFilters={onClearFilters}
onRetry={onRetry}
onRowClick={onRowClick}
onSearchChange={onSearchChange}
rowClassName={rowClassName}
search={search}
searchPlaceholder={t("search")}
state={state}
title={t("inventory")}
unavailable={unavailable}
/>

View File

@ -251,5 +251,21 @@
"copy": "Copy UNC address",
"copied": "Address copied",
"error": "Copy failed. Select the address and copy it manually."
},
"listState": {
"forbidden": "You do not have permission to view this company information.",
"retryCatalog": "Retry catalog",
"inventory": {
"loading": "Loading resources…",
"unavailable": "Resources could not be loaded. You can try again.",
"refreshing": "Updating resources…",
"stale": "Resources could not be updated. Showing the last available data."
},
"catalog": {
"loading": "Loading resources templates. Column configuration and creation will be available when loading finishes.",
"unavailable": "The catalog could not be loaded. Snapshot data is shown; column configuration and creation are unavailable.",
"refreshing": "Updating catalog…",
"stale": "The catalog could not be updated. Showing the last available definitions."
}
}
}

View File

@ -251,5 +251,21 @@
"copy": "Copiar dirección UNC",
"copied": "Dirección copiada",
"error": "No se ha podido copiar. Selecciona la dirección y cópiala manualmente."
},
"listState": {
"forbidden": "No tienes permiso para consultar esta información de la compañía.",
"retryCatalog": "Reintentar catálogo",
"inventory": {
"loading": "Cargando recursos…",
"unavailable": "No se pudieron cargar los recursos. Puedes volver a intentarlo.",
"refreshing": "Actualizando recursos…",
"stale": "No se pudieron actualizar los recursos. Se muestran los últimos datos disponibles."
},
"catalog": {
"loading": "Cargando las plantillas de recursos. La configuración de columnas y el alta estarán disponibles al terminar.",
"unavailable": "No se pudo cargar el catálogo. Se muestran los datos de las instantáneas; la configuración de columnas y el alta no están disponibles.",
"refreshing": "Actualizando catálogo…",
"stale": "No se pudo actualizar el catálogo. Se conservan sus últimas definiciones disponibles."
}
}
}

View File

@ -13,6 +13,7 @@ import { catalogApi, catalogKeys } from "../../../catalog/shared/api";
import { inventoryApi, inventoryKeys } from "../../shared/api";
import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys";
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
import { resourceListQueryDenied } from "../helpers/resource-list-query-state";
import type { ResourcesPageProps } from "../types";
function initialEditor(create: boolean) {
@ -135,23 +136,25 @@ export function useResourcesPageController({
params.delete("template");
});
const edited = query.data?.resources.find((item) => item.id === editor);
const selected = query.data?.resources.find((item) => item.id === selectedId);
const explicitView = query.data?.resources.find((item) => item.id === localViewId);
const denied = resourceListQueryDenied(query.error);
const resources = denied ? [] : (query.data?.resources ?? []);
const edited = resources.find((item) => item.id === editor);
const selected = resources.find((item) => item.id === selectedId);
const explicitView = resources.find((item) => item.id === localViewId);
const viewed =
explicitView ?? (detail.mode === "overlay" && detail.visible ? selected : undefined);
const visible =
query.data?.resources.filter((item) => {
if (templateId && item.values.templateId !== templateId) {
return false;
}
const visible = resources.filter((item) => {
if (templateId && item.values.templateId !== templateId) {
return false;
}
return `${item.values.name} ${item.specification.templateName}`
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase());
}) ?? [];
return `${item.values.name} ${item.specification.templateName}`
.toLocaleLowerCase()
.includes(search.toLocaleLowerCase());
});
return {
canChangeStatus: query.isSuccess && !statusMutation.isPending,
deleteId,
setDeleteId,
statusMutation,
@ -190,7 +193,7 @@ export function useResourcesPageController({
detailOpen: detail.visible && Boolean(selected) && !explicitView,
missingView: Boolean(selectedId && query.isSuccess && !selected),
visible,
loading: query.isPending || catalog.isPending,
unavailable: (!query.data && query.isError) || (!catalog.data && catalog.isError),
denied,
deleting: resources.find((resource) => resource.id === deleteId),
};
}

View File

@ -0,0 +1,32 @@
import type { EntityListState } from "@repo/rdx-ui/lib/entity-list-state";
import { entityListState } from "@repo/rdx-ui/lib/entity-list-state";
import type { UseQueryResult } from "@tanstack/react-query";
import type { TFunction } from "i18next";
import { InventoryApiError } from "../../shared/api/inventory-api";
export function resourceListQueryDenied(error: Error | null): boolean {
return error instanceof InventoryApiError && error.code === "FORBIDDEN";
}
export function resourceListQueryState(
query: Pick<UseQueryResult<unknown>, "data" | "isFetching" | "isError" | "error">,
kind: "inventory" | "catalog",
t: TFunction<"resources">
): EntityListState {
const denied = resourceListQueryDenied(query.error);
return entityListState(
{
available: query.data !== undefined && !denied,
fetching: query.isFetching,
failed: query.isError,
},
{
loading: t(`listState.${kind}.loading`),
unavailable: denied ? t("listState.forbidden") : t(`listState.${kind}.unavailable`),
refreshing: t(`listState.${kind}.refreshing`),
stale: t(`listState.${kind}.stale`),
}
);
}

View File

@ -6,11 +6,13 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { EntityListStatus } from "@repo/rdx-ui/components/entity-list-status";
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
import { contextualDetailColumns } from "@repo/rdx-ui/lib/contextual-detail-layout";
import { LayersIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Skeleton } from "@repo/shadcn-ui/components/skeleton";
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
import { useLayoutEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
@ -22,6 +24,7 @@ import { DeleteResourceDialog } from "../../lifecycle/ui/delete-resource-dialog"
import { ResourceEditorDialog } from "../../shared";
import { useResourcesPageController } from "../controllers";
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
import { resourceListQueryState } from "../helpers/resource-list-query-state";
import type { ResourcesPageProps } from "../types";
export function ResourcesPage(props: ResourcesPageProps) {
@ -44,8 +47,8 @@ export function ResourcesPage(props: ResourcesPageProps) {
entityHref,
missingView,
visible,
loading,
unavailable,
denied,
deleting,
selected,
selectResource,
detailOpen,
@ -53,11 +56,14 @@ export function ResourcesPage(props: ResourcesPageProps) {
deleteId,
setDeleteId,
statusMutation,
canChangeStatus,
resourceDeleted,
} = useResourcesPageController(props);
const { t, i18n } = useTranslation("resources");
const inventoryState = resourceListQueryState(query, "inventory", t);
const catalogState = resourceListQueryState(catalog, "catalog", t);
const canMutate = query.isSuccess && catalog.isSuccess;
const tabValue = templateId ?? "all";
const deleting = query.data?.resources.find((resource) => resource.id === deleteId);
const viewedId = viewed?.id;
const previousViewedIdRef = useRef(viewedId);
@ -118,7 +124,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
{t("showResourcePanel")}
</Button>
)}
<Button disabled={!(query.data && catalog.data)} onClick={() => openEditor("new")}>
<Button disabled={!canMutate} onClick={() => openEditor("new")}>
<LayersIcon aria-hidden="true" />
{t("newResource")}
</Button>{" "}
@ -130,18 +136,18 @@ export function ResourcesPage(props: ResourcesPageProps) {
title={t("inventory")}
/>
{loading && <p role="status">{t("loading")}</p>}
<InventoryFeedback error={query.error ?? catalog.error} />
<InventoryFeedback error={statusMutation.error} />
{(query.isError || catalog.isError) && (
<Button
onClick={() => {
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
}}
variant="outline"
>
{t("retry")}
</Button>
{query.data && !denied && (
<EntityListStatus
onRetry={() => catalog.refetch()}
retryFocus={() =>
detail.containerRef.current?.querySelector<HTMLInputElement>(
"input[data-quick-search]"
) ?? null
}
retryLabel={t("listState.retryCatalog")}
state={catalogState.status === "refreshing" ? undefined : catalogState}
/>
)}
<Tabs className="min-w-0 max-w-full" onValueChange={setTemplateId} value={tabValue}>
<ScrollableTabsList
@ -153,6 +159,9 @@ export function ResourcesPage(props: ResourcesPageProps) {
locale={i18n.language}
value={tabValue}
/>
{catalogState.status === "loading" && (
<Skeleton aria-hidden="true" className="h-2 w-28 motion-reduce:animate-none" />
)}
<TabsContent className="min-w-0" keepMounted value={tabValue}>
<EntityTable
columns={[
@ -228,7 +237,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => openEditor(item.id),
disabled: !catalog.data,
disabled: !canMutate,
}}
>
{" "}
@ -237,7 +246,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
{t("viewResource")}
</EntityAction>
<EntityAction
disabled={statusMutation.isPending}
disabled={!canChangeStatus}
onClick={() => statusMutation.mutate(item)}
>
{t(
@ -248,7 +257,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
</EntityAction>
<EntityAction
className="text-destructive"
disabled={statusMutation.isPending}
disabled={!canChangeStatus}
onClick={() => setDeleteId(item.id)}
>
{t("resourceLifecycle.delete")}
@ -258,13 +267,14 @@ export function ResourcesPage(props: ResourcesPageProps) {
),
},
]}
columnsDisabled={!catalog.data}
emptyMessage={t("empty")}
filterKey={tabValue}
filtersApplied={templateId !== null}
items={visible}
loading={loading}
locale={i18n.language}
onClearFilters={clearFilters}
onRetry={denied ? undefined : () => query.refetch()}
onRowClick={(item) => selectResource(item.id)}
onSearchChange={setSearch}
rowClassName={(item) =>
@ -274,15 +284,15 @@ export function ResourcesPage(props: ResourcesPageProps) {
}
search={search}
searchPlaceholder={t("search")}
state={inventoryState}
title={t("inventory")}
unavailable={unavailable}
/>
</TabsContent>
</Tabs>
</div>
{selected && detailOpen && detail.mode === "inline" && (
<ResourceSelectionPanel
canEdit={catalog.isSuccess}
canEdit={canMutate}
onClose={() => detail.setVisible(false)}
onEdit={() => openEditor(selected.id)}
onView={() => setViewId(selected.id)}
@ -294,7 +304,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
)}
/>
)}
{editor && catalog.data && (
{editor && !denied && catalog.data && (
<ResourceEditorDialog
companyId={companyId}
finalFocus={detail.triggerFinalFocus}

View File

@ -61,6 +61,7 @@ export function EntityListPagination({
onPageSize,
id,
presentation = "full",
disabled = false,
}: {
locale: string;
page: number;
@ -70,6 +71,7 @@ export function EntityListPagination({
onPageSize: (size: number) => void;
id: string;
presentation?: "full" | "picker";
disabled?: boolean;
}) {
const labels = entityListLabels(locale);
@ -88,6 +90,7 @@ export function EntityListPagination({
{labels.pageSize}
</label>
<SelectWithAction
disabled={disabled}
id={`${id}-size`}
label={labels.pageSize}
onValueChange={(value) => {
@ -105,14 +108,16 @@ export function EntityListPagination({
<Button
aria-label={labels.previous}
disabled={page === 0}
disabled={disabled || page === 0}
onClick={() => onPage(page - 1)}
size="sm"
variant="outline"
>
‹
</Button>
{presentation === "picker" ? (
{disabled ? (
<span aria-hidden="true">—</span>
) : presentation === "picker" ? (
<span aria-current="page" className="min-w-0 flex-1 text-center text-sm whitespace-nowrap">
<span className="sr-only">{labels.page} </span>
{page + 1} / {pages}
@ -133,7 +138,7 @@ export function EntityListPagination({
)}
<Button
aria-label={labels.next}
disabled={page + 1 >= pages}
disabled={disabled || page + 1 >= pages}
onClick={() => onPage(page + 1)}
size="sm"
variant="outline"

View File

@ -0,0 +1,66 @@
import { Alert, AlertDescription } from "@repo/shadcn-ui/components/alert";
import { Button } from "@repo/shadcn-ui/components/button";
import type { ReactNode } from "react";
import type { EntityListState } from "../lib/entity-list-state";
/** Mensajes y recuperación aportados por el consumidor; no conoce consultas ni datos de negocio. */
export function EntityListStatus({
state,
onRetry,
retryLabel,
retryFocus,
children,
}: {
state: EntityListState | undefined;
onRetry?: (() => Promise<unknown>) | undefined;
retryLabel: string;
retryFocus?: (() => HTMLElement | null) | undefined;
children?: ReactNode;
}) {
if (!state || state.status === "ready") {
return children ? (
<p aria-live={state ? undefined : "polite"} className="text-sm text-muted-foreground">
{children}
</p>
) : null;
}
if (state.status === "unavailable" || state.status === "stale") {
return (
<Alert variant="destructive">
<AlertDescription>
<div className="flex flex-wrap items-center justify-between gap-2">
<p>{state.message}</p>
{onRetry && (
<Button
aria-busy={state.retrying}
disabled={state.retrying}
onClick={() => {
// El botón puede desaparecer al recuperar datos; el destino persiste durante la consulta.
retryFocus?.()?.focus({ preventScroll: true });
return onRetry();
}}
size="sm"
type="button"
variant="outline"
>
{retryLabel}
</Button>
)}
</div>
</AlertDescription>
</Alert>
);
}
return (
<p
aria-live={state.status === "refreshing" ? "off" : "polite"}
className="text-sm text-muted-foreground"
role={state.status === "loading" ? "status" : undefined}
>
{state.message}
</p>
);
}

View File

@ -1,13 +1,16 @@
import { Button } from "@repo/shadcn-ui/components/button";
import { Input } from "@repo/shadcn-ui/components/input";
import type { ReactNode } from "react";
import { useId } from "react";
import { useId, useRef } from "react";
import { useEntityTable } from "../hooks/use-entity-table";
import type { EntityColumnGroup, EntityColumnMetadata } from "../lib/entity-column-configuration";
import { entityListLabels } from "../lib/entity-list";
import type { EntityListState } from "../lib/entity-list-state";
import { entityTableAvailability } from "../lib/entity-list-state";
import { EntityListPagination } from "./entity-list-controls";
import { EntityListStatus } from "./entity-list-status";
import { EntityTableConfiguration } from "./entity-table-configuration";
import { EntityTableContent } from "./entity-table-content";
@ -75,6 +78,9 @@ export function EntityTable<T extends { id: string }>({
filterKey = "",
columnVisibilityStorageKey,
columnGroups,
state,
onRetry,
columnsDisabled,
loading = false,
unavailable = false,
emptyMessage,
@ -96,6 +102,9 @@ export function EntityTable<T extends { id: string }>({
filterKey?: string;
columnVisibilityStorageKey?: string | undefined;
columnGroups?: readonly EntityColumnGroup[];
state?: EntityListState | undefined;
onRetry?: (() => Promise<unknown>) | undefined;
columnsDisabled?: boolean | undefined;
loading?: boolean;
unavailable?: boolean;
emptyMessage?: string;
@ -104,7 +113,11 @@ export function EntityTable<T extends { id: string }>({
layout?: "content" | "fill";
}) {
const id = useId();
const searchRef = useRef<HTMLInputElement>(null);
const labels = entityListLabels(locale);
const availability = entityTableAvailability(state, loading, unavailable);
const pending = availability.loading;
const missing = availability.unavailable;
const { table, total, range, pagination, setPagination, currentSearch, setLocalSearch } =
useEntityTable({
items,
@ -116,8 +129,8 @@ export function EntityTable<T extends { id: string }>({
columnVisibilityStorageKey,
});
const info = entityTableInfo(
loading,
unavailable,
pending,
missing,
labels.loading,
labels.range(
range.from,
@ -128,8 +141,8 @@ export function EntityTable<T extends { id: string }>({
)
);
const message = entityTableMessage(
loading,
unavailable,
pending,
missing,
Boolean(currentSearch || filtersApplied),
labels,
emptyMessage
@ -145,6 +158,7 @@ export function EntityTable<T extends { id: string }>({
<EntityTableConfiguration
columnGroups={columnGroups}
columnMetadata={columns}
columnsDisabled={columnsDisabled}
locale={locale}
onResetColumns={() => table.setColumnVisibility({})}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
@ -155,9 +169,10 @@ export function EntityTable<T extends { id: string }>({
return (
<section
aria-busy={loading}
aria-busy={availability.sectionBusy}
aria-label={title}
className={`min-w-0 rounded-xl border bg-card text-card-foreground ${layout === "fill" ? "@container/entity-table flex min-h-72 flex-1 flex-col overflow-hidden [&>[data-slot=table-container]]:min-h-0 [&>[data-slot=table-container]]:flex-1 [&>[data-slot=table-container]]:overflow-auto [&>[data-slot=table-container]]:overscroll-contain [&>[data-slot=table-container]]:[scrollbar-gutter:stable] [&_[data-slot=table-header]]:sticky [&_[data-slot=table-header]]:top-0 [&_[data-slot=table-header]]:z-20 [&_[data-slot=table-header]]:bg-background" : ""}`}
data-query-state={state?.status}
data-slot="entity-table"
>
<div
@ -178,6 +193,7 @@ export function EntityTable<T extends { id: string }>({
id={`${id}-search`}
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
placeholder={searchPlaceholder ?? labels.placeholder}
ref={searchRef}
value={currentSearch}
/>
</div>
@ -193,35 +209,44 @@ export function EntityTable<T extends { id: string }>({
</div>
{layout !== "fill" && (
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
<p aria-live="polite" className="text-sm text-muted-foreground">
{info}
</p>
<div className="min-w-0 flex-1">
<EntityListStatus
onRetry={onRetry}
retryFocus={() => searchRef.current}
retryLabel={labels.retry}
state={availability.summaryState}
>
{info}
</EntityListStatus>
</div>
{configuration}
</div>
)}
<EntityTableContent
columns={columns}
loading={loading}
loading={pending}
message={message}
onRowClick={onRowClick}
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
rowClassName={rowClassName}
state={state}
table={table}
title={title}
unavailable={unavailable}
unavailable={missing}
/>
<footer
className={`flex shrink-0 flex-wrap items-center justify-between border-t ${layout === "fill" ? "gap-2 px-2 py-2" : "gap-3 px-4 py-3"}`}
>
<p
aria-live={layout === "fill" ? "polite" : undefined}
aria-live={layout === "fill" ? availability.legacyAnnouncement : undefined}
className="text-sm text-muted-foreground"
>
{info}
</p>
<EntityListPagination
disabled={availability.paginationDisabled}
id={id}
locale={locale}
onPage={(pageIndex) => setPagination({ ...pagination, pageIndex })}

View File

@ -16,6 +16,7 @@ export function EntityTableConfiguration<T>({
columnGroups,
columnMetadata,
onResetColumns,
columnsDisabled = false,
}: {
table: TableModel<T>;
locale: string;
@ -24,6 +25,7 @@ export function EntityTableConfiguration<T>({
columnGroups?: readonly EntityColumnGroup[] | undefined;
columnMetadata?: readonly (EntityColumnMetadata & { id: string })[] | undefined;
onResetColumns?: (() => void) | undefined;
columnsDisabled?: boolean | undefined;
}) {
const id = useId();
const labels = entityListLabels(locale);
@ -39,7 +41,12 @@ export function EntityTableConfiguration<T>({
return (
<>
<div className="flex flex-wrap items-center gap-2">
<Button onClick={() => setColumnsOpen(true)} size="sm" variant="outline">
<Button
disabled={columnsDisabled}
onClick={() => setColumnsOpen(true)}
size="sm"
variant="outline"
>
{labels.columns}
</Button>
<div

View File

@ -10,7 +10,10 @@ import type { Table as TableModel } from "@tanstack/react-table";
import type { ReactNode } from "react";
import { Fragment, isValidElement } from "react";
import type { EntityListState } from "../lib/entity-list-state";
import type { EntityColumn } from "./entity-list";
import { EntityTableSkeleton } from "./entity-table-skeleton";
export function EntityTableContent<T extends { id: string }>({
table,
@ -22,6 +25,7 @@ export function EntityTableContent<T extends { id: string }>({
rowClassName,
onRowClick,
onSort,
state,
}: {
table: TableModel<T>;
columns: readonly EntityColumn<T>[];
@ -32,6 +36,7 @@ export function EntityTableContent<T extends { id: string }>({
rowClassName: ((item: T) => string) | undefined;
onRowClick: ((item: T) => void) | undefined;
onSort: () => void;
state?: EntityListState | undefined;
}) {
const visibleColumns = [
...table.getCenterVisibleLeafColumns(),
@ -40,7 +45,10 @@ export function EntityTableContent<T extends { id: string }>({
const total = table.getFilteredRowModel().rows.length;
return (
<Table aria-label={title}>
<Table
aria-busy={state?.status === "loading" || state?.status === "refreshing" || undefined}
aria-label={title}
>
<TableHeader>
<TableRow>
{visibleColumns.map((column) => {
@ -83,10 +91,24 @@ export function EntityTableContent<T extends { id: string }>({
</TableRow>
</TableHeader>
<TableBody>
{loading || unavailable ? (
{state?.status === "loading" ? (
<EntityTableSkeleton
columns={visibleColumns.map((column) => ({
id: column.id,
className: entityColumnClass(column.id, column.getIsPinned() === "right", false),
}))}
rows={table.getState().pagination.pageSize}
/>
) : loading || unavailable ? (
<TableRow>
<TableCell colSpan={visibleColumns.length}>
<span className="text-muted-foreground">{message}</span>
<TableCell
aria-hidden={state?.status === "unavailable" || undefined}
className={state?.status === "unavailable" ? "h-64" : undefined}
colSpan={visibleColumns.length}
>
{state?.status !== "unavailable" && (
<span className="text-muted-foreground">{message}</span>
)}
</TableCell>
</TableRow>
) : (

View File

@ -0,0 +1,23 @@
import { Skeleton } from "@repo/shadcn-ui/components/skeleton";
import { TableCell, TableRow } from "@repo/shadcn-ui/components/table";
/** Reserva la primera página sin fabricar entidades, totales ni acciones accesibles. */
export function EntityTableSkeleton({
columns,
rows,
}: {
columns: readonly { id: string; className: string }[];
rows: number;
}) {
return Array.from({ length: Math.min(rows, 10) }, (_, index) => `placeholder-${index}`).map(
(id) => (
<TableRow aria-hidden="true" data-slot="entity-table-skeleton-row" key={id}>
{columns.map((column) => (
<TableCell className={column.className} key={column.id}>
<Skeleton className="h-3 w-3/4 motion-reduce:animate-none" />
</TableCell>
))}
</TableRow>
)
);
}

View File

@ -12,6 +12,8 @@ import {
EntityTable,
} from "./components/entity-list";
import { entityListLabels, entityListPages, entityListRange } from "./lib/entity-list";
import type { EntityListState } from "./lib/entity-list-state";
import { entityListState, entityTableAvailability } from "./lib/entity-list-state";
import { PencilIcon } from "./lib/icons";
const items = Array.from({ length: 21 }, (_, index) => ({
@ -539,3 +541,131 @@ test("paginación por cursor: navegación accesible y límites sin inventar tota
assert.equal((html.match(/ disabled=""/g) ?? []).length, 2);
assert.equal(html.includes("Mostrando"), false);
});
test("estados presentacionales: carga, datos anteriores y recuperación son mutuamente distinguibles", () => {
const messages = {
loading: "Cargando activos",
unavailable: "Error inicial",
refreshing: "Actualizando activos",
stale: "Datos anteriores",
};
const cases: [boolean, boolean, boolean, EntityListState["status"]][] = [
[false, false, false, "loading"],
[false, true, false, "loading"],
[false, false, true, "unavailable"],
[false, true, true, "unavailable"],
[true, false, false, "ready"],
[true, true, false, "refreshing"],
[true, false, true, "stale"],
[true, true, true, "stale"],
];
for (const [available, fetching, failed, expected] of cases) {
const state = entityListState({ available, fetching, failed }, messages);
assert.equal(state.status, expected);
if (state.status === "stale" || state.status === "unavailable") {
assert.equal(state.retrying, fetching);
}
}
assert.equal(entityTableAvailability(undefined, true, false).sectionBusy, true);
assert.equal(entityTableAvailability({ status: "ready" }, true, true).loading, false);
assert.equal(entityTableAvailability({ status: "ready" }, true, true).unavailable, false);
});
test("carga inicial conserva estructura, no anuncia cero registros y no fabrica acciones", () => {
const html = renderToStaticMarkup(
<EntityTable
columns={[
{
id: "name",
label: "Nombre",
hideable: false,
render: () => {
throw new Error("No hay registros reales para renderizar");
},
},
{
id: "actions",
label: "Acciones",
render: () => <button type="button">Acción ficticia</button>,
},
]}
items={[]}
state={{ status: "loading", message: "Cargando activos…" }}
title="Activos"
/>
);
assert.ok(html.includes('data-slot="entity-table"'));
assert.ok(html.includes('data-slot="entity-table-skeleton-row"'));
assert.ok(html.includes('aria-hidden="true"'));
assert.ok(html.includes('data-quick-search=""'));
assert.ok(html.includes("<th"));
assert.match(html, /<button(?=[^>]*aria-label="Página siguiente")(?=[^>]*disabled="")[^>]*>/);
assert.ok(html.includes('role="status"'));
assert.equal(html.includes("Acción ficticia"), false);
assert.equal(html.includes("Mostrando 0-0 de 0"), false);
assert.equal(html.includes("No hay registros."), false);
});
test("refetch y error posterior muestran los datos disponibles sin skeletons", () => {
for (const state of [
{ status: "refreshing", message: "Actualizando activos…" },
{ status: "stale", message: "No se pudieron actualizar. Datos anteriores.", retrying: false },
] satisfies EntityListState[]) {
const html = renderToStaticMarkup(
<EntityTable
columns={[{ id: "name", label: "Nombre", render: () => "Activo confirmado" }]}
items={[{ id: "one" }]}
onRetry={() => Promise.resolve()}
state={state}
title="Activos"
/>
);
assert.ok(html.includes("Activo confirmado"));
assert.ok(html.includes(state.message));
assert.equal(html.includes('data-slot="entity-table-skeleton-row"'), false);
if (state.status === "stale") {
assert.ok(html.includes('role="alert"'));
assert.ok(html.includes("Reintentar"));
}
}
});
test("error inicial, vacío válido y filtros sin coincidencias tienen contenidos distintos en es/en", () => {
for (const locale of ["es", "en"]) {
const labels = entityListLabels(locale);
const render = (state: EntityListState, search = "") =>
renderToStaticMarkup(
<EntityTable
columns={[{ id: "name", label: "Nombre", render: () => "Identidad" }]}
items={[]}
locale={locale}
onRetry={() => Promise.resolve()}
search={search}
state={state}
title="Activos"
/>
);
const unavailable = render({
status: "unavailable",
message: "Error inicial recuperable",
retrying: false,
});
const empty = render({ status: "ready" });
const filtered = render({ status: "ready" }, "zzzz");
assert.ok(unavailable.includes("Error inicial recuperable"));
assert.ok(unavailable.includes(labels.retry));
assert.equal(unavailable.includes(labels.empty), false);
assert.equal(unavailable.includes('data-slot="entity-table-skeleton-row"'), false);
assert.ok(empty.includes(labels.empty));
assert.ok(filtered.includes(labels.noResults));
assert.ok(filtered.includes(labels.clear));
}
});

View File

@ -0,0 +1,61 @@
export type EntityListState =
| { status: "loading"; message: string }
| { status: "unavailable"; message: string; retrying: boolean }
| { status: "ready" }
| { status: "refreshing"; message: string }
| { status: "stale"; message: string; retrying: boolean };
/** Disponibilidad y actividad son independientes: una actualización fallida no invalida datos anteriores. */
export function entityListState(
snapshot: { available: boolean; fetching: boolean; failed: boolean },
messages: { loading: string; unavailable: string; refreshing: string; stale: string }
): EntityListState {
if (snapshot.failed) {
return {
status: snapshot.available ? "stale" : "unavailable",
message: snapshot.available ? messages.stale : messages.unavailable,
retrying: snapshot.fetching,
};
}
if (!snapshot.available) {
return { status: "loading", message: messages.loading };
}
return snapshot.fetching
? { status: "refreshing", message: messages.refreshing }
: { status: "ready" };
}
/** La API anterior conserva sus mensajes; el nuevo contrato es explícito y tiene precedencia. */
export function entityTableAvailability(
state: EntityListState | undefined,
loading: boolean,
unavailable: boolean
): {
loading: boolean;
unavailable: boolean;
sectionBusy: boolean | undefined;
paginationDisabled: boolean;
summaryState: EntityListState | undefined;
legacyAnnouncement: "polite" | undefined;
} {
if (!state) {
return {
loading,
unavailable,
sectionBusy: loading,
paginationDisabled: false,
summaryState: undefined,
legacyAnnouncement: "polite",
};
}
return {
loading: state.status === "loading",
unavailable: state.status === "unavailable",
sectionBusy: undefined,
paginationDisabled: state.status === "loading" || state.status === "unavailable",
summaryState: state,
legacyAnnouncement: undefined,
};
}

View File

@ -32,5 +32,6 @@
"description": "View and manage",
"pickerRange": "{{from}}–{{to}} of {{total}} {{entity}}",
"range": "Showing {{from}}-{{to}} of {{total}} {{entity}}",
"close": "Close"
"close": "Close",
"retry": "Retry"
}

View File

@ -32,5 +32,6 @@
"description": "Consulta y gestiona",
"pickerRange": "{{from}}–{{to}} de {{total}} {{entity}}",
"range": "Mostrando {{from}}-{{to}} de {{total}} {{entity}}",
"close": "Cerrar"
"close": "Cerrar",
"retry": "Reintentar"
}

View File

@ -0,0 +1,181 @@
#!/usr/bin/env python3
"""L02.3: estados reales de TanStack Query con red interceptada mediante Playwright MCP.
Requiere sesión del preparador y visual-audit-volume.py. No crea datos ni inicia servicios.
Los 503/403 y la reconexión son simulaciones explícitas, no fallos espontáneos del servidor.
"""
import contextlib
import json
import os
from pathlib import Path
import runpy
import tomllib
import unittest
from urllib.parse import urlparse
ROOT = Path(__file__).resolve().parent.parent
AUDIT = runpy.run_path(str(ROOT / 'scripts/visual-audit.py'))
SIZES = [(1920,1080),(1440,900),(1366,768),(1024,768),(768,1024),(390,844),(320,640)]
class ListStatesUiTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
value=os.environ.get('METIS_PLAYWRIGHT_STORAGE_STATE')
if not value: raise unittest.SkipTest('Requiere sesión sintética activa.')
state=(ROOT/value).resolve()
if not state.is_relative_to(ROOT/'.artifacts/playwright') or state.stat().st_mode & 0o077: raise RuntimeError('La sesión debe ser privada.')
report=json.loads((state.parent/'report.json').read_text())
if urlparse(report['origin']).hostname!='localhost' or report.get('ordinaryUser') is not True: raise RuntimeError('Requiere instalación sintética local ordinaria.')
cls.origin=report['origin'];cls.company=report['companyId'];cls.output=ROOT/'.artifacts/playwright/l02-3/after';cls.output.mkdir(parents=True,exist_ok=True)
cls.results=[];cls.measurements=cls.output/('measurements-'+str(os.getpid())+'.json')
cls.stack=contextlib.ExitStack();cls.addClassCleanup(cls.stack.close)
config=tomllib.loads((ROOT/'.codex/config.toml').read_text())['mcp_servers']['playwright']
cls.client=cls.stack.enter_context(AUDIT['browser'](config,state))
if cls.code('return (await page.request.get('+json.dumps(cls.origin+'/api/companies')+')).status();')!=200: raise RuntimeError('La sesión no está activa.')
cls.code("page.blockedWrites=[];await page.route('**/api/**',r=>{if(['GET','HEAD','OPTIONS'].includes(r.request().method()))return r.continue();page.blockedWrites.push(r.request().method());return r.abort()});return true;")
@classmethod
def code(cls, body):
try:
return AUDIT['result_json'](cls.client.tool('browser_run_code_unsafe',{'code':'async(page)=>{'+body+'}'}))
except RuntimeError as error:
(cls.output/('tool-error-'+str(os.getpid())+'.txt')).write_text(str(error))
raise
@classmethod
def tearDownClass(cls):
(cls.output/('console-'+str(os.getpid())+'.json')).write_text(json.dumps(AUDIT['tool'](cls.client,'browser_console_messages',{'level':'warning'}),ensure_ascii=False,indent=2)+'\n')
(cls.output/('blocked-writes-'+str(os.getpid())+'.json')).write_text(json.dumps(cls.code('return page.blockedWrites;'))+'\n')
def settle(self):
self.code("await page.evaluate(async()=>{await document.fonts.ready;await new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)));await Promise.all(document.getAnimations().filter(a=>a.effect?.getTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))});return true;")
def resize(self,w,h):
AUDIT['tool'](self.client,'browser_resize',{'width':w,'height':h});self.settle()
def navigate(self,section='assets'):
self.code('await page.goto('+json.dumps(self.origin+'/companies/'+self.company+'/'+section)+",{waitUntil:'domcontentloaded'});await page.locator('[data-slot=inventory-layout]').waitFor();return true;");self.settle()
def network(self,endpoint='assets',mode='hold'):
url=self.origin+'/api/'+endpoint+'/'+self.company
self.code('page.gates=[];page.started=new Promise(resolve=>page.onStart=resolve);page.networkUrl='+json.dumps(url)+';page.networkMode='+json.dumps(mode)+";await page.route(page.networkUrl,async r=>{page.onStart();if(page.networkMode==='hold'){await new Promise(resolve=>page.gates.push(resolve));await r.continue()}else if(page.networkMode==='empty'){const response=await r.fetch();const data=await response.json();if('assets'in data){data.assets=[];data.workstations=[];data.relationships=[]}else data.resources=[];await r.fulfill({response,json:data})}else await r.fulfill({status:page.networkMode==='forbidden'?403:503,contentType:'application/json',body:JSON.stringify({code:page.networkMode==='forbidden'?'FORBIDDEN':'INTERNAL_ERROR'})})});return true;")
def release(self):
self.code("const response=page.waitForResponse(r=>r.url()===page.networkUrl);for(const release of page.gates)release();await response;await page.unroute(page.networkUrl);return true;");self.settle()
def clear_network(self):
self.code('await page.unroute(page.networkUrl);return true;')
def ready(self):
self.code("await page.locator('[data-slot=entity-table][data-query-state=ready]').waitFor();return true;");self.settle()
def refetch(self):
self.code("await page.evaluate(()=>{window.dispatchEvent(new Event('offline'));window.dispatchEvent(new Event('online'))});await page.started;return true;")
def record(self,name,value):
self.results.append({'scenario':name,'value':value});self.measurements.write_text(json.dumps(self.results,ensure_ascii=False,indent=2)+'\n')
def capture(self,name):
AUDIT['capture'](self.client,self.output,name)
snapshot=AUDIT['tool'](self.client,'browser_snapshot',{})
(self.output/(name+'.txt')).write_text('\n'.join(x['text'] for x in snapshot['content'] if x.get('type')=='text'))
def metrics(self):
return self.code("""return page.locator('[data-slot=inventory-layout]').evaluate(root=>{
const table=root.querySelector('table'),container=root.querySelector('[data-slot=entity-table]'),search=root.querySelector('input[data-quick-search]'),main=root.closest('[data-slot=workspace-scroll]')??root.closest('main').parentElement,scroller=table?.closest('[data-slot=table-container]'),r=table?.getBoundingClientRect(),active=document.activeElement;
return {viewport:[innerWidth,innerHeight],state:container?.dataset.queryState,table:!!table,sameTable:!window.stateTable||table===window.stateTable,search:search?.value,searchEnabled:!!search&&!search.disabled,skeletons:root.querySelectorAll('[data-slot=entity-table-skeleton-row]').length,skeletonActions:root.querySelectorAll('[data-slot=entity-table-skeleton-row] button,[data-slot=entity-table-skeleton-row] a,[data-slot=entity-table-skeleton-row] [tabindex]').length,rows:table?.querySelectorAll('tbody tr:not([aria-hidden=true])').length,columnsDisabled:[...root.querySelectorAll('button')].find(b=>b.textContent==='Columnas')?.disabled,nextDisabled:root.querySelector('button[aria-label="Página siguiente"]')?.disabled,tableY:r?.y,headerY:root.querySelector('h2')?.getBoundingClientRect().y,selected:root.dataset.selectedAsset??root.dataset.selectedResource,detailMode:root.dataset.detailMode,detailOpen:root.dataset.detailOpen,sort:table?.querySelector('th[aria-sort]')?.getAttribute('aria-sort'),page:root.querySelector('[aria-current=page]')?.textContent,scrollLeft:scroller?.scrollLeft,scrollTop:main?.scrollTop,focusSame:!window.stateFocus||active===window.stateFocus,focusY:active.getBoundingClientRect().y,focusTag:active.tagName,focusText:active.textContent?.slice(0,60),overflow:document.documentElement.scrollWidth>innerWidth,alerts:[...root.querySelectorAll('[role=alert]')].map(e=>e.textContent),status:[...root.querySelectorAll('[role=status]')].map(e=>e.textContent),text:container?.textContent};
});""")
def test_01_initial_structure_and_arrival(self):
self.network()
self.navigate()
self.code("await page.started;await page.locator('[data-query-state=loading]').waitFor();await page.evaluate(()=>window.stateTable=document.querySelector('main table'));return true;")
for width,height in SIZES:
self.resize(width,height);value=self.metrics();self.record('initial-'+str(width),value)
self.assertTrue(value['table'] and value['searchEnabled']);self.assertGreater(value['skeletons'],0);self.assertEqual(value['skeletonActions'],0);self.assertTrue(value['nextDisabled']);self.assertFalse(value['overflow'])
if width in [320,1440,1920]:self.capture('initial-'+str(width))
self.code("await page.locator('main input[data-quick-search]').fill('AUDIT');await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.locator('main input[data-quick-search]').focus();await page.evaluate(()=>window.stateFocus=document.activeElement);return true;")
self.release();self.ready();value=self.metrics();self.record('initial-arrived',value)
self.assertTrue(value['sameTable'] and value['focusSame']);self.assertEqual(value['search'],'AUDIT');self.assertEqual(value['sort'],'descending');self.assertEqual(value['skeletons'],0);self.assertGreater(value['rows'],0);self.capture('initial-arrived-320')
def test_02_dependencies(self):
for dependency in ['asset-catalog','asset-infrastructure']:
self.resize(1440,900);self.network(dependency);self.navigate();self.ready();value=self.metrics();self.record('pending-'+dependency,value)
self.assertGreater(value['rows'],0);self.assertTrue(value['searchEnabled']);self.assertEqual(value['columnsDisabled'],dependency=='asset-catalog');self.capture('pending-'+dependency)
self.code("await page.evaluate(()=>window.stateTable=document.querySelector('main table'));return true;")
self.release();self.ready();self.assertTrue(self.metrics()['sameTable'])
self.network('asset-infrastructure','error');self.navigate();self.ready()
self.code("await page.getByRole('button',{name:'Reintentar infraestructura',exact:true}).waitFor({state:'visible'});return true;")
self.assertGreater(self.metrics()['rows'],0);self.capture('accessory-error');self.clear_network()
def test_03_refetch_context_and_error_recovery(self):
self.resize(1920,1080);self.navigate();self.ready()
self.code("await page.locator('main input[data-quick-search]').fill('AUDIT-ASSET');await page.getByRole('columnheader',{name:'Nombre',exact:true}).getByRole('button').click();await page.getByRole('button',{name:'Página 2',exact:true}).click();await page.getByRole('button',{name:'Columnas',exact:true}).click();await page.getByRole('checkbox',{name:'Notas',exact:true}).check();await page.getByRole('checkbox',{name:'Direcciones IP de interfaces',exact:true}).check();await page.getByRole('checkbox',{name:'Direcciones MAC de interfaces',exact:true}).check();await page.keyboard.press('Escape');await page.locator('main tbody tr').first().locator('td').nth(2).click();await page.locator('[data-slot=selection-panel]').waitFor();await page.evaluate(()=>{window.stateTable=document.querySelector('main table');window.stateFocus=window.stateTable.querySelector('tbody a');window.stateFocus.focus({preventScroll:true});document.querySelector('[data-slot=workspace-scroll]').scrollTop=150;window.stateTable.closest('[data-slot=table-container]').scrollLeft=120});return true;")
self.settle();before=self.metrics();self.record('context-before',before);self.assertGreater(before['scrollTop'],0);self.assertGreater(before['scrollLeft'],0)
self.network();self.refetch();self.code("await page.locator('[data-query-state=refreshing]').waitFor();return true;");self.settle();pending=self.metrics();self.record('refetch-pending',pending)
for key in ['sameTable','focusSame']:self.assertTrue(pending[key])
for key in ['selected','detailMode','detailOpen','sort','page','scrollLeft','scrollTop','tableY']:self.assertEqual(before[key],pending[key],key)
self.assertGreater(pending['rows'],0);self.assertEqual(pending['skeletons'],0);self.capture('refetch-pending-1920')
self.release();self.ready();self.network(mode='error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).waitFor();return true;");self.settle()
failed=self.metrics();self.record('refetch-failed',failed);self.assertGreater(failed['rows'],0);self.assertTrue(failed['sameTable'] and failed['focusSame']);self.assertEqual(failed['selected'],before['selected']);self.assertEqual(failed['page'],before['page']);self.capture('refetch-failed-1920')
self.clear_network();self.record('before-retry',self.metrics());self.code("await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).click();return true;");self.ready();self.record('retry-recovered',self.metrics());self.assertTrue(self.metrics()['sameTable'])
def test_04_initial_error_empty_filters_permissions(self):
self.resize(320,640);self.network(mode='error');self.navigate();self.code("await page.locator('[data-query-state=unavailable]').waitFor();return true;");value=self.metrics();self.record('initial-503',value);self.assertTrue(value['table'] and value['searchEnabled']);self.assertEqual(value['skeletons'],0);self.assertTrue(value['alerts']);self.capture('initial-503-320')
self.clear_network();self.code("await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).focus();await page.keyboard.press('Enter');return true;");self.ready();self.record('initial-retry-focus',self.metrics());self.assertGreater(self.metrics()['rows'],0);self.assertTrue(self.code("return page.locator('main input[data-quick-search]').evaluate(e=>e===document.activeElement);"))
self.network(mode='empty');self.navigate();self.ready();self.record('empty-diagnostic',self.metrics());self.capture('empty-diagnostic');self.assertTrue(self.code("return (await page.getByText('Esta compañía todavía no tiene activos.',{exact:true}).count())>0;"));self.capture('empty-320');self.clear_network()
self.navigate();self.ready();self.code("await page.locator('main input[data-quick-search]').fill('zzzz-no-existe');return true;");self.assertTrue(self.code("return (await page.getByText('No hay resultados para estos criterios.',{exact:true}).count())>0;"));self.capture('filtered-empty-320');self.code("await page.getByRole('button',{name:'Limpiar filtros',exact:true}).click();return true;");self.assertGreater(self.metrics()['rows'],0)
self.network(mode='forbidden');self.navigate();self.code("await page.locator('[data-query-state=unavailable]').waitFor();return true;");self.assertEqual(self.code("return page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).count();"),0);self.capture('forbidden-320');self.clear_network()
def test_05_resources(self):
self.resize(1024,768);self.network('resources');self.navigate('resources');self.code("await page.locator('[data-query-state=loading]').waitFor();await page.evaluate(()=>window.stateTable=document.querySelector('main table'));return true;");self.capture('resources-initial');self.release();self.ready();self.assertTrue(self.metrics()['sameTable']);self.assertGreater(self.metrics()['rows'],0)
self.network('resources','error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();return true;");self.assertGreater(self.metrics()['rows'],0);self.capture('resources-stale');self.clear_network();self.code("await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).click();return true;");self.ready()
def test_06_catalog_errors_and_joint_loading(self):
self.resize(1440,900)
urls=[self.origin+'/api/'+kind+'/'+self.company for kind in ['assets','asset-catalog']]
self.code('page.jointUrls='+json.dumps(urls)+";page.jointGates=[];page.jointStarted=new Promise(resolve=>page.jointOnStart=resolve);for(const url of page.jointUrls)await page.route(url,async r=>{await new Promise(resolve=>{page.jointGates.push(resolve);if(page.jointGates.length===2)page.jointOnStart()});await r.continue()});return true;")
self.navigate();self.code("await page.jointStarted;return true;")
value=self.metrics();self.record('joint-loading',value);self.assertEqual(value['state'],'loading');self.assertTrue(value['columnsDisabled']);self.capture('joint-loading')
self.code("const responses=page.jointUrls.map(url=>page.waitForResponse(url));for(const release of page.jointGates)release();await Promise.all(responses);for(const url of page.jointUrls)await page.unroute(url);return true;");self.ready()
self.network('asset-catalog','error');self.navigate();self.ready()
self.code("await page.getByRole('button',{name:'Reintentar catálogo',exact:true}).waitFor();return true;")
value=self.metrics();self.record('catalog-initial-503',value);self.assertGreater(value['rows'],0);self.assertTrue(value['columnsDisabled']);self.assertTrue(self.code("return page.getByRole('button',{name:'Nuevo activo',exact:true}).isDisabled();"));self.capture('catalog-initial-503')
self.clear_network();self.code("await page.getByRole('button',{name:'Reintentar catálogo',exact:true}).click();await page.getByRole('button',{name:'Columnas',exact:true}).waitFor();await page.waitForFunction(()=>![...document.querySelectorAll('main button')].find(e=>e.textContent==='Columnas').disabled);return true;")
self.network('asset-catalog','error');self.refetch();self.code("await page.getByRole('button',{name:'Reintentar catálogo',exact:true}).waitFor();return true;")
value=self.metrics();self.record('catalog-stale-503',value);self.assertFalse(value['columnsDisabled']);self.assertGreater(value['rows'],0);self.capture('catalog-stale-503');self.clear_network()
def test_07_editor_focus_reduced_motion_and_english(self):
self.resize(1440,900);self.navigate();self.ready()
self.code("await page.getByRole('button',{name:'Editar',exact:true}).first().click();await page.getByRole('dialog').waitFor();return true;")
before=self.code("return page.getByRole('dialog').evaluate(e=>({title:e.querySelector('h2')?.textContent,inputs:[...e.querySelectorAll('input')].map(i=>i.value)}));")
self.network(mode='error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();return true;")
after=self.code("return page.getByRole('dialog').evaluate(e=>({title:e.querySelector('h2')?.textContent,inputs:[...e.querySelectorAll('input')].map(i=>i.value)}));")
self.assertEqual(before,after);self.capture('editor-during-stale');self.clear_network();self.code("await page.keyboard.press('Escape');return true;")
self.network();self.code("await page.emulateMedia({reducedMotion:'reduce'});return true;");self.navigate();self.code("await page.locator('[data-query-state=loading]').waitFor();return true;")
motion=self.code("return page.locator('[data-slot=entity-table-skeleton-row] [data-slot=skeleton]').first().evaluate(e=>getComputedStyle(e).animationName);");self.assertEqual(motion,'none');self.record('reduced-motion',motion);self.release();self.ready()
self.code("""await page.emulateMedia({reducedMotion:'no-preference'});await page.locator('summary[aria-label="Menú de usuario"]').click();await page.getByRole('combobox',{name:'Idioma',exact:true}).click();await page.getByRole('option',{name:'English',exact:true}).click();await page.locator('summary[aria-label="User menu"]').click();return true;""")
try:
self.network(mode='error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();return true;")
text=self.metrics()['text'];self.assertIn('last available',text);self.assertTrue(self.code("return page.locator('[data-slot=entity-table]').getByRole('button',{name:'Retry',exact:true}).isVisible();"));self.capture('english-stale')
self.record('english-stale',self.metrics());self.clear_network()
finally:
self.code("""await page.locator('summary[aria-label="User menu"]').click();await page.getByRole('combobox',{name:'Language',exact:true}).click();await page.getByRole('option',{name:'Español',exact:true}).click();await page.locator('summary[aria-label="Menú de usuario"]').click();return true;""")
def test_08_template_and_status_contrast(self):
self.resize(1440,900);self.network();self.navigate();self.code("await page.locator('[data-query-state=loading]').waitFor();return true;")
contrast="""const rgb=color=>{const c=document.createElement('canvas').getContext('2d');c.fillStyle=color;c.fillRect(0,0,1,1);return [...c.getImageData(0,0,1,1).data].slice(0,3)};const canvas=document.createElement('canvas').getContext('2d');canvas.fillStyle='#fff';canvas.fillRect(0,0,1,1);const chain=[];for(let n=e;n;n=n.parentElement)chain.unshift(n);for(const n of chain){canvas.fillStyle=getComputedStyle(n).backgroundColor;canvas.fillRect(0,0,1,1)}const lum=values=>values.map(v=>v/255).map(v=>v<=.04045?v/12.92:((v+.055)/1.055)**2.4).reduce((sum,v,i)=>sum+v*[.2126,.7152,.0722][i],0);const fg=rgb(getComputedStyle(e).color),bg=[...canvas.getImageData(0,0,1,1).data].slice(0,3),l=[lum(fg),lum(bg)].sort((a,b)=>a-b);return {fg,bg,ratio:(l[1]+.05)/(l[0]+.05)};"""
loading=self.code("return page.locator('main [role=status]').first().evaluate(e=>{"+contrast+"});");self.record('loading-contrast',loading);self.assertGreaterEqual(loading['ratio'],4.5);self.release();self.ready()
self.code("await page.getByRole('tab',{name:'Impresora',exact:true}).click();await page.locator('main input[data-quick-search]').fill('AUDIT');return true;")
before=self.metrics();self.assertGreater(before['rows'],0);self.network(mode='error');self.refetch();self.code("await page.locator('[data-query-state=stale]').waitFor();return true;")
after=self.metrics();self.assertEqual(before['search'],after['search']);self.assertEqual(before['rows'],after['rows']);self.assertEqual(self.code("return page.getByRole('tab',{name:'Impresora',exact:true}).getAttribute('aria-selected');"),'true');self.record('template-stale',after)
error=self.code("return page.locator('[data-slot=entity-table] [role=alert] p').evaluate(e=>{"+contrast+"});");self.record('error-contrast',error);self.assertGreaterEqual(error['ratio'],4.5)
self.resize(320,640);self.code("await page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).scrollIntoViewIfNeeded();return true;")
rect=self.code("return page.locator('[data-slot=entity-table]').getByRole('button',{name:'Reintentar',exact:true}).evaluate(e=>{const r=e.getBoundingClientRect();return {x:r.x,right:r.right,y:r.y,bottom:r.bottom,width:innerWidth,height:innerHeight}});");self.record('retry-mobile-rect',rect);self.assertGreaterEqual(rect['x'],0);self.assertLessEqual(rect['right'],320);self.assertLessEqual(rect['bottom'],640);self.capture('stale-retry-visible-320');self.clear_network()
if __name__=='__main__':unittest.main()

View File

@ -149,8 +149,9 @@ class PickerUiTests(unittest.TestCase):
self.code("await page.getByRole('dialog').getByRole('button',{name:'Columnas',exact:true}).click();return true;")
states=self.code("return await page.getByRole('dialog').last().getByRole('checkbox').evaluateAll(es=>es.map(e=>({label:e.closest('label').textContent,disabled:e.getAttribute('aria-disabled')==='true'||e.hasAttribute('disabled')})));")
self.record('protected-columns',states)
self.assertTrue(next(x for x in states if x['label']=='Campo')['disabled'])
self.assertTrue(next(x for x in states if x['label']=='Selección')['disabled'])
self.assertTrue(self.code("return page.getByRole('dialog').last().getByRole('checkbox',{name:'Campo',exact:true}).isDisabled();"))
# L02.2 excluye el control interno selection del configurador; sigue presente en la tabla.
self.assertFalse(any(x['label']=='Selección' for x in states))
self.code("await page.getByRole('dialog').last().getByRole('checkbox',{name:'Detalles',exact:true}).uncheck();await page.keyboard.press('Escape');await page.getByRole('dialog').getByRole('radio').first().focus();await page.keyboard.press('Space');return true;")
self.assertEqual(self.code("return await page.getByRole('dialog').getByRole('radio').first().getAttribute('aria-checked');"),'true')
self.assertEqual(self.code("return await page.locator('output').textContent();"),'—')

View File

@ -77,6 +77,9 @@ class VisualUiTests(unittest.TestCase):
def navigate(self, section):
self.tool("browser_navigate", {"url": self.origin + self.company + "/" + section})
self.code("await page.locator('[data-slot=workspace]').waitFor(); return true;")
if section.split('?')[0] in ('assets', 'resources'):
# L02.3 monta la estructura antes de recibir datos: medir el estado disponible explícitamente.
self.code("await page.locator('[data-slot=entity-table][data-query-state=ready]').waitFor();await page.locator('main header button:enabled').last().waitFor();return true;")
def capture(self, name):
AUDIT["capture"](self.client, self.output, name)
@ -152,7 +155,7 @@ class VisualUiTests(unittest.TestCase):
for origin, opening, closing in [("header", "mouse", "Escape"), ("header", "keyboard", "Escape"), ("header", "mouse", "Cancelar"), ("row", "keyboard", "Escape"), ("row", "mouse", "Cancelar"), ("missing", "keyboard", "Escape"), ("disabled", "mouse", "Escape")]:
self.navigate("assets")
label = "Editar" if origin == "row" else "Nuevo activo"
self.code("const trigger=page.getByRole('button',{name:" + json.dumps(label) + ",exact:true}); await trigger.waitFor();" + ("await trigger.focus();await page.keyboard.press('Enter');" if opening == "keyboard" else "await trigger.click();") + "await page.getByRole('dialog').waitFor();return true;")
self.code("const trigger=page.getByRole('button',{name:" + json.dumps(label) + ",exact:true}).first(); await trigger.waitFor();" + ("await trigger.focus();await page.keyboard.press('Enter');" if opening == "keyboard" else "await trigger.click();") + "await page.getByRole('dialog').waitFor();return true;")
if origin == "missing":
self.code("await page.locator('[data-slot=inventory-layout] button').filter({hasText:'Nuevo activo'}).evaluate(e=>e.remove());return true;")
if origin == "disabled":
@ -264,6 +267,8 @@ class VisualUiTests(unittest.TestCase):
self.record("navigation-menu-transfer",switched)
self.assertEqual(switched,{"dialogs":1,"focusInside":True})
self.capture("navigation-global-menu-390")
# Con quince compañías el menú global necesita scroll; el foco debe hacer visible el destino.
self.code("await page.getByRole('dialog').getByRole('link',{name:'Inicio',exact:true}).focus();return true;")
self.assertTrue(self.code("return await page.getByRole('dialog').getByRole('link',{name:'Inicio',exact:true}).evaluate(e=>{const r=e.getBoundingClientRect();return r.top>=0&&r.bottom<=innerHeight;});"))
self.code("await page.keyboard.press('Escape');await page.getByRole('dialog').waitFor({state:'hidden'});return true;")
self.code("await page.waitForFunction(()=>document.activeElement.getAttribute('aria-label')?.startsWith('Secciones de '));return true;")