Skip to content

feat(site): add WorkspacesButton component #10011

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Oct 5, 2023
Merged
Changes from 1 commit
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
f0a2aae
chore: Add OverflowY component
Parkreiner Oct 3, 2023
2420167
chore: Add PopoverContainer component
Parkreiner Oct 3, 2023
88d73af
chore: Add SearchBox
Parkreiner Oct 3, 2023
209eed4
feat: add WorkspacesButton
Parkreiner Oct 3, 2023
9300b11
chore: Install MUI utils package
Parkreiner Oct 3, 2023
2b13f1e
chore: integrate WorkspacesButton
Parkreiner Oct 3, 2023
e86207e
chore: reorganize files
Parkreiner Oct 3, 2023
5b249d5
fix: resolve hover state visual glitch
Parkreiner Oct 3, 2023
b1e1271
chore: Add story for OverflowY
Parkreiner Oct 3, 2023
b367495
fix: remove dynamic name from OverflowY story
Parkreiner Oct 3, 2023
5a34769
chore: update stories again
Parkreiner Oct 3, 2023
b9f6cb8
fix: remove all references to icons (for now)
Parkreiner Oct 3, 2023
1996e2b
refactor: move flex shrink to be OverflowY concern
Parkreiner Oct 4, 2023
5ecbbe6
fix: remove needless render key
Parkreiner Oct 4, 2023
55ab10a
fix: make sure popover closes before navigation
Parkreiner Oct 5, 2023
1a01127
refactor: clean up WorkspacesButton to use more native MUI
Parkreiner Oct 5, 2023
80928c6
Merge branch 'main' into mes/workspace-button-2
Parkreiner Oct 5, 2023
55c6061
fix: update integration into rest of view
Parkreiner Oct 5, 2023
5aea06f
fix: remove JS security concern
Parkreiner Oct 5, 2023
2aee4fe
refactor: parameterize button language
Parkreiner Oct 5, 2023
12ac56f
revert: undo sql/go file change
Parkreiner Oct 5, 2023
99f2656
fix: remove permissions dependency
Parkreiner Oct 5, 2023
430e30c
fix: simplify button prop types
Parkreiner Oct 5, 2023
ff37ab5
fix: lift data dependencies to page component
Parkreiner Oct 5, 2023
1b934ab
refactor: clean up props
Parkreiner Oct 5, 2023
84c6642
fix: update dependencies again for Storybook
Parkreiner Oct 5, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat: add WorkspacesButton
  • Loading branch information
Parkreiner committed Oct 3, 2023
commit 209eed45f7c8a946bcbe60c9454212ad9a6acdff
244 changes: 244 additions & 0 deletions site/src/components/WorkspacesButton/WorkspacesButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
import { ReactNode, useState } from "react";
import { useOrganizationId, usePermissions } from "hooks";

import { useQuery } from "@tanstack/react-query";
import { type Template } from "api/typesGenerated";
import { templates } from "api/queries/templates";
import { Link as RouterLink } from "react-router-dom";
import Box from "@mui/system/Box";
import Button from "@mui/material/Button";
import Link from "@mui/material/Link";
import AddIcon from "@mui/icons-material/AddOutlined";
import OpenIcon from "@mui/icons-material/OpenInNewOutlined";
import { Loader } from "components/Loader/Loader";
import { PopoverContainer } from "./PopoverContainer";
import { OverflowY } from "./OverflowY";
import { SearchBox } from "./SearchBox";
import { EmptyState } from "components/EmptyState/EmptyState";
import { Avatar } from "components/Avatar/Avatar";

const ICON_SIZE = 18;
const COLUMN_GAP = 1.5;

function sortTemplatesByUsersDesc(
templates: readonly Template[],
searchTerm: string,
) {
const allWhitespace = /^\s+$/.test(searchTerm);
if (allWhitespace) {
return templates;
}

const termMatcher = new RegExp(searchTerm.replaceAll(/[^\w]/g, "."), "i");
return templates
.filter((template) => termMatcher.test(template.display_name))
.sort((t1, t2) => t2.active_user_count - t1.active_user_count)
.slice(0, 10);
}

