feat: enhance entity row actions with primary action support

- Added primary action support to EntityRowActions component, allowing for a main action button with an icon and label.
- Updated various components to utilize the new primary action feature, replacing existing action buttons with the new structure.
- Introduced EyeIcon for view actions and PencilIcon for edit actions across multiple entity lists.
- Improved accessibility by ensuring primary actions have appropriate aria-labels.
- Refactored CatalogRowActions to remove direct view action and integrate with the new structure.
- Adjusted tests to cover new primary action functionality and ensure proper rendering and behavior.
This commit is contained in:
David Arranz 2026-10-17 11:23:46 +02:00
parent 68980d9131
commit ceb4eb88c6
33 changed files with 381 additions and 145 deletions

View File

@ -1010,3 +1010,19 @@ registros que presenta cada listado, incluidos los inactivos, sin aplicar sus fi
Los inventarios y perfiles usan `useListQuery` con las claves existentes por compañía:
comparten caché e invalidaciones de altas, cambios y bajas, sin nuevas rutas HTTP ni
estado remoto duplicado. Antes de cargar la compañía, esas consultas están deshabilitadas.
### Acción principal de fila
`EntityRowActions` dispone de `primaryAction` con `label`, `icon`, `onClick` y
`disabled` opcional. Presenta un botón compacto de icono con tooltip y nombre
accesible antes del desplegable de acciones secundarias. El consumidor aporta
textos traducidos y callbacks; el componente no conoce entidades ni endpoints.
La operación principal no debe repetirse entre los `EntityAction` secundarios.
Omitir `children` cuando solo exista la principal. Si las acciones secundarias
dependen de condiciones, declarar `menuVisible` con la misma condición para
evitar un menú vacío. Mantener el lápiz para editar y el ojo para abrir o ver.
Los listados de configuraciones conservan deshabilitada la edición de registros
retirados o nunca configurados. Las acciones de baja o eliminación siguen en
el desplegable. Véase el [patrón de listados](patron-de-listados-de-entidades.md).

View File

@ -40,7 +40,7 @@ El pie opcional está fuera de la tabla y solo aparece cuando existe contenido c
- El identificador principal abre la ficha existente si hay una. Usar enlace si existe una ruta real; botón si abre diálogo, detalle en memoria o panel. No inventar rutas.
- Representar estados mediante indicador visual y texto, nunca solo color. Mantener los avisos de revisión e información contextual.
- 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.
- 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. La acción principal se presenta como botón de icono con tooltip y nombre accesible, antes del menú de tres puntos. Usar el lápiz para Editar y el ojo para Abrir o Ver; elegir la operación según la finalidad de cada listado. La principal no se repite en el menú. Las acciones secundarias, incluidas las destructivas, permanecen en el desplegable, cuyo disparador muestra únicamente los tres puntos y conserva etiqueta accesible y título. Si no hay secundarias, no mostrar el desplegable. Conservar las restricciones y los estados deshabilitados existentes. Reutilizar `EntityRowActions.primaryAction`; el consumidor aporta el icono, el texto traducido y el callback. 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.
@ -139,7 +139,9 @@ La primera columna tiene anchura mínima para evitar nombres ilegibles. El despl
- No se han creado ni alterado registros de negocio durante la revisión del navegador.
### Ajuste de acciones de fila (7 de octubre de 2026)
### Ajuste inicial de acciones de fila (7 de octubre de 2026)
La regla vigente de acción principal visible figura en Reglas de interacción; este apartado registra la verificación del ajuste inicial.
Disparador solo con icono de tres puntos, etiqueta accesible y título conservados. Columna actions siempre al final, compacta, alineada y anclada a la derecha; no admite ocultación, búsqueda u ordenación. El fondo opaco respeta hover, menú abierto y selección del inventario.

View File

