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:
parent
ba034794c6
commit
9fc7d3a2e0
@ -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.
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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[]`.
|
||||
|
||||
266
docs/estados-listados-l02-3.md
Normal file
266
docs/estados-listados-l02-3.md
Normal 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.
|
||||
@ -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.
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`),
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@ -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`),
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
66
packages/rdx-ui/src/components/entity-list-status.tsx
Normal file
66
packages/rdx-ui/src/components/entity-list-status.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 })}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
23
packages/rdx-ui/src/components/entity-table-skeleton.tsx
Normal file
23
packages/rdx-ui/src/components/entity-table-skeleton.tsx
Normal 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>
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
61
packages/rdx-ui/src/lib/entity-list-state.ts
Normal file
61
packages/rdx-ui/src/lib/entity-list-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
181
scripts/list-states-ui.test.py
Normal file
181
scripts/list-states-ui.test.py
Normal 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()
|
||||
@ -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();"),'—')
|
||||
|
||||
@ -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;")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user