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 |
|
| 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. |
|
| 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 datos y controles disponibles. No duplicar la caché en estado local. |
|
| 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. |
|
| 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. |
|
| É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. |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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.
|
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.**
|
**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.
|
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
|
**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
|
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.
|
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
|
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.
|
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
|
### Configuración avanzada de columnas — L02.2
|
||||||
|
|
||||||
`EntityColumn` admite `groupId?: string` y `keywords?: readonly string[]`.
|
`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.
|
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
|
## 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.
|
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_INACTIVE": "The share is inactive.",
|
||||||
"SMB_RESOURCE_NOT_FOUND": "This share is no longer available."
|
"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_INACTIVE": "La carpeta está inactiva.",
|
||||||
"SMB_RESOURCE_NOT_FOUND": "La carpeta ya no está disponible."
|
"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 { inventoryKeys } from "../../../shared/queries";
|
||||||
import { removeAsset } from "../../shared/api";
|
import { removeAsset } from "../../shared/api";
|
||||||
import { getAssetInventory } from "../api";
|
import { getAssetInventory } from "../api";
|
||||||
|
import { assetListQueryDenied } from "../helpers/asset-list-query-state";
|
||||||
import { INVENTORY_DETAIL_BUDGET } from "../helpers/inventory-detail-layout";
|
import { INVENTORY_DETAIL_BUDGET } from "../helpers/inventory-detail-layout";
|
||||||
import type { AssetInventoryPageProps } from "../types";
|
import type { AssetInventoryPageProps } from "../types";
|
||||||
|
|
||||||
@ -99,14 +100,15 @@ export function useAssetInventoryPageController({
|
|||||||
setSelectedId(id);
|
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 selected = selectedId ? assets.find((asset) => asset.id === selectedId) : undefined;
|
||||||
const edited = assets.find((asset) => asset.id === editor);
|
const edited = assets.find((asset) => asset.id === editor);
|
||||||
const deleted = assets.find((asset) => asset.id === deleteId);
|
const deleted = assets.find((asset) => asset.id === deleteId);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loading: query.isPending || catalog.isPending || infrastructure.isPending,
|
assets,
|
||||||
hasLoadError: query.isError || catalog.isError || infrastructure.isError,
|
denied,
|
||||||
query,
|
query,
|
||||||
infrastructure,
|
infrastructure,
|
||||||
entityHref,
|
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,
|
workstations,
|
||||||
template,
|
template,
|
||||||
onCreate,
|
onCreate,
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
asset: AssetDTO;
|
asset: AssetDTO;
|
||||||
workstations: readonly WorkstationDTO[];
|
workstations: readonly WorkstationDTO[];
|
||||||
template: CatalogItemDTO | undefined;
|
template: CatalogItemDTO | undefined;
|
||||||
onCreate: (assetId: string) => void;
|
onCreate: (assetId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("assets");
|
const { t } = useTranslation("assets");
|
||||||
const action = assetWorkstationAction(asset, workstations, template);
|
const action = assetWorkstationAction(asset, workstations, template);
|
||||||
@ -41,6 +43,7 @@ export function AssetWorkstationAction({
|
|||||||
return action.kind === "create" ? (
|
return action.kind === "create" ? (
|
||||||
<Button
|
<Button
|
||||||
className="h-auto justify-start px-0 py-1 text-left whitespace-normal"
|
className="h-auto justify-start px-0 py-1 text-left whitespace-normal"
|
||||||
|
disabled={disabled}
|
||||||
onClick={() => onCreate(asset.id)}
|
onClick={() => onCreate(asset.id)}
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -4,19 +4,19 @@ import {
|
|||||||
EntityListHeader,
|
EntityListHeader,
|
||||||
EntityRowActions,
|
EntityRowActions,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} 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 { contextualDetailColumns } from "@repo/rdx-ui/lib/contextual-detail-layout";
|
||||||
import { BoxIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
|
import { BoxIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
import { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { useLayoutEffect, useRef } from "react";
|
import { useLayoutEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { CatalogError } from "../../../catalog";
|
|
||||||
import { InventoryFeedback } from "../../../shared";
|
|
||||||
import { WorkstationEditorDialog } from "../../../workstations/shared/ui/workstation-editor-dialog";
|
import { WorkstationEditorDialog } from "../../../workstations/shared/ui/workstation-editor-dialog";
|
||||||
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
|
import { AssetDetailDialog, AssetSelectionPanel } from "../../detail";
|
||||||
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
|
import { AssetEditorDialog, DeleteAssetDialog } from "../../shared";
|
||||||
import { AssetList } from "../../shared/ui/asset-list";
|
import { AssetList } from "../../shared/ui/asset-list";
|
||||||
import { useAssetInventoryPageController } from "../controllers";
|
import { useAssetInventoryPageController } from "../controllers";
|
||||||
|
import { assetListQueryState } from "../helpers/asset-list-query-state";
|
||||||
import { INVENTORY_DETAIL_BUDGET } from "../helpers/inventory-detail-layout";
|
import { INVENTORY_DETAIL_BUDGET } from "../helpers/inventory-detail-layout";
|
||||||
import type { AssetInventoryPageProps } from "../types";
|
import type { AssetInventoryPageProps } from "../types";
|
||||||
|
|
||||||
@ -37,8 +37,8 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
query,
|
query,
|
||||||
catalog,
|
catalog,
|
||||||
infrastructure,
|
infrastructure,
|
||||||
loading,
|
assets,
|
||||||
hasLoadError,
|
denied,
|
||||||
templateId,
|
templateId,
|
||||||
setTemplateId,
|
setTemplateId,
|
||||||
clearFilters,
|
clearFilters,
|
||||||
@ -64,6 +64,10 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
} = useAssetInventoryPageController(props);
|
} = useAssetInventoryPageController(props);
|
||||||
const { containerRef: inventoryRef } = detail;
|
const { containerRef: inventoryRef } = detail;
|
||||||
const { t, i18n } = useTranslation("assets");
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (missingView && document.activeElement === document.body) {
|
if (missingView && document.activeElement === document.body) {
|
||||||
@ -142,7 +146,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
disabled={!(catalog.data && query.data)}
|
disabled={!canMutate}
|
||||||
onClick={() => openEditor("new")}
|
onClick={() => openEditor("new")}
|
||||||
ref={newAssetButtonRef}
|
ref={newAssetButtonRef}
|
||||||
>
|
>
|
||||||
@ -157,102 +161,104 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
title={t("inventory")}
|
title={t("inventory")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && <p role="status">{t("loading")}</p>}
|
{query.data && !denied && (
|
||||||
<InventoryFeedback error={query.error} />
|
<>
|
||||||
<CatalogError error={catalog.error} />
|
<EntityListStatus
|
||||||
{infrastructure.isError && (
|
onRetry={() => catalog.refetch()}
|
||||||
<p className="text-sm text-destructive" role="alert">
|
retryFocus={() =>
|
||||||
{t("assetColumns.infrastructureError")}
|
inventoryRef.current?.querySelector<HTMLInputElement>("input[data-quick-search]") ??
|
||||||
</p>
|
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 && (
|
<AssetList
|
||||||
<Button
|
assets={assets}
|
||||||
onClick={() => {
|
catalog={catalog.data?.items ?? []}
|
||||||
Promise.all([query.refetch(), catalog.refetch(), infrastructure.refetch()]).catch(
|
catalogState={catalogState}
|
||||||
() => undefined
|
columnVisibilityStorageKey="asset-inventory-columns"
|
||||||
);
|
extraColumns={[
|
||||||
}}
|
{
|
||||||
variant="outline"
|
id: "workstation",
|
||||||
>
|
label: t("workstationInAsset"),
|
||||||
{t("retry")}
|
hideable: false,
|
||||||
</Button>
|
render: (asset) => (
|
||||||
)}
|
<AssetWorkstationAction
|
||||||
{query.data && (
|
asset={asset}
|
||||||
<AssetList
|
disabled={!canMutate}
|
||||||
assets={query.data.assets}
|
onCreate={setWorkstationAssetId}
|
||||||
catalog={catalog.data?.items ?? []}
|
template={catalog.data?.items.find((item) => item.id === asset.values.templateId)}
|
||||||
columnVisibilityStorageKey="asset-inventory-columns"
|
workstations={query.data?.workstations ?? []}
|
||||||
extraColumns={[
|
/>
|
||||||
{
|
),
|
||||||
id: "workstation",
|
},
|
||||||
label: t("workstationInAsset"),
|
{
|
||||||
hideable: false,
|
id: "actions",
|
||||||
render: (asset) => (
|
label: t("actions"),
|
||||||
<AssetWorkstationAction
|
hideable: false,
|
||||||
asset={asset}
|
render: (asset) => (
|
||||||
onCreate={setWorkstationAssetId}
|
<EntityRowActions
|
||||||
template={catalog.data?.items.find(
|
label={t("actions")}
|
||||||
(item) => item.id === asset.values.templateId
|
primaryAction={{
|
||||||
)}
|
label: t("edit"),
|
||||||
workstations={query.data?.workstations ?? []}
|
icon: <PencilIcon aria-hidden="true" />,
|
||||||
/>
|
onClick: () => openEditor(asset.id),
|
||||||
),
|
disabled: !canMutate,
|
||||||
},
|
}}
|
||||||
{
|
>
|
||||||
id: "actions",
|
{" "}
|
||||||
label: t("actions"),
|
<div className="contents">
|
||||||
hideable: false,
|
<EntityAction onClick={() => navigateTo(entityHref(asset.id))}>
|
||||||
render: (asset) => (
|
{t("viewAsset")}
|
||||||
<EntityRowActions
|
</EntityAction>
|
||||||
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>
|
|
||||||
|
|
||||||
<EntityAction
|
<EntityAction
|
||||||
className="text-destructive data-highlighted:text-destructive"
|
className="text-destructive data-highlighted:text-destructive"
|
||||||
onClick={() => setDeleteId(asset.id)}
|
onClick={() => setDeleteId(asset.id)}
|
||||||
>
|
>
|
||||||
{t("deleteAsset")}
|
{t("deleteAsset")}
|
||||||
</EntityAction>
|
</EntityAction>
|
||||||
</div>{" "}
|
</div>{" "}
|
||||||
</EntityRowActions>
|
</EntityRowActions>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
infrastructure={infrastructure.data?.items ?? []}
|
infrastructure={infrastructure.data?.items ?? []}
|
||||||
onClearFilters={clearFilters}
|
onClearFilters={clearFilters}
|
||||||
onRowClick={(asset) => selectAsset(asset.id)}
|
onRetry={denied ? undefined : () => query.refetch()}
|
||||||
onSearchChange={setSearch}
|
onRowClick={(asset) => selectAsset(asset.id)}
|
||||||
onTemplateChange={setTemplateId}
|
onSearchChange={setSearch}
|
||||||
renderName={(asset) => (
|
onTemplateChange={setTemplateId}
|
||||||
<BrowserLink
|
renderName={(asset) => (
|
||||||
aria-pressed={selected?.id === asset.id && detailOpen}
|
<BrowserLink
|
||||||
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"
|
aria-pressed={selected?.id === asset.id && detailOpen}
|
||||||
href={entityHref(asset.id)}
|
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>
|
{asset.values.name}
|
||||||
)}
|
</BrowserLink>
|
||||||
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
|
)}
|
||||||
search={search}
|
rowClassName={(asset) => selectedRowClass(asset.id, selected?.id, detailOpen)}
|
||||||
sites={lookups.sites}
|
search={search}
|
||||||
templateId={templateId}
|
sites={lookups.sites}
|
||||||
/>
|
state={inventoryState}
|
||||||
)}
|
templateId={templateId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<AssetSelectionPanel
|
<AssetSelectionPanel
|
||||||
asset={selected}
|
asset={selected}
|
||||||
canEdit={catalog.isSuccess}
|
canEdit={canMutate}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
lookups={lookups}
|
lookups={lookups}
|
||||||
onClose={() => detail.setVisible(false)}
|
onClose={() => detail.setVisible(false)}
|
||||||
@ -262,7 +268,7 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
|
|||||||
open={detailOpen && detail.mode === "inline"}
|
open={detailOpen && detail.mode === "inline"}
|
||||||
template={catalog.data?.items.find((item) => item.id === selected?.values.templateId)}
|
template={catalog.data?.items.find((item) => item.id === selected?.values.templateId)}
|
||||||
/>
|
/>
|
||||||
{editor && catalog.data && (
|
{editor && !denied && catalog.data && (
|
||||||
<AssetEditorDialog
|
<AssetEditorDialog
|
||||||
asset={edited}
|
asset={edited}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import type { EntityColumn } from "@repo/rdx-ui/components/entity-list";
|
import type { EntityColumn } from "@repo/rdx-ui/components/entity-list";
|
||||||
import { EntityStatus, EntityTable } 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 { 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 { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -35,6 +37,9 @@ export function AssetList({
|
|||||||
layout = "content",
|
layout = "content",
|
||||||
loading = false,
|
loading = false,
|
||||||
unavailable = false,
|
unavailable = false,
|
||||||
|
state,
|
||||||
|
catalogState,
|
||||||
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
assets: readonly AssetDTO[];
|
assets: readonly AssetDTO[];
|
||||||
catalog: readonly CatalogItemDTO[];
|
catalog: readonly CatalogItemDTO[];
|
||||||
@ -54,6 +59,9 @@ export function AssetList({
|
|||||||
layout?: "content" | "fill";
|
layout?: "content" | "fill";
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
unavailable?: boolean;
|
unavailable?: boolean;
|
||||||
|
state?: EntityListState | undefined;
|
||||||
|
catalogState?: EntityListState;
|
||||||
|
onRetry?: (() => Promise<unknown>) | undefined;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation("assets");
|
const { t, i18n } = useTranslation("assets");
|
||||||
const templates = new Map(
|
const templates = new Map(
|
||||||
@ -98,6 +106,9 @@ export function AssetList({
|
|||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
value={templateId ?? "all"}
|
value={templateId ?? "all"}
|
||||||
/>
|
/>
|
||||||
|
{catalogState?.status === "loading" && (
|
||||||
|
<Skeleton aria-hidden="true" className="h-2 w-28 motion-reduce:animate-none" />
|
||||||
|
)}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
className={`min-w-0 ${layout === "fill" ? "flex min-h-0 flex-col" : ""}`}
|
className={`min-w-0 ${layout === "fill" ? "flex min-h-0 flex-col" : ""}`}
|
||||||
keepMounted
|
keepMounted
|
||||||
@ -210,6 +221,9 @@ export function AssetList({
|
|||||||
.filter((column) => column.id === "actions")
|
.filter((column) => column.id === "actions")
|
||||||
.map((column) => ({ ...column, groupId: "general" })),
|
.map((column) => ({ ...column, groupId: "general" })),
|
||||||
]}
|
]}
|
||||||
|
columnsDisabled={
|
||||||
|
catalogState?.status === "loading" || catalogState?.status === "unavailable"
|
||||||
|
}
|
||||||
columnVisibilityStorageKey={columnVisibilityStorageKey}
|
columnVisibilityStorageKey={columnVisibilityStorageKey}
|
||||||
emptyMessage={t("noAssets")}
|
emptyMessage={t("noAssets")}
|
||||||
entityLabel={
|
entityLabel={
|
||||||
@ -222,11 +236,13 @@ export function AssetList({
|
|||||||
loading={loading}
|
loading={loading}
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
onClearFilters={onClearFilters}
|
onClearFilters={onClearFilters}
|
||||||
|
onRetry={onRetry}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
onSearchChange={onSearchChange}
|
onSearchChange={onSearchChange}
|
||||||
rowClassName={rowClassName}
|
rowClassName={rowClassName}
|
||||||
search={search}
|
search={search}
|
||||||
searchPlaceholder={t("search")}
|
searchPlaceholder={t("search")}
|
||||||
|
state={state}
|
||||||
title={t("inventory")}
|
title={t("inventory")}
|
||||||
unavailable={unavailable}
|
unavailable={unavailable}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -251,5 +251,21 @@
|
|||||||
"copy": "Copy UNC address",
|
"copy": "Copy UNC address",
|
||||||
"copied": "Address copied",
|
"copied": "Address copied",
|
||||||
"error": "Copy failed. Select the address and copy it manually."
|
"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",
|
"copy": "Copiar dirección UNC",
|
||||||
"copied": "Dirección copiada",
|
"copied": "Dirección copiada",
|
||||||
"error": "No se ha podido copiar. Selecciona la dirección y cópiala manualmente."
|
"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 { inventoryApi, inventoryKeys } from "../../shared/api";
|
||||||
import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys";
|
import { resourceRelatedQueryKeys } from "../../shared/api/resource-related-query-keys";
|
||||||
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
|
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
|
||||||
|
import { resourceListQueryDenied } from "../helpers/resource-list-query-state";
|
||||||
import type { ResourcesPageProps } from "../types";
|
import type { ResourcesPageProps } from "../types";
|
||||||
|
|
||||||
function initialEditor(create: boolean) {
|
function initialEditor(create: boolean) {
|
||||||
@ -135,23 +136,25 @@ export function useResourcesPageController({
|
|||||||
params.delete("template");
|
params.delete("template");
|
||||||
});
|
});
|
||||||
|
|
||||||
const edited = query.data?.resources.find((item) => item.id === editor);
|
const denied = resourceListQueryDenied(query.error);
|
||||||
const selected = query.data?.resources.find((item) => item.id === selectedId);
|
const resources = denied ? [] : (query.data?.resources ?? []);
|
||||||
const explicitView = query.data?.resources.find((item) => item.id === localViewId);
|
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 =
|
const viewed =
|
||||||
explicitView ?? (detail.mode === "overlay" && detail.visible ? selected : undefined);
|
explicitView ?? (detail.mode === "overlay" && detail.visible ? selected : undefined);
|
||||||
const visible =
|
const visible = resources.filter((item) => {
|
||||||
query.data?.resources.filter((item) => {
|
if (templateId && item.values.templateId !== templateId) {
|
||||||
if (templateId && item.values.templateId !== templateId) {
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return `${item.values.name} ${item.specification.templateName}`
|
return `${item.values.name} ${item.specification.templateName}`
|
||||||
.toLocaleLowerCase()
|
.toLocaleLowerCase()
|
||||||
.includes(search.toLocaleLowerCase());
|
.includes(search.toLocaleLowerCase());
|
||||||
}) ?? [];
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
canChangeStatus: query.isSuccess && !statusMutation.isPending,
|
||||||
deleteId,
|
deleteId,
|
||||||
setDeleteId,
|
setDeleteId,
|
||||||
statusMutation,
|
statusMutation,
|
||||||
@ -190,7 +193,7 @@ export function useResourcesPageController({
|
|||||||
detailOpen: detail.visible && Boolean(selected) && !explicitView,
|
detailOpen: detail.visible && Boolean(selected) && !explicitView,
|
||||||
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
missingView: Boolean(selectedId && query.isSuccess && !selected),
|
||||||
visible,
|
visible,
|
||||||
loading: query.isPending || catalog.isPending,
|
denied,
|
||||||
unavailable: (!query.data && query.isError) || (!catalog.data && catalog.isError),
|
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,
|
EntityStatus,
|
||||||
EntityTable,
|
EntityTable,
|
||||||
} from "@repo/rdx-ui/components/entity-list";
|
} 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 { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
|
||||||
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
|
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
|
||||||
import { contextualDetailColumns } from "@repo/rdx-ui/lib/contextual-detail-layout";
|
import { contextualDetailColumns } from "@repo/rdx-ui/lib/contextual-detail-layout";
|
||||||
import { LayersIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
|
import { LayersIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
|
||||||
import { Button } from "@repo/shadcn-ui/components/button";
|
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 { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
|
||||||
import { useLayoutEffect, useRef } from "react";
|
import { useLayoutEffect, useRef } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -22,6 +24,7 @@ import { DeleteResourceDialog } from "../../lifecycle/ui/delete-resource-dialog"
|
|||||||
import { ResourceEditorDialog } from "../../shared";
|
import { ResourceEditorDialog } from "../../shared";
|
||||||
import { useResourcesPageController } from "../controllers";
|
import { useResourcesPageController } from "../controllers";
|
||||||
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
|
import { RESOURCE_DETAIL_BUDGET } from "../helpers/resource-detail-budget";
|
||||||
|
import { resourceListQueryState } from "../helpers/resource-list-query-state";
|
||||||
import type { ResourcesPageProps } from "../types";
|
import type { ResourcesPageProps } from "../types";
|
||||||
|
|
||||||
export function ResourcesPage(props: ResourcesPageProps) {
|
export function ResourcesPage(props: ResourcesPageProps) {
|
||||||
@ -44,8 +47,8 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
entityHref,
|
entityHref,
|
||||||
missingView,
|
missingView,
|
||||||
visible,
|
visible,
|
||||||
loading,
|
denied,
|
||||||
unavailable,
|
deleting,
|
||||||
selected,
|
selected,
|
||||||
selectResource,
|
selectResource,
|
||||||
detailOpen,
|
detailOpen,
|
||||||
@ -53,11 +56,14 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
deleteId,
|
deleteId,
|
||||||
setDeleteId,
|
setDeleteId,
|
||||||
statusMutation,
|
statusMutation,
|
||||||
|
canChangeStatus,
|
||||||
resourceDeleted,
|
resourceDeleted,
|
||||||
} = useResourcesPageController(props);
|
} = useResourcesPageController(props);
|
||||||
const { t, i18n } = useTranslation("resources");
|
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 tabValue = templateId ?? "all";
|
||||||
const deleting = query.data?.resources.find((resource) => resource.id === deleteId);
|
|
||||||
const viewedId = viewed?.id;
|
const viewedId = viewed?.id;
|
||||||
const previousViewedIdRef = useRef(viewedId);
|
const previousViewedIdRef = useRef(viewedId);
|
||||||
|
|
||||||
@ -118,7 +124,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
{t("showResourcePanel")}
|
{t("showResourcePanel")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button disabled={!(query.data && catalog.data)} onClick={() => openEditor("new")}>
|
<Button disabled={!canMutate} onClick={() => openEditor("new")}>
|
||||||
<LayersIcon aria-hidden="true" />
|
<LayersIcon aria-hidden="true" />
|
||||||
{t("newResource")}
|
{t("newResource")}
|
||||||
</Button>{" "}
|
</Button>{" "}
|
||||||
@ -130,18 +136,18 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
title={t("inventory")}
|
title={t("inventory")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{loading && <p role="status">{t("loading")}</p>}
|
|
||||||
<InventoryFeedback error={query.error ?? catalog.error} />
|
|
||||||
<InventoryFeedback error={statusMutation.error} />
|
<InventoryFeedback error={statusMutation.error} />
|
||||||
{(query.isError || catalog.isError) && (
|
{query.data && !denied && (
|
||||||
<Button
|
<EntityListStatus
|
||||||
onClick={() => {
|
onRetry={() => catalog.refetch()}
|
||||||
Promise.all([query.refetch(), catalog.refetch()]).catch(() => undefined);
|
retryFocus={() =>
|
||||||
}}
|
detail.containerRef.current?.querySelector<HTMLInputElement>(
|
||||||
variant="outline"
|
"input[data-quick-search]"
|
||||||
>
|
) ?? null
|
||||||
{t("retry")}
|
}
|
||||||
</Button>
|
retryLabel={t("listState.retryCatalog")}
|
||||||
|
state={catalogState.status === "refreshing" ? undefined : catalogState}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<Tabs className="min-w-0 max-w-full" onValueChange={setTemplateId} value={tabValue}>
|
<Tabs className="min-w-0 max-w-full" onValueChange={setTemplateId} value={tabValue}>
|
||||||
<ScrollableTabsList
|
<ScrollableTabsList
|
||||||
@ -153,6 +159,9 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
value={tabValue}
|
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}>
|
<TabsContent className="min-w-0" keepMounted value={tabValue}>
|
||||||
<EntityTable
|
<EntityTable
|
||||||
columns={[
|
columns={[
|
||||||
@ -228,7 +237,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
label: t("edit"),
|
label: t("edit"),
|
||||||
icon: <PencilIcon aria-hidden="true" />,
|
icon: <PencilIcon aria-hidden="true" />,
|
||||||
onClick: () => openEditor(item.id),
|
onClick: () => openEditor(item.id),
|
||||||
disabled: !catalog.data,
|
disabled: !canMutate,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{" "}
|
{" "}
|
||||||
@ -237,7 +246,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
{t("viewResource")}
|
{t("viewResource")}
|
||||||
</EntityAction>
|
</EntityAction>
|
||||||
<EntityAction
|
<EntityAction
|
||||||
disabled={statusMutation.isPending}
|
disabled={!canChangeStatus}
|
||||||
onClick={() => statusMutation.mutate(item)}
|
onClick={() => statusMutation.mutate(item)}
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
@ -248,7 +257,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
</EntityAction>
|
</EntityAction>
|
||||||
<EntityAction
|
<EntityAction
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
disabled={statusMutation.isPending}
|
disabled={!canChangeStatus}
|
||||||
onClick={() => setDeleteId(item.id)}
|
onClick={() => setDeleteId(item.id)}
|
||||||
>
|
>
|
||||||
{t("resourceLifecycle.delete")}
|
{t("resourceLifecycle.delete")}
|
||||||
@ -258,13 +267,14 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
|
columnsDisabled={!catalog.data}
|
||||||
emptyMessage={t("empty")}
|
emptyMessage={t("empty")}
|
||||||
filterKey={tabValue}
|
filterKey={tabValue}
|
||||||
filtersApplied={templateId !== null}
|
filtersApplied={templateId !== null}
|
||||||
items={visible}
|
items={visible}
|
||||||
loading={loading}
|
|
||||||
locale={i18n.language}
|
locale={i18n.language}
|
||||||
onClearFilters={clearFilters}
|
onClearFilters={clearFilters}
|
||||||
|
onRetry={denied ? undefined : () => query.refetch()}
|
||||||
onRowClick={(item) => selectResource(item.id)}
|
onRowClick={(item) => selectResource(item.id)}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
rowClassName={(item) =>
|
rowClassName={(item) =>
|
||||||
@ -274,15 +284,15 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
}
|
}
|
||||||
search={search}
|
search={search}
|
||||||
searchPlaceholder={t("search")}
|
searchPlaceholder={t("search")}
|
||||||
|
state={inventoryState}
|
||||||
title={t("inventory")}
|
title={t("inventory")}
|
||||||
unavailable={unavailable}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
{selected && detailOpen && detail.mode === "inline" && (
|
{selected && detailOpen && detail.mode === "inline" && (
|
||||||
<ResourceSelectionPanel
|
<ResourceSelectionPanel
|
||||||
canEdit={catalog.isSuccess}
|
canEdit={canMutate}
|
||||||
onClose={() => detail.setVisible(false)}
|
onClose={() => detail.setVisible(false)}
|
||||||
onEdit={() => openEditor(selected.id)}
|
onEdit={() => openEditor(selected.id)}
|
||||||
onView={() => setViewId(selected.id)}
|
onView={() => setViewId(selected.id)}
|
||||||
@ -294,7 +304,7 @@ export function ResourcesPage(props: ResourcesPageProps) {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{editor && catalog.data && (
|
{editor && !denied && catalog.data && (
|
||||||
<ResourceEditorDialog
|
<ResourceEditorDialog
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
finalFocus={detail.triggerFinalFocus}
|
finalFocus={detail.triggerFinalFocus}
|
||||||
|
|||||||
@ -61,6 +61,7 @@ export function EntityListPagination({
|
|||||||
onPageSize,
|
onPageSize,
|
||||||
id,
|
id,
|
||||||
presentation = "full",
|
presentation = "full",
|
||||||
|
disabled = false,
|
||||||
}: {
|
}: {
|
||||||
locale: string;
|
locale: string;
|
||||||
page: number;
|
page: number;
|
||||||
@ -70,6 +71,7 @@ export function EntityListPagination({
|
|||||||
onPageSize: (size: number) => void;
|
onPageSize: (size: number) => void;
|
||||||
id: string;
|
id: string;
|
||||||
presentation?: "full" | "picker";
|
presentation?: "full" | "picker";
|
||||||
|
disabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const labels = entityListLabels(locale);
|
const labels = entityListLabels(locale);
|
||||||
|
|
||||||
@ -88,6 +90,7 @@ export function EntityListPagination({
|
|||||||
{labels.pageSize}
|
{labels.pageSize}
|
||||||
</label>
|
</label>
|
||||||
<SelectWithAction
|
<SelectWithAction
|
||||||
|
disabled={disabled}
|
||||||
id={`${id}-size`}
|
id={`${id}-size`}
|
||||||
label={labels.pageSize}
|
label={labels.pageSize}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
@ -105,14 +108,16 @@ export function EntityListPagination({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
aria-label={labels.previous}
|
aria-label={labels.previous}
|
||||||
disabled={page === 0}
|
disabled={disabled || page === 0}
|
||||||
onClick={() => onPage(page - 1)}
|
onClick={() => onPage(page - 1)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
</Button>
|
</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 aria-current="page" className="min-w-0 flex-1 text-center text-sm whitespace-nowrap">
|
||||||
<span className="sr-only">{labels.page} </span>
|
<span className="sr-only">{labels.page} </span>
|
||||||
{page + 1} / {pages}
|
{page + 1} / {pages}
|
||||||
@ -133,7 +138,7 @@ export function EntityListPagination({
|
|||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
aria-label={labels.next}
|
aria-label={labels.next}
|
||||||
disabled={page + 1 >= pages}
|
disabled={disabled || page + 1 >= pages}
|
||||||
onClick={() => onPage(page + 1)}
|
onClick={() => onPage(page + 1)}
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
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 { Button } from "@repo/shadcn-ui/components/button";
|
||||||
import { Input } from "@repo/shadcn-ui/components/input";
|
import { Input } from "@repo/shadcn-ui/components/input";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useId } from "react";
|
import { useId, useRef } from "react";
|
||||||
|
|
||||||
import { useEntityTable } from "../hooks/use-entity-table";
|
import { useEntityTable } from "../hooks/use-entity-table";
|
||||||
import type { EntityColumnGroup, EntityColumnMetadata } from "../lib/entity-column-configuration";
|
import type { EntityColumnGroup, EntityColumnMetadata } from "../lib/entity-column-configuration";
|
||||||
import { entityListLabels } from "../lib/entity-list";
|
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 { EntityListPagination } from "./entity-list-controls";
|
||||||
|
import { EntityListStatus } from "./entity-list-status";
|
||||||
import { EntityTableConfiguration } from "./entity-table-configuration";
|
import { EntityTableConfiguration } from "./entity-table-configuration";
|
||||||
import { EntityTableContent } from "./entity-table-content";
|
import { EntityTableContent } from "./entity-table-content";
|
||||||
|
|
||||||
@ -75,6 +78,9 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
filterKey = "",
|
filterKey = "",
|
||||||
columnVisibilityStorageKey,
|
columnVisibilityStorageKey,
|
||||||
columnGroups,
|
columnGroups,
|
||||||
|
state,
|
||||||
|
onRetry,
|
||||||
|
columnsDisabled,
|
||||||
loading = false,
|
loading = false,
|
||||||
unavailable = false,
|
unavailable = false,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
@ -96,6 +102,9 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
filterKey?: string;
|
filterKey?: string;
|
||||||
columnVisibilityStorageKey?: string | undefined;
|
columnVisibilityStorageKey?: string | undefined;
|
||||||
columnGroups?: readonly EntityColumnGroup[];
|
columnGroups?: readonly EntityColumnGroup[];
|
||||||
|
state?: EntityListState | undefined;
|
||||||
|
onRetry?: (() => Promise<unknown>) | undefined;
|
||||||
|
columnsDisabled?: boolean | undefined;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
unavailable?: boolean;
|
unavailable?: boolean;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
@ -104,7 +113,11 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
layout?: "content" | "fill";
|
layout?: "content" | "fill";
|
||||||
}) {
|
}) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null);
|
||||||
const labels = entityListLabels(locale);
|
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 } =
|
const { table, total, range, pagination, setPagination, currentSearch, setLocalSearch } =
|
||||||
useEntityTable({
|
useEntityTable({
|
||||||
items,
|
items,
|
||||||
@ -116,8 +129,8 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
columnVisibilityStorageKey,
|
columnVisibilityStorageKey,
|
||||||
});
|
});
|
||||||
const info = entityTableInfo(
|
const info = entityTableInfo(
|
||||||
loading,
|
pending,
|
||||||
unavailable,
|
missing,
|
||||||
labels.loading,
|
labels.loading,
|
||||||
labels.range(
|
labels.range(
|
||||||
range.from,
|
range.from,
|
||||||
@ -128,8 +141,8 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
const message = entityTableMessage(
|
const message = entityTableMessage(
|
||||||
loading,
|
pending,
|
||||||
unavailable,
|
missing,
|
||||||
Boolean(currentSearch || filtersApplied),
|
Boolean(currentSearch || filtersApplied),
|
||||||
labels,
|
labels,
|
||||||
emptyMessage
|
emptyMessage
|
||||||
@ -145,6 +158,7 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
<EntityTableConfiguration
|
<EntityTableConfiguration
|
||||||
columnGroups={columnGroups}
|
columnGroups={columnGroups}
|
||||||
columnMetadata={columns}
|
columnMetadata={columns}
|
||||||
|
columnsDisabled={columnsDisabled}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onResetColumns={() => table.setColumnVisibility({})}
|
onResetColumns={() => table.setColumnVisibility({})}
|
||||||
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
|
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
|
||||||
@ -155,9 +169,10 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
aria-busy={loading}
|
aria-busy={availability.sectionBusy}
|
||||||
aria-label={title}
|
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" : ""}`}
|
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"
|
data-slot="entity-table"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -178,6 +193,7 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
id={`${id}-search`}
|
id={`${id}-search`}
|
||||||
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
|
onChange={(event) => (onSearchChange ?? setLocalSearch)(event.target.value)}
|
||||||
placeholder={searchPlaceholder ?? labels.placeholder}
|
placeholder={searchPlaceholder ?? labels.placeholder}
|
||||||
|
ref={searchRef}
|
||||||
value={currentSearch}
|
value={currentSearch}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -193,35 +209,44 @@ export function EntityTable<T extends { id: string }>({
|
|||||||
</div>
|
</div>
|
||||||
{layout !== "fill" && (
|
{layout !== "fill" && (
|
||||||
<div className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-b px-4 py-3">
|
<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">
|
<div className="min-w-0 flex-1">
|
||||||
{info}
|
<EntityListStatus
|
||||||
</p>
|
onRetry={onRetry}
|
||||||
|
retryFocus={() => searchRef.current}
|
||||||
|
retryLabel={labels.retry}
|
||||||
|
state={availability.summaryState}
|
||||||
|
>
|
||||||
|
{info}
|
||||||
|
</EntityListStatus>
|
||||||
|
</div>
|
||||||
{configuration}
|
{configuration}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<EntityTableContent
|
<EntityTableContent
|
||||||
columns={columns}
|
columns={columns}
|
||||||
loading={loading}
|
loading={pending}
|
||||||
message={message}
|
message={message}
|
||||||
onRowClick={onRowClick}
|
onRowClick={onRowClick}
|
||||||
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
|
onSort={() => setPagination((previous) => ({ ...previous, pageIndex: 0 }))}
|
||||||
rowClassName={rowClassName}
|
rowClassName={rowClassName}
|
||||||
|
state={state}
|
||||||
table={table}
|
table={table}
|
||||||
title={title}
|
title={title}
|
||||||
unavailable={unavailable}
|
unavailable={missing}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer
|
<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"}`}
|
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
|
<p
|
||||||
aria-live={layout === "fill" ? "polite" : undefined}
|
aria-live={layout === "fill" ? availability.legacyAnnouncement : undefined}
|
||||||
className="text-sm text-muted-foreground"
|
className="text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
{info}
|
{info}
|
||||||
</p>
|
</p>
|
||||||
<EntityListPagination
|
<EntityListPagination
|
||||||
|
disabled={availability.paginationDisabled}
|
||||||
id={id}
|
id={id}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
onPage={(pageIndex) => setPagination({ ...pagination, pageIndex })}
|
onPage={(pageIndex) => setPagination({ ...pagination, pageIndex })}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ export function EntityTableConfiguration<T>({
|
|||||||
columnGroups,
|
columnGroups,
|
||||||
columnMetadata,
|
columnMetadata,
|
||||||
onResetColumns,
|
onResetColumns,
|
||||||
|
columnsDisabled = false,
|
||||||
}: {
|
}: {
|
||||||
table: TableModel<T>;
|
table: TableModel<T>;
|
||||||
locale: string;
|
locale: string;
|
||||||
@ -24,6 +25,7 @@ export function EntityTableConfiguration<T>({
|
|||||||
columnGroups?: readonly EntityColumnGroup[] | undefined;
|
columnGroups?: readonly EntityColumnGroup[] | undefined;
|
||||||
columnMetadata?: readonly (EntityColumnMetadata & { id: string })[] | undefined;
|
columnMetadata?: readonly (EntityColumnMetadata & { id: string })[] | undefined;
|
||||||
onResetColumns?: (() => void) | undefined;
|
onResetColumns?: (() => void) | undefined;
|
||||||
|
columnsDisabled?: boolean | undefined;
|
||||||
}) {
|
}) {
|
||||||
const id = useId();
|
const id = useId();
|
||||||
const labels = entityListLabels(locale);
|
const labels = entityListLabels(locale);
|
||||||
@ -39,7 +41,12 @@ export function EntityTableConfiguration<T>({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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}
|
{labels.columns}
|
||||||
</Button>
|
</Button>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -10,7 +10,10 @@ import type { Table as TableModel } from "@tanstack/react-table";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { Fragment, isValidElement } from "react";
|
import { Fragment, isValidElement } from "react";
|
||||||
|
|
||||||
|
import type { EntityListState } from "../lib/entity-list-state";
|
||||||
|
|
||||||
import type { EntityColumn } from "./entity-list";
|
import type { EntityColumn } from "./entity-list";
|
||||||
|
import { EntityTableSkeleton } from "./entity-table-skeleton";
|
||||||
|
|
||||||
export function EntityTableContent<T extends { id: string }>({
|
export function EntityTableContent<T extends { id: string }>({
|
||||||
table,
|
table,
|
||||||
@ -22,6 +25,7 @@ export function EntityTableContent<T extends { id: string }>({
|
|||||||
rowClassName,
|
rowClassName,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
onSort,
|
onSort,
|
||||||
|
state,
|
||||||
}: {
|
}: {
|
||||||
table: TableModel<T>;
|
table: TableModel<T>;
|
||||||
columns: readonly EntityColumn<T>[];
|
columns: readonly EntityColumn<T>[];
|
||||||
@ -32,6 +36,7 @@ export function EntityTableContent<T extends { id: string }>({
|
|||||||
rowClassName: ((item: T) => string) | undefined;
|
rowClassName: ((item: T) => string) | undefined;
|
||||||
onRowClick: ((item: T) => void) | undefined;
|
onRowClick: ((item: T) => void) | undefined;
|
||||||
onSort: () => void;
|
onSort: () => void;
|
||||||
|
state?: EntityListState | undefined;
|
||||||
}) {
|
}) {
|
||||||
const visibleColumns = [
|
const visibleColumns = [
|
||||||
...table.getCenterVisibleLeafColumns(),
|
...table.getCenterVisibleLeafColumns(),
|
||||||
@ -40,7 +45,10 @@ export function EntityTableContent<T extends { id: string }>({
|
|||||||
const total = table.getFilteredRowModel().rows.length;
|
const total = table.getFilteredRowModel().rows.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Table aria-label={title}>
|
<Table
|
||||||
|
aria-busy={state?.status === "loading" || state?.status === "refreshing" || undefined}
|
||||||
|
aria-label={title}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{visibleColumns.map((column) => {
|
{visibleColumns.map((column) => {
|
||||||
@ -83,10 +91,24 @@ export function EntityTableContent<T extends { id: string }>({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<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>
|
<TableRow>
|
||||||
<TableCell colSpan={visibleColumns.length}>
|
<TableCell
|
||||||
<span className="text-muted-foreground">{message}</span>
|
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>
|
</TableCell>
|
||||||
</TableRow>
|
</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,
|
EntityTable,
|
||||||
} from "./components/entity-list";
|
} from "./components/entity-list";
|
||||||
import { entityListLabels, entityListPages, entityListRange } from "./lib/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";
|
import { PencilIcon } from "./lib/icons";
|
||||||
|
|
||||||
const items = Array.from({ length: 21 }, (_, index) => ({
|
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.match(/ disabled=""/g) ?? []).length, 2);
|
||||||
assert.equal(html.includes("Mostrando"), false);
|
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",
|
"description": "View and manage",
|
||||||
"pickerRange": "{{from}}–{{to}} of {{total}} {{entity}}",
|
"pickerRange": "{{from}}–{{to}} of {{total}} {{entity}}",
|
||||||
"range": "Showing {{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",
|
"description": "Consulta y gestiona",
|
||||||
"pickerRange": "{{from}}–{{to}} de {{total}} {{entity}}",
|
"pickerRange": "{{from}}–{{to}} de {{total}} {{entity}}",
|
||||||
"range": "Mostrando {{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;")
|
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')})));")
|
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.record('protected-columns',states)
|
||||||
self.assertTrue(next(x for x in states if x['label']=='Campo')['disabled'])
|
self.assertTrue(self.code("return page.getByRole('dialog').last().getByRole('checkbox',{name:'Campo',exact:true}).isDisabled();"))
|
||||||
self.assertTrue(next(x for x in states if x['label']=='Selección')['disabled'])
|
# 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.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.getByRole('dialog').getByRole('radio').first().getAttribute('aria-checked');"),'true')
|
||||||
self.assertEqual(self.code("return await page.locator('output').textContent();"),'—')
|
self.assertEqual(self.code("return await page.locator('output').textContent();"),'—')
|
||||||
|
|||||||
@ -77,6 +77,9 @@ class VisualUiTests(unittest.TestCase):
|
|||||||
def navigate(self, section):
|
def navigate(self, section):
|
||||||
self.tool("browser_navigate", {"url": self.origin + self.company + "/" + section})
|
self.tool("browser_navigate", {"url": self.origin + self.company + "/" + section})
|
||||||
self.code("await page.locator('[data-slot=workspace]').waitFor(); return true;")
|
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):
|
def capture(self, name):
|
||||||
AUDIT["capture"](self.client, self.output, 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")]:
|
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")
|
self.navigate("assets")
|
||||||
label = "Editar" if origin == "row" else "Nuevo activo"
|
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":
|
if origin == "missing":
|
||||||
self.code("await page.locator('[data-slot=inventory-layout] button').filter({hasText:'Nuevo activo'}).evaluate(e=>e.remove());return true;")
|
self.code("await page.locator('[data-slot=inventory-layout] button').filter({hasText:'Nuevo activo'}).evaluate(e=>e.remove());return true;")
|
||||||
if origin == "disabled":
|
if origin == "disabled":
|
||||||
@ -264,6 +267,8 @@ class VisualUiTests(unittest.TestCase):
|
|||||||
self.record("navigation-menu-transfer",switched)
|
self.record("navigation-menu-transfer",switched)
|
||||||
self.assertEqual(switched,{"dialogs":1,"focusInside":True})
|
self.assertEqual(switched,{"dialogs":1,"focusInside":True})
|
||||||
self.capture("navigation-global-menu-390")
|
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.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.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;")
|
self.code("await page.waitForFunction(()=>document.activeElement.getAttribute('aria-label')?.startsWith('Secciones de '));return true;")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user