@ -4,7 +4,7 @@ import {
EntityListHeader,
EntityRowActions,
} from "@repo/rdx-ui/components/entity-list";
import { BoxIcon } from "@repo/rdx-ui/lib/icons";
import { BoxIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -131,7 +131,15 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
label: t("actions"),
hideable: false,
render: (asset) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(asset.id),
disabled: !catalog.data,
}}
>
{" "}
<div className="contents">
<EntityAction
@ -141,9 +149,6 @@ export function AssetInventoryPage(props: AssetInventoryPageProps) {
{t("viewAsset")}
</EntityAction>
<EntityAction disabled={!catalog.data} onClick={() => setEditor(asset.id)}>
{t("edit")}
</EntityAction>
<EntityAction
className="text-destructive data-highlighted:text-destructive"
onClick={() => setDeleteId(asset.id)}

View File

@ -6,7 +6,7 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { SelectWithAction } from "@repo/rdx-ui/components/select-with-action";
import { Building2Icon } from "@repo/rdx-ui/lib/icons";
import { Building2Icon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useId } from "react";
import { useTranslation } from "react-i18next";
@ -90,15 +90,18 @@ export function BrandCatalogPage() {
label: t("actions"),
hideable: false,
render: (brand) => (
<EntityRowActions label={t("actions")}>
<EntityAction
onClick={() => {
<EntityRowActions
label={t("actions")}
menuVisible={!brand.builtin}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => {
setEdit(brand);
setOpen(true);
}}
>
{t("edit")}
</EntityAction>
},
}}
>
{!brand.builtin && (
<EntityAction onClick={() => setDeleting(brand)}>
{t("brands.delete")}

View File

@ -1,4 +1,5 @@
import { EntityRowActions, EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { EyeIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
@ -63,7 +64,14 @@ export function CatalogCell({
case "actions":
return (
<>
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("view"),
icon: <EyeIcon aria-hidden="true" />,
onClick: onView,
}}
>
<CatalogRowActions
deletable={scope === "global" && !item.builtin && item.companyId === null}
editable={editable}
@ -72,7 +80,6 @@ export function CatalogCell({
onDelete={onDelete}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</EntityRowActions>

View File

@ -10,7 +10,6 @@ export function CatalogRowActions({
pending,
onEdit,
onCopy,
onView,
onStatus,
onDelete,
}: {
@ -20,7 +19,6 @@ export function CatalogRowActions({
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
onDelete: () => void;
}) {
@ -28,7 +26,6 @@ export function CatalogRowActions({
return (
<div className="contents">
<EntityAction onClick={onView}>{t("view")}</EntityAction>
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>

View File

@ -6,7 +6,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { MonitorIcon } from "@repo/rdx-ui/lib/icons";
import { MonitorIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -159,13 +159,20 @@ export function WorkstationsPage(props: WorkstationsPageProps) {
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("workstationHistory")}
</EntityAction>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
{(item.values.active ||
item.values.assetIds.length > 0 ||
item.values.personId) && (

View File

@ -1,4 +1,5 @@
import { EntityAction, EntityRowActions } from "@repo/rdx-ui/components/entity-list";
import { PencilIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
import type { AccessItemDTO, AccessResponseDTO, PersonDTO } from "../../../../common";
@ -74,12 +75,17 @@ export function AccessCell({
return (
<>
{" "}
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: onEdit,
disabled: !own,
}}
>
{" "}
<div className="contents">
<EntityAction disabled={!own} onClick={onEdit}>
{t("edit")}
</EntityAction>
<EntityAction onClick={onHistory}>{t("history")}</EntityAction>
</div>{" "}
</EntityRowActions>{" "}

View File

@ -5,7 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { LayoutTemplateIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -142,15 +142,20 @@ export function ApplicationConfigurationsPage(props: ApplicationConfigurationsPa
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
disabled: ["removed", "never_configured"].includes(item.values.state),
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}

View File

@ -5,7 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { FileKeyIcon } from "@repo/rdx-ui/lib/icons";
import { FileKeyIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -149,15 +149,20 @@ export function CertificateConfigurationsPage(props: CertificateConfigurationsPa
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
disabled: ["removed", "never_configured"].includes(item.values.state),
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}

View File

@ -1,12 +1,11 @@
import { BrowserLink } from "@metis/core/client";
import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { Building2Icon } from "@repo/rdx-ui/lib/icons";
import { Building2Icon, EyeIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -113,9 +112,14 @@ export function CompaniesPage(props: CompaniesPageProps) {
label: t("actions"),
hideable: false,
render: (company) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => selectCompany(company.id)}>{t("open")}</EntityAction>
</EntityRowActions>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("open"),
icon: <EyeIcon aria-hidden="true" />,
onClick: () => selectCompany(company.id),
}}
/>
),
},
]}

View File

@ -1,11 +1,10 @@
import {
EntityAction,
EntityListHeader,
EntityRowActions,
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { BriefcaseIcon } from "@repo/rdx-ui/lib/icons";
import { BriefcaseIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -78,9 +77,14 @@ export function DepartmentSection({ company }: { company: CompanyDTO }) {
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
</EntityRowActions>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
}}
/>
),
},
]}

View File

@ -5,7 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { FolderIcon } from "@repo/rdx-ui/lib/icons";
import { FolderIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -148,15 +148,20 @@ export function FolderMappingsPage(props: FolderMappingsPageProps) {
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
disabled: ["removed", "never_configured"].includes(item.values.state),
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}

View File

@ -5,7 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { MailIcon } from "@repo/rdx-ui/lib/icons";
import { MailIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -139,15 +139,20 @@ export function MailConfigurationsPage(props: MailConfigurationsPageProps) {
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
disabled: ["removed", "never_configured"].includes(item.values.state),
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}

View File

@ -5,7 +5,13 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { PhoneLink } from "@repo/rdx-ui/components/phone-link";
import { BriefcaseIcon, ChevronLeftIcon, MailIcon, PhoneIcon } from "@repo/rdx-ui/lib/icons";
import {
BriefcaseIcon,
ChevronLeftIcon,
MailIcon,
PencilIcon,
PhoneIcon,
} from "@repo/rdx-ui/lib/icons";
import { AvatarBadge } from "@repo/shadcn-ui/components/avatar";
import { Button } from "@repo/shadcn-ui/components/button";
import { Card, CardContent } from "@repo/shadcn-ui/components/card";
@ -195,8 +201,14 @@ export function PersonDetails({
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
}}
>
<EntityAction onClick={() => setHistoryId(item.id)}>
{t("history")}
</EntityAction>

View File

@ -15,7 +15,7 @@ import {
} from "@repo/rdx-ui/components/entity-list";
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { PhoneLink } from "@repo/rdx-ui/components/phone-link";
import { PersonIcon } from "@repo/rdx-ui/lib/icons";
import { EyeIcon, PersonIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { useId, useState } from "react";
@ -246,7 +246,17 @@ export function PeopleSection({
label: t("actions"),
hideable: false,
render: (person) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("open"),
icon: <EyeIcon aria-hidden="true" />,
onClick: () => {
setSelection({ mode: "detail", id: person.id });
selectEntity(person.id);
},
}}
>
<EntityAction
disabled={!person.active}
onClick={() =>
@ -258,14 +268,6 @@ export function PeopleSection({
>
{assetTranslation("newWorkstation")}
</EntityAction>
<EntityAction
onClick={() => {
setSelection({ mode: "detail", id: person.id });
selectEntity(person.id);
}}
>
{t("open")}
</EntityAction>
</EntityRowActions>
),
},

View File

@ -5,7 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { PrinterIcon } from "@repo/rdx-ui/lib/icons";
import { PencilIcon, PrinterIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useState } from "react";
import { useTranslation } from "react-i18next";
@ -134,15 +134,20 @@ export function PrinterConfigurationsPage(props: PrinterConfigurationsPageProps)
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
disabled: ["removed", "never_configured"].includes(item.values.state),
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("mail.history")}
</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
</div>{" "}
<EntityAction className="text-destructive" onClick={() => setDeleteId(item.id)}>
{t("deleteRecord.title")}

View File

@ -5,7 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { LayoutTemplateIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -120,10 +120,16 @@ export function ProcessTemplatesPage() {
label: t("actions"),
hideable: false,
render: (template) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(template.id),
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setEditor(template.id)}>{t("edit")}</EntityAction>
<EntityAction
onClick={() => {
setCopy(template.id);

View File

@ -6,7 +6,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { ChevronLeftIcon, LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { ChevronLeftIcon, EyeIcon, LayoutTemplateIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -155,10 +155,14 @@ export function ProcessesPage(props: ProcessesPageProps) {
label: t("actions"),
hideable: false,
render: (p) => (
<EntityRowActions label={t("actions")}>
<EntityAction onClick={() => setSelected(p.id)}>
{t("processes.openDetail")}
</EntityAction>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("processes.openDetail"),
icon: <EyeIcon aria-hidden="true" />,
onClick: () => setSelected(p.id),
}}
>
<EntityAction
onClick={() => {
if (

View File

@ -5,7 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
import { KeyRoundIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -122,16 +122,18 @@ export function RemoteConnectionsPage(props: RemoteConnectionsPageProps) {
label: t("actions"),
hideable: false,
render: (c) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(c.id),
disabled: isClosed(c.values.state),
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(c.id)}>{t("remote.history")}</EntityAction>
<EntityAction
disabled={isClosed(c.values.state)}
onClick={() => setEditor(c.id)}
>
{t("edit")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),

View File

@ -6,7 +6,7 @@ import {
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { FormLabel } from "@repo/rdx-ui/components/form-label";
import { MapPinIcon } from "@repo/rdx-ui/lib/icons";
import { MapPinIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { useId } from "react";
@ -85,10 +85,15 @@ export function SiteSection({ company }: { company: CompanyDTO }) {
label: t("actions"),
hideable: false,
render: (site) => (
<EntityRowActions label={t("actions")}>
<EntityAction disabled={mutation.isPending} onClick={() => setSelectedId(site.id)}>
{t("edit")}
</EntityAction>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setSelectedId(site.id),
disabled: mutation.isPending,
}}
>
<EntityAction disabled={mutation.isPending} onClick={() => mutation.mutate(site)}>
{t(site.active ? "siteLifecycle.archive" : "siteLifecycle.restore")}
</EntityAction>

View File

@ -12,7 +12,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { LayersIcon } from "@repo/rdx-ui/lib/icons";
import { LayersIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Checkbox } from "@repo/shadcn-ui/components/checkbox";
import { useId, useState } from "react";
@ -223,11 +223,17 @@ export function SoftwareInstallationsPanel({
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
disabled: ["removed", "never_configured"].includes(item.values.state),
}}
>
<EntityAction onClick={() => setView(item.id)}>{t("mail.history")}</EntityAction>
{!["removed", "never_configured"].includes(item.values.state) && (
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
)}
<EntityAction className="text-destructive" onClick={() => setDeleting(item.id)}>
{t("deleteRecord.title")}
</EntityAction>

View File

@ -5,7 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { KeyRoundIcon } from "@repo/rdx-ui/lib/icons";
import { KeyRoundIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -121,13 +121,19 @@ export function TeleworkAuthorizationsPage(props: TeleworkAuthorizationsPageProp
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setView(item.id)}>
{t("telework.history")}
</EntityAction>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
</div>{" "}
</EntityRowActions>
),

View File

@ -6,7 +6,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
import { PencilIcon, WorkProfileIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -152,7 +152,14 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
}}
>
{" "}
<div className="contents">
<EntityAction
@ -188,7 +195,7 @@ export function WorkProfilesPage(props: WorkProfilesPageProps) {
<EntityAction onClick={() => setView(item.id)}>
{t("profiles.history")}
</EntityAction>
<EntityAction onClick={() => setEditor(item.id)}>{t("edit")}</EntityAction>
<EntityAction
disabled={!item.values.active}
onClick={() => setLifecycle({ id: item.id, action: "deactivate" })}

View File

@ -5,7 +5,7 @@ import {
EntityStatus,
EntityTable,
} from "@repo/rdx-ui/components/entity-list";
import { UsersIcon } from "@repo/rdx-ui/lib/icons";
import { PencilIcon, UsersIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { useTranslation } from "react-i18next";
@ -85,11 +85,16 @@ export function UserManagement() {
label: t("actions"),
hideable: false,
render: (user) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
menuVisible={!user.isInitialAdmin}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setSelection({ id: user.id, action: "edit" }),
}}
>
<div className="contents">
<EntityAction onClick={() => setSelection({ id: user.id, action: "edit" })}>
{t("edit")}
</EntityAction>
{!user.isInitialAdmin && (
<EntityAction
onClick={() => setSelection({ id: user.id, action: "password" })}

View File

@ -1,4 +1,5 @@
import { EntityRowActions, EntityStatus } from "@repo/rdx-ui/components/entity-list";
import { EyeIcon } from "@repo/rdx-ui/lib/icons";
import { useTranslation } from "react-i18next";
import type { CatalogItemDTO } from "../../../../common";
@ -65,14 +66,20 @@ export function CatalogCell({
case "actions":
return (
<>
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("view"),
icon: <EyeIcon aria-hidden="true" />,
onClick: onView,
}}
>
<CatalogRowActions
editable={editable}
item={item}
onCopy={onCopy}
onEdit={onEdit}
onStatus={onStatus}
onView={onView}
pending={pending}
/>
</EntityRowActions>

View File

@ -9,7 +9,6 @@ export function CatalogRowActions({
pending,
onEdit,
onCopy,
onView,
onStatus,
}: {
item: CatalogItemDTO;
@ -17,14 +16,12 @@ export function CatalogRowActions({
pending: boolean;
onEdit: () => void;
onCopy: () => void;
onView: () => void;
onStatus: (active: boolean | null) => void;
}) {
const { t } = useTranslation("resources");
return (
<div className="contents">
<EntityAction onClick={onView}>{t("view")}</EntityAction>
{editable && <EntityAction onClick={onEdit}>{t("edit")}</EntityAction>}
{item.values.kind === "template" && <EntityAction onClick={onCopy}>{t("copy")}</EntityAction>}
<EntityAction disabled={pending} onClick={() => onStatus(!item.effectiveActive)}>

View File

@ -8,7 +8,7 @@ import {
} from "@repo/rdx-ui/components/entity-list";
import { ScrollableTabsList } from "@repo/rdx-ui/components/scrollable-tabs-list";
import { characteristicsSummary } from "@repo/rdx-ui/lib/characteristics-summary";
import { LayersIcon } from "@repo/rdx-ui/lib/icons";
import { LayersIcon, PencilIcon } from "@repo/rdx-ui/lib/icons";
import { Button } from "@repo/shadcn-ui/components/button";
import { Tabs, TabsContent } from "@repo/shadcn-ui/components/tabs";
import { useTranslation } from "react-i18next";
@ -176,15 +176,20 @@ export function ResourcesPage(props: ResourcesPageProps) {
label: t("actions"),
hideable: false,
render: (item) => (
<EntityRowActions label={t("actions")}>
<EntityRowActions
label={t("actions")}
primaryAction={{
label: t("edit"),
icon: <PencilIcon aria-hidden="true" />,
onClick: () => setEditor(item.id),
disabled: !catalog.data,
}}
>
{" "}
<div className="contents">
<EntityAction onClick={() => setViewId(item.id)}>
{t("viewResource")}
</EntityAction>
<EntityAction disabled={!catalog.data} onClick={() => setEditor(item.id)}>
{t("edit")}
</EntityAction>
</div>{" "}
</EntityRowActions>
),

View File

@ -23,7 +23,7 @@ export function CountBadge({
aria-label={count === undefined ? status : undefined}
className={`min-w-6 px-1.5 tabular-nums ${className ?? ""}`}
title={status}
variant="secondary"
variant="link"
>
{count === undefined ? "—" : new Intl.NumberFormat(locale).format(count)}
</Badge>

View File

@ -3,31 +3,11 @@ import { Button } from "@repo/shadcn-ui/components/button";
import type { ReactNode } from "react";
import { ENTITY_PAGE_SIZES, entityListLabels, entityListPages } from "../lib/entity-list";
import { EllipsisIcon } from "../lib/icons";
export { EntityRowActions } from "./entity-row-actions";
import { SelectWithAction } from "./select-with-action";
export function EntityRowActions({ children, label }: { children: ReactNode; label: string }) {
return (
<Menu.Root>
<Menu.Trigger
aria-label={label}
render={<Button size="icon-sm" variant="ghost" />}
title={label}
>
<EllipsisIcon aria-hidden="true" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner align="end" className="z-50" sideOffset={4}>
<Menu.Popup className="min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none">
{children}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
);
}
export function EntityAction({
children,
onClick,

View File

@ -0,0 +1,72 @@
import { Menu } from "@base-ui/react/menu";
import { Button } from "@repo/shadcn-ui/components/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@repo/shadcn-ui/components/tooltip";
import type { ReactNode } from "react";
import { EllipsisIcon } from "../lib/icons";
export function EntityRowActions({
children,
label,
primaryAction,
menuVisible = children !== undefined,
}: {
children?: ReactNode;
label: string;
primaryAction?: {
label: string;
icon: ReactNode;
onClick: () => void;
disabled?: boolean;
};
menuVisible?: boolean;
}) {
return (
<div className="inline-flex items-center justify-end gap-1 whitespace-nowrap">
{primaryAction && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger
render={
<Button
aria-label={primaryAction.label}
disabled={primaryAction.disabled}
onClick={primaryAction.onClick}
size="icon-sm"
type="button"
variant="ghost"
/>
}
>
{primaryAction.icon}
</TooltipTrigger>
<TooltipContent>{primaryAction.label}</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{menuVisible && (
<Menu.Root>
<Menu.Trigger
aria-label={label}
render={<Button size="icon-sm" type="button" variant="ghost" />}
title={label}
>
<EllipsisIcon aria-hidden="true" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Positioner align="end" className="z-50" sideOffset={4}>
<Menu.Popup className="min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-md outline-none">
{children}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
);
}

View File

@ -11,6 +11,7 @@ import {
EntityTable,
} from "./components/entity-list";
import { entityListLabels, entityListPages, entityListRange } from "./lib/entity-list";
import { PencilIcon } from "./lib/icons";
const items = Array.from({ length: 21 }, (_, index) => ({
id: String(index),
@ -219,6 +220,48 @@ test("acciones: disparador de solo icono con etiqueta accesible y menú contextu
assert.equal(html.includes(">Acciones de Ana"), false);
});
test("acciones: operación principal visible con nombre accesible y secundarias en el menú", () => {
for (const label of ["Editar", "Edit"]) {
const html = renderToStaticMarkup(
<EntityRowActions
label="Acciones"
primaryAction={{ label, icon: <PencilIcon aria-hidden="true" />, onClick: () => undefined }}
>
<EntityAction onClick={() => undefined}>Eliminar</EntityAction>
</EntityRowActions>
);
assert.ok(html.includes(`aria-label="${label}"`));
assert.ok(html.includes('data-slot="tooltip-trigger"'));
assert.ok(html.includes('aria-haspopup="menu"'));
assert.ok(html.indexOf(`aria-label="${label}"`) < html.indexOf('aria-label="Acciones"'));
assert.equal(html.includes(`>${label}</button>`), false);
}
});
test("acciones: conserva la restricción de la principal y evita menús sin secundarias", () => {
const primaryAction = {
label: "Editar",
icon: <PencilIcon aria-hidden="true" />,
onClick: () => undefined,
disabled: true,
};
const html = renderToStaticMarkup(
<EntityRowActions label="Acciones" primaryAction={primaryAction} />
);
assert.match(html, /<button[^>]*disabled=""/);
assert.equal(html.includes('aria-haspopup="menu"'), false);
const hidden = renderToStaticMarkup(
<EntityRowActions label="Acciones" menuVisible={false} primaryAction={primaryAction}>
{false}
</EntityRowActions>
);
assert.equal(hidden.includes('aria-haspopup="menu"'), false);
});
test("el estado activo se distingue en verde del inactivo", () => {
const active = renderToStaticMarkup(<EntityStatus active>Activa</EntityStatus>);
const inactive = renderToStaticMarkup(<EntityStatus active={false}>Inactiva</EntityStatus>);

View File

@ -20,6 +20,7 @@ export {
IconDeviceMobile as SmartphoneIcon,
IconDeviceTablet as TabletIcon,
IconDots as EllipsisIcon,
IconEye as EyeIcon,
IconFileCertificate as FileKeyIcon,
IconFolder as FolderIcon,
IconGripVertical as GripVerticalIcon,