1
- import { useLayoutEffect , useRef , useState } from "react" ;
1
+ /**
2
+ * @file Defines the visual logic for the Roles cell in the Users page table.
3
+ *
4
+ * The previous implementation tried to dynamically truncate the number of roles
5
+ * that would get displayed in a cell, only truncating if there were more roles
6
+ * than room in the cell. But there was a problem – that information can't
7
+ * exist on the first render, because the DOM nodes haven't been made yet.
8
+ *
9
+ * The only way to avoid UI flickering was by juggling between useLayoutEffect
10
+ * for direct DOM node mutations for any renders that had new data, and normal
11
+ * state logic for all other renders. It was clunky, and required duplicating
12
+ * the logic in two places (making things easy to accidentally break), so we
13
+ * went with a simpler design. If we decide we really do need to display the
14
+ * users like that, though, know that it will be painful
15
+ */
2
16
import { useTheme } from "@emotion/react" ;
3
17
import { type User , type Role } from "api/typesGenerated" ;
4
18
@@ -7,76 +21,9 @@ import { Pill } from "components/Pill/Pill";
7
21
import TableCell from "@mui/material/TableCell" ;
8
22
import Stack from "@mui/material/Stack" ;
9
23
10
- const fallbackRole : Role = {
11
- name : "member" ,
12
- display_name : "Member" ,
13
- } as const ;
14
-
15
- const roleNamesByAccessLevel : readonly string [ ] = [
16
- "owner" ,
17
- "user-admin" ,
18
- "template-admin" ,
19
- "auditor" ,
20
- ] ;
21
-
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
- } > ;
34
-
35
- function getRoleDisplayInfo ( userRoles : readonly Role [ ] ) : RoleDisplayInfo {
36
- if ( userRoles . length === 0 ) {
37
- return {
38
- hasOwner : false ,
39
- roles : [ fallbackRole ] ,
40
- } ;
41
- }
42
-
43
- const matchedOwnerRole = userRoles . find ( ( role ) => role . name === "owner" ) ;
44
- if ( matchedOwnerRole !== undefined ) {
45
- return {
46
- hasOwner : true ,
47
- roles : [ matchedOwnerRole ] ,
48
- } ;
49
- }
50
-
51
- const sortedRoles = [ ...userRoles ] . sort ( ( r1 , r2 ) => {
52
- if ( r1 . name === r2 . name ) {
53
- return 0 ;
54
- }
55
-
56
- return r1 . name < r2 . name ? - 1 : 1 ;
57
- } ) ;
58
-
59
- return { hasOwner : false , roles : sortedRoles } ;
60
- }
61
-
62
- function getSelectedRoleNames ( roles : readonly Role [ ] ) {
63
- const roleNameSet = new Set ( roles . map ( ( role ) => role . name ) ) ;
64
- if ( roleNameSet . size === 0 ) {
65
- roleNameSet . add ( fallbackRole . name ) ;
66
- }
67
-
68
- return roleNameSet ;
69
- }
70
-
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` ;
75
- }
76
-
77
- type Props = {
24
+ type UserRoleCellProps = {
78
25
canEditUsers : boolean ;
79
- roles : undefined | readonly Role [ ] ;
26
+ allAvailableRoles : Role [ ] | undefined ;
80
27
user : User ;
81
28
isLoading : boolean ;
82
29
oidcRoleSyncEnabled : boolean ;
@@ -85,99 +32,24 @@ type Props = {
85
32
86
33
export function UserRoleCell ( {
87
34
canEditUsers,
88
- roles ,
35
+ allAvailableRoles ,
89
36
user,
90
37
isLoading,
91
38
oidcRoleSyncEnabled,
92
39
onUserRolesUpdate,
93
- } : Props ) {
40
+ } : UserRoleCellProps ) {
94
41
const theme = useTheme ( ) ;
95
42
96
- // Unless the user happens to be an owner, it is physically impossible for
97
- // React to know how many pills should be omitted for space reasons each time
98
- // a new set of roles comes in. Have to do a smoke-and-mirrors routine to help
99
- // mask that and avoid UI flickering
100
- const cellRef = useRef < HTMLDivElement > ( null ) ;
101
- const pillContainerRef = useRef < HTMLDivElement > ( null ) ;
102
- const overflowButtonRef = useRef < HTMLButtonElement > ( null ) ;
103
-
104
- /**
105
- * @todo – The logic only works properly on the first render - the
106
- * moment you update a user's permissions, the UI doesn't do anything, even
107
- * with the gnarly manual state sync in place. The cached update is
108
- * triggering, but not the
109
- *
110
- * Likely causes:
111
- * 1. Mutation logic isn't getting applied properly
112
- * 2. Trace through the parent component logic and see if I need to update
113
- * things to reference the roles prop instead of user.roles
114
- */
115
-
116
- const roleDisplayInfo = getRoleDisplayInfo ( user . roles ) ;
117
-
118
- // Have to do manual state syncs to make sure that cells change as roles get
119
- // updated; there isn't a good render key to use to simplify this, and the
120
- // MutationObserver API doesn't work with React's order of operations well
121
- // enough to avoid flickering - it's just not fast enough in the right ways
122
- const [ cachedUser , setCachedUser ] = useState ( user ) ;
123
- const [ rolesToTruncate , setRolesToTruncate ] = useState (
124
- roleDisplayInfo . hasOwner
125
- ? user . roles . length - roleDisplayInfo . roles . length
126
- : null ,
127
- ) ;
128
-
129
- if ( user !== cachedUser ) {
130
- const needTruncationSync =
131
- user . roles . length !== cachedUser . roles . length ||
132
- user . roles . every ( ( role , index ) => role === cachedUser . roles [ index ] ) ;
133
-
134
- setCachedUser ( user ) ;
135
- if ( needTruncationSync ) {
136
- setRolesToTruncate ( null ) ;
137
- }
138
- }
139
-
140
- // Mutates the contents of the pill container to hide overflowing content on
141
- // the first render, and then updates rolesToTruncate so that these overflow
142
- // calculations can be done with 100% pure state/props calculations for all
143
- // re-renders (at least until the roles list changes by content again)
144
- useLayoutEffect ( ( ) => {
145
- const cell = cellRef . current ;
146
- const pillContainer = pillContainerRef . current ;
147
- if ( rolesToTruncate !== null || cell === null || pillContainer === null ) {
148
- return ;
149
- }
150
-
151
- let nodesRemoved = 0 ;
152
- const childrenCopy = [ ...pillContainer . children ] ;
153
-
154
- for ( let i = childrenCopy . length - 1 ; i >= 0 ; i -- ) {
155
- const child = childrenCopy [ i ] as HTMLElement ;
156
- if ( pillContainer . clientWidth <= cell . clientWidth ) {
157
- break ;
158
- }
159
-
160
- // Can't remove child, because then React will freak out about DOM nodes
161
- // disappearing in ways it wasn't aware of; have to rely on CSS styling
162
- child . style . visibility = "none" ;
163
- nodesRemoved ++ ;
164
- }
165
-
166
- setRolesToTruncate ( nodesRemoved ) ;
167
- if ( overflowButtonRef . current !== null ) {
168
- const mutationText = getOverflowButtonText ( nodesRemoved ) ;
169
- overflowButtonRef . current . innerText = mutationText ;
170
- }
171
- } , [ rolesToTruncate ] ) ;
172
-
173
- const finalRoleList = roleDisplayInfo . roles ;
43
+ const [ mainDisplayRole = fallbackRole , ...extraRoles ] =
44
+ sortRolesByAccessLevel ( user . roles ?? [ ] ) ;
45
+ const hasOwnerRole = mainDisplayRole . name === "owner" ;
174
46
175
47
return (
176
- < TableCell ref = { cellRef } >
48
+ < TableCell >
177
49
< Stack direction = "row" spacing = { 1 } >
178
50
{ canEditUsers && (
179
51
< EditRolesButton
180
- roles = { sortRolesByAccessLevel ( roles ?? [ ] ) }
52
+ roles = { sortRolesByAccessLevel ( allAvailableRoles ?? [ ] ) }
181
53
selectedRoleNames = { getSelectedRoleNames ( user . roles ) }
182
54
isLoading = { isLoading }
183
55
userLoginType = { user . login_type }
@@ -193,43 +65,71 @@ export function UserRoleCell({
193
65
/>
194
66
) }
195
67
196
- < Stack direction = "row" spacing = { 1 } >
197
- < Stack direction = "row" spacing = { 1 } ref = { pillContainerRef } >
198
- { finalRoleList . map ( ( role ) => {
199
- const isOwnerRole = role . name === "owner" ;
200
-
201
- return (
202
- < Pill
203
- key = { role . name }
204
- text = { role . display_name }
205
- css = { {
206
- backgroundColor : isOwnerRole
207
- ? theme . palette . info . dark
208
- : theme . palette . background . paperLight ,
209
- borderColor : isOwnerRole
210
- ? theme . palette . info . light
211
- : theme . palette . divider ,
212
- } }
213
- />
214
- ) ;
215
- } ) }
216
- </ Stack >
217
-
218
- { /*
219
- * Have to render this, even when rolesToTruncate is null, in order
220
- * for the layoutEffect trick to work properly
221
- */ }
222
- { rolesToTruncate !== 0 && (
223
- < Pill
224
- text = { getOverflowButtonText ( rolesToTruncate ?? 0 ) }
225
- css = { {
226
- backgroundColor : theme . palette . background . paperLight ,
227
- borderColor : theme . palette . divider ,
228
- } }
229
- />
230
- ) }
231
- </ Stack >
68
+ < Pill
69
+ text = { mainDisplayRole . display_name }
70
+ css = { {
71
+ backgroundColor : hasOwnerRole
72
+ ? theme . palette . info . dark
73
+ : theme . palette . background . paperLight ,
74
+ borderColor : hasOwnerRole
75
+ ? theme . palette . info . light
76
+ : theme . palette . divider ,
77
+ } }
78
+ />
79
+
80
+ { extraRoles . length > 0 && < OverflowRolePill roles = { extraRoles } /> }
232
81
</ Stack >
233
82
</ TableCell >
234
83
) ;
235
84
}
85
+
86
+ type OverflowRolePillProps = {
87
+ roles : readonly Role [ ] ;
88
+ } ;
89
+
90
+ function OverflowRolePill ( { roles } : OverflowRolePillProps ) {
91
+ const theme = useTheme ( ) ;
92
+
93
+ return (
94
+ < Pill
95
+ text = { `+${ roles . length } more` }
96
+ css = { {
97
+ backgroundColor : theme . palette . background . paperLight ,
98
+ borderColor : theme . palette . divider ,
99
+ } }
100
+ />
101
+ ) ;
102
+ }
103
+
104
+ const fallbackRole : Role = {
105
+ name : "member" ,
106
+ display_name : "Member" ,
107
+ } as const ;
108
+
109
+ const roleNamesByAccessLevel : readonly string [ ] = [
110
+ "owner" ,
111
+ "user-admin" ,
112
+ "template-admin" ,
113
+ "auditor" ,
114
+ ] ;
115
+
116
+ function sortRolesByAccessLevel ( roles : Role [ ] ) {
117
+ if ( roles . length === 0 ) {
118
+ return roles ;
119
+ }
120
+
121
+ return [ ...roles ] . sort (
122
+ ( r1 , r2 ) =>
123
+ roleNamesByAccessLevel . indexOf ( r1 . name ) -
124
+ roleNamesByAccessLevel . indexOf ( r2 . name ) ,
125
+ ) ;
126
+ }
127
+
128
+ function getSelectedRoleNames ( roles : readonly Role [ ] ) {
129
+ const roleNameSet = new Set ( roles . map ( ( role ) => role . name ) ) ;
130
+ if ( roleNameSet . size === 0 ) {
131
+ roleNameSet . add ( fallbackRole . name ) ;
132
+ }
133
+
134
+ return roleNameSet ;
135
+ }
0 commit comments