1
- import { type PropsWithChildren , type ReactNode , useState } from "react" ;
2
- import { useTheme } from "@emotion/react" ;
3
- import { Language } from "./WorkspacesPageView" ;
4
-
1
+ import {
2
+ type PropsWithChildren ,
3
+ type ReactNode ,
4
+ useState ,
5
+ useRef ,
6
+ } from "react" ;
5
7
import { type Template } from "api/typesGenerated" ;
6
8
import { type UseQueryResult } from "react-query" ;
7
-
8
- import { Link as RouterLink } from "react-router-dom" ;
9
+ import {
10
+ Link as RouterLink ,
11
+ LinkProps as RouterLinkProps ,
12
+ } from "react-router-dom" ;
9
13
import Box from "@mui/system/Box" ;
10
14
import Button from "@mui/material/Button" ;
11
15
import Link from "@mui/material/Link" ;
12
16
import AddIcon from "@mui/icons-material/AddOutlined" ;
13
17
import OpenIcon from "@mui/icons-material/OpenInNewOutlined" ;
14
- import Typography from "@mui/material/Typography" ;
15
-
16
18
import { Loader } from "components/Loader/Loader" ;
17
19
import { OverflowY } from "components/OverflowY/OverflowY" ;
18
20
import { EmptyState } from "components/EmptyState/EmptyState" ;
19
21
import { Avatar } from "components/Avatar/Avatar" ;
20
22
import { SearchBox } from "./WorkspacesSearchBox" ;
21
- import {
22
- PopoverContainer ,
23
- PopoverLink ,
24
- } from "components/PopoverContainer/PopoverContainer" ;
23
+ import Popover from "@mui/material/Popover" ;
25
24
26
25
const ICON_SIZE = 18 ;
27
26
const COLUMN_GAP = 1.5 ;
28
27
29
- function sortTemplatesByUsersDesc (
30
- templates : readonly Template [ ] ,
31
- searchTerm : string ,
32
- ) {
33
- const allWhitespace = / ^ \s + $ / . test ( searchTerm ) ;
34
- if ( allWhitespace ) {
35
- return templates ;
36
- }
37
-
38
- const termMatcher = new RegExp ( searchTerm . replaceAll ( / [ ^ \w ] / g, "." ) , "i" ) ;
39
- return templates
40
- . filter (
41
- ( template ) =>
42
- termMatcher . test ( template . display_name ) ||
43
- termMatcher . test ( template . name ) ,
44
- )
45
- . sort ( ( t1 , t2 ) => t2 . active_user_count - t1 . active_user_count )
46
- . slice ( 0 , 10 ) ;
47
- }
48
-
49
- function WorkspaceResultsRow ( { template } : { template : Template } ) {
50
- const theme = useTheme ( ) ;
51
-
52
- return (
53
- < PopoverLink to = { `/templates/${ template . name } /workspace` } >
54
- < Box
55
- sx = { {
56
- display : "flex" ,
57
- columnGap : COLUMN_GAP ,
58
- alignItems : "center" ,
59
- paddingX : 2 ,
60
- paddingY : 1 ,
61
- overflowY : "hidden" ,
62
- } }
63
- >
64
- < Avatar
65
- src = { template . icon }
66
- fitImage
67
- alt = { template . display_name || "Coder template" }
68
- sx = { {
69
- width : `${ ICON_SIZE } px` ,
70
- height : `${ ICON_SIZE } px` ,
71
- fontSize : `${ ICON_SIZE * 0.5 } px` ,
72
- fontWeight : 700 ,
73
- } }
74
- >
75
- { template . display_name || "-" }
76
- </ Avatar >
77
-
78
- < Box
79
- sx = { {
80
- lineHeight : 1 ,
81
- width : "100%" ,
82
- overflow : "hidden" ,
83
- color : "white" ,
84
- } }
85
- >
86
- < Typography
87
- component = "p"
88
- sx = { { marginY : 0 , paddingBottom : 0.5 , lineHeight : 1 } }
89
- noWrap
90
- >
91
- { template . display_name || template . name || "[Unnamed]" }
92
- </ Typography >
93
-
94
- < Box
95
- component = "p"
96
- sx = { {
97
- marginY : 0 ,
98
- fontSize : 14 ,
99
- color : theme . palette . text . secondary ,
100
- } }
101
- >
102
- { /*
103
- * There are some templates that have -1 as their user count –
104
- * basically functioning like a null value in JS. Can safely just
105
- * treat them as if they were 0.
106
- */ }
107
- { template . active_user_count <= 0
108
- ? "No"
109
- : template . active_user_count } { " " }
110
- developer
111
- { template . active_user_count === 1 ? "" : "s" }
112
- </ Box >
113
- </ Box >
114
- </ Box >
115
- </ PopoverLink >
116
- ) ;
117
- }
118
-
119
28
type TemplatesQuery = UseQueryResult < Template [ ] > ;
120
29
121
30
type WorkspacesButtonProps = PropsWithChildren < {
@@ -128,13 +37,14 @@ export function WorkspacesButton({
128
37
templatesFetchStatus,
129
38
templates,
130
39
} : WorkspacesButtonProps ) {
131
- const theme = useTheme ( ) ;
132
-
133
40
// Dataset should always be small enough that client-side filtering should be
134
41
// good enough. Can swap out down the line if it becomes an issue
135
42
const [ searchTerm , setSearchTerm ] = useState ( "" ) ;
136
43
const processed = sortTemplatesByUsersDesc ( templates ?? [ ] , searchTerm ) ;
137
44
45
+ const anchorRef = useRef < HTMLButtonElement > ( null ) ;
46
+ const [ isOpen , setIsOpen ] = useState ( false ) ;
47
+
138
48
let emptyState : ReactNode = undefined ;
139
49
if ( templates ?. length === 0 ) {
140
50
emptyState = (
@@ -152,75 +62,186 @@ export function WorkspacesButton({
152
62
}
153
63
154
64
return (
155
- < PopoverContainer
156
- // Stopgap value until bug where string-based horizontal origin isn't
157
- // being applied consistently can get figured out
158
- originX = { - 115 }
159
- originY = "bottom"
160
- sx = { { display : "flex" , flexFlow : "column nowrap" } }
161
- anchorButton = {
162
- < Button startIcon = { < AddIcon /> } variant = "contained" >
163
- { children }
164
- </ Button >
165
- }
166
- >
167
- < SearchBox
168
- value = { searchTerm }
169
- onValueChange = { ( newValue ) => setSearchTerm ( newValue ) }
170
- placeholder = "Type/select a workspace template"
171
- label = "Template select for workspace"
172
- sx = { { flexShrink : 0 , columnGap : COLUMN_GAP } }
173
- />
174
-
175
- < OverflowY
176
- maxHeight = { 380 }
177
- sx = { {
178
- display : "flex" ,
179
- flexFlow : "column nowrap" ,
180
- paddingY : 1 ,
65
+ < >
66
+ < Button
67
+ startIcon = { < AddIcon /> }
68
+ variant = "contained"
69
+ ref = { anchorRef }
70
+ onClick = { ( ) => {
71
+ setIsOpen ( true ) ;
181
72
} }
182
73
>
183
- { templatesFetchStatus === "loading" ? (
184
- < Loader size = { 14 } />
185
- ) : (
186
- < >
187
- { processed . map ( ( template ) => (
188
- < WorkspaceResultsRow key = { template . id } template = { template } />
189
- ) ) }
190
-
191
- { emptyState }
192
- </ >
193
- ) }
194
- </ OverflowY >
195
-
196
- < Link
197
- component = { RouterLink }
198
- to = "/templates"
199
- sx = { {
200
- outline : "none" ,
201
- "&:focus" : {
202
- backgroundColor : theme . palette . action . focus ,
203
- } ,
74
+ { children }
75
+ </ Button >
76
+ < Popover
77
+ disablePortal
78
+ open = { isOpen }
79
+ onClose = { ( ) => setIsOpen ( false ) }
80
+ anchorEl = { anchorRef . current }
81
+ anchorOrigin = { {
82
+ vertical : "bottom" ,
83
+ horizontal : "right" ,
84
+ } }
85
+ transformOrigin = { {
86
+ vertical : "top" ,
87
+ horizontal : "right" ,
204
88
} }
89
+ css = { ( theme ) => ( {
90
+ marginTop : theme . spacing ( 1 ) ,
91
+ "& .MuiPaper-root" : {
92
+ width : theme . spacing ( 40 ) ,
93
+ } ,
94
+ } ) }
205
95
>
206
- < Box
96
+ < SearchBox
97
+ value = { searchTerm }
98
+ onValueChange = { ( newValue ) => setSearchTerm ( newValue ) }
99
+ placeholder = "Type/select a workspace template"
100
+ label = "Template select for workspace"
101
+ sx = { { flexShrink : 0 , columnGap : COLUMN_GAP } }
102
+ />
103
+
104
+ < OverflowY
105
+ maxHeight = { 380 }
207
106
sx = { {
208
- padding : 2 ,
209
107
display : "flex" ,
210
- flexFlow : "row nowrap" ,
211
- alignItems : "center" ,
212
- columnGap : COLUMN_GAP ,
213
- borderTop : `1px solid ${ theme . palette . divider } ` ,
108
+ flexDirection : "column" ,
109
+ paddingY : 1 ,
214
110
} }
215
111
>
216
- < Box component = "span" sx = { { width : `${ ICON_SIZE } px` } } >
217
- < OpenIcon
218
- sx = { { fontSize : "16px" , marginX : "auto" , display : "block" } }
219
- />
220
- </ Box >
221
- < span > { Language . seeAllTemplates } </ span >
112
+ { templatesFetchStatus === "loading" ? (
113
+ < Loader size = { 14 } />
114
+ ) : (
115
+ < >
116
+ { processed . map ( ( template ) => (
117
+ < WorkspaceResultsRow key = { template . id } template = { template } />
118
+ ) ) }
119
+
120
+ { emptyState }
121
+ </ >
122
+ ) }
123
+ </ OverflowY >
124
+
125
+ < Box
126
+ css = { ( theme ) => ( {
127
+ padding : theme . spacing ( 1 , 0 ) ,
128
+ borderTop : `1px solid ${ theme . palette . divider } ` ,
129
+ } ) }
130
+ >
131
+ < PopoverLink
132
+ to = "/templates"
133
+ css = { ( theme ) => ( {
134
+ display : "flex" ,
135
+ alignItems : "center" ,
136
+ columnGap : theme . spacing ( COLUMN_GAP ) ,
137
+
138
+ color : theme . palette . primary . main ,
139
+ } ) }
140
+ >
141
+ < OpenIcon css = { { width : 14 , height : 14 } } />
142
+ < span > See all templates</ span >
143
+ </ PopoverLink >
222
144
</ Box >
223
- </ Link >
224
- </ PopoverContainer >
145
+ </ Popover >
146
+ </ >
147
+ ) ;
148
+ }
149
+
150
+ function WorkspaceResultsRow ( { template } : { template : Template } ) {
151
+ return (
152
+ < PopoverLink
153
+ to = { `/templates/${ template . name } /workspace` }
154
+ css = { ( theme ) => ( {
155
+ display : "flex" ,
156
+ gap : theme . spacing ( COLUMN_GAP ) ,
157
+ alignItems : "center" ,
158
+ } ) }
159
+ >
160
+ < Avatar
161
+ src = { template . icon }
162
+ fitImage
163
+ alt = { template . display_name || "Coder template" }
164
+ sx = { {
165
+ width : `${ ICON_SIZE } px` ,
166
+ height : `${ ICON_SIZE } px` ,
167
+ fontSize : `${ ICON_SIZE * 0.5 } px` ,
168
+ fontWeight : 700 ,
169
+ } }
170
+ >
171
+ { template . display_name || "-" }
172
+ </ Avatar >
173
+
174
+ < Box
175
+ css = { ( theme ) => ( {
176
+ color : theme . palette . text . primary ,
177
+ display : "flex" ,
178
+ flexDirection : "column" ,
179
+ lineHeight : "140%" ,
180
+ fontSize : 14 ,
181
+ overflow : "hidden" ,
182
+ } ) }
183
+ >
184
+ < span css = { { whiteSpace : "nowrap" , textOverflow : "ellipsis" } } >
185
+ { template . display_name || template . name || "[Unnamed]" }
186
+ </ span >
187
+ < span
188
+ css = { ( theme ) => ( {
189
+ fontSize : 13 ,
190
+ color : theme . palette . text . secondary ,
191
+ } ) }
192
+ >
193
+ { /*
194
+ * There are some templates that have -1 as their user count –
195
+ * basically functioning like a null value in JS. Can safely just
196
+ * treat them as if they were 0.
197
+ */ }
198
+ { template . active_user_count <= 0 ? "No" : template . active_user_count } { " " }
199
+ developer
200
+ { template . active_user_count === 1 ? "" : "s" }
201
+ </ span >
202
+ </ Box >
203
+ </ PopoverLink >
204
+ ) ;
205
+ }
206
+
207
+ function PopoverLink ( props : RouterLinkProps ) {
208
+ return (
209
+ < RouterLink
210
+ { ...props }
211
+ css = { ( theme ) => ( {
212
+ color : theme . palette . text . primary ,
213
+ padding : theme . spacing ( 1 , 2 ) ,
214
+ fontSize : 14 ,
215
+ outline : "none" ,
216
+ textDecoration : "none" ,
217
+ "&:focus" : {
218
+ backgroundColor : theme . palette . action . focus ,
219
+ } ,
220
+ "&:hover" : {
221
+ textDecoration : "none" ,
222
+ backgroundColor : theme . palette . action . hover ,
223
+ } ,
224
+ } ) }
225
+ />
225
226
) ;
226
227
}
228
+
229
+ function sortTemplatesByUsersDesc (
230
+ templates : readonly Template [ ] ,
231
+ searchTerm : string ,
232
+ ) {
233
+ const allWhitespace = / ^ \s + $ / . test ( searchTerm ) ;
234
+ if ( allWhitespace ) {
235
+ return templates ;
236
+ }
237
+
238
+ const termMatcher = new RegExp ( searchTerm . replaceAll ( / [ ^ \w ] / g, "." ) , "i" ) ;
239
+ return templates
240
+ . filter (
241
+ ( template ) =>
242
+ termMatcher . test ( template . display_name ) ||
243
+ termMatcher . test ( template . name ) ,
244
+ )
245
+ . sort ( ( t1 , t2 ) => t2 . active_user_count - t1 . active_user_count )
246
+ . slice ( 0 , 10 ) ;
247
+ }
0 commit comments