function WorkspaceResultsRow({ template }: { template: Template }) {
return (
<Link
key={template.id}
component={RouterLink}
// Sending user directly to workspace creation page for UX
// reasons; avoids extra clicks on the user's part
to={`/templates/${template.name}/workspace`}
sx={{
textDecoration: "none",
outline: "none",
"&:focus": {
backgroundColor: (theme) => theme.palette.action.focus,
},
"&:hover": {
backgroundColor: (theme) => theme.palette.action.hover,
},
}}
>
<Box
sx={{
display: "flex",
columnGap: COLUMN_GAP,
alignItems: "center",
paddingX: 2,
paddingY: 1,
overflowY: "hidden",
}}
>
<Avatar
src={template.icon}
fitImage
alt={template.display_name || "Coder template"}
sx={{
width: `${ICON_SIZE}px`,
height: `${ICON_SIZE}px`,
fontSize: `${ICON_SIZE * 0.5}px`,
fontWeight: 700,
}}
>
{template.display_name || "-"}
</Avatar>

<Box
sx={{
lineHeight: 1,
width: "100%",
overflow: "hidden",
color: "white",
}}
>
<Box
component="p"
sx={{
marginY: 0,
paddingBottom: 0.5,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{template.display_name || "[Unnamed]"}
</Box>

<Box
component="p"
sx={{
marginY: 0,
fontSize: 14,
color: (theme) => theme.palette.text.secondary,
}}
>
{/*
* There are some templates that have -1 as their user count –
* basically functioning like a null value in JS. Can safely just
* treat them as if they were 0.
*/}
{template.active_user_count <= 0
? "No"
: template.active_user_count}{" "}
developer
{template.active_user_count === 1 ? "" : "s"}
</Box>
</Box>
</Box>
</Link>
);
}

export function WorkspacesButton() {
const organizationId = useOrganizationId();
const permissions = usePermissions();

const templatesQuery = useQuery({
...templates(organizationId),

// Creating icons via the selector to guarantee icons array stays as stable
// as possible, and only changes when the query produces new data
select: (templates) => {
return {
list: templates,
icons: templates.map((t) => t.icon),
};
},
});

// Dataset should always be small enough that client-side filtering should be
// good enough. Can swap out down the line if it becomes an issue
const [searchTerm, setSearchTerm] = useState("");
const processed = sortTemplatesByUsersDesc(
templatesQuery.data?.list ?? [],
searchTerm,
);

let emptyState: ReactNode = undefined;
if (templatesQuery.data?.list.length === 0) {
emptyState = (
<EmptyState
message="No templates yet"
cta={
<Link to="/templates" component={RouterLink}>
Create one now.
</Link>
}
/>
);
} else if (processed.length === 0) {
emptyState = <EmptyState message="No templates match your text" />;
}

return (
<PopoverContainer
// Stopgap value until bug where string-based horizontal origin isn't
// being applied consistently can get figured out
originX={-115}
originY="bottom"
sx={{ display: "flex", flexFlow: "column nowrap" }}
anchorButton={
<Button startIcon={<AddIcon />} variant="contained">
Create Workspace&hellip;
</Button>
}
>
<SearchBox
value={searchTerm}
onValueChange={(newValue) => setSearchTerm(newValue)}
placeholder="Type/select a workspace template"
label="Template select for workspace"
sx={{ flexShrink: 0, columnGap: COLUMN_GAP }}
/>

<OverflowY
maxHeight={380}
sx={{
flexShrink: 1,
display: "flex",
flexFlow: "column nowrap",
paddingY: 1,
}}
>
{templatesQuery.isLoading ? (
<Loader size={14} />
) : (
<>
{processed.map((template) => (
<WorkspaceResultsRow key={template.id} template={template} />
))}

{emptyState}
</>
)}
</OverflowY>

{permissions.createTemplates && (
<Link
component={RouterLink}
to="/templates"
sx={{
outline: "none",
"&:focus": {
backgroundColor: (theme) => theme.palette.action.focus,
},
}}
>
<Box
sx={{
padding: 2,
display: "flex",
flexFlow: "row nowrap",
alignItems: "center",
columnGap: COLUMN_GAP,
borderTop: (theme) => `1px solid ${theme.palette.divider}`,
}}
>
<Box component="span" sx={{ width: `${ICON_SIZE}px` }}>
<OpenIcon
sx={{ fontSize: "16px", marginX: "auto", display: "block" }}
/>
</Box>
<span>See all templates</span>
</Box>
</Link>
)}
</PopoverContainer>
);
}