feat: add catalog field picker dialog and list components
- Introduced `CatalogFieldPickerDialog` and `CatalogFieldPickerList` for selecting catalog fields. - Implemented `appendTemplateFields` helper function for managing template fields. - Added tests for catalog field selection and template field management. - Created reusable components for field picking and reordering in the UI library. - Enhanced drag-and-drop functionality for reordering items in lists.
This commit is contained in:
parent
ece38c741e
commit
ccb52fb534
@ -88,6 +88,9 @@ React, TanStack Query, Tailwind y shadcn/ui (Base UI, estilo Vega). Los componen
|
||||
Estado remoto en TanStack Query; claves centralizadas e invalidación explícita después de mutaciones.
|
||||
No duplicar estado remoto ni usar efectos para derivar valores calculables durante render.
|
||||
Mantener accesibilidad y estados de carga, error, vacío y acciones deshabilitadas.
|
||||
Los títulos de grupos en todos los diálogos siguen el estilo de «Datos de la plantilla»: `text-xs font-medium uppercase tracking-wide text-muted-foreground`. Reutilizar `FormSectionTitle` de @repo/rdx-ui, también dentro de `FormSection`, conservando el nivel semántico del encabezado. La variante del contenedor no cambia este estilo. Los títulos principales del diálogo y los nombres editables de secciones conservan su tratamiento propio.
|
||||
Todos los diálogos de selección de tipo `PickerList` utilizan `FormDialog layout="picker"`: altura estable adaptada a la ventana, independiente del número de resultados. La tabla ocupa el espacio disponible con `EntityTable layout="fill"`; búsqueda, filtros, paginación y acciones quedan estables, y los resultados tienen desplazamiento interno con espacio reservado para la barra. En ventanas pequeñas se permite desplazamiento adicional del cuerpo para mantener accesibles todos los controles.
|
||||
En los diálogos de selección de tipo `PickerList`, la columna del checkbox o radio se sitúa siempre al principio, antes de las columnas de datos, con identificador `selection`, cabecera solo visible para lectores de pantalla y anchura ajustada al control, próxima a la columna del nombre, sin ordenación ni posibilidad de ocultarla. No se trata como columna `actions` ni se ancla a la derecha. Pulsar cualquier punto no interactivo de una fila disponible debe activar/desactivar su checkbox, o seleccionar su radio en modo único. Reutilizar `onRowClick` de la tabla común, respetar las filas deshabilitadas y conservar la navegación por teclado del control. Pulsar directamente el checkbox, radio u otro control debe ejecutar solo su interacción propia, sin duplicar el cambio por propagación.
|
||||
Separar componentes por responsabilidades reales, sin fragmentar JSX trivial.
|
||||
Antes de crear o modificar pantallas, reconocer activamente patrones visuales y de interacción comunes entre ellas, así como componentes repetidos dentro de una misma pantalla. Aplicar soluciones coherentes a todos los casos equivalentes, conservando las necesidades propias de cada entidad.
|
||||
Reutilizar y extender la biblioteca visual existente antes de duplicar estructura, estilos o comportamiento. Extraer componentes compartidos cuando haya repetición real: cabeceras, listados, formularios modales, grupos de campos, selectores con alta, estados y ayuda contextual.
|
||||
|
||||
@ -141,7 +141,7 @@ Los entornos necesitan un sistema activo seleccionado. Los permisos nuevos hered
|
||||
|
||||
Uso de cuenta, estado de configuración e idioma son listas cerradas. Las referencias históricas de cobertura (necesidad, autorización, titularidad y pertenencia) conservan sus operaciones propias. Los campos inmutables en edición no ofrecen altas. Los permisos múltiples de una necesidad continúan como casillas.
|
||||
|
||||
El catálogo de activos utiliza SelectWithAction para añadir campos a una plantilla y crearlos mediante un modal adicional. Los tipos de dato constituyen una lista cerrada. Las plantillas de serie admiten edición modal desde el catálogo global; para personalizar exclusivamente una compañía se crean copias. Los campos de serie admiten edición modal desde el catálogo global.
|
||||
El catálogo de activos utiliza un diálogo de selección múltiple basado en EntityTable para añadir campos a una plantilla y conserva el alta rápida mediante un modal independiente. Los tipos de dato constituyen una lista cerrada. Las plantillas de serie admiten edición modal desde el catálogo global; para personalizar exclusivamente una compañía se crean copias. Los campos de serie admiten edición modal desde el catálogo global.
|
||||
|
||||
El inventario añade altas rápidas de plantilla y sede en los activos. La plantilla creada desde un activo solo ofrece campos existentes para mantener un único nivel adicional de modal. Las características utilizan controles según su tipo; los valores sí/no conservan un estado vacío distinto de No.
|
||||
|
||||
@ -304,7 +304,7 @@ TemplateSectionsEditor recibe secciones, referencias de campos, nombres, textos
|
||||
|
||||
La cabecera permanece visible. Un formulario modal utiliza una raíz flex con altura mínima cero, `FormBody` para campos, errores y ayuda, y `FormActions` como hermano final fuera del cuerpo desplazable. Los botones permanecen dentro del formulario HTML original: se conservan envío por teclado, validación nativa, estados pendientes y cancelación. La ayuda queda al final del cuerpo, inmediatamente antes de las acciones. Los diálogos de consulta conservan el cuerpo desplazable y su pie externo.
|
||||
|
||||
`FormSection` agrupa datos relacionados mediante título accesible y un borde discreto. Activos, recursos y plantillas lo utilizan para sus datos principales. Las características y las secciones del editor se distribuyen en dos bloques por fila cuando el contenedor dispone de al menos 900 px; los controles dentro de cada bloque mantienen una o dos columnas según el espacio. Una plantilla sin secciones puede aprovechar hasta tres columnas de características. Observaciones y ayuda ocupan toda la fila. La disposición responde al espacio del modal, no solo al ancho del navegador.
|
||||
`FormSection` agrupa datos relacionados mediante título accesible; la variante `card` añade un borde discreto y `plain` conserva el bloque sin marco. Ambas utilizan `FormSectionTitle`. Activos, recursos y plantillas lo utilizan para sus datos principales. Las características se distribuyen en dos bloques por fila cuando el contenedor dispone de al menos 900 px; los controles dentro de cada bloque mantienen una o dos columnas según el espacio. Una plantilla sin secciones puede aprovechar hasta tres columnas de características. Observaciones y ayuda ocupan toda la fila. La disposición responde al espacio del modal, no solo al ancho del navegador.
|
||||
|
||||
El desplazamiento vertical se conserva cuando el contenido supera la altura disponible; no se ocultan campos ni se reduce artificialmente su tamaño para eliminarlo. No se introducen pestañas ni páginas nuevas sin una necesidad específica del formulario.
|
||||
|
||||
@ -343,7 +343,7 @@ Los módulos conectan este componente mediante `DeleteRecordDialog`, separado de
|
||||
|
||||
## IconPicker
|
||||
|
||||
`@repo/rdx-ui/components/icon-picker` presenta opciones visuales mediante botones con nombre accesible, tooltip nativo y `aria-pressed`. Recibe opciones (valor, etiqueta traducida e icono), valor actual, callback y estado deshabilitado. No conoce entidades ni endpoints. Los catálogos de plantillas de activos y recursos aportan sus iconos Lucide y validan sus claves; el componente admite teclado y distribución responsive.
|
||||
`@repo/rdx-ui/components/icon-picker` presenta un desplegable compacto mediante `Select`, `SelectTrigger`, `SelectValue` y `SelectItem` de shadcn/ui (Base UI). Muestra el icono seleccionado y su etiqueta traducida; cada opción combina icono y texto, incluida «Sin icono». Recibe opciones (valor, etiqueta e icono), valor actual, callback, estado deshabilitado y descripción opcional enlazada mediante `aria-describedby`. Su anchura máxima en escritorio es 16 rem y se adapta al espacio disponible en móvil. La etiqueta se asocia al control y los iconos se ocultan del nombre accesible. Mantiene teclado, foco y desplazamiento del desplegable mediante la primitiva existente. No conoce entidades ni endpoints. Las plantillas de activos y recursos reutilizan el mismo componente y aportan sus iconos Lucide, traducciones y validación de claves.
|
||||
|
||||
## Consulta compacta de plantillas
|
||||
|
||||
@ -437,7 +437,7 @@ El nuevo puesto se organiza en sede/responsable, activos, ordenador principal y
|
||||
|
||||
`AssetPickerDialog` carga inventario y catálogo mediante sus queries existentes. Conserva una selección provisional hasta confirmar; Cancelar, Escape y cerrar no modifican el formulario padre. No invoca endpoints de escritura. El diálogo muestra estados de carga/error y reintento, contador de seleccionados, controles accesibles y confirmación deshabilitada si hay selecciones inválidas o se supera el límite. Las selecciones sobreviven a búsqueda, pestañas y paginación. No navega ni cambia filtros URL de la pantalla subyacente.
|
||||
|
||||
`AssetList`, en assets/shared/ui, concentra las pestañas por UUID de plantilla (incluidas vacías/inactivas y especificaciones históricas), búsqueda rápida por nombre/plantilla/estado/sede y las columnas Nombre, Plantilla, Estado y Sede. Tanto inventario como selector lo componen con `EntityTable`. El inventario aporta enlaces, acciones y resaltado; el selector aporta checkboxes o radios de shadcn/ui en la columna fija de acciones, accesible también al desplazar horizontalmente la tabla. Se reutilizan ordenación, columnas configurables, rangos y paginación existentes. Este componente conoce activos y permanece en su módulo; `EntityTable`, `FormDialog`, `FormBody`, `FormActions` y `ButtonGroup` siguen siendo los patrones genéricos independientes del dominio.
|
||||
`AssetList`, en assets/shared/ui, concentra las pestañas por UUID de plantilla (incluidas vacías/inactivas y especificaciones históricas), búsqueda rápida por nombre/plantilla/estado/sede y las columnas Nombre, Plantilla, Estado y Sede. Tanto inventario como selector lo componen con `EntityTable`. El inventario aporta enlaces, acciones y resaltado; el selector aporta checkboxes o radios de shadcn/ui en la primera columna de selección. Se reutilizan ordenación, columnas configurables, rangos y paginación existentes. Este componente conoce activos y permanece en su módulo; `EntityTable`, `FormDialog`, `FormBody`, `FormActions` y `ButtonGroup` siguen siendo los patrones genéricos independientes del dominio.
|
||||
|
||||
El inventario no selecciona ningún activo al entrar en el listado. Pulsar el nombre o una zona
|
||||
sin controles de la fila abre su ficha lateral (en móvil, el diálogo existente), conservando
|
||||
@ -490,3 +490,57 @@ rejillas de una o dos columnas. La ayuda contextual permanece al final del cuerp
|
||||
utilizando Avatar y Button de shadcn/ui. Sin imagen se muestra un placeholder de cámara;
|
||||
al editar se presenta la foto vigente. La selección es provisional hasta guardar y las
|
||||
URLs de vista previa se liberan al cambiar de archivo o desmontar el componente.
|
||||
|
||||
## Jerarquía del editor de plantillas
|
||||
|
||||
El alta y la edición de plantillas de activos y recursos agrupan los datos generales con `FormSection` e incorporan campos desde las cabeceras de sus grupos. `TemplateSectionsEditor` permite secciones apiladas o una cuadrícula responsive mediante `sectionLayout="stack" | "responsive"`, con una cabecera numerada sobre fondo diferenciado que contiene el input del nombre, el asa de arrastre y las acciones de orden y eliminación. El cuerpo conserva la descripción y un área de campos separada por un borde. `TemplateSectionEditor` encapsula cada sección con `Collapsible`: todas empiezan abiertas y un botón de cabecera permite plegarlas con ratón o teclado. El nombre, el asa y las acciones siguen visibles al plegar. El contenido permanece montado para conservar los controles; si la validación nativa detecta un control inválido oculto, la sección se abre antes de que el navegador lo enfoque. La apertura se conserva por identidad al reordenar y no se guarda como parte de la plantilla. Los editores de activos y recursos usan la cuadrícula responsive: una columna hasta 1.000 px de ancho disponible y dos a partir de ese umbral, con separación de 16 px y tarjetas de igual altura en cada fila, determinada por la sección más alta. El orden recorre cada fila de izquierda a derecha y se conserva en el DOM, la numeración, el teclado y el arrastre; la altura de cada fila se recalcula al plegar o desplegar sus secciones, manteniendo ambas tarjetas con la misma altura. El bloque «Sin sección» permanece debajo, a ancho completo.
|
||||
|
||||
`TemplateSectionFields` muestra un título traducido y el número de campos. Cada campo tiene fondo propio y separa su nombre y acciones de la configuración de obligatoriedad y asignación, cuyo selector conserva una etiqueta visible. Los campos se distribuyen en dos columnas a partir de 700 px de su propio grupo y en una para anchos menores; cada grupo establece su contenedor para evitar columnas demasiado estrechas cuando las secciones se muestran en paralelo. El bloque «Sin sección» se diferencia con borde discontinuo; las secciones vacías conservan un mensaje explícito. Se reutilizan los colores semánticos del tema para mantener el contraste en modo claro y oscuro.
|
||||
|
||||
### Orden mediante arrastre y teclado
|
||||
|
||||
`TemplateSectionsEditor` y `TemplateSectionFields` utilizan `useDragReorder` y `ReorderHandle`, independientes del dominio. Cada sección y campo incorpora un asa etiquetada, con ayuda traducida y posición actual en su nombre accesible. Se puede arrastrar desde el asa y soltar en la mitad superior o inferior de otro elemento para colocarlo antes o después; una línea de color primario marca el destino sin modificar la altura del contenido. El elemento de origen se atenúa durante el arrastre. El orden se confirma únicamente al soltar y se conserva hasta guardar el formulario; cancelar el arrastre no cambia los datos.
|
||||
|
||||
El arrastre utiliza la API HTML nativa, sin dependencias adicionales. Cada lista tiene un ámbito propio: las secciones se reordenan entre sí y los campos dentro de su sección, incluido el bloque «Sin sección». En la presentación de tarjetas, el selector permite trasladar un campo a otro grupo. No se aceptan arrastres externos ni de otro diálogo. Las teclas arriba y abajo sobre el asa permiten mover el elemento conservando el foco; los botones explícitos de orden siguen disponibles, también para dispositivos sin arrastre nativo. Las asas quedan deshabilitadas cuando se guarda o no hay otro elemento en su lista.
|
||||
|
||||
### Selección múltiple de campos de plantillas de activos y recursos
|
||||
|
||||
El alta y la edición de plantillas de activos y recursos abren el `CatalogFieldPickerDialog` de su módulo desde «Añadir campo» en las cabeceras de los grupos de campos, siguiendo el patrón de `AssetPickerDialog` y `AssetPickerList`: `FormDialog`, `FormBody`, `FormActions`, `EntityTable` y `Checkbox`. La tabla reutiliza búsqueda, ordenación, columnas y paginación, con nombre, descripción, tipo y unidad como contexto. Solo recibe campos activos del catálogo del formulario que todavía no están en la plantilla; no añade consultas ni dependencias.
|
||||
|
||||
La selección permanece al buscar o paginar y se confirma como un lote mediante «Añadir campos». Cancelar descarta la selección provisional. Los campos nuevos se añaden al final, como opcionales en la sección de destino («Sin sección» desde el grupo sin sección), conservando el orden, agrupación y obligatoriedad de los existentes. `appendTemplateFields` deduplica y valida el conjunto con el schema existente; las casillas impiden superar su límite. El alta rápida «Nuevo campo» se ofrece en el pie de `CatalogFieldPickerDialog`, a la izquierda, mediante el slot `leadingActions` de `FormActions`. La prop opcional `quickCreate` aporta el ámbito y permite deshabilitar el botón con `disabled`; omitirla oculta la acción e impide abrir el alta. El editor de plantillas traduce su opción existente `allowQuickCreate` a este contrato. El diálogo de selección permanece montado durante el alta para conservar la búsqueda, página y selección provisional. Guardar el nuevo campo actualiza el catálogo y lo marca en esa selección; solo «Añadir campos» modifica el borrador de la plantilla. Cancelar el alta conserva la selección; cancelar el selector no elimina un campo ya creado del catálogo. Los cambios se persisten al guardar la plantilla.
|
||||
|
||||
### Interacción de filas en PickerList
|
||||
|
||||
En todos los diálogos de tipo `PickerList`, la columna del checkbox o radio ocupa la primera posición, antes del nombre y los demás datos. Usa el identificador `selection`, no se ordena ni se oculta y no se ancla a la derecha como las acciones. `EntityTable` oculta visualmente su cabecera mediante `sr-only`, conservando el nombre accesible; ajusta su anchura al control y reduce el relleno derecho para acercarlo al nombre. Se conserva el ancho mínimo de la columna del nombre. Los avisos de indisponibilidad de activos se muestran junto al nombre, para no ensanchar la columna de selección. `AssetList` permite anteponerla mediante `leadingColumns`, manteniendo las acciones adicionales al final. Pulsar una zona no interactiva de una fila disponible cambia su selección: alterna el checkbox en modo múltiple y selecciona el radio en modo único. `AssetPickerList` y `CatalogFieldPickerList` reutilizan `onRowClick` de `EntityTable` (a través de `AssetList` cuando corresponde), que ya excluye clics en controles internos. Así, pulsar directamente una casilla o un radio produce un único cambio y mantiene el uso por teclado. Las filas deshabilitadas respetan el mismo bloqueo que su control y muestran el cursor de acción no disponible. Esta regla se aplica también a los nuevos pickers.
|
||||
|
||||
### Altura estable de los diálogos PickerList
|
||||
|
||||
Todos los diálogos `PickerList` usan `FormDialog layout="picker"`. Esta variante fija la altura a la ventana disponible en móvil, con un margen total de 2 rem; en escritorio utiliza el menor valor entre el 85 % de la altura dinámica de la ventana y 900 px, respetando siempre el margen. La altura no depende de carga, errores, búsqueda, número de resultados o tamaño de página. Los formularios de alta y edición conservan la altura por contenido, también cuando se abren desde un picker.
|
||||
|
||||
`FormBody` hereda esta disposición del diálogo y distribuye sus hijos en columna. `EntityTable layout="fill"` ocupa el espacio restante; búsqueda, filtros, información y paginación no se desplazan con los resultados. El contenedor de tabla tiene scroll interno y `scrollbar-gutter: stable`; las cabeceras permanecen visibles al recorrer las filas. El área mantiene su espacio con pocos resultados o ninguno. En ventanas pequeñas, el cuerpo también puede desplazarse para evitar recortar controles. `AssetList` propaga `layout="fill"` por las pestañas y `AssetPickerList` conserva ese comportamiento en selección única y múltiple. `AssetPickerDialog` y `CatalogFieldPickerDialog` aplican esta variante común.
|
||||
|
||||
### Incorporar campos desde una sección de plantilla
|
||||
|
||||
En los editores de plantillas de activos y recursos, la cabecera «Campos» de cada sección ofrece «Añadir campo». Reutiliza `CatalogFieldPickerDialog` y muestra la sección de destino; permite seleccionar campos existentes o usar el alta rápida del pie cuando está habilitada. Al confirmar, solo las referencias nuevas se añaden como opcionales a ese grupo; no mueve ni cambia la obligatoriedad de los campos existentes. El bloque independiente «Añadir campos a la plantilla» se ha retirado. Si todavía no existen secciones, se muestra el grupo «Campos» con su botón de incorporación y ayuda de estado vacío, cuyo destino es «Sin sección». Cancelar no cambia el borrador.
|
||||
|
||||
`TemplateSectionsEditor` recibe la acción opcional `addFieldAction`, que traslada el destino al módulo consumidor. `TemplateSectionFields` presenta esa acción en su cabecera, con el contexto accesible del grupo y bloqueo durante el guardado; también se puede reutilizar en «Sin sección» cuando está visible. Los componentes genéricos no conocen el selector ni sus DTO. Cada módulo conserva el destino por identidad mientras el picker está abierto, comprueba su pertenencia a la plantilla y reutiliza `appendTemplateFields` para deduplicar y validar el lote.
|
||||
|
||||
### Selector de campos compartido
|
||||
|
||||
`FieldPickerDialog`, `FieldPickerList` y `useFieldPickerController`, en `@repo/rdx-ui`, comparten la selección múltiple, confirmación, alta opcional y bloqueo por capacidad entre activos y recursos. La tabla recibe datos presentacionales (identidad, nombre, descripción, tipo traducido y unidad) y etiquetas. El diálogo recibe la tabla y el alta mediante composición, sin importar DTO, consultas, schemas ni endpoints de los módulos. Los adaptadores `CatalogFieldPickerDialog` y `CatalogFieldPickerList` de cada módulo aportan esos datos, sus traducciones y su formulario de alta.
|
||||
|
||||
`appendTemplateBindings` concentra la incorporación idempotente de referencias, conservando las existentes y asignando solo las nuevas al destino. Cada `appendTemplateFields` valida el resultado con el schema normativo de su módulo. Las plantillas de recursos mantienen su configuración de uso y las restricciones de alta rápida del consumidor.
|
||||
|
||||
### Datos de las plantillas de activos y recursos
|
||||
|
||||
El alta y la edición de plantillas de activos y recursos reutilizan `TemplateMetadataSection`, que compone `FormSection variant="plain"` para «Datos de la plantilla», sin borde ni relleno interior de tarjeta. Los controles se distribuyen en dos columnas iguales a partir de 560 px del contenedor: nombre e icono debajo en la primera, descripción en la segunda. Ambas columnas tienen la misma altura; el área de descripción crece para alinearse con el borde inferior del selector de icono. En espacios estrechos se apilan en el orden nombre, icono y descripción. El icono conserva una anchura máxima de 16 rem. El componente recibe título traducido, `primaryFields`, `descriptionField` y contenido adicional opcional mediante `children`, sin conocer datos ni formularios de los módulos. Añade 16 px a la separación base de 20 px entre grupos. Recursos conserva su selector de uso y su ayuda bajo las dos columnas.
|
||||
|
||||
## Títulos de grupos en diálogos
|
||||
|
||||
Todos los diálogos con grupos utilizan el estilo de «Datos de la plantilla»: texto pequeño, peso medio, mayúsculas, espaciado entre letras y color atenuado (`text-xs font-medium uppercase tracking-wide text-muted-foreground`). `FormSectionTitle` centraliza esta presentación y recibe `children`, `id` opcional y `as="h3" | "h4" | "h5"` para conservar la jerarquía semántica. El consumidor aporta el texto traducido; las mayúsculas se aplican mediante CSS.
|
||||
|
||||
`FormSection`, `TemplateSectionsEditor` y `TemplateSectionFields` comparten este título, con independencia de sus bordes, acciones o contenido. La regla también se aplica a grupos de consulta, como los puestos del historial de perfiles. Los títulos principales del diálogo y los nombres editables de secciones mantienen su presentación propia.
|
||||
|
||||
### Campos compactos en plantillas de activos y recursos
|
||||
|
||||
`TemplateSectionsEditor fieldLayout="compact"` presenta cada campo en una fila: asa de arrastre, switch de obligatoriedad, nombre flexible, flechas arriba/abajo y papelera. El nombre puede ocupar varias líneas en pantallas estrechas. El switch conserva una etiqueta accesible con «Obligatorio» y el nombre del campo; su texto visual y el selector de sección se omiten. Las notas de campos inactivos siguen disponibles para lectores de pantalla. Se mantienen el arrastre, las flechas por teclado, los límites de orden y los estados deshabilitados. Ambos editores utilizan esta presentación compacta.
|
||||
|
||||
@ -50,6 +50,7 @@ Las rutas reflejan la [estructura por entidades y operaciones](estructura-web-po
|
||||
- [CatalogDefinitionDialog](../modules/assets/src/web/catalog/detail/ui/catalog-definition-dialog.tsx)
|
||||
- [CatalogEditorDialog](../modules/assets/src/web/catalog/shared/ui/catalog-editor-dialog.tsx)
|
||||
- [CatalogFieldCreateDialog](../modules/assets/src/web/catalog-fields/create/ui/catalog-field-create-dialog.tsx)
|
||||
- [CatalogFieldPickerDialog](../modules/assets/src/web/catalog-fields/select/ui/catalog-field-picker-dialog.tsx): selección múltiple provisional para añadir campos a una plantilla; reutiliza la tabla común y confirma el lote antes de cambiar el borrador.
|
||||
- [CatalogRelationCreateDialog](../modules/assets/src/web/catalog-relations/create/ui/catalog-relation-create-dialog.tsx)
|
||||
- [CatalogTemplateCreateDialog](../modules/assets/src/web/catalog-templates/create/ui/catalog-template-create-dialog.tsx)
|
||||
- [EndRelationshipDialog](../modules/assets/src/web/asset-relationships/end/ui/end-relationship-dialog.tsx)
|
||||
@ -154,6 +155,7 @@ Las rutas reflejan la [estructura por entidades y operaciones](estructura-web-po
|
||||
- [CatalogDefinitionDialog](../modules/resources/src/web/catalog/detail/ui/catalog-definition-dialog.tsx)
|
||||
- [CatalogEditorDialog](../modules/resources/src/web/catalog/shared/ui/catalog-editor-dialog.tsx)
|
||||
- [CatalogFieldCreateDialog](../modules/resources/src/web/catalog-fields/create/ui/catalog-field-create-dialog.tsx)
|
||||
- [CatalogFieldPickerDialog](../modules/resources/src/web/catalog-fields/select/ui/catalog-field-picker-dialog.tsx): adaptador del selector compartido para incorporar campos al destino y ofrecer alta rápida configurable.
|
||||
- [CatalogTemplateCreateDialog](../modules/resources/src/web/catalog-templates/create/ui/catalog-template-create-dialog.tsx)
|
||||
- [ResourceDetailDialog](../modules/resources/src/web/resources/detail/ui/resource-detail-dialog.tsx)
|
||||
- [ResourceEditorDialog](../modules/resources/src/web/resources/shared/ui/resource-editor-dialog.tsx)
|
||||
|
||||
@ -32,6 +32,8 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c
|
||||
- Las relaciones son navegables cuando el destino ya ofrece navegación adecuada; no introducir enlaces ficticios ni nuevas rutas.
|
||||
- Acciones de fila siempre al final, con cabecera y celdas alineadas a la derecha y columna anclada al borde derecho durante el desplazamiento horizontal. La columna actions no se oculta ni se ordena. El disparador muestra únicamente el icono de tres puntos; conserva etiqueta accesible y título, sin repetir «Acciones» en las filas. El menú contextual mantiene las restricciones existentes. No convertir acciones de negocio en acciones masivas.
|
||||
- Selección múltiple solo cuando una operación real la necesita. No añadir casillas sin consumidor. Mantener separada la selección del activo para su panel lateral.
|
||||
- Los diálogos `PickerList` mantienen una altura estable mediante `FormDialog layout="picker"` y llenan el área de resultados con `EntityTable layout="fill"`. Buscar, paginar, cargar o quedarse sin resultados no cambia la altura del diálogo. Búsqueda, filtros, paginación y acciones permanecen fuera del desplazamiento de resultados; el cuerpo permite desplazamiento adicional si la ventana es demasiado pequeña.
|
||||
- En diálogos `PickerList`, la selección ocupa siempre la primera columna, con identificador `selection`, sin ordenación ni ocultación y sin anclaje a la derecha. Toda fila disponible permite activar/desactivar su checkbox o seleccionar su radio mediante `onRowClick`. Las filas deshabilitadas no cambian la selección; los controles internos conservan su interacción y el teclado, sin un segundo cambio por propagación del clic.
|
||||
- Cancelar un modal conserva búsqueda, filtros, página y selección de la lista. Las actualizaciones continúan invalidando las consultas actuales.
|
||||
- Las consultas que alimentan un listado usan `useListQuery`: se vuelven a consultar cada 60 segundos mientras la pestaña está visible y también al recuperar foco o conexión. No se consulta en segundo plano ni se aplica este intervalo a formularios, sesión u otras consultas que no presenten un listado.
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/assets/select/asset-selection.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/asset-catalog.test.ts src/api/domain/asset.test.ts src/api/domain/workstation.test.ts src/api/domain/asset-relationship.test.ts src/web/catalog/shared/catalog.test.tsx src/web/assets/shared/inventory.test.tsx src/api/domain/support.test.ts src/web/support-connections/shared/support.test.tsx src/api/domain/brand.test.ts src/web/brands/shared/brands.test.tsx src/api/domain/template-sections.test.ts src/web/workstations/shared/helpers/workstation-creation.test.ts src/web/assets/select/asset-selection.test.tsx src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "5.2.2",
|
||||
|
||||
@ -242,7 +242,7 @@
|
||||
"deleteDefinitionGuidance": "Only unused custom global definitions can be deleted. Built-in definitions and those retained by assets, history, relationships or templates cannot be deleted.",
|
||||
"deleteError": "The definition could not be deleted.",
|
||||
"cancel": "Cancel",
|
||||
"selectField": "Add a field to the template",
|
||||
"selectField": "Add fields to the template",
|
||||
"choose": "Select a field",
|
||||
"addField": "New field",
|
||||
"clear": "Clear selection",
|
||||
@ -288,7 +288,26 @@
|
||||
"UNAUTHENTICATED": "Sign in again.",
|
||||
"INVALID_SECTIONS": "Sections must have unique names and each field must belong to a section of this template."
|
||||
},
|
||||
"fieldPicker": {
|
||||
"destination": "Destination section: {{name}}",
|
||||
"limit": "You have reached the template field limit. Deselect fields from this selection or remove fields from the template to add others.",
|
||||
"open": "Select fields",
|
||||
"title": "Add fields to the template",
|
||||
"help": "Select one or more fields and confirm to add them as optional to the destination section. Only active fields that are not already in the template are shown.",
|
||||
"select": "Select",
|
||||
"selectField": "Select {{name}}",
|
||||
"selected": "Selected fields: {{count}}",
|
||||
"confirm": "Add fields",
|
||||
"empty": "There are no more fields available to add.",
|
||||
"invalid": "The selection exceeds the template field limit."
|
||||
},
|
||||
"sections": {
|
||||
"addField": "Add field",
|
||||
"expand": "Expand section",
|
||||
"collapse": "Collapse section",
|
||||
"reorder": "Reorder",
|
||||
"reorderHelp": "Drag the handle and drop on the upper or lower half of the target. You can also focus the handle and use the up and down arrow keys. Fields are reordered within their section.",
|
||||
"fields": "Fields",
|
||||
"title": "Template sections",
|
||||
"add": "Add section",
|
||||
"name": "Section name",
|
||||
|
||||
@ -242,7 +242,7 @@
|
||||
"deleteDefinitionGuidance": "Solo se pueden eliminar definiciones globales personalizadas que no estén en uso. Las de serie y las que se conservan en activos, historial, relaciones o plantillas no se eliminan.",
|
||||
"deleteError": "No se ha podido eliminar la definición.",
|
||||
"cancel": "Cancelar",
|
||||
"selectField": "Añadir un campo a la plantilla",
|
||||
"selectField": "Añadir campos a la plantilla",
|
||||
"choose": "Selecciona un campo",
|
||||
"addField": "Nuevo campo",
|
||||
"clear": "Quitar selección",
|
||||
@ -288,7 +288,26 @@
|
||||
"UNAUTHENTICATED": "Vuelve a iniciar sesión.",
|
||||
"INVALID_SECTIONS": "Las secciones deben tener nombres únicos y cada campo debe pertenecer a una sección de esta plantilla."
|
||||
},
|
||||
"fieldPicker": {
|
||||
"destination": "Sección de destino: {{name}}",
|
||||
"limit": "Has alcanzado el límite de campos de la plantilla. Desmarca campos de esta selección o retira alguno de la plantilla para añadir otros.",
|
||||
"open": "Seleccionar campos",
|
||||
"title": "Añadir campos a la plantilla",
|
||||
"help": "Selecciona uno o más campos y confirma para añadirlos como opcionales en la sección de destino. Solo se muestran campos activos que todavía no están en la plantilla.",
|
||||
"select": "Seleccionar",
|
||||
"selectField": "Seleccionar {{name}}",
|
||||
"selected": "Campos seleccionados: {{count}}",
|
||||
"confirm": "Añadir campos",
|
||||
"empty": "No hay más campos disponibles para añadir.",
|
||||
"invalid": "La selección supera el límite de campos de la plantilla."
|
||||
},
|
||||
"sections": {
|
||||
"addField": "Añadir campo",
|
||||
"expand": "Desplegar sección",
|
||||
"collapse": "Plegar sección",
|
||||
"reorder": "Reordenar",
|
||||
"reorderHelp": "Arrastra desde el asa y suelta en la parte superior o inferior del destino. También puedes enfocar el asa y usar las flechas arriba y abajo. Los campos se ordenan dentro de su sección.",
|
||||
"fields": "Campos",
|
||||
"title": "Secciones de la plantilla",
|
||||
"add": "Añadir sección",
|
||||
"name": "Nombre de la sección",
|
||||
|
||||
@ -91,7 +91,13 @@ test("selector: comparte búsqueda, pestañas por identidad y columnas del inven
|
||||
assert.ok(html.includes("Monitores"));
|
||||
assert.ok(html.includes("Sede Central"));
|
||||
assert.ok(html.includes(language === "es" ? "Seleccionar Equipo 2" : "Select Equipo 2"));
|
||||
assert.ok(html.includes('role="checkbox"'));
|
||||
const firstHeader = html.match(/<th\b[^>]*>[\s\S]*?<\/th>/)?.[0];
|
||||
const firstCell = html.match(/<tbody[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>/)?.[1];
|
||||
|
||||
assert.ok(firstHeader?.includes(language === "es" ? "Selección" : "Selection"));
|
||||
assert.equal(firstHeader?.includes('data-pinned="right"'), false);
|
||||
assert.equal(firstHeader?.includes("aria-sort"), false);
|
||||
assert.ok(firstCell?.includes('role="checkbox"'));
|
||||
assert.ok(html.includes('aria-checked="true"'));
|
||||
assert.ok(html.includes("Ocupado"));
|
||||
assert.ok(html.includes(language === "es" ? "Buscar" : "Search"));
|
||||
@ -134,7 +140,9 @@ test("selector único: controles radio y disparador de diálogo; conserva el alt
|
||||
/>
|
||||
);
|
||||
|
||||
assert.ok(table.includes('role="radio"'));
|
||||
const firstCell = table.match(/<tbody[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>/)?.[1];
|
||||
|
||||
assert.ok(firstCell?.includes('role="radio"'));
|
||||
assert.equal(table.includes('role="checkbox"'), false);
|
||||
const trigger = await render(
|
||||
"es",
|
||||
|
||||
@ -22,6 +22,7 @@ export function AssetPickerDialog({
|
||||
<FormDialog
|
||||
cancelLabel={t("cancel")}
|
||||
formActions
|
||||
layout="picker"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
|
||||
@ -23,9 +23,10 @@ export function AssetPickerList({
|
||||
const list = (
|
||||
<AssetList
|
||||
{...listProps}
|
||||
extraColumns={[
|
||||
layout="fill"
|
||||
leadingColumns={[
|
||||
{
|
||||
id: "actions",
|
||||
id: "selection",
|
||||
label: t("assetPicker.select"),
|
||||
hideable: false,
|
||||
render: (asset) => (
|
||||
@ -44,13 +45,30 @@ export function AssetPickerList({
|
||||
value={asset.id}
|
||||
/>
|
||||
)}
|
||||
{unavailableLabels.has(asset.id) && (
|
||||
<p className="text-xs text-muted-foreground">{unavailableLabels.get(asset.id)}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
onRowClick={(asset) => {
|
||||
if (!disabled(asset.id)) {
|
||||
onToggle(asset.id);
|
||||
}
|
||||
}}
|
||||
renderName={(asset) => (
|
||||
<>
|
||||
{listProps.renderName ? (
|
||||
listProps.renderName(asset)
|
||||
) : (
|
||||
<span className="font-medium">{asset.values.name}</span>
|
||||
)}
|
||||
{unavailableLabels.has(asset.id) && (
|
||||
<p className="text-xs text-muted-foreground">{unavailableLabels.get(asset.id)}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
rowClassName={(asset) =>
|
||||
`${listProps.rowClassName?.(asset) ?? ""} ${disabled(asset.id) ? "cursor-not-allowed!" : ""}`
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@ -59,6 +77,7 @@ export function AssetPickerList({
|
||||
) : (
|
||||
<RadioGroup
|
||||
aria-label={t("assetPicker.single")}
|
||||
className="flex min-h-[28rem] flex-1 flex-col sm:min-h-96"
|
||||
onValueChange={(id) => {
|
||||
if (typeof id === "string") {
|
||||
onToggle(id);
|
||||
|
||||
@ -20,9 +20,11 @@ export function AssetList({
|
||||
onSearchChange,
|
||||
onClearFilters,
|
||||
renderName,
|
||||
leadingColumns = [],
|
||||
extraColumns = [],
|
||||
rowClassName = () => "",
|
||||
onRowClick,
|
||||
layout = "content",
|
||||
}: {
|
||||
assets: readonly AssetDTO[];
|
||||
catalog: readonly CatalogItemDTO[];
|
||||
@ -33,9 +35,11 @@ export function AssetList({
|
||||
onSearchChange: (search: string) => void;
|
||||
onClearFilters: () => void;
|
||||
renderName?: ((asset: AssetDTO) => ReactNode) | undefined;
|
||||
leadingColumns?: readonly EntityColumn<AssetDTO>[] | undefined;
|
||||
extraColumns?: readonly EntityColumn<AssetDTO>[] | undefined;
|
||||
rowClassName?: ((asset: AssetDTO) => string) | undefined;
|
||||
onRowClick?: ((asset: AssetDTO) => void) | undefined;
|
||||
layout?: "content" | "fill";
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
const templates = new Map(
|
||||
@ -67,8 +71,12 @@ export function AssetList({
|
||||
);
|
||||
|
||||
return (
|
||||
<Tabs onValueChange={onTemplateChange} value={templateId ?? "all"}>
|
||||
<div className="min-w-0 overflow-x-auto">
|
||||
<Tabs
|
||||
className={layout === "fill" ? "min-h-[28rem] flex-1 sm:min-h-96" : undefined}
|
||||
onValueChange={onTemplateChange}
|
||||
value={templateId ?? "all"}
|
||||
>
|
||||
<div className="min-w-0 shrink-0 overflow-x-auto">
|
||||
<TabsList aria-label={t("filterByTemplate")} variant="line">
|
||||
<TabsTrigger value="all">{t("allAssets")}</TabsTrigger>
|
||||
{[...templates].map(([id, name]) => (
|
||||
@ -78,9 +86,14 @@ export function AssetList({
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent keepMounted value={templateId ?? "all"}>
|
||||
<TabsContent
|
||||
className={layout === "fill" ? "flex min-h-0 flex-col" : undefined}
|
||||
keepMounted
|
||||
value={templateId ?? "all"}
|
||||
>
|
||||
<EntityTable
|
||||
columns={[
|
||||
...leadingColumns,
|
||||
{
|
||||
id: "name",
|
||||
label: t("name"),
|
||||
@ -139,6 +152,7 @@ export function AssetList({
|
||||
filterKey={templateId ?? "all"}
|
||||
filtersApplied={templateId !== null}
|
||||
items={items}
|
||||
layout={layout}
|
||||
locale={i18n.language}
|
||||
onClearFilters={onClearFilters}
|
||||
onRowClick={onRowClick}
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export { CatalogFieldCreateDialog } from "./create";
|
||||
|
||||
export { CatalogFieldPickerDialog } from "./select";
|
||||
export { CatalogFieldForm } from "./shared";
|
||||
|
||||
@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
import { createInstance } from "i18next";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { buildCatalogResponse } from "../../../api/application/asset-catalog-response.builder";
|
||||
import { AssetCatalog } from "../../../api/domain/asset-catalog.aggregate";
|
||||
import { seedAssetCatalog } from "../../../api/domain/asset-catalog-seed";
|
||||
import { appendTemplateFields } from "../../catalog-templates/shared/helpers/append-template-fields";
|
||||
import { assetsEnglish, assetsSpanish } from "../../shared";
|
||||
|
||||
import { CatalogFieldPickerList } from "./ui/catalog-field-picker-list";
|
||||
|
||||
const id = (value: number) => `0199b200-0000-7000-8000-${String(value).padStart(12, "0")}`;
|
||||
|
||||
test("selección múltiple añade un lote sin duplicados y conserva las reglas y el orden existentes", () => {
|
||||
const existing = [{ fieldId: id(1), required: true, sectionId: id(9) }];
|
||||
const result = appendTemplateFields(existing, [id(1), id(3), id(2), id(3)]);
|
||||
|
||||
assert.ok(result.success);
|
||||
assert.deepEqual(result.data, [
|
||||
existing[0],
|
||||
{ fieldId: id(3), required: false },
|
||||
{ fieldId: id(2), required: false },
|
||||
]);
|
||||
assert.deepEqual(existing, [{ fieldId: id(1), required: true, sectionId: id(9) }]);
|
||||
const repeated = appendTemplateFields(result.data, [id(1), id(3), id(2)]);
|
||||
|
||||
assert.ok(repeated.success);
|
||||
assert.deepEqual(repeated.data, result.data);
|
||||
});
|
||||
|
||||
test("añadir desde una sección asigna solo los campos nuevos a su destino y conserva los existentes", () => {
|
||||
const existing = [{ fieldId: id(1), required: true, sectionId: id(9) }];
|
||||
const result = appendTemplateFields(existing, [id(2), id(1), id(3)], id(8));
|
||||
|
||||
assert.ok(result.success);
|
||||
assert.deepEqual(result.data, [
|
||||
existing[0],
|
||||
{ fieldId: id(2), required: false, sectionId: id(8) },
|
||||
{ fieldId: id(3), required: false, sectionId: id(8) },
|
||||
]);
|
||||
const repeated = appendTemplateFields(result.data, [id(2), id(3)], id(9));
|
||||
|
||||
assert.ok(repeated.success);
|
||||
assert.deepEqual(repeated.data, result.data);
|
||||
assert.equal(appendTemplateFields(existing, [id(2)], "invalid-section").success, false);
|
||||
});
|
||||
|
||||
test("la selección respeta el límite del schema, valida identificadores y rechaza el conjunto vacío", () => {
|
||||
const existing = Array.from({ length: 99 }, (_, index) => ({
|
||||
fieldId: id(index + 1),
|
||||
required: false,
|
||||
}));
|
||||
|
||||
assert.equal(appendTemplateFields(existing, [id(100)]).success, true);
|
||||
assert.equal(appendTemplateFields(existing, [id(100), id(101)]).success, false);
|
||||
assert.equal(appendTemplateFields(existing, ["invalid"]).success, false);
|
||||
assert.equal(appendTemplateFields([], []).success, false);
|
||||
assert.equal(existing.length, 99);
|
||||
});
|
||||
|
||||
test("el listado reutiliza tabla, búsqueda y casillas traducidas y conserva las selecciones bloqueadas", async () => {
|
||||
const fields = buildCatalogResponse(seedAssetCatalog(AssetCatalog.create()), Maybe.none())
|
||||
.items.filter((item) => item.values.kind === "field" && item.effectiveActive)
|
||||
.slice(0, 2);
|
||||
const selected = fields[0];
|
||||
const unavailable = fields[1];
|
||||
|
||||
assert.ok(selected && unavailable);
|
||||
for (const language of ["es", "en"]) {
|
||||
const i18n = createInstance();
|
||||
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { assets: assetsSpanish }, en: { assets: assetsEnglish } },
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<CatalogFieldPickerList
|
||||
disabled={(value) => value === unavailable.id}
|
||||
fields={fields}
|
||||
onToggle={() => undefined}
|
||||
selectedIds={[selected.id]}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
assert.ok(html.includes("<table"));
|
||||
assert.ok(html.includes(language === "es" ? "Buscar" : "Search"));
|
||||
assert.ok(html.includes(language === "es" ? "Seleccionar " : "Select "));
|
||||
const firstHeader = html.match(/<th\b[^>]*>[\s\S]*?<\/th>/)?.[0];
|
||||
const firstCell = html.match(/<tbody[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>/)?.[1];
|
||||
|
||||
assert.ok(firstHeader?.includes(language === "es" ? "Seleccionar" : "Select"));
|
||||
assert.equal(firstHeader?.includes('data-pinned="right"'), false);
|
||||
assert.equal(firstHeader?.includes("aria-sort"), false);
|
||||
assert.ok(firstCell?.includes('role="checkbox"'));
|
||||
assert.ok(html.includes('aria-checked="true"'));
|
||||
assert.ok(html.includes('aria-disabled="true"') || html.includes(" disabled"));
|
||||
assert.ok(html.includes(selected.values.name));
|
||||
assert.ok(html.includes(unavailable.values.name));
|
||||
assert.ok(html.includes(language === "es" ? "Tipo de dato" : "Data type"));
|
||||
}
|
||||
});
|
||||
1
modules/assets/src/web/catalog-fields/select/index.ts
Normal file
1
modules/assets/src/web/catalog-fields/select/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { CatalogFieldPickerDialog } from "./ui/catalog-field-picker-dialog";
|
||||
@ -0,0 +1,52 @@
|
||||
import { FieldPickerDialog } from "@repo/rdx-ui/components/field-picker-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
import { CatalogFieldCreateDialog } from "../../create/ui/catalog-field-create-dialog";
|
||||
|
||||
import { CatalogFieldPickerList } from "./catalog-field-picker-list";
|
||||
|
||||
export function CatalogFieldPickerDialog(props: {
|
||||
fields: readonly CatalogItemDTO[];
|
||||
canAdd: (ids: readonly string[]) => boolean;
|
||||
onConfirm: (ids: readonly string[]) => boolean;
|
||||
onClose: () => void;
|
||||
sectionName?: string | undefined;
|
||||
quickCreate?: { scope: string; disabled?: boolean } | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("assets");
|
||||
const quickCreate = props.quickCreate;
|
||||
|
||||
return (
|
||||
<FieldPickerDialog
|
||||
canAdd={props.canAdd}
|
||||
items={props.fields}
|
||||
labels={{
|
||||
title: t("fieldPicker.title"),
|
||||
cancel: t("cancel"),
|
||||
help: t("fieldPicker.help"),
|
||||
destination: t("fieldPicker.destination", {
|
||||
name: props.sectionName ?? t("sections.ungrouped"),
|
||||
}),
|
||||
selected: (count) => t("fieldPicker.selected", { count }),
|
||||
limit: t("fieldPicker.limit"),
|
||||
invalid: t("fieldPicker.invalid"),
|
||||
create: t("addField"),
|
||||
confirm: t("fieldPicker.confirm"),
|
||||
}}
|
||||
onClose={props.onClose}
|
||||
onConfirm={props.onConfirm}
|
||||
quickCreate={
|
||||
quickCreate
|
||||
? {
|
||||
disabled: quickCreate.disabled,
|
||||
render: (callbacks) => (
|
||||
<CatalogFieldCreateDialog {...callbacks} scope={quickCreate.scope} />
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
renderList={(selection) => <CatalogFieldPickerList {...selection} fields={props.fields} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import type { FieldPickerSelection } from "@repo/rdx-ui/components/field-picker-list";
|
||||
import { FieldPickerList } from "@repo/rdx-ui/components/field-picker-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
|
||||
export function CatalogFieldPickerList({
|
||||
fields,
|
||||
...selection
|
||||
}: FieldPickerSelection & {
|
||||
fields: readonly CatalogItemDTO[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("assets");
|
||||
|
||||
return (
|
||||
<FieldPickerList
|
||||
{...selection}
|
||||
fields={fields.flatMap((field) =>
|
||||
field.values.kind === "field"
|
||||
? [
|
||||
{
|
||||
id: field.id,
|
||||
name: field.values.name,
|
||||
description: field.values.description,
|
||||
type: t(field.values.type),
|
||||
unit: field.values.unit,
|
||||
},
|
||||
]
|
||||
: []
|
||||
)}
|
||||
labels={{
|
||||
title: t("fieldPicker.title"),
|
||||
name: t("name"),
|
||||
type: t("type"),
|
||||
unit: t("unit"),
|
||||
select: t("fieldPicker.select"),
|
||||
selectField: (name) => t("fieldPicker.selectField", { name }),
|
||||
empty: t("fieldPicker.empty"),
|
||||
fields: t("fields"),
|
||||
search: t("search"),
|
||||
}}
|
||||
locale={i18n.language}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -6,6 +6,7 @@ import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useCatalogSave } from "../../../catalog/shared/controllers";
|
||||
import { appendTemplateFields } from "../helpers/append-template-fields";
|
||||
import { TemplateFormSchema } from "../schemas";
|
||||
import type { CatalogTemplateFormProps, TemplateFormValues } from "../types";
|
||||
|
||||
@ -17,7 +18,7 @@ export function useCatalogTemplateFormController({
|
||||
onSaved,
|
||||
}: CatalogTemplateFormProps) {
|
||||
const { t } = useTranslation("assets");
|
||||
const [creatingField, setCreatingField] = useState(false);
|
||||
const [fieldSelection, setFieldSelection] = useState<{ sectionId?: string }>();
|
||||
const source = item ?? copy;
|
||||
const current = source?.values.kind === "template" ? source.values : undefined;
|
||||
const layout = copy
|
||||
@ -38,18 +39,62 @@ export function useCatalogTemplateFormController({
|
||||
});
|
||||
const mutation = useCatalogSave(scope, onSaved, item?.id);
|
||||
const selected = form.watch("fields");
|
||||
const sections = form.watch("sections") ?? [];
|
||||
const destination = sections.find((section) => section.id === fieldSelection?.sectionId);
|
||||
const validDestination = !fieldSelection?.sectionId || !!destination;
|
||||
|
||||
const openFieldPicker = (sectionId?: string) => setFieldSelection(sectionId ? { sectionId } : {});
|
||||
|
||||
const closeFieldPicker = () => setFieldSelection(undefined);
|
||||
|
||||
const fields = items.filter((entry) => entry.values.kind === "field");
|
||||
|
||||
const add = (fieldId: string) => {
|
||||
if (fieldId && !selected.some((field) => field.fieldId === fieldId)) {
|
||||
form.setValue("fields", [...selected, { fieldId, required: false }], {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
const availableFields = fields.filter(
|
||||
(entry) => entry.effectiveActive && !selected.some((field) => field.fieldId === entry.id)
|
||||
);
|
||||
|
||||
const canAdd = (fieldIds: readonly string[]) =>
|
||||
!mutation.isPending &&
|
||||
validDestination &&
|
||||
appendTemplateFields(form.getValues("fields"), fieldIds, fieldSelection?.sectionId).success;
|
||||
|
||||
const add = (fieldIds: readonly string[]) => {
|
||||
if (mutation.isPending || !validDestination) {
|
||||
return false;
|
||||
}
|
||||
const result = appendTemplateFields(
|
||||
form.getValues("fields"),
|
||||
fieldIds,
|
||||
fieldSelection?.sectionId
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
form.setError("fields", { type: "maxLength", message: t("fieldPicker.invalid") });
|
||||
|
||||
return false;
|
||||
}
|
||||
form.setValue("fields", result.data, { shouldDirty: true, shouldValidate: true });
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const submit = form.handleSubmit((values) => mutation.mutate(values));
|
||||
|
||||
return { creatingField, setCreatingField, form, mutation, selected, fields, add, submit };
|
||||
return {
|
||||
fieldSelection,
|
||||
openFieldPicker,
|
||||
closeFieldPicker,
|
||||
sections,
|
||||
destinationName: fieldSelection?.sectionId
|
||||
? destination?.name || t("sections.name")
|
||||
: undefined,
|
||||
form,
|
||||
mutation,
|
||||
selected,
|
||||
fields,
|
||||
availableFields,
|
||||
canAdd,
|
||||
add,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
import { appendTemplateBindings } from "@repo/rdx-ui/lib/template-sections";
|
||||
|
||||
import { TemplateFormSchema } from "../schemas";
|
||||
import type { TemplateFormValues } from "../types";
|
||||
|
||||
/** Valida las referencias nuevas con el contrato normativo del módulo. */
|
||||
export function appendTemplateFields(
|
||||
fields: TemplateFormValues["fields"],
|
||||
fieldIds: readonly string[],
|
||||
sectionId?: string
|
||||
) {
|
||||
return TemplateFormSchema.shape.fields.safeParse(
|
||||
appendTemplateBindings(fields, fieldIds, sectionId)
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,7 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { IconPicker } from "@repo/rdx-ui/components/icon-picker";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { TemplateMetadataSection } from "@repo/rdx-ui/components/template-metadata-section";
|
||||
import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-sections-editor";
|
||||
import { XIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
@ -15,7 +14,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { AssetTemplateIconSchema } from "../../../../common/asset-template-icons";
|
||||
import { CatalogError } from "../../../catalog/shared/ui/catalog-error";
|
||||
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
|
||||
import { CatalogFieldCreateDialog } from "../../../catalog-fields";
|
||||
import { CatalogFieldPickerDialog } from "../../../catalog-fields";
|
||||
import { useCatalogTemplateFormController } from "../controllers";
|
||||
import type { CatalogTemplateFormProps } from "../types";
|
||||
|
||||
@ -23,8 +22,21 @@ import { AssetTemplateIcon } from "./asset-template-icon";
|
||||
|
||||
export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
const { scope, allowQuickCreate = true } = props;
|
||||
const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } =
|
||||
useCatalogTemplateFormController(props);
|
||||
const {
|
||||
fieldSelection,
|
||||
openFieldPicker,
|
||||
closeFieldPicker,
|
||||
sections,
|
||||
destinationName,
|
||||
form,
|
||||
mutation,
|
||||
selected,
|
||||
fields,
|
||||
availableFields,
|
||||
canAdd,
|
||||
add,
|
||||
submit,
|
||||
} = useCatalogTemplateFormController(props);
|
||||
const { t } = useTranslation("assets");
|
||||
const id = useId();
|
||||
|
||||
@ -32,72 +44,58 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
<>
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<FormSection title={t("formLayout.template")}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<TemplateMetadataSection
|
||||
descriptionField={
|
||||
<>
|
||||
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
|
||||
<Textarea
|
||||
className="min-h-24 flex-1"
|
||||
id={`${id}-description`}
|
||||
maxLength={1000}
|
||||
rows={2}
|
||||
{...form.register("description")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IconPicker
|
||||
disabled={mutation.isPending}
|
||||
label={t("templateIcon.label")}
|
||||
onChange={(value) => {
|
||||
const icon = AssetTemplateIconSchema.safeParse(value);
|
||||
</>
|
||||
}
|
||||
primaryFields={
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<IconPicker
|
||||
disabled={mutation.isPending}
|
||||
label={t("templateIcon.label")}
|
||||
onChange={(value) => {
|
||||
const icon = AssetTemplateIconSchema.safeParse(value);
|
||||
|
||||
form.setValue("icon", icon.success ? icon.data : undefined, { shouldDirty: true });
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label: t("templateIcon.none"),
|
||||
icon: <XIcon aria-hidden="true" className="size-4" />,
|
||||
},
|
||||
...AssetTemplateIconSchema.options.map((icon) => ({
|
||||
value: icon,
|
||||
label: t(`templateIcon.options.${icon}`),
|
||||
icon: <AssetTemplateIcon icon={icon} />,
|
||||
})),
|
||||
]}
|
||||
value={form.watch("icon") ?? ""}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("templateIcon.help")}</p>
|
||||
</FormSection>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-field`}>{t("selectField")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
allowQuickCreate
|
||||
? { label: t("addField"), onClick: () => setCreatingField(true) }
|
||||
: undefined
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
id={`${id}-field`}
|
||||
label={t("selectField")}
|
||||
onValueChange={add}
|
||||
options={fields
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.effectiveActive && !selected.some((field) => field.fieldId === entry.id)
|
||||
)
|
||||
.map((entry) => ({ value: entry.id, label: entry.values.name }))}
|
||||
placeholder={t("choose")}
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
{!selected.length && <p className="text-sm text-muted-foreground">{t("noFields")}</p>}
|
||||
form.setValue("icon", icon.success ? icon.data : undefined, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label: t("templateIcon.none"),
|
||||
icon: <XIcon aria-hidden="true" className="size-4" />,
|
||||
},
|
||||
...AssetTemplateIconSchema.options.map((icon) => ({
|
||||
value: icon,
|
||||
label: t(`templateIcon.options.${icon}`),
|
||||
icon: <AssetTemplateIcon icon={icon} />,
|
||||
})),
|
||||
]}
|
||||
value={form.watch("icon") ?? ""}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
title={t("formLayout.template")}
|
||||
/>
|
||||
<TemplateSectionsEditor
|
||||
addFieldAction={{ label: t("sections.addField"), onClick: openFieldPicker }}
|
||||
createId={() => UniqueID.generateNewID().toPrimitive()}
|
||||
disabled={mutation.isPending}
|
||||
fieldLayout="compact"
|
||||
fieldNames={Object.fromEntries(fields.map((field) => [field.id, field.values.name]))}
|
||||
fieldNotes={Object.fromEntries(
|
||||
fields
|
||||
@ -107,6 +105,11 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
fields={selected}
|
||||
labels={{
|
||||
title: t("sections.title"),
|
||||
expand: t("sections.expand"),
|
||||
collapse: t("sections.collapse"),
|
||||
fields: t("sections.fields"),
|
||||
reorder: t("sections.reorder"),
|
||||
reorderHelp: t("sections.reorderHelp"),
|
||||
add: t("sections.add"),
|
||||
name: t("sections.name"),
|
||||
description: t("sections.description"),
|
||||
@ -118,14 +121,20 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
required: t("sections.required"),
|
||||
assign: t("sections.assign"),
|
||||
help: t("sections.help"),
|
||||
empty: t("sections.empty"),
|
||||
empty: t(sections.length ? "sections.empty" : "noFields"),
|
||||
}}
|
||||
onChange={({ sections, fields: next }) => {
|
||||
form.setValue("sections", sections, { shouldDirty: true });
|
||||
form.setValue("fields", next, { shouldDirty: true });
|
||||
}}
|
||||
sections={form.watch("sections") ?? []}
|
||||
sectionLayout="responsive"
|
||||
sections={sections}
|
||||
/>
|
||||
{form.formState.errors.fields?.type === "maxLength" && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("fieldPicker.invalid")}
|
||||
</p>
|
||||
)}
|
||||
{form.formState.errors.sections && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("errors.INVALID_SECTIONS")}
|
||||
@ -143,18 +152,14 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{creatingField && (
|
||||
<CatalogFieldCreateDialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCreatingField(false);
|
||||
}
|
||||
}}
|
||||
onSaved={(fieldId) => {
|
||||
add(fieldId);
|
||||
setCreatingField(false);
|
||||
}}
|
||||
scope={scope}
|
||||
{fieldSelection && (
|
||||
<CatalogFieldPickerDialog
|
||||
canAdd={canAdd}
|
||||
fields={availableFields}
|
||||
onClose={closeFieldPicker}
|
||||
onConfirm={add}
|
||||
quickCreate={allowQuickCreate ? { scope, disabled: mutation.isPending } : undefined}
|
||||
sectionName={destinationName}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -57,7 +57,7 @@ test("el catálogo muestra plantillas de serie y acción de alta sin formularios
|
||||
assert.equal(html.includes(">Editar</button>"), false);
|
||||
}
|
||||
});
|
||||
test("los formularios tienen ayuda inferior y selector de campo con alta integrada", async () => {
|
||||
test("los formularios tienen ayuda inferior y reservan el alta rápida al diálogo de selección de campos", async () => {
|
||||
const field = await render("es", <CatalogFieldForm onSaved={() => undefined} scope="global" />);
|
||||
|
||||
assert.ok(field.indexOf('name="description"') < field.indexOf("<aside"));
|
||||
@ -68,7 +68,12 @@ test("los formularios tienen ayuda inferior y selector de campo con alta integra
|
||||
<CatalogTemplateForm items={[]} onSaved={() => undefined} scope="global" />
|
||||
);
|
||||
|
||||
assert.ok(template.includes('aria-label="Nuevo campo"'));
|
||||
assert.equal(template.includes('aria-label="Nuevo campo"'), false);
|
||||
assert.equal(template.includes(">Nuevo campo</button>"), false);
|
||||
assert.ok(template.includes('aria-haspopup="dialog"'));
|
||||
assert.equal(template.includes("Seleccionar campos"), false);
|
||||
assert.equal(template.includes("Añadir campos a la plantilla"), false);
|
||||
assert.ok(template.includes('aria-label="Añadir campo: Sin sección"'));
|
||||
assert.ok(template.includes("Todos son opcionales inicialmente"));
|
||||
assert.ok(template.indexOf("<aside") < template.indexOf('type="submit"'));
|
||||
});
|
||||
@ -96,11 +101,14 @@ test("las plantillas ofrecen selección visual traducida y conservan el icono al
|
||||
);
|
||||
|
||||
assert.ok(html.includes(label));
|
||||
assert.ok(html.includes(none));
|
||||
assert.match(
|
||||
html,
|
||||
/aria-label="Laptop" aria-pressed="true"|aria-label="Portátil" aria-pressed="true"/
|
||||
assert.ok(html.includes('role="combobox"'));
|
||||
assert.ok(html.includes(language === "es" ? "Portátil" : "Laptop"));
|
||||
const empty = await render(
|
||||
language,
|
||||
<CatalogTemplateForm items={catalog.items} onSaved={() => undefined} scope="global" />
|
||||
);
|
||||
|
||||
assert.ok(empty.includes(none));
|
||||
assert.ok(html.includes("lucide-laptop"));
|
||||
}
|
||||
});
|
||||
|
||||
@ -26,11 +26,13 @@ export function CompanyAvatar({
|
||||
queryFn: listCompanies,
|
||||
enabled: Boolean(companyId) && logoVersion === undefined,
|
||||
});
|
||||
|
||||
const version =
|
||||
logoVersion === undefined
|
||||
? companies.data?.companies.find((company) => company.id === companyId)?.logoVersion
|
||||
: logoVersion;
|
||||
const src = companyId && version ? companyLogoUrl(companyId, version) : null;
|
||||
|
||||
const [failedSource, setFailedSource] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { WorkstationDTO } from "@metis/assets/common";
|
||||
import { FormDialog } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormSectionTitle } from "@repo/rdx-ui/components/form-section-title";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { WorkProfileDTO } from "../../../../common/work-profiles";
|
||||
@ -43,7 +44,7 @@ export function WorkProfileHistoryDialog({
|
||||
{profile.needsReview && <p className="text-amber-700">{t("profiles.review")}</p>}
|
||||
<p className="text-sm text-muted-foreground">{t("profiles.unknownConfiguration")}</p>
|
||||
<div>
|
||||
<h3 className="font-medium">{t("profiles.stations")}</h3>
|
||||
<FormSectionTitle>{t("profiles.stations")}</FormSectionTitle>
|
||||
<p className="text-sm">
|
||||
{workstations
|
||||
.filter(
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/api/domain/resource-catalog.test.ts src/api/domain/resource.test.ts src/web/catalog/shared/catalog.test.tsx src/web/resources/shared/resources.test.tsx src/web/resources/shared/resource-values.test.ts src/api/domain/template-sections.test.ts src/web/catalog-fields/select/catalog-field-selection.test.tsx"
|
||||
},
|
||||
"dependencies": {
|
||||
"@metis/core": "workspace:*",
|
||||
|
||||
@ -122,7 +122,7 @@
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"cancel": "Cancel",
|
||||
"selectField": "Add a field to the template",
|
||||
"selectField": "Add fields to the template",
|
||||
"choose": "Select a field",
|
||||
"addField": "New field",
|
||||
"required": "Required",
|
||||
@ -164,7 +164,26 @@
|
||||
"UNAUTHENTICATED": "Sign in again.",
|
||||
"INVALID_SECTIONS": "Sections must have unique names and each field must belong to a section of this template."
|
||||
},
|
||||
"fieldPicker": {
|
||||
"destination": "Destination section: {{name}}",
|
||||
"limit": "You have reached the template field limit. Deselect fields from this selection or remove fields from the template to add others.",
|
||||
"open": "Select fields",
|
||||
"title": "Add fields to the template",
|
||||
"help": "Select one or more fields and confirm to add them as optional to the destination section. Only active fields that are not already in the template are shown.",
|
||||
"select": "Select",
|
||||
"selectField": "Select {{name}}",
|
||||
"selected": "Selected fields: {{count}}",
|
||||
"confirm": "Add fields",
|
||||
"empty": "There are no more fields available to add.",
|
||||
"invalid": "The selection exceeds the template field limit."
|
||||
},
|
||||
"sections": {
|
||||
"addField": "Add field",
|
||||
"expand": "Expand section",
|
||||
"collapse": "Collapse section",
|
||||
"reorder": "Reorder",
|
||||
"reorderHelp": "Drag the handle and drop on the upper or lower half of the target. You can also focus the handle and use the up and down arrow keys. Fields are reordered within their section.",
|
||||
"fields": "Fields",
|
||||
"title": "Template sections",
|
||||
"add": "Add section",
|
||||
"name": "Section name",
|
||||
|
||||
@ -122,7 +122,7 @@
|
||||
"save": "Guardar",
|
||||
"saving": "Guardando…",
|
||||
"cancel": "Cancelar",
|
||||
"selectField": "Añadir un campo a la plantilla",
|
||||
"selectField": "Añadir campos a la plantilla",
|
||||
"choose": "Selecciona un campo",
|
||||
"addField": "Nuevo campo",
|
||||
"required": "Obligatorio",
|
||||
@ -164,7 +164,26 @@
|
||||
"UNAUTHENTICATED": "Vuelve a iniciar sesión.",
|
||||
"INVALID_SECTIONS": "Las secciones deben tener nombres únicos y cada campo debe pertenecer a una sección de esta plantilla."
|
||||
},
|
||||
"fieldPicker": {
|
||||
"destination": "Sección de destino: {{name}}",
|
||||
"limit": "Has alcanzado el límite de campos de la plantilla. Desmarca campos de esta selección o retira alguno de la plantilla para añadir otros.",
|
||||
"open": "Seleccionar campos",
|
||||
"title": "Añadir campos a la plantilla",
|
||||
"help": "Selecciona uno o más campos y confirma para añadirlos como opcionales en la sección de destino. Solo se muestran campos activos que todavía no están en la plantilla.",
|
||||
"select": "Seleccionar",
|
||||
"selectField": "Seleccionar {{name}}",
|
||||
"selected": "Campos seleccionados: {{count}}",
|
||||
"confirm": "Añadir campos",
|
||||
"empty": "No hay más campos disponibles para añadir.",
|
||||
"invalid": "La selección supera el límite de campos de la plantilla."
|
||||
},
|
||||
"sections": {
|
||||
"addField": "Añadir campo",
|
||||
"expand": "Desplegar sección",
|
||||
"collapse": "Plegar sección",
|
||||
"reorder": "Reordenar",
|
||||
"reorderHelp": "Arrastra desde el asa y suelta en la parte superior o inferior del destino. También puedes enfocar el asa y usar las flechas arriba y abajo. Los campos se ordenan dentro de su sección.",
|
||||
"fields": "Campos",
|
||||
"title": "Secciones de la plantilla",
|
||||
"add": "Añadir sección",
|
||||
"name": "Nombre de la sección",
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
export { CatalogFieldCreateDialog } from "./create";
|
||||
|
||||
export { CatalogFieldPickerDialog } from "./select";
|
||||
export { CatalogFieldForm } from "./shared";
|
||||
|
||||
@ -0,0 +1,108 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { Maybe } from "@repo/rdx-utils";
|
||||
import { createInstance } from "i18next";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
|
||||
import { buildCatalogResponse } from "../../../api/application/resource-catalog-response.builder";
|
||||
import { ResourceCatalog } from "../../../api/domain/resource-catalog.aggregate";
|
||||
import { seedResourceCatalog } from "../../../api/domain/resource-catalog-seed";
|
||||
import { appendTemplateFields } from "../../catalog-templates/shared/helpers/append-template-fields";
|
||||
import { resourcesEnglish, resourcesSpanish } from "../../shared";
|
||||
|
||||
import { CatalogFieldPickerList } from "./ui/catalog-field-picker-list";
|
||||
|
||||
const id = (value: number) => `0199b200-0000-7000-8000-${String(value).padStart(12, "0")}`;
|
||||
|
||||
test("selección múltiple añade un lote sin duplicados y conserva las reglas y el orden existentes", () => {
|
||||
const existing = [{ fieldId: id(1), required: true, sectionId: id(9) }];
|
||||
const result = appendTemplateFields(existing, [id(1), id(3), id(2), id(3)]);
|
||||
|
||||
assert.ok(result.success);
|
||||
assert.deepEqual(result.data, [
|
||||
existing[0],
|
||||
{ fieldId: id(3), required: false },
|
||||
{ fieldId: id(2), required: false },
|
||||
]);
|
||||
assert.deepEqual(existing, [{ fieldId: id(1), required: true, sectionId: id(9) }]);
|
||||
const repeated = appendTemplateFields(result.data, [id(1), id(3), id(2)]);
|
||||
|
||||
assert.ok(repeated.success);
|
||||
assert.deepEqual(repeated.data, result.data);
|
||||
});
|
||||
|
||||
test("añadir desde una sección asigna solo los campos nuevos a su destino y conserva los existentes", () => {
|
||||
const existing = [{ fieldId: id(1), required: true, sectionId: id(9) }];
|
||||
const result = appendTemplateFields(existing, [id(2), id(1), id(3)], id(8));
|
||||
|
||||
assert.ok(result.success);
|
||||
assert.deepEqual(result.data, [
|
||||
existing[0],
|
||||
{ fieldId: id(2), required: false, sectionId: id(8) },
|
||||
{ fieldId: id(3), required: false, sectionId: id(8) },
|
||||
]);
|
||||
const repeated = appendTemplateFields(result.data, [id(2), id(3)], id(9));
|
||||
|
||||
assert.ok(repeated.success);
|
||||
assert.deepEqual(repeated.data, result.data);
|
||||
assert.equal(appendTemplateFields(existing, [id(2)], "invalid-section").success, false);
|
||||
});
|
||||
|
||||
test("la selección respeta el límite del schema, valida identificadores y rechaza el conjunto vacío", () => {
|
||||
const existing = Array.from({ length: 99 }, (_, index) => ({
|
||||
fieldId: id(index + 1),
|
||||
required: false,
|
||||
}));
|
||||
|
||||
assert.equal(appendTemplateFields(existing, [id(100)]).success, true);
|
||||
assert.equal(appendTemplateFields(existing, [id(100), id(101)]).success, false);
|
||||
assert.equal(appendTemplateFields(existing, ["invalid"]).success, false);
|
||||
assert.equal(appendTemplateFields([], []).success, false);
|
||||
assert.equal(existing.length, 99);
|
||||
});
|
||||
|
||||
test("el listado reutiliza tabla, búsqueda y casillas traducidas y conserva las selecciones bloqueadas", async () => {
|
||||
const fields = buildCatalogResponse(seedResourceCatalog(ResourceCatalog.create()), Maybe.none())
|
||||
.items.filter((item) => item.values.kind === "field" && item.effectiveActive)
|
||||
.slice(0, 2);
|
||||
const selected = fields[0];
|
||||
const unavailable = fields[1];
|
||||
|
||||
assert.ok(selected && unavailable);
|
||||
for (const language of ["es", "en"]) {
|
||||
const i18n = createInstance();
|
||||
|
||||
await i18n.init({
|
||||
lng: language,
|
||||
resources: { es: { resources: resourcesSpanish }, en: { resources: resourcesEnglish } },
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<CatalogFieldPickerList
|
||||
disabled={(value) => value === unavailable.id}
|
||||
fields={fields}
|
||||
onToggle={() => undefined}
|
||||
selectedIds={[selected.id]}
|
||||
/>
|
||||
</I18nextProvider>
|
||||
);
|
||||
|
||||
assert.ok(html.includes("<table"));
|
||||
assert.ok(html.includes(language === "es" ? "Buscar" : "Search"));
|
||||
assert.ok(html.includes(language === "es" ? "Seleccionar " : "Select "));
|
||||
const firstHeader = html.match(/<th\b[^>]*>[\s\S]*?<\/th>/)?.[0];
|
||||
const firstCell = html.match(/<tbody[\s\S]*?<td\b[^>]*>([\s\S]*?)<\/td>/)?.[1];
|
||||
|
||||
assert.ok(firstHeader?.includes(language === "es" ? "Seleccionar" : "Select"));
|
||||
assert.equal(firstHeader?.includes('data-pinned="right"'), false);
|
||||
assert.equal(firstHeader?.includes("aria-sort"), false);
|
||||
assert.ok(firstCell?.includes('role="checkbox"'));
|
||||
assert.ok(html.includes('aria-checked="true"'));
|
||||
assert.ok(html.includes('aria-disabled="true"') || html.includes(" disabled"));
|
||||
assert.ok(html.includes(selected.values.name));
|
||||
assert.ok(html.includes(unavailable.values.name));
|
||||
assert.ok(html.includes(language === "es" ? "Tipo de dato" : "Data type"));
|
||||
}
|
||||
});
|
||||
1
modules/resources/src/web/catalog-fields/select/index.ts
Normal file
1
modules/resources/src/web/catalog-fields/select/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { CatalogFieldPickerDialog } from "./ui/catalog-field-picker-dialog";
|
||||
@ -0,0 +1,52 @@
|
||||
import { FieldPickerDialog } from "@repo/rdx-ui/components/field-picker-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
import { CatalogFieldCreateDialog } from "../../create/ui/catalog-field-create-dialog";
|
||||
|
||||
import { CatalogFieldPickerList } from "./catalog-field-picker-list";
|
||||
|
||||
export function CatalogFieldPickerDialog(props: {
|
||||
fields: readonly CatalogItemDTO[];
|
||||
canAdd: (ids: readonly string[]) => boolean;
|
||||
onConfirm: (ids: readonly string[]) => boolean;
|
||||
onClose: () => void;
|
||||
sectionName?: string | undefined;
|
||||
quickCreate?: { scope: string; disabled?: boolean } | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation("resources");
|
||||
const quickCreate = props.quickCreate;
|
||||
|
||||
return (
|
||||
<FieldPickerDialog
|
||||
canAdd={props.canAdd}
|
||||
items={props.fields}
|
||||
labels={{
|
||||
title: t("fieldPicker.title"),
|
||||
cancel: t("cancel"),
|
||||
help: t("fieldPicker.help"),
|
||||
destination: t("fieldPicker.destination", {
|
||||
name: props.sectionName ?? t("sections.ungrouped"),
|
||||
}),
|
||||
selected: (count) => t("fieldPicker.selected", { count }),
|
||||
limit: t("fieldPicker.limit"),
|
||||
invalid: t("fieldPicker.invalid"),
|
||||
create: t("addField"),
|
||||
confirm: t("fieldPicker.confirm"),
|
||||
}}
|
||||
onClose={props.onClose}
|
||||
onConfirm={props.onConfirm}
|
||||
quickCreate={
|
||||
quickCreate
|
||||
? {
|
||||
disabled: quickCreate.disabled,
|
||||
render: (callbacks) => (
|
||||
<CatalogFieldCreateDialog {...callbacks} scope={quickCreate.scope} />
|
||||
),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
renderList={(selection) => <CatalogFieldPickerList {...selection} fields={props.fields} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import type { FieldPickerSelection } from "@repo/rdx-ui/components/field-picker-list";
|
||||
import { FieldPickerList } from "@repo/rdx-ui/components/field-picker-list";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import type { CatalogItemDTO } from "../../../../common";
|
||||
|
||||
export function CatalogFieldPickerList({
|
||||
fields,
|
||||
...selection
|
||||
}: FieldPickerSelection & {
|
||||
fields: readonly CatalogItemDTO[];
|
||||
}) {
|
||||
const { t, i18n } = useTranslation("resources");
|
||||
|
||||
return (
|
||||
<FieldPickerList
|
||||
{...selection}
|
||||
fields={fields.flatMap((field) =>
|
||||
field.values.kind === "field"
|
||||
? [
|
||||
{
|
||||
id: field.id,
|
||||
name: field.values.name,
|
||||
description: field.values.description,
|
||||
type: t(field.values.type),
|
||||
unit: field.values.unit,
|
||||
},
|
||||
]
|
||||
: []
|
||||
)}
|
||||
labels={{
|
||||
title: t("fieldPicker.title"),
|
||||
name: t("name"),
|
||||
type: t("type"),
|
||||
unit: t("unit"),
|
||||
select: t("fieldPicker.select"),
|
||||
selectField: (name) => t("fieldPicker.selectField", { name }),
|
||||
empty: t("fieldPicker.empty"),
|
||||
fields: t("fields"),
|
||||
search: t("search"),
|
||||
}}
|
||||
locale={i18n.language}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useCatalogSave } from "../../../catalog/shared/controllers";
|
||||
import { templateUsage } from "../helpers";
|
||||
import { appendTemplateFields } from "../helpers/append-template-fields";
|
||||
import { TemplateFormSchema } from "../schemas";
|
||||
import type { CatalogTemplateFormProps, TemplateFormValues } from "../types";
|
||||
|
||||
@ -18,7 +19,7 @@ export function useCatalogTemplateFormController({
|
||||
onSaved,
|
||||
}: CatalogTemplateFormProps) {
|
||||
const { t } = useTranslation("resources");
|
||||
const [creatingField, setCreatingField] = useState(false);
|
||||
const [fieldSelection, setFieldSelection] = useState<{ sectionId?: string }>();
|
||||
const source = item ?? copy;
|
||||
const current = source?.values.kind === "template" ? source.values : undefined;
|
||||
const layout = copy
|
||||
@ -40,18 +41,62 @@ export function useCatalogTemplateFormController({
|
||||
});
|
||||
const mutation = useCatalogSave(scope, onSaved, item?.id);
|
||||
const selected = form.watch("fields");
|
||||
const sections = form.watch("sections") ?? [];
|
||||
const destination = sections.find((section) => section.id === fieldSelection?.sectionId);
|
||||
const validDestination = !fieldSelection?.sectionId || !!destination;
|
||||
|
||||
const openFieldPicker = (sectionId?: string) => setFieldSelection(sectionId ? { sectionId } : {});
|
||||
|
||||
const closeFieldPicker = () => setFieldSelection(undefined);
|
||||
|
||||
const fields = items.filter((entry) => entry.values.kind === "field");
|
||||
|
||||
const add = (fieldId: string) => {
|
||||
if (fieldId && !selected.some((field) => field.fieldId === fieldId)) {
|
||||
form.setValue("fields", [...selected, { fieldId, required: false }], {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
const availableFields = fields.filter(
|
||||
(entry) => entry.effectiveActive && !selected.some((field) => field.fieldId === entry.id)
|
||||
);
|
||||
|
||||
const canAdd = (fieldIds: readonly string[]) =>
|
||||
!mutation.isPending &&
|
||||
validDestination &&
|
||||
appendTemplateFields(form.getValues("fields"), fieldIds, fieldSelection?.sectionId).success;
|
||||
|
||||
const add = (fieldIds: readonly string[]) => {
|
||||
if (mutation.isPending || !validDestination) {
|
||||
return false;
|
||||
}
|
||||
const result = appendTemplateFields(
|
||||
form.getValues("fields"),
|
||||
fieldIds,
|
||||
fieldSelection?.sectionId
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
form.setError("fields", { type: "maxLength", message: t("fieldPicker.invalid") });
|
||||
|
||||
return false;
|
||||
}
|
||||
form.setValue("fields", result.data, { shouldDirty: true, shouldValidate: true });
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const submit = form.handleSubmit((values) => mutation.mutate(values));
|
||||
|
||||
return { creatingField, setCreatingField, form, mutation, selected, fields, add, submit };
|
||||
return {
|
||||
fieldSelection,
|
||||
openFieldPicker,
|
||||
closeFieldPicker,
|
||||
sections,
|
||||
destinationName: fieldSelection?.sectionId
|
||||
? destination?.name || t("sections.name")
|
||||
: undefined,
|
||||
form,
|
||||
mutation,
|
||||
selected,
|
||||
fields,
|
||||
availableFields,
|
||||
canAdd,
|
||||
add,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
import { appendTemplateBindings } from "@repo/rdx-ui/lib/template-sections";
|
||||
|
||||
import { TemplateFormSchema } from "../schemas";
|
||||
import type { TemplateFormValues } from "../types";
|
||||
|
||||
/** Valida las referencias nuevas con el contrato normativo del módulo. */
|
||||
export function appendTemplateFields(
|
||||
fields: TemplateFormValues["fields"],
|
||||
fieldIds: readonly string[],
|
||||
sectionId?: string
|
||||
) {
|
||||
return TemplateFormSchema.shape.fields.safeParse(
|
||||
appendTemplateBindings(fields, fieldIds, sectionId)
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,8 @@
|
||||
import { UniqueID } from "@repo/rdx-ddd";
|
||||
import { FormActions, FormBody } from "@repo/rdx-ui/components/form-dialog";
|
||||
import { FormSection } from "@repo/rdx-ui/components/form-section";
|
||||
import { IconPicker } from "@repo/rdx-ui/components/icon-picker";
|
||||
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
|
||||
import { TemplateMetadataSection } from "@repo/rdx-ui/components/template-metadata-section";
|
||||
import { TemplateSectionsEditor } from "@repo/rdx-ui/components/template-sections-editor";
|
||||
import { XIcon } from "@repo/rdx-ui/lib/icons";
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
@ -15,7 +15,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ResourceTemplateIconSchema } from "../../../../common/resource-template-icons";
|
||||
import { CatalogError } from "../../../catalog/shared/ui/catalog-error";
|
||||
import { CatalogHelp } from "../../../catalog/shared/ui/catalog-help";
|
||||
import { CatalogFieldCreateDialog } from "../../../catalog-fields";
|
||||
import { CatalogFieldPickerDialog } from "../../../catalog-fields";
|
||||
import { useCatalogTemplateFormController } from "../controllers";
|
||||
import type { CatalogTemplateFormProps } from "../types";
|
||||
|
||||
@ -23,8 +23,21 @@ import { ResourceTemplateIcon } from "./resource-template-icon";
|
||||
|
||||
export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
const { scope, item, allowQuickCreate = true } = props;
|
||||
const { creatingField, setCreatingField, form, mutation, selected, fields, add, submit } =
|
||||
useCatalogTemplateFormController(props);
|
||||
const {
|
||||
fieldSelection,
|
||||
openFieldPicker,
|
||||
closeFieldPicker,
|
||||
sections,
|
||||
destinationName,
|
||||
form,
|
||||
mutation,
|
||||
selected,
|
||||
fields,
|
||||
availableFields,
|
||||
canAdd,
|
||||
add,
|
||||
submit,
|
||||
} = useCatalogTemplateFormController(props);
|
||||
const { t } = useTranslation("resources");
|
||||
const id = useId();
|
||||
|
||||
@ -32,45 +45,53 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
<>
|
||||
<form className="flex min-h-0 flex-1 flex-col overflow-hidden" onSubmit={submit}>
|
||||
<FormBody>
|
||||
<FormSection title={t("formLayout.template")}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<TemplateMetadataSection
|
||||
descriptionField={
|
||||
<>
|
||||
<Label htmlFor={`${id}-description`}>{t("description")}</Label>
|
||||
<Textarea
|
||||
className="min-h-24 flex-1"
|
||||
id={`${id}-description`}
|
||||
maxLength={1000}
|
||||
rows={2}
|
||||
{...form.register("description")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<IconPicker
|
||||
disabled={mutation.isPending}
|
||||
label={t("templateIcon.label")}
|
||||
onChange={(value) => {
|
||||
const icon = ResourceTemplateIconSchema.safeParse(value);
|
||||
</>
|
||||
}
|
||||
primaryFields={
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-name`}>{t("name")}</Label>
|
||||
<Input id={`${id}-name`} maxLength={100} required {...form.register("name")} />
|
||||
</div>
|
||||
<IconPicker
|
||||
disabled={mutation.isPending}
|
||||
label={t("templateIcon.label")}
|
||||
onChange={(value) => {
|
||||
const icon = ResourceTemplateIconSchema.safeParse(value);
|
||||
|
||||
form.setValue("icon", icon.success ? icon.data : undefined, { shouldDirty: true });
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label: t("templateIcon.none"),
|
||||
icon: <XIcon aria-hidden="true" className="size-4" />,
|
||||
},
|
||||
...ResourceTemplateIconSchema.options.map((icon) => ({
|
||||
value: icon,
|
||||
label: t(`templateIcon.options.${icon}`),
|
||||
icon: <ResourceTemplateIcon icon={icon} />,
|
||||
})),
|
||||
]}
|
||||
value={form.watch("icon") ?? ""}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">{t("templateIcon.help")}</p>
|
||||
form.setValue("icon", icon.success ? icon.data : undefined, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}}
|
||||
options={[
|
||||
{
|
||||
value: "",
|
||||
label: t("templateIcon.none"),
|
||||
icon: <XIcon aria-hidden="true" className="size-4" />,
|
||||
},
|
||||
...ResourceTemplateIconSchema.options.map((icon) => ({
|
||||
value: icon,
|
||||
label: t(`templateIcon.options.${icon}`),
|
||||
icon: <ResourceTemplateIcon icon={icon} />,
|
||||
})),
|
||||
]}
|
||||
value={form.watch("icon") ?? ""}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
title={t("formLayout.template")}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-usage`}>{t("templateUsage")}</Label>
|
||||
<SelectWithAction
|
||||
@ -101,33 +122,12 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t("templateUsageHelp")}</p>
|
||||
</div>
|
||||
</FormSection>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-field`}>{t("selectField")}</Label>
|
||||
<SelectWithAction
|
||||
action={
|
||||
allowQuickCreate
|
||||
? { label: t("addField"), onClick: () => setCreatingField(true) }
|
||||
: undefined
|
||||
}
|
||||
disabled={mutation.isPending}
|
||||
id={`${id}-field`}
|
||||
label={t("selectField")}
|
||||
onValueChange={add}
|
||||
options={fields
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.effectiveActive && !selected.some((field) => field.fieldId === entry.id)
|
||||
)
|
||||
.map((entry) => ({ value: entry.id, label: entry.values.name }))}
|
||||
placeholder={t("choose")}
|
||||
value=""
|
||||
/>
|
||||
</div>
|
||||
{!selected.length && <p className="text-sm text-muted-foreground">{t("noFields")}</p>}
|
||||
</TemplateMetadataSection>
|
||||
<TemplateSectionsEditor
|
||||
addFieldAction={{ label: t("sections.addField"), onClick: openFieldPicker }}
|
||||
createId={() => UniqueID.generateNewID().toPrimitive()}
|
||||
disabled={mutation.isPending}
|
||||
fieldLayout="compact"
|
||||
fieldNames={Object.fromEntries(fields.map((field) => [field.id, field.values.name]))}
|
||||
fieldNotes={Object.fromEntries(
|
||||
fields
|
||||
@ -137,6 +137,11 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
fields={selected}
|
||||
labels={{
|
||||
title: t("sections.title"),
|
||||
expand: t("sections.expand"),
|
||||
collapse: t("sections.collapse"),
|
||||
fields: t("sections.fields"),
|
||||
reorder: t("sections.reorder"),
|
||||
reorderHelp: t("sections.reorderHelp"),
|
||||
add: t("sections.add"),
|
||||
name: t("sections.name"),
|
||||
description: t("sections.description"),
|
||||
@ -148,14 +153,20 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
required: t("sections.required"),
|
||||
assign: t("sections.assign"),
|
||||
help: t("sections.help"),
|
||||
empty: t("sections.empty"),
|
||||
empty: t(sections.length ? "sections.empty" : "noFields"),
|
||||
}}
|
||||
onChange={({ sections, fields: next }) => {
|
||||
form.setValue("sections", sections, { shouldDirty: true });
|
||||
form.setValue("fields", next, { shouldDirty: true });
|
||||
}}
|
||||
sections={form.watch("sections") ?? []}
|
||||
sectionLayout="responsive"
|
||||
sections={sections}
|
||||
/>
|
||||
{form.formState.errors.fields?.type === "maxLength" && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("fieldPicker.invalid")}
|
||||
</p>
|
||||
)}
|
||||
{form.formState.errors.sections && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{t("errors.INVALID_SECTIONS")}
|
||||
@ -173,18 +184,14 @@ export function CatalogTemplateForm(props: CatalogTemplateFormProps) {
|
||||
</Button>
|
||||
</FormActions>
|
||||
</form>
|
||||
{creatingField && (
|
||||
<CatalogFieldCreateDialog
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCreatingField(false);
|
||||
}
|
||||
}}
|
||||
onSaved={(fieldId) => {
|
||||
add(fieldId);
|
||||
setCreatingField(false);
|
||||
}}
|
||||
scope={scope}
|
||||
{fieldSelection && (
|
||||
<CatalogFieldPickerDialog
|
||||
canAdd={canAdd}
|
||||
fields={availableFields}
|
||||
onClose={closeFieldPicker}
|
||||
onConfirm={add}
|
||||
quickCreate={allowQuickCreate ? { scope, disabled: mutation.isPending } : undefined}
|
||||
sectionName={destinationName}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@ -26,7 +26,7 @@ const ICONS = {
|
||||
|
||||
export function ResourceTemplateIcon({
|
||||
icon,
|
||||
className = "size-4 shrink-0 text-muted-foreground",
|
||||
className = "size-4 shrink-0",
|
||||
}: {
|
||||
icon: ResourceTemplateIconKey | undefined;
|
||||
className?: string;
|
||||
|
||||
@ -57,7 +57,7 @@ test("el catálogo muestra plantillas de serie y acción de alta sin formularios
|
||||
assert.equal(html.includes(">Editar</button>"), false);
|
||||
}
|
||||
});
|
||||
test("los formularios tienen ayuda inferior y selector de campo con alta integrada", async () => {
|
||||
test("los formularios tienen ayuda inferior y selección de campos en diálogo con alta rápida", async () => {
|
||||
const field = await render("es", <CatalogFieldForm onSaved={() => undefined} scope="global" />);
|
||||
|
||||
assert.ok(field.indexOf('name="description"') < field.indexOf("<aside"));
|
||||
@ -68,7 +68,11 @@ test("los formularios tienen ayuda inferior y selector de campo con alta integra
|
||||
<CatalogTemplateForm items={[]} onSaved={() => undefined} scope="global" />
|
||||
);
|
||||
|
||||
assert.ok(template.includes('aria-label="Nuevo campo"'));
|
||||
assert.equal(template.includes('aria-label="Nuevo campo"'), false);
|
||||
assert.equal(template.includes("Seleccionar campos"), false);
|
||||
assert.equal(template.includes("Añadir campos a la plantilla"), false);
|
||||
assert.ok(template.includes('aria-label="Añadir campo: Sin sección"'));
|
||||
assert.ok(template.includes('aria-haspopup="dialog"'));
|
||||
assert.ok(template.includes("Todos son opcionales inicialmente"));
|
||||
assert.ok(template.indexOf("<aside") < template.indexOf('type="submit"'));
|
||||
});
|
||||
@ -97,6 +101,12 @@ test("iconos de recursos: catálogo y selector traducido conservan la selección
|
||||
]) {
|
||||
assert.ok(list.includes(`lucide-${icon}`));
|
||||
}
|
||||
const empty = await render(
|
||||
language,
|
||||
<CatalogTemplateForm items={catalog.items} onSaved={() => undefined} scope="global" />
|
||||
);
|
||||
|
||||
assert.ok(empty.includes(none));
|
||||
for (const source of [{ item: mailbox }, { copy: mailbox }]) {
|
||||
const html = await render(
|
||||
language,
|
||||
@ -109,8 +119,8 @@ test("iconos de recursos: catálogo y selector traducido conservan la selección
|
||||
);
|
||||
|
||||
assert.ok(html.includes(label));
|
||||
assert.ok(html.includes(none));
|
||||
assert.ok(html.includes(`aria-label="${mail}" aria-pressed="true"`));
|
||||
assert.ok(html.includes('role="combobox"'));
|
||||
assert.ok(html.includes(mail));
|
||||
assert.ok(html.includes("lucide-mail"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx"
|
||||
"test": "TSX_TSCONFIG_PATH=../../tsconfig.json node --import tsx --test src/select-with-action.test.tsx src/entity-list.test.tsx src/field-sections.test.tsx src/phone-link.test.tsx src/lib/reorder-items.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
|
||||
@ -64,6 +64,7 @@ export function EntityTable<T extends { id: string }>({
|
||||
emptyMessage,
|
||||
rowClassName,
|
||||
onRowClick,
|
||||
layout = "content",
|
||||
}: {
|
||||
items: readonly T[];
|
||||
columns: readonly EntityColumn<T>[];
|
||||
@ -82,6 +83,7 @@ export function EntityTable<T extends { id: string }>({
|
||||
emptyMessage?: string;
|
||||
rowClassName?: (item: T) => string;
|
||||
onRowClick?: ((item: T) => void) | undefined;
|
||||
layout?: "content" | "fill";
|
||||
}) {
|
||||
const id = useId();
|
||||
const labels = entityListLabels(locale);
|
||||
@ -111,9 +113,9 @@ export function EntityTable<T extends { id: string }>({
|
||||
<section
|
||||
aria-busy={loading}
|
||||
aria-label={title}
|
||||
className="min-w-0 rounded-xl border bg-card text-card-foreground"
|
||||
className={`min-w-0 rounded-xl border bg-card text-card-foreground ${layout === "fill" ? "flex min-h-96 flex-1 flex-col overflow-hidden sm:min-h-80 [&>[data-slot=table-container]]:min-h-0 [&>[data-slot=table-container]]:flex-1 [&>[data-slot=table-container]]:overflow-auto [&>[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" : ""}`}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b p-4">
|
||||
<div className="flex shrink-0 flex-wrap items-start justify-between gap-3 border-b p-4">
|
||||
<div className="w-full sm:max-w-sm">
|
||||
<label className="sr-only" htmlFor={`${id}-search`}>
|
||||
{searchPlaceholder ?? labels.search}
|
||||
@ -134,7 +136,7 @@ export function EntityTable<T extends { id: string }>({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex 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">
|
||||
{info}
|
||||
</p>
|
||||
@ -157,7 +159,7 @@ export function EntityTable<T extends { id: string }>({
|
||||
unavailable={unavailable}
|
||||
/>
|
||||
|
||||
<footer className="flex flex-wrap items-center justify-between gap-3 border-t px-4 py-3">
|
||||
<footer className="flex shrink-0 flex-wrap items-center justify-between gap-3 border-t px-4 py-3">
|
||||
<p className="text-sm text-muted-foreground">{info}</p>
|
||||
<EntityListPagination
|
||||
id={id}
|
||||
|
||||
@ -53,7 +53,7 @@ export function EntityTableContent<T extends { id: string }>({
|
||||
? "descending"
|
||||
: undefined
|
||||
}
|
||||
className={entityColumnClass(column.getIsPinned() === "right", true)}
|
||||
className={entityColumnClass(column.id, column.getIsPinned() === "right", true)}
|
||||
data-pinned={column.getIsPinned() || undefined}
|
||||
key={column.id}
|
||||
scope="col"
|
||||
@ -73,7 +73,7 @@ export function EntityTableContent<T extends { id: string }>({
|
||||
</span>
|
||||
</button>
|
||||
) : (
|
||||
String(column.columnDef.header)
|
||||
<span>{String(column.columnDef.header)}</span>
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
@ -112,7 +112,7 @@ export function EntityTableContent<T extends { id: string }>({
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<TableCell
|
||||
className={entityColumnClass(column.getIsPinned() === "right", false)}
|
||||
className={entityColumnClass(column.id, column.getIsPinned() === "right", false)}
|
||||
data-pinned={column.getIsPinned() || undefined}
|
||||
key={column.id}
|
||||
>
|
||||
@ -134,14 +134,22 @@ export function EntityTableContent<T extends { id: string }>({
|
||||
);
|
||||
}
|
||||
|
||||
function entityColumnClass(pinned: boolean, header: boolean) {
|
||||
function entityColumnClass(columnId: string, pinned: boolean, header: boolean) {
|
||||
if (columnId === "selection") {
|
||||
const labelVisibility = header ? "[&>span]:sr-only" : "";
|
||||
|
||||
return `w-px min-w-0 py-3 pr-1 pl-4 text-center whitespace-nowrap [&:has([role=checkbox])]:pr-1 ${labelVisibility}`;
|
||||
}
|
||||
|
||||
if (pinned) {
|
||||
return header
|
||||
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"
|
||||
: "sticky right-0 z-10 h-10 w-20 min-w-20 bg-card px-3 py-3 pr-4 text-right whitespace-nowrap group-hover/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] group-has-aria-expanded/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] ";
|
||||
}
|
||||
|
||||
const nameWidth = columnId === "name" ? "min-w-40" : "first:min-w-40";
|
||||
|
||||
return header
|
||||
? "first:min-w-40 bg-background font-medium"
|
||||
: "h-14 first:min-w-40 max-w-lg whitespace-normal break-words px-3 py-3";
|
||||
? `${nameWidth} bg-background font-medium`
|
||||
: `h-14 ${nameWidth} max-w-lg whitespace-normal break-words px-3 py-3`;
|
||||
}
|
||||
|
||||
100
packages/rdx-ui/src/components/field-picker-dialog.tsx
Normal file
100
packages/rdx-ui/src/components/field-picker-dialog.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { useFieldPickerController } from "../hooks/use-field-picker-controller";
|
||||
|
||||
import type { FieldPickerSelection } from "./field-picker-list";
|
||||
import { FormActions, FormBody, FormDialog } from "./form-dialog";
|
||||
|
||||
/** Selección provisional y alta opcional compartidas, independientes de catálogos y endpoints. */
|
||||
export function FieldPickerDialog(props: {
|
||||
items: readonly { id: string }[];
|
||||
canAdd: (ids: readonly string[]) => boolean;
|
||||
onConfirm: (ids: readonly string[]) => boolean;
|
||||
onClose: () => void;
|
||||
labels: {
|
||||
title: string;
|
||||
cancel: string;
|
||||
help: string;
|
||||
destination: string;
|
||||
selected: (count: number) => string;
|
||||
limit: string;
|
||||
invalid: string;
|
||||
create: string;
|
||||
confirm: string;
|
||||
};
|
||||
renderList: (selection: FieldPickerSelection) => ReactNode;
|
||||
quickCreate?:
|
||||
| {
|
||||
disabled?: boolean | undefined;
|
||||
render: (callbacks: {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved: (id: string) => void;
|
||||
}) => ReactNode;
|
||||
}
|
||||
| undefined;
|
||||
}) {
|
||||
const c = useFieldPickerController({
|
||||
fields: props.items,
|
||||
canAdd: props.canAdd,
|
||||
onConfirm: props.onConfirm,
|
||||
onClose: props.onClose,
|
||||
});
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
cancelLabel={props.labels.cancel}
|
||||
formActions
|
||||
layout="picker"
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
props.onClose();
|
||||
}
|
||||
}}
|
||||
open
|
||||
title={props.labels.title}
|
||||
wide
|
||||
>
|
||||
<FormBody>
|
||||
<p className="text-sm text-muted-foreground">{props.labels.help}</p>
|
||||
<p className="text-sm font-medium">{props.labels.destination}</p>
|
||||
{props.renderList({ disabled: c.disabled, onToggle: c.toggle, selectedIds: c.selectedIds })}
|
||||
<p aria-live="polite" className="text-sm text-muted-foreground">
|
||||
{props.labels.selected(c.selectedIds.length)}
|
||||
</p>
|
||||
{props.items.some((field) => !c.selectedIds.includes(field.id) && c.disabled(field.id)) && (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
{props.labels.limit}
|
||||
</p>
|
||||
)}
|
||||
{!c.canConfirm && c.selectedIds.length > 0 && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{props.labels.invalid}
|
||||
</p>
|
||||
)}
|
||||
</FormBody>
|
||||
<FormActions
|
||||
leadingActions={
|
||||
props.quickCreate && (
|
||||
<Button
|
||||
aria-haspopup="dialog"
|
||||
disabled={props.quickCreate.disabled}
|
||||
onClick={() => c.setCreatingField(true)}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{props.labels.create}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Button disabled={!c.canConfirm} onClick={c.confirm} type="button">
|
||||
{props.labels.confirm}
|
||||
</Button>
|
||||
</FormActions>
|
||||
{props.quickCreate &&
|
||||
c.creatingField &&
|
||||
props.quickCreate.render({ onOpenChange: c.setCreatingField, onSaved: c.selectCreated })}
|
||||
</FormDialog>
|
||||
);
|
||||
}
|
||||
92
packages/rdx-ui/src/components/field-picker-list.tsx
Normal file
92
packages/rdx-ui/src/components/field-picker-list.tsx
Normal file
@ -0,0 +1,92 @@
|
||||
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
|
||||
|
||||
import { EntityTable } from "./entity-list";
|
||||
|
||||
export type FieldPickerSelection = {
|
||||
selectedIds: readonly string[];
|
||||
disabled: (id: string) => boolean;
|
||||
onToggle: (id: string) => void;
|
||||
};
|
||||
|
||||
/** Tabla de selección de campos; datos y textos pertenecen al consumidor. */
|
||||
export function FieldPickerList({
|
||||
fields,
|
||||
labels,
|
||||
locale,
|
||||
selectedIds,
|
||||
disabled,
|
||||
onToggle,
|
||||
}: FieldPickerSelection & {
|
||||
fields: readonly { id: string; name: string; description: string; type: string; unit: string }[];
|
||||
labels: {
|
||||
title: string;
|
||||
name: string;
|
||||
type: string;
|
||||
unit: string;
|
||||
select: string;
|
||||
selectField: (name: string) => string;
|
||||
empty: string;
|
||||
fields: string;
|
||||
search: string;
|
||||
};
|
||||
locale: string;
|
||||
}) {
|
||||
return (
|
||||
<EntityTable
|
||||
columns={[
|
||||
{
|
||||
id: "selection",
|
||||
label: labels.select,
|
||||
hideable: false,
|
||||
render: (field) => (
|
||||
<Checkbox
|
||||
aria-label={labels.selectField(field.name)}
|
||||
checked={selectedIds.includes(field.id)}
|
||||
disabled={disabled(field.id)}
|
||||
onCheckedChange={() => onToggle(field.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
label: labels.name,
|
||||
hideable: false,
|
||||
value: (field) => `${field.name} ${field.description}`,
|
||||
render: (field) => (
|
||||
<div className="space-y-1">
|
||||
<span className="font-medium">{field.name}</span>
|
||||
{field.description && (
|
||||
<p className="text-xs text-muted-foreground">{field.description}</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "type",
|
||||
label: labels.type,
|
||||
value: (field) => field.type,
|
||||
render: (field) => field.type,
|
||||
},
|
||||
{
|
||||
id: "unit",
|
||||
label: labels.unit,
|
||||
value: (field) => field.unit,
|
||||
render: (field) => field.unit,
|
||||
},
|
||||
]}
|
||||
emptyMessage={labels.empty}
|
||||
entityLabel={labels.fields}
|
||||
items={fields}
|
||||
layout="fill"
|
||||
locale={locale}
|
||||
onRowClick={(field) => {
|
||||
if (!disabled(field.id)) {
|
||||
onToggle(field.id);
|
||||
}
|
||||
}}
|
||||
rowClassName={(field) => (disabled(field.id) ? "cursor-not-allowed!" : "")}
|
||||
searchPlaceholder={labels.search}
|
||||
title={labels.title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -9,6 +9,8 @@ import {
|
||||
} from "@repo/shadcn-ui/components/dialog";
|
||||
import { type ReactNode, createContext, useContext } from "react";
|
||||
|
||||
const FormLayoutContext = createContext<"content" | "picker">("content");
|
||||
|
||||
const FormCancelContext = createContext<string | undefined>(undefined);
|
||||
|
||||
export function FormDialog({
|
||||
@ -20,6 +22,7 @@ export function FormDialog({
|
||||
wide = false,
|
||||
size,
|
||||
formActions = false,
|
||||
layout = "content",
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@ -29,6 +32,7 @@ export function FormDialog({
|
||||
wide?: boolean;
|
||||
size?: "small" | "medium" | "large";
|
||||
formActions?: boolean;
|
||||
layout?: "content" | "picker";
|
||||
}) {
|
||||
const width = size ?? (wide ? "large" : "small");
|
||||
const widths = {
|
||||
@ -37,10 +41,12 @@ export function FormDialog({
|
||||
large: "sm:w-[90vw] sm:max-w-[1200px]",
|
||||
};
|
||||
|
||||
const height = layout === "picker" ? "h-[calc(100dvh-2rem)] sm:h-[min(85dvh,900px)]" : "";
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent
|
||||
className={`flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 ${widths[width]}`}
|
||||
className={`flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 ${widths[width]} ${height}`}
|
||||
showCloseButton={true}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-5 pr-14">
|
||||
@ -51,7 +57,7 @@ export function FormDialog({
|
||||
data-slot="form-dialog-content"
|
||||
>
|
||||
<FormCancelContext.Provider value={formActions ? cancelLabel : undefined}>
|
||||
{children}
|
||||
<FormLayoutContext.Provider value={layout}>{children}</FormLayoutContext.Provider>
|
||||
</FormCancelContext.Provider>
|
||||
</div>
|
||||
{!formActions && (
|
||||
@ -66,11 +72,18 @@ export function FormDialog({
|
||||
);
|
||||
}
|
||||
|
||||
export function FormActions({ children }: { children: ReactNode }) {
|
||||
export function FormActions({
|
||||
children,
|
||||
leadingActions,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
leadingActions?: ReactNode;
|
||||
}) {
|
||||
const cancelLabel = useContext(FormCancelContext);
|
||||
|
||||
return (
|
||||
<DialogFooter className="shrink-0 border-t bg-popover py-4">
|
||||
{leadingActions && <div className="flex sm:mr-auto">{leadingActions}</div>}
|
||||
{cancelLabel && (
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>{cancelLabel}</DialogClose>
|
||||
)}
|
||||
@ -81,8 +94,17 @@ export function FormActions({ children }: { children: ReactNode }) {
|
||||
|
||||
/** El cuerpo reserva espacio simétrico para que el scroll no recorte el anillo de foco. */
|
||||
export function FormBody({ children }: { children: ReactNode }) {
|
||||
const layout = useContext(FormLayoutContext);
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 space-y-5 overflow-y-auto px-1 pt-1 pb-5" data-slot="form-body">
|
||||
<div
|
||||
className={
|
||||
layout === "picker"
|
||||
? "flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1 pt-1 pb-5 [scrollbar-gutter:stable] [&>*]:shrink-0"
|
||||
: "min-h-0 flex-1 space-y-5 overflow-y-auto px-1 pt-1 pb-5"
|
||||
}
|
||||
data-slot="form-body"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
21
packages/rdx-ui/src/components/form-section-title.tsx
Normal file
21
packages/rdx-ui/src/components/form-section-title.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/** Título común para grupos de un diálogo, independiente de su nivel semántico. */
|
||||
export function FormSectionTitle({
|
||||
children,
|
||||
id,
|
||||
as: Heading = "h3",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
id?: string;
|
||||
as?: "h3" | "h4" | "h5";
|
||||
}) {
|
||||
return (
|
||||
<Heading
|
||||
className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-4"
|
||||
id={id}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useId } from "react";
|
||||
|
||||
import { FormSectionTitle } from "./form-section-title";
|
||||
|
||||
/** Bloque visual reutilizable con título accesible; no altera validación ni datos. */
|
||||
export function FormSection({
|
||||
title,
|
||||
@ -18,16 +20,7 @@ export function FormSection({
|
||||
aria-labelledby={id}
|
||||
className={variant === "plain" ? "@container space-y-3" : "space-y-4 rounded-lg border p-4"}
|
||||
>
|
||||
<h3
|
||||
className={
|
||||
variant === "plain"
|
||||
? "text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
||||
: "text-sm font-semibold"
|
||||
}
|
||||
id={id}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<FormSectionTitle id={id}>{title}</FormSectionTitle>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -1,41 +1,75 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@repo/shadcn-ui/components/select";
|
||||
import type { ReactNode } from "react";
|
||||
import { useId } from "react";
|
||||
|
||||
/** Selección compacta con vista previa; el consumidor aporta iconos, textos y claves válidas. */
|
||||
export function IconPicker({
|
||||
label,
|
||||
description,
|
||||
describedBy,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
describedBy?: string;
|
||||
options: readonly { value: string; label: string; icon: ReactNode }[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const id = useId();
|
||||
const selected = options.find((option) => option.value === value);
|
||||
const descriptionIds =
|
||||
[description ? `${id}-description` : undefined, describedBy].filter(Boolean).join(" ") ||
|
||||
undefined;
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-2" disabled={disabled}>
|
||||
<legend className="text-sm font-medium">{label}</legend>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
aria-label={option.label}
|
||||
aria-pressed={value === option.value}
|
||||
className={
|
||||
value === option.value ? "border-primary text-primary ring-1 ring-primary" : ""
|
||||
}
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
size="icon"
|
||||
title={option.label}
|
||||
type="button"
|
||||
variant={value === option.value ? "secondary" : "outline"}
|
||||
>
|
||||
{option.icon}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
items={options.map((option) => ({ value: option.value, label: option.label }))}
|
||||
onValueChange={(next) => {
|
||||
if (typeof next === "string") {
|
||||
onChange(next);
|
||||
}
|
||||
}}
|
||||
value={value}
|
||||
>
|
||||
<SelectTrigger aria-describedby={descriptionIds} className="w-full sm:max-w-64" id={id}>
|
||||
<SelectValue>
|
||||
<span aria-hidden="true" className="shrink-0">
|
||||
{selected?.icon}
|
||||
</span>
|
||||
<span className="truncate">{selected?.label}</span>
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<span aria-hidden="true" className="shrink-0">
|
||||
{option.icon}
|
||||
</span>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground" id={`${id}-description`}>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
15
packages/rdx-ui/src/components/reorder-drop-indicator.tsx
Normal file
15
packages/rdx-ui/src/components/reorder-drop-indicator.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import type { DropPlacement } from "../lib/reorder-items";
|
||||
|
||||
/** Marca el punto de inserción sin desplazar controles ni interceptar el arrastre. */
|
||||
export function ReorderDropIndicator({ placement }: { placement: DropPlacement | undefined }) {
|
||||
if (!placement) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={`pointer-events-none absolute inset-x-0 z-10 h-1 bg-primary ${placement === "before" ? "top-0" : "bottom-0"}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
28
packages/rdx-ui/src/components/reorder-handle.tsx
Normal file
28
packages/rdx-ui/src/components/reorder-handle.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
/** Asa con nombre traducido; el consumidor aporta los eventos de arrastre y teclado. */
|
||||
export function ReorderHandle({
|
||||
label,
|
||||
...props
|
||||
}: Pick<
|
||||
ComponentProps<typeof Button>,
|
||||
"draggable" | "disabled" | "onDragStart" | "onDragEnd" | "onKeyDown" | "aria-describedby"
|
||||
> & {
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
aria-label={label}
|
||||
className="shrink-0 cursor-grab touch-auto text-muted-foreground active:cursor-grabbing"
|
||||
size="icon-sm"
|
||||
title={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<GripVerticalIcon aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
28
packages/rdx-ui/src/components/template-metadata-section.tsx
Normal file
28
packages/rdx-ui/src/components/template-metadata-section.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { FormSection } from "./form-section";
|
||||
|
||||
/** Datos generales de una plantilla, con columnas equilibradas y separación entre grupos. */
|
||||
export function TemplateMetadataSection({
|
||||
title,
|
||||
primaryFields,
|
||||
descriptionField,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
primaryFields: ReactNode;
|
||||
descriptionField: ReactNode;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="pb-4">
|
||||
<FormSection title={title} variant="plain">
|
||||
<div className="grid items-stretch gap-4 @min-[560px]:grid-cols-2">
|
||||
<div className="flex min-w-0 flex-col gap-4">{primaryFields}</div>
|
||||
<div className="flex min-w-0 flex-col gap-2">{descriptionField}</div>
|
||||
</div>
|
||||
{children}
|
||||
</FormSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
packages/rdx-ui/src/components/template-section-editor.tsx
Normal file
130
packages/rdx-ui/src/components/template-section-editor.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@repo/shadcn-ui/components/collapsible";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { ArrowDownIcon, ArrowUpIcon, ChevronRightIcon, Trash2Icon } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
|
||||
import type { FieldSection } from "./field-sections";
|
||||
import type { SectionEditorLabels } from "./template-sections-editor";
|
||||
|
||||
/** Conserva la edición y el estado de apertura de una sección al reordenarla. */
|
||||
export function TemplateSectionEditor({
|
||||
section,
|
||||
index,
|
||||
sectionCount,
|
||||
labels,
|
||||
disabled,
|
||||
dragHandle,
|
||||
onChange,
|
||||
onMove,
|
||||
onRemove,
|
||||
children,
|
||||
}: {
|
||||
section: FieldSection;
|
||||
index: number;
|
||||
sectionCount: number;
|
||||
labels: SectionEditorLabels;
|
||||
disabled: boolean;
|
||||
dragHandle: ReactNode;
|
||||
onChange: (section: FieldSection) => void;
|
||||
onMove: (offset: -1 | 1) => void;
|
||||
onRemove: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const prefix = useId();
|
||||
const [open, setOpen] = useState(true);
|
||||
|
||||
return (
|
||||
<Collapsible disabled={disabled} onOpenChange={setOpen} open={open}>
|
||||
<div className="flex flex-wrap items-center gap-3 border-b bg-muted/70 px-4 py-3">
|
||||
<CollapsibleTrigger
|
||||
aria-label={`${open ? labels.collapse : labels.expand}: ${section.name || labels.name}`}
|
||||
render={<Button size="icon-sm" type="button" variant="ghost" />}
|
||||
>
|
||||
<ChevronRightIcon aria-hidden="true" className={open ? "rotate-90" : ""} />
|
||||
</CollapsibleTrigger>
|
||||
{dragHandle}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded-md border bg-background text-xs font-semibold text-muted-foreground"
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<h4 className="sr-only">{section.name || labels.name}</h4>
|
||||
<div className="min-w-40 flex-1 space-y-2">
|
||||
<Input
|
||||
aria-label={labels.name}
|
||||
id={`${prefix}-name`}
|
||||
maxLength={100}
|
||||
onChange={(event) => onChange({ ...section, name: event.target.value })}
|
||||
placeholder={labels.name}
|
||||
required
|
||||
value={section.name}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
aria-label={`${labels.moveUp}: ${section.name}`}
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => onMove(-1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`${labels.moveDown}: ${section.name}`}
|
||||
disabled={disabled || index === sectionCount - 1}
|
||||
onClick={() => onMove(1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`${labels.remove}: ${section.name}`}
|
||||
disabled={disabled}
|
||||
onClick={onRemove}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CollapsibleContent
|
||||
className="data-[closed]:hidden"
|
||||
keepMounted
|
||||
onInvalidCapture={() => {
|
||||
// Abrir antes de que el navegador enfoque un control inválido oculto.
|
||||
flushSync(() => setOpen(true));
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-description`}>{labels.description}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-description`}
|
||||
maxLength={1000}
|
||||
onChange={(event) => onChange({ ...section, description: event.target.value })}
|
||||
rows={2}
|
||||
value={section.description}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t p-4">{children}</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,15 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { ArrowDownIcon, ArrowUpIcon, Trash2Icon } from "lucide-react";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
|
||||
import { useDragReorder } from "../hooks/use-drag-reorder";
|
||||
|
||||
import type { FieldSection } from "./field-sections";
|
||||
import { FormSectionTitle } from "./form-section-title";
|
||||
import { ReorderDropIndicator } from "./reorder-drop-indicator";
|
||||
import { ReorderHandle } from "./reorder-handle";
|
||||
import type { SelectAction } from "./select-with-action";
|
||||
import { SelectWithAction } from "./select-with-action";
|
||||
import { SwitchField } from "./switch-field";
|
||||
import type { SectionBinding, SectionEditorLabels } from "./template-sections-editor";
|
||||
@ -16,7 +23,10 @@ export function TemplateSectionFields({
|
||||
labels,
|
||||
disabled,
|
||||
onChange,
|
||||
addAction,
|
||||
layout = "cards",
|
||||
}: {
|
||||
layout?: "cards" | "compact";
|
||||
sectionId?: string | undefined;
|
||||
sections: readonly FieldSection[];
|
||||
fields: readonly SectionBinding[];
|
||||
@ -24,11 +34,27 @@ export function TemplateSectionFields({
|
||||
fieldNotes?: Readonly<Record<string, string>> | undefined;
|
||||
labels: SectionEditorLabels;
|
||||
disabled: boolean;
|
||||
addAction?: SelectAction | undefined;
|
||||
onChange: (fields: SectionBinding[]) => void;
|
||||
}) {
|
||||
const prefix = useId();
|
||||
const grouped = fields.filter((field) => field.sectionId === sectionId);
|
||||
|
||||
const { getHandleProps, getItemProps, getDropPlacement } = useDragReorder({
|
||||
items: grouped,
|
||||
getId: (field) => field.fieldId,
|
||||
disabled,
|
||||
onChange: (next) => {
|
||||
const reordered = next.values();
|
||||
|
||||
onChange(
|
||||
fields.map((field) =>
|
||||
field.sectionId === sectionId ? (reordered.next().value ?? field) : field
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const move = (fieldId: string, offset: number) => {
|
||||
const position = grouped.findIndex((field) => field.fieldId === fieldId);
|
||||
const neighbor = grouped[position + offset];
|
||||
@ -46,114 +72,185 @@ export function TemplateSectionFields({
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
if (!(sectionId || grouped.length)) {
|
||||
if (!(sectionId || grouped.length || (addAction && !sections.length))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{!sectionId && sections.length > 0 && (
|
||||
<h4 className="text-sm font-medium">{labels.ungrouped}</h4>
|
||||
<section
|
||||
aria-labelledby={`${prefix}-title`}
|
||||
className={
|
||||
sectionId
|
||||
? "@container space-y-3"
|
||||
: "@container space-y-3 rounded-lg border border-dashed bg-muted/30 p-4"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FormSectionTitle as="h5" id={`${prefix}-title`}>
|
||||
{sectionId || !sections.length ? labels.fields : labels.ungrouped}
|
||||
</FormSectionTitle>
|
||||
<span className="rounded-full border bg-background px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{grouped.length}
|
||||
</span>
|
||||
{addAction && (
|
||||
<Button
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`${addAction.label}: ${sections.find((section) => section.id === sectionId)?.name || (sectionId ? labels.name : labels.ungrouped)}`}
|
||||
className="ml-auto"
|
||||
disabled={disabled || addAction.disabled}
|
||||
onClick={addAction.onClick}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{addAction.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="sr-only" id={`${prefix}-reorder-help`}>
|
||||
{labels.reorderHelp}
|
||||
</p>
|
||||
{!grouped.length && (
|
||||
<p className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
|
||||
{labels.empty}
|
||||
</p>
|
||||
)}
|
||||
{!grouped.length && <p className="text-xs text-muted-foreground">{labels.empty}</p>}
|
||||
{grouped.map((field, index) => {
|
||||
const name = fieldNames[field.fieldId] ?? field.fieldId;
|
||||
|
||||
return (
|
||||
<div className="space-y-2 rounded-md border p-3" key={field.fieldId}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="break-words text-sm font-medium">{name}</p>
|
||||
{fieldNotes?.[field.fieldId] && (
|
||||
<p className="text-xs text-muted-foreground">{fieldNotes[field.fieldId]}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
aria-label={`${labels.moveUp}: ${name}`}
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => move(field.fieldId, -1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`${labels.moveDown}: ${name}`}
|
||||
disabled={disabled || index === grouped.length - 1}
|
||||
onClick={() => move(field.fieldId, 1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`${labels.removeField}: ${name}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(fields.filter((item) => item.fieldId !== field.fieldId))}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SwitchField
|
||||
checked={field.required}
|
||||
disabled={disabled}
|
||||
id={`${prefix}-${field.fieldId}-required`}
|
||||
label={
|
||||
<>
|
||||
<div className="grid gap-3 @min-[700px]:grid-cols-2">
|
||||
{grouped.map((field, index) => {
|
||||
const name = fieldNames[field.fieldId] ?? field.fieldId;
|
||||
const requiredSwitch = (
|
||||
<SwitchField
|
||||
checked={field.required}
|
||||
disabled={disabled}
|
||||
id={`${prefix}-${field.fieldId}-required`}
|
||||
label={
|
||||
<>
|
||||
<span className={layout === "compact" ? "sr-only" : undefined}>
|
||||
{labels.required}
|
||||
<span className="sr-only">: {name}</span>
|
||||
</>
|
||||
}
|
||||
onCheckedChange={(required) =>
|
||||
onChange(
|
||||
fields.map((item) =>
|
||||
item.fieldId === field.fieldId ? { ...item, required } : item
|
||||
)
|
||||
</span>
|
||||
<span className="sr-only">: {name}</span>
|
||||
</>
|
||||
}
|
||||
onCheckedChange={(required) =>
|
||||
onChange(
|
||||
fields.map((item) =>
|
||||
item.fieldId === field.fieldId ? { ...item, required } : item
|
||||
)
|
||||
}
|
||||
/>
|
||||
{sections.length > 0 && (
|
||||
<div className="min-w-40 flex-1">
|
||||
<SelectWithAction
|
||||
)
|
||||
}
|
||||
title={`${labels.required}: ${name}`}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
{...getItemProps(field.fieldId)}
|
||||
className={
|
||||
layout === "compact"
|
||||
? "relative min-w-0 rounded-md border bg-background px-2 py-1 data-[dragging=true]:opacity-50"
|
||||
: "relative flex min-w-0 flex-col gap-3 rounded-md border bg-background p-3 shadow-xs data-[dragging=true]:opacity-50"
|
||||
}
|
||||
key={field.fieldId}
|
||||
>
|
||||
<ReorderDropIndicator placement={getDropPlacement(field.fieldId)} />
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<ReorderHandle
|
||||
{...getHandleProps(field.fieldId)}
|
||||
aria-describedby={`${prefix}-reorder-help`}
|
||||
label={`${labels.reorder}: ${name} (${index + 1}/${grouped.length})`}
|
||||
/>
|
||||
{layout === "compact" && requiredSwitch}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p
|
||||
className="break-words text-sm font-medium"
|
||||
title={fieldNotes?.[field.fieldId]}
|
||||
>
|
||||
{name}
|
||||
</p>
|
||||
{fieldNotes?.[field.fieldId] && (
|
||||
<p
|
||||
className={layout === "compact" ? "sr-only" : "text-xs text-muted-foreground"}
|
||||
>
|
||||
{fieldNotes[field.fieldId]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center">
|
||||
<Button
|
||||
aria-label={`${labels.moveUp}: ${name}`}
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => move(field.fieldId, -1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`${labels.moveDown}: ${name}`}
|
||||
disabled={disabled || index === grouped.length - 1}
|
||||
onClick={() => move(field.fieldId, 1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`${labels.removeField}: ${name}`}
|
||||
disabled={disabled}
|
||||
id={`${prefix}-${field.fieldId}-section`}
|
||||
label={`${labels.assign}: ${name}`}
|
||||
onValueChange={(section) =>
|
||||
onChange(
|
||||
fields.map((item) =>
|
||||
item.fieldId === field.fieldId
|
||||
? {
|
||||
fieldId: item.fieldId,
|
||||
required: item.required,
|
||||
...(section ? { sectionId: section } : {}),
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
onClick={() =>
|
||||
onChange(fields.filter((item) => item.fieldId !== field.fieldId))
|
||||
}
|
||||
options={[
|
||||
...sections.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.name || labels.name,
|
||||
})),
|
||||
]}
|
||||
placeholder={labels.ungrouped}
|
||||
value={field.sectionId ?? ""}
|
||||
/>
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{layout === "cards" && (
|
||||
<div className="flex flex-wrap items-end gap-3 border-t pt-3">
|
||||
{requiredSwitch}
|
||||
{sections.length > 0 && (
|
||||
<div className="min-w-40 flex-1 space-y-2">
|
||||
<Label htmlFor={`${prefix}-${field.fieldId}-section`}>{labels.assign}</Label>
|
||||
<SelectWithAction
|
||||
disabled={disabled}
|
||||
id={`${prefix}-${field.fieldId}-section`}
|
||||
label={`${labels.assign}: ${name}`}
|
||||
onValueChange={(section) =>
|
||||
onChange(
|
||||
fields.map((item) =>
|
||||
item.fieldId === field.fieldId
|
||||
? {
|
||||
fieldId: item.fieldId,
|
||||
required: item.required,
|
||||
...(section ? { sectionId: section } : {}),
|
||||
}
|
||||
: item
|
||||
)
|
||||
)
|
||||
}
|
||||
options={[
|
||||
...sections.map((section) => ({
|
||||
value: section.id,
|
||||
label: section.name || labels.name,
|
||||
})),
|
||||
]}
|
||||
placeholder={labels.ungrouped}
|
||||
value={field.sectionId ?? ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,19 +1,26 @@
|
||||
import { Button } from "@repo/shadcn-ui/components/button";
|
||||
import { Input } from "@repo/shadcn-ui/components/input";
|
||||
import { Label } from "@repo/shadcn-ui/components/label";
|
||||
import { Textarea } from "@repo/shadcn-ui/components/textarea";
|
||||
import { ArrowDownIcon, ArrowUpIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useId } from "react";
|
||||
|
||||
import { useDragReorder } from "../hooks/use-drag-reorder";
|
||||
import { removeTemplateSection } from "../lib/template-sections";
|
||||
|
||||
import type { FieldSection } from "./field-sections";
|
||||
import { FormSectionTitle } from "./form-section-title";
|
||||
import { ReorderDropIndicator } from "./reorder-drop-indicator";
|
||||
import { ReorderHandle } from "./reorder-handle";
|
||||
import { TemplateSectionEditor } from "./template-section-editor";
|
||||
import { TemplateSectionFields } from "./template-section-fields";
|
||||
|
||||
export type SectionBinding = { fieldId: string; required: boolean; sectionId?: string | undefined };
|
||||
|
||||
export type SectionEditorLabels = {
|
||||
title: string;
|
||||
expand: string;
|
||||
collapse: string;
|
||||
fields: string;
|
||||
reorder: string;
|
||||
reorderHelp: string;
|
||||
add: string;
|
||||
name: string;
|
||||
description: string;
|
||||
@ -37,7 +44,12 @@ export function TemplateSectionsEditor({
|
||||
disabled = false,
|
||||
createId,
|
||||
onChange,
|
||||
addFieldAction,
|
||||
fieldLayout = "cards",
|
||||
sectionLayout = "stack",
|
||||
}: {
|
||||
fieldLayout?: "cards" | "compact";
|
||||
sectionLayout?: "stack" | "responsive";
|
||||
sections: readonly FieldSection[];
|
||||
fields: readonly SectionBinding[];
|
||||
fieldNames: Readonly<Record<string, string>>;
|
||||
@ -45,12 +57,20 @@ export function TemplateSectionsEditor({
|
||||
labels: SectionEditorLabels;
|
||||
disabled?: boolean;
|
||||
createId: () => string;
|
||||
addFieldAction?: { label: string; onClick: (sectionId: string | undefined) => void };
|
||||
onChange: (value: { sections: FieldSection[]; fields: SectionBinding[] }) => void;
|
||||
}) {
|
||||
const prefix = useId();
|
||||
|
||||
const saveSections = (next: FieldSection[]) => onChange({ sections: next, fields: [...fields] });
|
||||
|
||||
const { getHandleProps, getItemProps, getDropPlacement } = useDragReorder({
|
||||
items: sections,
|
||||
getId: (section) => section.id,
|
||||
disabled,
|
||||
onChange: saveSections,
|
||||
});
|
||||
|
||||
const moveSection = (index: number, offset: number) => {
|
||||
const next = [...sections];
|
||||
const first = next[index];
|
||||
@ -67,11 +87,20 @@ export function TemplateSectionsEditor({
|
||||
|
||||
const fieldList = (sectionId?: string | undefined) => (
|
||||
<TemplateSectionFields
|
||||
addAction={
|
||||
addFieldAction
|
||||
? {
|
||||
label: addFieldAction.label,
|
||||
onClick: () => addFieldAction.onClick(sectionId),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
disabled={disabled}
|
||||
fieldNames={fieldNames}
|
||||
fieldNotes={fieldNotes}
|
||||
fields={fields}
|
||||
labels={labels}
|
||||
layout={fieldLayout}
|
||||
onChange={(next) => onChange({ sections: [...sections], fields: next })}
|
||||
sectionId={sectionId}
|
||||
sections={sections}
|
||||
@ -79,9 +108,9 @@ export function TemplateSectionsEditor({
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-medium">{labels.title}</h3>
|
||||
<section aria-labelledby={`${prefix}-title`} className="@container/template-sections space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<FormSectionTitle id={`${prefix}-title`}>{labels.title}</FormSectionTitle>
|
||||
<Button
|
||||
disabled={disabled || sections.length >= 30}
|
||||
onClick={() => saveSections([...sections, { id: createId(), name: "", description: "" }])}
|
||||
@ -93,80 +122,47 @@ export function TemplateSectionsEditor({
|
||||
{labels.add}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{labels.help}</p>
|
||||
<div className="grid items-start gap-4 @min-[900px]:grid-cols-2">
|
||||
|
||||
<p className="sr-only" id={`${prefix}-reorder-help`}>
|
||||
{labels.reorderHelp}
|
||||
</p>
|
||||
<div
|
||||
className={
|
||||
sectionLayout === "responsive"
|
||||
? "grid grid-cols-1 items-stretch gap-4 @min-[1000px]/template-sections:grid-cols-2"
|
||||
: "space-y-5"
|
||||
}
|
||||
>
|
||||
{sections.map((section, index) => (
|
||||
<fieldset
|
||||
className="space-y-3 rounded-md border p-3"
|
||||
{...getItemProps(section.id)}
|
||||
className="relative min-w-0 overflow-hidden rounded-lg border border-border bg-muted/30 data-[dragging=true]:opacity-50"
|
||||
disabled={disabled}
|
||||
key={section.id}
|
||||
>
|
||||
<legend className="sr-only">{section.name || labels.name}</legend>
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<Label htmlFor={`${prefix}-${section.id}-name`}>{labels.name}</Label>
|
||||
<Input
|
||||
id={`${prefix}-${section.id}-name`}
|
||||
maxLength={100}
|
||||
onChange={(event) =>
|
||||
saveSections(
|
||||
sections.map((item) =>
|
||||
item.id === section.id ? { ...item, name: event.target.value } : item
|
||||
)
|
||||
)
|
||||
}
|
||||
required
|
||||
value={section.name}
|
||||
<ReorderDropIndicator placement={getDropPlacement(section.id)} />
|
||||
<TemplateSectionEditor
|
||||
disabled={disabled}
|
||||
dragHandle={
|
||||
<ReorderHandle
|
||||
{...getHandleProps(section.id)}
|
||||
aria-describedby={`${prefix}-reorder-help`}
|
||||
label={`${labels.reorder}: ${section.name || labels.name} (${index + 1}/${sections.length})`}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
aria-label={`${labels.moveUp}: ${section.name}`}
|
||||
disabled={disabled || index === 0}
|
||||
onClick={() => moveSection(index, -1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowUpIcon />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`${labels.moveDown}: ${section.name}`}
|
||||
disabled={disabled || index === sections.length - 1}
|
||||
onClick={() => moveSection(index, 1)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowDownIcon />
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`${labels.remove}: ${section.name}`}
|
||||
disabled={disabled}
|
||||
onClick={() => removeSection(section.id)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Trash2Icon />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${prefix}-${section.id}-description`}>{labels.description}</Label>
|
||||
<Textarea
|
||||
id={`${prefix}-${section.id}-description`}
|
||||
maxLength={1000}
|
||||
onChange={(event) =>
|
||||
saveSections(
|
||||
sections.map((item) =>
|
||||
item.id === section.id ? { ...item, description: event.target.value } : item
|
||||
)
|
||||
)
|
||||
}
|
||||
rows={2}
|
||||
value={section.description}
|
||||
/>
|
||||
</div>
|
||||
{fieldList(section.id)}
|
||||
}
|
||||
index={index}
|
||||
labels={labels}
|
||||
onChange={(next) =>
|
||||
saveSections(sections.map((item) => (item.id === section.id ? next : item)))
|
||||
}
|
||||
onMove={(offset) => moveSection(index, offset)}
|
||||
onRemove={() => removeSection(section.id)}
|
||||
section={section}
|
||||
sectionCount={sections.length}
|
||||
>
|
||||
{fieldList(section.id)}
|
||||
</TemplateSectionEditor>
|
||||
</fieldset>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -6,6 +6,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { FieldSections } from "./components/field-sections";
|
||||
import { TabbedFieldSections } from "./components/tabbed-field-sections";
|
||||
import { TemplateFieldSummary } from "./components/template-field-summary";
|
||||
import { TemplateSectionsEditor } from "./components/template-sections-editor";
|
||||
import { copyTemplateLayout, removeTemplateSection } from "./lib/template-sections";
|
||||
|
||||
const sections = [
|
||||
@ -133,3 +134,56 @@ test("pestañas de grupos conservan controles ocultos, orden y campos sin grupo,
|
||||
assert.ok(html.includes('hidden=""'));
|
||||
assert.ok(html.includes('required=""'));
|
||||
});
|
||||
|
||||
test("el editor ofrece asas etiquetadas para secciones y campos, deshabilitadas al guardar", () => {
|
||||
const props = {
|
||||
sections,
|
||||
fields: [...fields, { fieldId: "cpu", sectionId: "hardware", required: true }],
|
||||
fieldNames: { ram: "RAM", cpu: "Procesador", date: "Fecha", notes: "Notas" },
|
||||
createId: () => "new-section",
|
||||
addFieldAction: { label: "Añadir campo", onClick: () => undefined },
|
||||
onChange: () => undefined,
|
||||
labels: {
|
||||
title: "Secciones",
|
||||
expand: "Desplegar sección",
|
||||
collapse: "Plegar sección",
|
||||
fields: "Campos",
|
||||
reorder: "Reordenar",
|
||||
reorderHelp: "Arrastra el asa o usa las flechas del teclado.",
|
||||
add: "Añadir sección",
|
||||
name: "Nombre",
|
||||
description: "Descripción",
|
||||
ungrouped: "Sin sección",
|
||||
moveUp: "Subir",
|
||||
moveDown: "Bajar",
|
||||
remove: "Eliminar sección",
|
||||
removeField: "Quitar campo",
|
||||
required: "Obligatorio",
|
||||
assign: "Sección",
|
||||
help: "Organiza los campos.",
|
||||
empty: "Sin campos",
|
||||
},
|
||||
};
|
||||
const html = renderToStaticMarkup(<TemplateSectionsEditor {...props} />);
|
||||
|
||||
assert.ok(html.includes('aria-label="Reordenar: Hardware (1/2)"'));
|
||||
assert.ok(html.includes('aria-label="Reordenar: RAM (1/2)"'));
|
||||
assert.ok(html.includes('aria-label="Reordenar: Notas (1/1)"'));
|
||||
assert.ok(html.includes('aria-label="Añadir campo: Hardware"'));
|
||||
assert.ok(html.includes('aria-label="Añadir campo: Compra"'));
|
||||
assert.ok(html.includes('aria-label="Añadir campo: Sin sección"'));
|
||||
assert.ok(html.includes(props.labels.reorderHelp));
|
||||
assert.ok(html.includes('aria-label="Plegar sección: Hardware"'));
|
||||
assert.ok(html.includes('aria-label="Plegar sección: Compra"'));
|
||||
assert.equal((html.match(/aria-expanded="true"/g) ?? []).length, 2);
|
||||
assert.equal((html.match(/data-slot="collapsible-content"/g) ?? []).length, 2);
|
||||
assert.ok(html.includes('aria-label="Nombre"'));
|
||||
assert.ok(html.includes("Datos técnicos"));
|
||||
assert.equal((html.match(/<button[^>]*draggable="true"/g) ?? []).length, 4);
|
||||
assert.ok(html.includes('aria-label="Subir: Procesador"'));
|
||||
assert.ok(html.includes('aria-label="Bajar: Hardware"'));
|
||||
const pending = renderToStaticMarkup(<TemplateSectionsEditor {...props} disabled />);
|
||||
|
||||
assert.equal(pending.includes('draggable="true"'), false);
|
||||
assert.equal((pending.match(/<button[^>]*draggable="false"[^>]*disabled/g) ?? []).length, 6);
|
||||
});
|
||||
|
||||
145
packages/rdx-ui/src/hooks/use-drag-reorder.ts
Normal file
145
packages/rdx-ui/src/hooks/use-drag-reorder.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import type { DragEvent, KeyboardEvent } from "react";
|
||||
import { useId, useLayoutEffect, useRef, useState } from "react";
|
||||
|
||||
import type { DropPlacement } from "../lib/reorder-items";
|
||||
import { reorderItems } from "../lib/reorder-items";
|
||||
|
||||
type DragState = {
|
||||
sourceId: string;
|
||||
targetId?: string;
|
||||
placement?: DropPlacement;
|
||||
};
|
||||
|
||||
/** Arrastre nativo acotado a una lista; solo confirma el orden al soltar. */
|
||||
export function useDragReorder<T>({
|
||||
items,
|
||||
getId,
|
||||
onChange,
|
||||
disabled,
|
||||
}: {
|
||||
items: readonly T[];
|
||||
getId: (item: T) => string;
|
||||
onChange: (items: T[]) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const scope = useId();
|
||||
const [drag, setDrag] = useState<DragState>();
|
||||
const pendingFocus = useRef<HTMLElement | undefined>(undefined);
|
||||
|
||||
// Mover un nodo en el DOM puede hacer perder el foco: se restaura tras aplicar el nuevo orden.
|
||||
useLayoutEffect(() => {
|
||||
const handle = pendingFocus.current;
|
||||
|
||||
pendingFocus.current = undefined;
|
||||
if (handle?.isConnected) {
|
||||
handle.focus();
|
||||
}
|
||||
});
|
||||
|
||||
const move = (sourceId: string, targetId: string, placement: DropPlacement) => {
|
||||
const next = reorderItems(
|
||||
items,
|
||||
items.findIndex((item) => getId(item) === sourceId),
|
||||
items.findIndex((item) => getId(item) === targetId),
|
||||
placement
|
||||
);
|
||||
|
||||
if (next) {
|
||||
onChange(next);
|
||||
}
|
||||
};
|
||||
|
||||
const placementAt = (event: DragEvent<HTMLElement>): DropPlacement => {
|
||||
const bounds = event.currentTarget.getBoundingClientRect();
|
||||
|
||||
return event.clientY < bounds.top + bounds.height / 2 ? "before" : "after";
|
||||
};
|
||||
|
||||
const getHandleProps = (id: string) => ({
|
||||
draggable: !disabled && items.length > 1,
|
||||
disabled: disabled || items.length < 2,
|
||||
onDragStart: (event: DragEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
if (disabled || items.length < 2) {
|
||||
event.preventDefault();
|
||||
|
||||
return;
|
||||
}
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("application/x-rdx-reorder", `${scope}:${id}`);
|
||||
setDrag({ sourceId: id });
|
||||
},
|
||||
onDragEnd: () => setDrag(undefined),
|
||||
onKeyDown: (event: KeyboardEvent<HTMLElement>) => {
|
||||
if (disabled || (event.key !== "ArrowUp" && event.key !== "ArrowDown")) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const offset = event.key === "ArrowUp" ? -1 : 1;
|
||||
const index = items.findIndex((item) => getId(item) === id);
|
||||
const neighbor = items[index + offset];
|
||||
|
||||
if (neighbor) {
|
||||
pendingFocus.current = event.currentTarget;
|
||||
move(id, getId(neighbor), offset < 0 ? "before" : "after");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getDropPlacement = (id: string) =>
|
||||
!disabled && drag?.targetId === id ? drag.placement : undefined;
|
||||
|
||||
const getItemProps = (id: string) => ({
|
||||
"data-dragging": !disabled && drag?.sourceId === id ? true : undefined,
|
||||
"data-drop-position": getDropPlacement(id),
|
||||
onDragOver: (event: DragEvent<HTMLElement>) => {
|
||||
if (disabled || !drag) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
if (drag.sourceId === id) {
|
||||
if (drag.targetId) {
|
||||
setDrag({ sourceId: drag.sourceId });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
const placement = placementAt(event);
|
||||
|
||||
if (drag.targetId !== id || drag.placement !== placement) {
|
||||
setDrag({ sourceId: drag.sourceId, targetId: id, placement });
|
||||
}
|
||||
},
|
||||
onDragLeave: (event: DragEvent<HTMLElement>) => {
|
||||
if (
|
||||
event.relatedTarget instanceof Node &&
|
||||
event.currentTarget.contains(event.relatedTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (drag?.targetId === id) {
|
||||
setDrag({ sourceId: drag.sourceId });
|
||||
}
|
||||
},
|
||||
onDrop: (event: DragEvent<HTMLElement>) => {
|
||||
if (!drag) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setDrag(undefined);
|
||||
// No se aceptan datos externos ni arrastres procedentes de otra lista o diálogo.
|
||||
if (
|
||||
!disabled &&
|
||||
event.dataTransfer.getData("application/x-rdx-reorder") === `${scope}:${drag.sourceId}`
|
||||
) {
|
||||
move(drag.sourceId, id, placementAt(event));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return { getHandleProps, getItemProps, getDropPlacement };
|
||||
}
|
||||
55
packages/rdx-ui/src/hooks/use-field-picker-controller.ts
Normal file
55
packages/rdx-ui/src/hooks/use-field-picker-controller.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import { useState } from "react";
|
||||
|
||||
/** La selección es provisional hasta confirmar; buscar y paginar no la eliminan. */
|
||||
export function useFieldPickerController({
|
||||
fields,
|
||||
canAdd,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
fields: readonly { id: string }[];
|
||||
canAdd: (ids: readonly string[]) => boolean;
|
||||
onConfirm: (ids: readonly string[]) => boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [pendingIds, setPendingIds] = useState<readonly string[]>([]);
|
||||
const [creatingField, setCreatingField] = useState(false);
|
||||
const available = new Set(fields.map((field) => field.id));
|
||||
const selectedIds = pendingIds.filter((id) => available.has(id));
|
||||
const canConfirm = selectedIds.length > 0 && canAdd(selectedIds);
|
||||
|
||||
const disabled = (id: string) =>
|
||||
!(available.has(id) && (selectedIds.includes(id) || canAdd([...selectedIds, id])));
|
||||
|
||||
const toggle = (id: string) => {
|
||||
if (disabled(id)) {
|
||||
return;
|
||||
}
|
||||
setPendingIds(
|
||||
selectedIds.includes(id) ? selectedIds.filter((value) => value !== id) : [...selectedIds, id]
|
||||
);
|
||||
};
|
||||
|
||||
const selectCreated = (id: string) => {
|
||||
// El consumidor publica el elemento antes del callback; su identidad queda en la selección provisional.
|
||||
setPendingIds((previous) => (previous.includes(id) ? previous : [...previous, id]));
|
||||
setCreatingField(false);
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
if (canConfirm && onConfirm(selectedIds)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
canConfirm,
|
||||
disabled,
|
||||
toggle,
|
||||
confirm,
|
||||
creatingField,
|
||||
setCreatingField,
|
||||
selectCreated,
|
||||
};
|
||||
}
|
||||
46
packages/rdx-ui/src/lib/reorder-items.test.ts
Normal file
46
packages/rdx-ui/src/lib/reorder-items.test.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
import { reorderItems } from "./reorder-items";
|
||||
|
||||
test("soltar antes o después del destino conserva el orden de los demás elementos", () => {
|
||||
const items = ["a", "b", "c", "d"];
|
||||
|
||||
assert.deepEqual(reorderItems(items, 0, 2, "before"), ["b", "a", "c", "d"]);
|
||||
assert.deepEqual(reorderItems(items, 0, 2, "after"), ["b", "c", "a", "d"]);
|
||||
assert.deepEqual(reorderItems(items, 3, 1, "before"), ["a", "d", "b", "c"]);
|
||||
assert.deepEqual(reorderItems(items, 3, 1, "after"), ["a", "b", "d", "c"]);
|
||||
assert.deepEqual(reorderItems(items, 0, 3, "after"), ["b", "c", "d", "a"]);
|
||||
assert.deepEqual(reorderItems(items, 3, 0, "before"), ["d", "a", "b", "c"]);
|
||||
assert.deepEqual(items, ["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
test("destinos inválidos y posiciones sin cambio no producen una nueva ordenación", () => {
|
||||
const items = ["a", "b", "c"];
|
||||
|
||||
for (const placement of ["before", "after"] as const) {
|
||||
assert.equal(reorderItems([], 0, 0, placement), undefined);
|
||||
assert.equal(reorderItems(items, -1, 1, placement), undefined);
|
||||
assert.equal(reorderItems(items, 0, 3, placement), undefined);
|
||||
assert.equal(reorderItems(items, 3, 0, placement), undefined);
|
||||
assert.equal(reorderItems(items, 0, -1, placement), undefined);
|
||||
assert.equal(reorderItems(items, 0.5, 1, placement), undefined);
|
||||
assert.equal(reorderItems(items, 0, Number.NaN, placement), undefined);
|
||||
assert.equal(reorderItems(items, 1, 1, placement), undefined);
|
||||
assert.equal(reorderItems(["a"], 0, 0, placement), undefined);
|
||||
}
|
||||
assert.equal(reorderItems(items, 0, 1, "before"), undefined);
|
||||
assert.equal(reorderItems(items, 1, 0, "after"), undefined);
|
||||
});
|
||||
|
||||
test("reordenar mantiene identidades, secciones y obligatoriedad sin modificar los objetos", () => {
|
||||
const first = Object.freeze({ fieldId: "brand", sectionId: "identity", required: true });
|
||||
const second = Object.freeze({ fieldId: "model", sectionId: "identity", required: false });
|
||||
const items = Object.freeze([first, second]);
|
||||
const result = reorderItems(items, 1, 0, "before");
|
||||
|
||||
assert.deepEqual(result, [second, first]);
|
||||
assert.equal(result?.[0], second);
|
||||
assert.equal(result?.[1], first);
|
||||
assert.equal(reorderItems(result ?? [], 1, 0, "after"), undefined);
|
||||
});
|
||||
33
packages/rdx-ui/src/lib/reorder-items.ts
Normal file
33
packages/rdx-ui/src/lib/reorder-items.ts
Normal file
@ -0,0 +1,33 @@
|
||||
export type DropPlacement = "before" | "after";
|
||||
|
||||
/** Devuelve un orden nuevo o ausencia de cambio; nunca modifica la colección recibida. */
|
||||
export function reorderItems<T>(
|
||||
items: readonly T[],
|
||||
sourceIndex: number,
|
||||
targetIndex: number,
|
||||
placement: DropPlacement
|
||||
): T[] | undefined {
|
||||
if (
|
||||
!(Number.isInteger(sourceIndex) && Number.isInteger(targetIndex)) ||
|
||||
sourceIndex < 0 ||
|
||||
targetIndex < 0 ||
|
||||
sourceIndex >= items.length ||
|
||||
targetIndex >= items.length
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const insertionIndex = targetIndex + (placement === "after" ? 1 : 0);
|
||||
const destination = insertionIndex - (sourceIndex < insertionIndex ? 1 : 0);
|
||||
|
||||
if (sourceIndex === destination) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const next = [...items];
|
||||
const removed = next.splice(sourceIndex, 1);
|
||||
|
||||
next.splice(destination, 0, ...removed);
|
||||
|
||||
return next;
|
||||
}
|
||||
@ -36,3 +36,22 @@ export function copyTemplateLayout(
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Incorpora campos opcionales al destino sin alterar los ya incluidos, aunque se repita el lote. */
|
||||
export function appendTemplateBindings(
|
||||
fields: readonly SectionBinding[],
|
||||
fieldIds: readonly string[],
|
||||
sectionId?: string
|
||||
): SectionBinding[] {
|
||||
const included = new Set(fields.map((field) => field.fieldId));
|
||||
const next = [...fields];
|
||||
|
||||
for (const fieldId of fieldIds) {
|
||||
if (!included.has(fieldId)) {
|
||||
next.push({ fieldId, required: false, ...(sectionId ? { sectionId } : {}) });
|
||||
included.add(fieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user