Skip to content

Commit 420174b

Browse files
Add access roles column (#2062)
* Add Type column to access table * Remove old Project and Silo role columns * Remove duplicate columns Co-authored-by: David Crespo <david-crespo@users.noreply.github.com> * Add component for ExpandedCountWithDetails * Access roles refactor suggestions (#2089) * mostly move role badges logic out of cell * ListPlusCell * extract TipIcon and add one to Role column header --------- Co-authored-by: David Crespo <david.crespo@oxidecomputer.com>
1 parent fc860a2 commit 420174b

File tree

12 files changed

+160
-96
lines changed

12 files changed

+160
-96
lines changed

app/components/ListPlusCell.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
9+
import React from 'react'
10+
11+
import { Tooltip } from '~/ui/lib/Tooltip'
12+
13+
type ListPlusCellProps = {
14+
tooltipTitle: string
15+
children: React.ReactNode
16+
}
17+
18+
/**
19+
* Gives a count with a tooltip that expands to show details when the user hovers over it
20+
*/
21+
export const ListPlusCell = ({ tooltipTitle, children }: ListPlusCellProps) => {
22+
const [first, ...rest] = React.Children.toArray(children)
23+
const content = (
24+
<div>
25+
<div className="mb-2">{tooltipTitle}</div>
26+
{...rest}
27+
</div>
28+
)
29+
return (
30+
<div className="flex items-baseline gap-2">
31+
{first}
32+
{rest.length > 0 && (
33+
<Tooltip content={content} placement="bottom">
34+
<div className="text-mono-sm">+{rest.length}</div>
35+
</Tooltip>
36+
)}
37+
</div>
38+
)
39+
}

app/components/RoleBadgeCell.tsx

Lines changed: 0 additions & 30 deletions
This file was deleted.

app/forms/project-access.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export function ProjectAccessEditUserSideModal({
107107
form={form}
108108
formType="edit"
109109
resourceName="role"
110-
title={`Change role for ${name}`}
110+
title={`Change project role for ${name}`}
111111
onSubmit={({ roleName }) => {
112112
updatePolicy.mutate({
113113
path: { project },

app/forms/silo-access.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ export function SiloAccessEditUserSideModal({
102102
form={form}
103103
formType="edit"
104104
resourceName="role"
105-
title={`Change role for ${name}`}
105+
title={`Change silo role for ${name}`}
106106
onSubmit={({ roleName }) => {
107107
updatePolicy.mutate({
108108
body: updateRole({ identityId, identityType, roleName }, policy),

app/pages/SiloAccessPage.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,19 @@ import {
2323
import { Access24Icon } from '@oxide/design-system/icons/react'
2424

2525
import { HL } from '~/components/HL'
26-
import { RoleBadgeCell } from '~/components/RoleBadgeCell'
2726
import {
2827
SiloAccessAddUserSideModal,
2928
SiloAccessEditUserSideModal,
3029
} from '~/forms/silo-access'
3130
import { confirmDelete } from '~/stores/confirm-delete'
3231
import { getActionsCol } from '~/table/columns/action-col'
3332
import { Table } from '~/table/Table'
33+
import { Badge } from '~/ui/lib/Badge'
3434
import { Button } from '~/ui/lib/Button'
3535
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
3636
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
3737
import { TableActions, TableEmptyBox } from '~/ui/lib/Table'
38-
import { accessTypeLabel } from '~/util/access'
38+
import { accessTypeLabel, getBadgeColor } from '~/util/access'
3939
import { groupBy, isTruthy } from '~/util/array'
4040

4141
const EmptyState = ({ onClick }: { onClick: () => void }) => (
@@ -117,8 +117,11 @@ export function SiloAccessPage() {
117117
cell: (props) => accessTypeLabel(props.getValue()),
118118
}),
119119
colHelper.accessor('siloRole', {
120-
header: 'Silo role',
121-
cell: RoleBadgeCell,
120+
header: 'Role',
121+
cell: (props) => {
122+
const role = props.getValue()
123+
return role ? <Badge color={getBadgeColor(role)}>silo.{role}</Badge> : null
124+
},
122125
}),
123126
// TODO: tooltips on disabled elements explaining why
124127
getActionsCol((row: UserRow) => [

app/pages/project/access/ProjectAccessPage.tsx

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
apiQueryClient,
1515
byGroupThenName,
1616
deleteRole,
17-
getEffectiveRole,
17+
roleOrder,
1818
useApiMutation,
1919
useApiQueryClient,
2020
usePrefetchedApiQuery,
@@ -25,7 +25,7 @@ import {
2525
import { Access24Icon } from '@oxide/design-system/icons/react'
2626

2727
import { HL } from '~/components/HL'
28-
import { RoleBadgeCell } from '~/components/RoleBadgeCell'
28+
import { ListPlusCell } from '~/components/ListPlusCell'
2929
import {
3030
ProjectAccessAddUserSideModal,
3131
ProjectAccessEditUserSideModal,
@@ -34,12 +34,14 @@ import { getProjectSelector, useProjectSelector } from '~/hooks'
3434
import { confirmDelete } from '~/stores/confirm-delete'
3535
import { getActionsCol } from '~/table/columns/action-col'
3636
import { Table } from '~/table/Table'
37+
import { Badge } from '~/ui/lib/Badge'
3738
import { Button } from '~/ui/lib/Button'
3839
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
3940
import { PageHeader, PageTitle } from '~/ui/lib/PageHeader'
4041
import { TableActions, TableEmptyBox } from '~/ui/lib/Table'
41-
import { accessTypeLabel } from '~/util/access'
42-
import { groupBy, isTruthy } from '~/util/array'
42+
import { TipIcon } from '~/ui/lib/TipIcon'
43+
import { accessTypeLabel, getBadgeColor } from '~/util/access'
44+
import { groupBy, isTruthy, sortBy } from '~/util/array'
4345

4446
const EmptyState = ({ onClick }: { onClick: () => void }) => (
4547
<TableEmptyBox>
@@ -69,9 +71,8 @@ type UserRow = {
6971
id: string
7072
identityType: IdentityType
7173
name: string
72-
siloRole: RoleKey | undefined
7374
projectRole: RoleKey | undefined
74-
effectiveRole: RoleKey
75+
roleBadges: { roleSource: string; roleName: RoleKey }[]
7576
}
7677

7778
const colHelper = createColumnHelper<UserRow>()
@@ -92,26 +93,23 @@ export function ProjectAccessPage() {
9293
const rows = useMemo(() => {
9394
return groupBy(siloRows.concat(projectRows), (u) => u.id)
9495
.map(([userId, userAssignments]) => {
95-
const siloRole = userAssignments.find((a) => a.roleSource === 'silo')?.roleName
96-
const projectRole = userAssignments.find(
97-
(a) => a.roleSource === 'project'
98-
)?.roleName
96+
const { name, identityType } = userAssignments[0]
9997

100-
const roles = [siloRole, projectRole].filter(isTruthy)
98+
const siloAccessRow = userAssignments.find((a) => a.roleSource === 'silo')
99+
const projectAccessRow = userAssignments.find((a) => a.roleSource === 'project')
101100

102-
const { name, identityType } = userAssignments[0]
101+
const roleBadges = sortBy(
102+
[siloAccessRow, projectAccessRow].filter(isTruthy),
103+
(r) => roleOrder[r.roleName] // sorts strongest role first
104+
)
103105

104-
const row: UserRow = {
106+
return {
105107
id: userId,
106108
identityType,
107109
name,
108-
siloRole,
109-
projectRole,
110-
// we know there has to be at least one
111-
effectiveRole: getEffectiveRole(roles)!,
112-
}
113-
114-
return row
110+
projectRole: projectAccessRow?.roleName,
111+
roleBadges,
112+
} satisfies UserRow
115113
})
116114
.sort(byGroupThenName)
117115
}, [siloRows, projectRows])
@@ -132,14 +130,27 @@ export function ProjectAccessPage() {
132130
header: 'Type',
133131
cell: (props) => accessTypeLabel(props.getValue()),
134132
}),
135-
colHelper.accessor('siloRole', {
136-
header: 'Silo role',
137-
cell: RoleBadgeCell,
138-
}),
139-
colHelper.accessor('projectRole', {
140-
header: 'Project role',
141-
cell: RoleBadgeCell,
133+
colHelper.accessor('roleBadges', {
134+
header: () => (
135+
<span className="inline-flex items-center">
136+
Role
137+
<TipIcon className="ml-2">
138+
A user or group&apos;s effective role for this project is the strongest role
139+
on either the silo or project.
140+
</TipIcon>
141+
</span>
142+
),
143+
cell: (props) => (
144+
<ListPlusCell tooltipTitle="Other roles">
145+
{props.getValue().map(({ roleName, roleSource }) => (
146+
<Badge key={roleSource} color={getBadgeColor(roleName)}>
147+
{roleSource}.{roleName}
148+
</Badge>
149+
))}
150+
</ListPlusCell>
151+
),
142152
}),
153+
143154
// TODO: tooltips on disabled elements explaining why
144155
getActionsCol((row: UserRow) => [
145156
{

app/ui/lib/FieldLabel.tsx

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,7 @@
88
import cn from 'classnames'
99
import type { ElementType, PropsWithChildren } from 'react'
1010

11-
import { Question12Icon } from '@oxide/design-system/icons/react'
12-
13-
import { Tooltip } from '~/ui/lib/Tooltip'
11+
import { TipIcon } from './TipIcon'
1412

1513
interface FieldLabelProps {
1614
id: string
@@ -43,13 +41,7 @@ export const FieldLabel = ({
4341
</span>
4442
)}
4543
</Component>
46-
{tip && (
47-
<Tooltip content={tip} placement="top">
48-
<button className="svg:pointer-events-none" type="button">
49-
<Question12Icon className="text-quinary" />
50-
</button>
51-
</Tooltip>
52-
)}
44+
{tip && <TipIcon>{tip}</TipIcon>}
5345
</div>
5446
)
5547
}

app/ui/lib/TipIcon.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import cn from 'classnames'
9+
10+
import { Question12Icon } from '@oxide/design-system/icons/react'
11+
12+
import { Tooltip } from './Tooltip'
13+
14+
type TipIconProps = {
15+
children: React.ReactNode
16+
className?: string
17+
}
18+
export function TipIcon({ children, className }: TipIconProps) {
19+
return (
20+
<Tooltip content={children} placement="top">
21+
<button
22+
className={cn('inline-flex svg:pointer-events-none', className)}
23+
type="button"
24+
>
25+
<Question12Icon className="text-quinary" />
26+
</button>
27+
</Tooltip>
28+
)
29+
}

app/util/access.spec.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,15 @@
77
*/
88
import { expect, test } from 'vitest'
99

10-
import { accessTypeLabel } from './access'
10+
import { accessTypeLabel, getBadgeColor } from './access'
1111

1212
test('accessTypeLabel', () => {
1313
expect(accessTypeLabel('silo_group')).toEqual('Group')
1414
expect(accessTypeLabel('silo_user')).toEqual('User')
1515
})
16+
17+
test('getBadgeColor', () => {
18+
expect(getBadgeColor('admin')).toEqual('default')
19+
expect(getBadgeColor('collaborator')).toEqual('purple')
20+
expect(getBadgeColor('viewer')).toEqual('blue')
21+
})

app/util/access.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,18 @@
55
*
66
* Copyright Oxide Computer Company
77
*/
8+
89
import type { IdentityType } from '~/api'
10+
import type { BadgeColor } from '~/ui/lib/Badge'
911

1012
export const accessTypeLabel = (identityType: IdentityType) =>
1113
identityType === 'silo_group' ? 'Group' : 'User'
14+
15+
export const getBadgeColor = (role: 'admin' | 'collaborator' | 'viewer'): BadgeColor => {
16+
const badgeColor = {
17+
admin: 'default',
18+
collaborator: 'purple',
19+
viewer: 'blue',
20+
}
21+
return badgeColor[role] as BadgeColor
22+
}

0 commit comments

Comments
 (0)