diff --git a/app/components/AccessBadge.tsx b/app/components/AccessBadge.tsx index 90fe221423..7b24661c29 100644 --- a/app/components/AccessBadge.tsx +++ b/app/components/AccessBadge.tsx @@ -10,7 +10,7 @@ import type { RoleKey } from '~/api' import { Badge } from '~/ui/lib/Badge' import { getBadgeColor } from '~/util/access' -type AccessBadgeProps = { labelPrefix: 'silo' | 'project'; role: RoleKey } +type AccessBadgeProps = { labelPrefix: string; role: RoleKey } export const AccessBadge = ({ labelPrefix, role }: AccessBadgeProps) => ( diff --git a/app/components/ExpandedCountWithDetails.tsx b/app/components/ExpandedCountWithDetails.tsx deleted file mode 100644 index 991ae781e4..0000000000 --- a/app/components/ExpandedCountWithDetails.tsx +++ /dev/null @@ -1,36 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ - -import { Tooltip } from '~/ui/lib/Tooltip' - -type ExpandedCountWithDetailsProps = { - count: number - title: string - details: React.ReactNode -} - -/** - * Gives a count with a tooltip that expands to show details when the user hovers over it - */ -export const ExpandedCountWithDetails = ({ - count, - title, - details, -}: ExpandedCountWithDetailsProps) => { - const content = ( -
-
{title}
- {details} -
- ) - return ( - -
+{count}
-
- ) -} diff --git a/app/components/ListPlusCell.tsx b/app/components/ListPlusCell.tsx new file mode 100644 index 0000000000..a5e1e80914 --- /dev/null +++ b/app/components/ListPlusCell.tsx @@ -0,0 +1,39 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import React from 'react' + +import { Tooltip } from '~/ui/lib/Tooltip' + +type ListPlusCellProps = { + tooltipTitle: string + children: React.ReactNode +} + +/** + * Gives a count with a tooltip that expands to show details when the user hovers over it + */ +export const ListPlusCell = ({ tooltipTitle, children }: ListPlusCellProps) => { + const [first, ...rest] = React.Children.toArray(children) + const content = ( +
+
{tooltipTitle}
+ {...rest} +
+ ) + return ( +
+ {first} + {rest.length > 0 && ( + +
+{rest.length}
+
+ )} +
+ ) +} diff --git a/app/components/ProjectAccessRolesCell.tsx b/app/components/ProjectAccessRolesCell.tsx deleted file mode 100644 index 969be75ffb..0000000000 --- a/app/components/ProjectAccessRolesCell.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/* - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, you can obtain one at https://mozilla.org/MPL/2.0/. - * - * Copyright Oxide Computer Company - */ -import type { CellContext } from '@tanstack/react-table' - -import type { RoleKey } from '@oxide/api' - -import { AccessBadge } from './AccessBadge' -import { ExpandedCountWithDetails } from './ExpandedCountWithDetails' - -/** - * Highlight the "effective" role, providing a tooltip for the alternate role. - * - * Example: User has collab on silo and viewer on project. Collab supersedes - * because it is the "stronger" role, i.e., it strictly includes the perms on - * viewer. So collab is highlighted as the "effective" role. - */ -export const ProjectAccessRolesCell = < - RowData extends { projectRole?: RoleKey; siloRole?: RoleKey }, ->( - info: CellContext -) => { - const effectiveRoleString = info.getValue() - if (!effectiveRoleString) return null - - const siloRole = info.row.original.siloRole - const formattedSiloRole = siloRole ? ( - - ) : undefined - - const projectRole = info.row.original.projectRole - const formattedProjectRole = projectRole ? ( - - ) : undefined - - const effectiveRoleIsSiloRole = effectiveRoleString === siloRole - const effectiveRole = effectiveRoleIsSiloRole ? formattedSiloRole : formattedProjectRole - const hasAlternateRole = Boolean(siloRole && projectRole) - const alternateRole = effectiveRoleIsSiloRole ? formattedProjectRole : formattedSiloRole - - return ( -
- {effectiveRole} - {hasAlternateRole && ( - - )} -
- ) -} diff --git a/app/pages/project/access/ProjectAccessPage.tsx b/app/pages/project/access/ProjectAccessPage.tsx index 4d7aa7ffc5..3e93d857da 100644 --- a/app/pages/project/access/ProjectAccessPage.tsx +++ b/app/pages/project/access/ProjectAccessPage.tsx @@ -14,7 +14,7 @@ import { apiQueryClient, byGroupThenName, deleteRole, - getEffectiveRole, + roleOrder, useApiMutation, useApiQueryClient, usePrefetchedApiQuery, @@ -24,8 +24,9 @@ import { } from '@oxide/api' import { Access24Icon } from '@oxide/design-system/icons/react' +import { AccessBadge } from '~/components/AccessBadge' import { HL } from '~/components/HL' -import { ProjectAccessRolesCell } from '~/components/ProjectAccessRolesCell' +import { ListPlusCell } from '~/components/ListPlusCell' import { ProjectAccessAddUserSideModal, ProjectAccessEditUserSideModal, @@ -39,7 +40,7 @@ import { EmptyMessage } from '~/ui/lib/EmptyMessage' import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' import { TableActions, TableEmptyBox } from '~/ui/lib/Table' import { accessTypeLabel } from '~/util/access' -import { groupBy, isTruthy } from '~/util/array' +import { groupBy, isTruthy, sortBy } from '~/util/array' const EmptyState = ({ onClick }: { onClick: () => void }) => ( @@ -69,9 +70,8 @@ type UserRow = { id: string identityType: IdentityType name: string - siloRole: RoleKey | undefined projectRole: RoleKey | undefined - effectiveRole: RoleKey + roleBadges: { roleSource: string; roleName: RoleKey }[] } const colHelper = createColumnHelper() @@ -92,26 +92,23 @@ export function ProjectAccessPage() { const rows = useMemo(() => { return groupBy(siloRows.concat(projectRows), (u) => u.id) .map(([userId, userAssignments]) => { - const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName - const projectRole = userAssignments.find( - (a) => a.roleSource === 'project' - )?.roleName + const { name, identityType } = userAssignments[0] - const roles = [siloRole, projectRole].filter(isTruthy) + const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo') + const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project') - const { name, identityType } = userAssignments[0] + const roleBadges = sortBy( + [siloAccessRow, projectAccessRow].filter(isTruthy), + (r) => roleOrder[r.roleName] // sorts strongest role first + ) - const row: UserRow = { + return { id: userId, identityType, name, - siloRole, - projectRole, - // we know there has to be at least one - effectiveRole: getEffectiveRole(roles)!, - } - - return row + projectRole: projectAccessRow?.roleName, + roleBadges, + } satisfies UserRow }) .sort(byGroupThenName) }, [siloRows, projectRows]) @@ -132,7 +129,16 @@ export function ProjectAccessPage() { header: 'Type', cell: (props) => accessTypeLabel(props.getValue()), }), - colHelper.accessor('effectiveRole', { header: 'Role', cell: ProjectAccessRolesCell }), + colHelper.accessor('roleBadges', { + header: 'Role', + cell: (props) => ( + + {props.getValue().map(({ roleName, roleSource }) => ( + + ))} + + ), + }), // TODO: tooltips on disabled elements explaining why getActionsCol((row: UserRow) => [