Truncate access review roles with badge list

Long role strings in the access review table broke row layout when
drivers joined many roles into one comma-separated value. Expose
roles as a string array in GraphQL by splitting the stored role at
the API layer, and render the first three roles as badges with a
"+X more" popover for the rest.

Closes ENG-459.

Signed-off-by: Ludovic Vielle <ludovic@probo.com>
This commit is contained in:
Ludovic Vielle
2026-06-12 18:23:04 +02:00
parent bf20ca1a90
commit 8094e7cfd0
80 changed files with 768 additions and 378 deletions

View File

@@ -0,0 +1,88 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
import { Badge, Td } from "@probo/ui";
import * as Popover from "@radix-ui/react-popover";
import { graphql, useFragment } from "react-relay";
import type { AccessEntryRolesCell_accessEntry$key } from "#/__generated__/core/AccessEntryRolesCell_accessEntry.graphql";
import { NotAvailable } from "./accessReviewHelpers";
const VISIBLE_ROLE_COUNT = 3;
const accessEntryRolesCellFragment = graphql`
fragment AccessEntryRolesCell_accessEntry on AccessReviewEntry {
roles
}
`;
type Props = {
accessEntryKey: AccessEntryRolesCell_accessEntry$key;
};
export function AccessEntryRolesCell({ accessEntryKey }: Props) {
const entry = useFragment(accessEntryRolesCellFragment, accessEntryKey);
const roles = entry.roles;
if (roles.length === 0) {
return (
<Td className="max-w-xs">
<NotAvailable />
</Td>
);
}
const visibleRoles = roles.slice(0, VISIBLE_ROLE_COUNT);
const hiddenRoles = roles.slice(VISIBLE_ROLE_COUNT);
return (
<Td noLink className="max-w-xs">
<div className="flex flex-wrap gap-1">
{visibleRoles.map((role, index) => (
<Badge key={`${index}-${role}`} variant="neutral" className="text-xs">
{role}
</Badge>
))}
{hiddenRoles.length > 0 && (
<Popover.Root>
<Popover.Trigger asChild>
<button type="button" className="inline-flex">
<Badge variant="neutral" className="text-xs cursor-pointer">
+
{hiddenRoles.length}
</Badge>
</button>
</Popover.Trigger>
<Popover.Portal>
<Popover.Content
className="z-50 rounded-md border bg-level-0 p-3 shadow-md max-w-sm"
sideOffset={4}
align="start"
>
<div className="flex flex-wrap gap-1">
{hiddenRoles.map((role, index) => (
<Badge key={`${index}-${role}`} variant="neutral" className="text-xs">
{role}
</Badge>
))}
</div>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
)}
</div>
</Td>
);
}

View File

@@ -56,6 +56,7 @@ import type { CampaignDetailPageQuery } from "#/__generated__/core/CampaignDetai
import type { CampaignDetailPageStartMutation } from "#/__generated__/core/CampaignDetailPageStartMutation.graphql";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { AccessEntryRolesCell } from "../_components/AccessEntryRolesCell";
import {
decisionBadgeVariant,
decisionLabel,
@@ -169,7 +170,7 @@ export const campaignDetailPageQuery = graphql`
id
email
fullName
role
...AccessEntryRolesCell_accessEntry
isAdmin
active
mfaStatus
@@ -721,7 +722,7 @@ function CampaignSourceCard({ source, isPendingActions }: { source: CampaignSour
</span>
</Td>
<Td>{edge.node.email || <NotAvailable />}</Td>
<Td>{edge.node.role || <NotAvailable />}</Td>
<AccessEntryRolesCell accessEntryKey={edge.node} />
<Td>{edge.node.isAdmin ? __("Yes") : __("No")}</Td>
<Td>
{edge.node.active == null

View File

@@ -56,7 +56,7 @@ const addScopeMutation = graphql`
id
email
fullName
role
roles
isAdmin
mfaStatus
lastLogin