Skip to content

Commit 209eed4

Browse files
committed
feat: add WorkspacesButton
1 parent 88d73af commit 209eed4

File tree

1 file changed

+244
-0
lines changed

1 file changed

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

0 commit comments

Comments
 (0)