1
- import { useState } from "react" ;
1
+ import { useLayoutEffect , useRef , useState } from "react" ;
2
2
import { useTheme } from "@emotion/react" ;
3
3
import { type User , type Role } from "api/typesGenerated" ;
4
4
@@ -7,35 +7,56 @@ import { Pill } from "components/Pill/Pill";
7
7
import TableCell from "@mui/material/TableCell" ;
8
8
import Stack from "@mui/material/Stack" ;
9
9
10
- const roleNameDisplayOrder : readonly string [ ] = [
10
+ const fallbackRole : Role = {
11
+ name : "member" ,
12
+ display_name : "Member" ,
13
+ } as const ;
14
+
15
+ const roleNamesByAccessLevel : readonly string [ ] = [
11
16
"owner" ,
12
17
"user-admin" ,
13
18
"template-admin" ,
14
19
"auditor" ,
15
20
] ;
16
21
17
- const fallbackRole : Role = {
18
- name : "member" ,
19
- display_name : "Member" ,
20
- } as const ;
22
+ function sortRolesByAccessLevel ( roles : readonly Role [ ] ) {
23
+ return [ ...roles ] . sort (
24
+ ( r1 , r2 ) =>
25
+ roleNamesByAccessLevel . indexOf ( r1 . name ) -
26
+ roleNamesByAccessLevel . indexOf ( r2 . name ) ,
27
+ ) ;
28
+ }
29
+
30
+ type RoleDisplayInfo = Readonly < {
31
+ hasOwner : boolean ;
32
+ roles : readonly Role [ ] ;
33
+ } > ;
21
34
22
- function getPillRoleList ( userRoles : readonly Role [ ] ) : readonly Role [ ] {
35
+ function getRoleDisplayInfo ( userRoles : readonly Role [ ] ) : RoleDisplayInfo {
23
36
if ( userRoles . length === 0 ) {
24
- return [ fallbackRole ] ;
37
+ return {
38
+ hasOwner : false ,
39
+ roles : [ fallbackRole ] ,
40
+ } ;
25
41
}
26
42
27
43
const matchedOwnerRole = userRoles . find ( ( role ) => role . name === "owner" ) ;
28
44
if ( matchedOwnerRole !== undefined ) {
29
- return [ matchedOwnerRole ] ;
45
+ return {
46
+ hasOwner : true ,
47
+ roles : [ matchedOwnerRole ] ,
48
+ } ;
30
49
}
31
50
32
- return [ ...userRoles ] . sort ( ( r1 , r2 ) => {
51
+ const sortedRoles = [ ...userRoles ] . sort ( ( r1 , r2 ) => {
33
52
if ( r1 . name === r2 . name ) {
34
53
return 0 ;
35
54
}
36
55
37
56
return r1 . name < r2 . name ? - 1 : 1 ;
38
57
} ) ;
58
+
59
+ return { hasOwner : false , roles : sortedRoles } ;
39
60
}
40
61
41
62
function getSelectedRoleNames ( roles : readonly Role [ ] ) {
@@ -47,12 +68,10 @@ function getSelectedRoleNames(roles: readonly Role[]) {
47
68
return roleNameSet ;
48
69
}
49
70
50
- function sortRolesByAccessLevel ( roles : readonly Role [ ] ) {
51
- return [ ...roles ] . sort (
52
- ( r1 , r2 ) =>
53
- roleNameDisplayOrder . indexOf ( r1 . name ) -
54
- roleNameDisplayOrder . indexOf ( r2 . name ) ,
55
- ) ;
71
+ // Defined as a function to ensure that render approach and mutation approaches
72
+ // in the component don't get out of sync
73
+ function getOverflowButtonText ( overflowCount : number ) {
74
+ return `+${ overflowCount } more` ;
56
75
}
57
76
58
77
type Props = {
@@ -74,13 +93,59 @@ export function UserRoleCell({
74
93
} : Props ) {
75
94
const theme = useTheme ( ) ;
76
95
77
- const pillRoleList = getPillRoleList ( user . roles ) ;
78
- const [ rolesTruncated , setRolesTruncated ] = useState (
79
- user . roles . length - pillRoleList . length ,
96
+ const cellRef = useRef < HTMLDivElement > ( null ) ;
97
+ const pillContainerRef = useRef < HTMLDivElement > ( null ) ;
98
+ const overflowButtonRef = useRef < HTMLButtonElement > ( null ) ;
99
+
100
+ // Unless the user happens to be an owner, it is physically impossible for
101
+ // React to know how many pills should be omitted for space reasons on the
102
+ // first render, because that info comes from real DOM nodes, which can't
103
+ // exist until the first render pass. Have to do a smoke-and-mirrors routine
104
+ // to help mask that and avoid UI flickering
105
+ const roleDisplayInfo = getRoleDisplayInfo ( user . roles ) ;
106
+ const [ rolesToTruncate , setRolesToTruncate ] = useState (
107
+ roleDisplayInfo . hasOwner
108
+ ? user . roles . length - roleDisplayInfo . roles . length
109
+ : null ,
80
110
) ;
81
111
112
+ // Mutates the contents of the pill container to hide overflowing content on
113
+ // the first render, and then updates rolesToTruncate so that these overflow
114
+ // calculations can be done with 100% pure state/props calculations for all
115
+ // re-renders
116
+ useLayoutEffect ( ( ) => {
117
+ const cell = cellRef . current ;
118
+ const pillContainer = pillContainerRef . current ;
119
+ if ( roleDisplayInfo . hasOwner || cell === null || pillContainer === null ) {
120
+ return ;
121
+ }
122
+
123
+ let nodesRemoved = 0 ;
124
+ const childrenCopy = [ ...pillContainer . children ] ;
125
+
126
+ for ( let i = childrenCopy . length - 1 ; i >= 0 ; i -- ) {
127
+ const child = childrenCopy [ i ] as HTMLElement ;
128
+ if ( pillContainer . clientWidth <= cell . clientWidth ) {
129
+ break ;
130
+ }
131
+
132
+ // Can't remove child, because then React will freak out about DOM nodes
133
+ // disappearing in ways it wasn't aware of; have to rely on CSS styling
134
+ child . style . visibility = "none" ;
135
+ nodesRemoved ++ ;
136
+ }
137
+
138
+ setRolesToTruncate ( nodesRemoved ) ;
139
+ if ( overflowButtonRef . current !== null ) {
140
+ const mutationText = getOverflowButtonText ( nodesRemoved ) ;
141
+ overflowButtonRef . current . innerText = mutationText ;
142
+ }
143
+ } , [ roleDisplayInfo . hasOwner ] ) ;
144
+
145
+ const finalRoleList = roleDisplayInfo . roles ;
146
+
82
147
return (
83
- < TableCell >
148
+ < TableCell ref = { cellRef } >
84
149
< Stack direction = "row" spacing = { 1 } >
85
150
{ canEditUsers && (
86
151
< EditRolesButton
@@ -100,23 +165,35 @@ export function UserRoleCell({
100
165
/>
101
166
) }
102
167
103
- { pillRoleList . map ( ( role ) => {
104
- const isOwnerRole = role . name === "owner" ;
105
- const { palette } = theme ;
106
-
107
- return (
108
- < Pill
109
- key = { role . name }
110
- text = { role . display_name }
111
- css = { {
112
- backgroundColor : isOwnerRole
113
- ? palette . info . dark
114
- : palette . background . paperLight ,
115
- borderColor : isOwnerRole ? palette . info . light : palette . divider ,
116
- } }
117
- />
118
- ) ;
119
- } ) }
168
+ < Stack direction = "row" spacing = { 1 } >
169
+ < Stack direction = "row" spacing = { 1 } ref = { pillContainerRef } >
170
+ { finalRoleList . map ( ( role ) => {
171
+ const isOwnerRole = role . name === "owner" ;
172
+ const { palette } = theme ;
173
+
174
+ return (
175
+ < Pill
176
+ key = { role . name }
177
+ text = { role . display_name }
178
+ css = { {
179
+ backgroundColor : isOwnerRole
180
+ ? palette . info . dark
181
+ : palette . background . paperLight ,
182
+ borderColor : isOwnerRole
183
+ ? palette . info . light
184
+ : palette . divider ,
185
+ } }
186
+ />
187
+ ) ;
188
+ } ) }
189
+ </ Stack >
190
+
191
+ { rolesToTruncate !== 0 && (
192
+ < button ref = { overflowButtonRef } >
193
+ { getOverflowButtonText ( rolesToTruncate ?? 0 ) }
194
+ </ button >
195
+ ) }
196
+ </ Stack >
120
197
</ Stack >
121
198
</ TableCell >
122
199
) ;
0 commit comments