refactor(site): migrate WorkspacesButton to Combobox (#27723)

Migrates the New workspace template picker off MUI Link, Emotion `css`,
and the custom Popover/MenuSearch stack onto the shared Combobox used by
filters.

Selecting a template still navigates to create a workspace. The list
max-height uses the popover's available height so the footer stays
visible and scrolling works on short viewports.
This commit is contained in:
Jake Howell
2026-08-06 01:28:25 +00:00
committed by GitHub
parent db4b6dc556
commit a79031a3f5
2 changed files with 104 additions and 171 deletions
-8
View File
@@ -1,8 +0,0 @@
import type { FC } from "react";
import {
SearchField,
type SearchFieldProps,
} from "#/components/SearchField/SearchField";
export const MenuSearch: FC<SearchFieldProps> = (props) => {
return <SearchField className="w-full [&_input]:rounded-none" {...props} />;
};
+104 -163
View File
@@ -1,24 +1,22 @@
import Link from "@mui/material/Link";
import { ExternalLinkIcon } from "lucide-react";
import { type FC, type ReactNode, useState } from "react";
import { type FC, type ReactNode, useMemo } from "react";
import type { UseQueryResult } from "react-query";
import {
Link as RouterLink,
type LinkProps as RouterLinkProps,
} from "react-router";
import { Link as RouterLink, useNavigate } from "react-router";
import type { Template } from "#/api/typesGenerated";
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
import { Avatar } from "#/components/Avatar/Avatar";
import { Button } from "#/components/Button/Button";
import { Loader } from "#/components/Loader/Loader";
import { MenuSearch } from "#/components/Menu/MenuSearch";
import { OverflowY } from "#/components/OverflowY/OverflowY";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "#/components/Popover/Popover";
import { SearchEmpty } from "#/components/Search/Search";
Combobox,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxTrigger,
} from "#/components/Combobox/Combobox";
import { Link } from "#/components/Link/Link";
import { Spinner } from "#/components/Spinner/Spinner";
import { linkToTemplate, useLinks } from "#/modules/navigation";
type TemplatesQuery = UseQueryResult<Template[]>;
@@ -34,177 +32,120 @@ export const WorkspacesButton: FC<WorkspacesButtonProps> = ({
templatesFetchStatus,
templates,
}) => {
const navigate = useNavigate();
const getLink = useLinks();
// 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(templates ?? [], searchTerm);
let emptyState: ReactNode;
if (templates?.length === 0) {
emptyState = (
<SearchEmpty>
No templates yet.{" "}
<Link to="/templates" component={RouterLink}>
Create one now.
</Link>
</SearchEmpty>
);
} else if (processed.length === 0) {
emptyState = <SearchEmpty>No templates found</SearchEmpty>;
}
const sortedTemplates = useMemo(
() => sortTemplatesByUsersDesc(templates ?? []),
[templates],
);
return (
<Popover>
<PopoverTrigger asChild>
<Combobox
onValueChange={(templateId) => {
if (!templateId || !templates) {
return;
}
const template = templates.find((t) => t.id === templateId);
if (!template) {
return;
}
navigate(
`${getLink(linkToTemplate(template.organization_name, template.name))}/workspace`,
);
}}
>
<ComboboxTrigger asChild>
<Button size="lg">
{children}
<ChevronDownIcon />
</Button>
</PopoverTrigger>
<PopoverContent
</ComboboxTrigger>
<ComboboxContent
align="end"
className="bg-surface-secondary border-surface-quaternary w-[320px] overflow-hidden flex flex-col"
className="w-[320px] max-w-[320px] overflow-hidden bg-surface-secondary border-surface-quaternary"
>
<MenuSearch
value={searchTerm}
autoFocus
onChange={setSearchTerm}
<ComboboxInput
placeholder="Type/select a workspace template"
aria-label="Template select for workspace"
/>
<OverflowY maxHeight={380} className="flex flex-col py-2 min-h-0">
{templatesFetchStatus === "pending" ? (
<Loader size="sm" />
) : (
<>
{processed.map((template) => (
<WorkspaceResultsRow key={template.id} template={template} />
))}
{emptyState}
</>
)}
</OverflowY>
<div
css={(theme) => ({
padding: "8px 0",
borderTop: `1px solid ${theme.palette.divider}`,
})}
<ComboboxList
// Cap the list to the popover's available height minus the search
// input and footer. A nested max-height (e.g. 380px) larger than
// the remaining space clips the footer on short viewports.
style={{
maxHeight:
"min(380px, calc(var(--radix-popper-available-height) - 8rem))",
}}
>
<PopoverLink
{templatesFetchStatus === "pending" ? (
<div className="flex items-center justify-center py-4">
<Spinner size="sm" loading />
</div>
) : (
sortedTemplates.map((template) => (
<ComboboxItem
key={template.id}
value={template.id}
keywords={[template.display_name, template.name]}
className="px-4 data-[selected=true]:bg-surface-tertiary font-normal gap-3 [&>svg:last-child]:hidden"
>
<Avatar
variant="icon"
size="sm"
src={template.icon}
fallback={template.display_name || template.name}
/>
<div className="flex min-w-0 flex-1 flex-col overflow-hidden leading-[140%]">
<span className="truncate text-content-primary">
{template.display_name || template.name || "[Unnamed]"}
</span>
<span className="truncate text-[13px] text-content-secondary">
{activeDeveloperLabel(template.active_user_count)}
</span>
</div>
</ComboboxItem>
))
)}
</ComboboxList>
{templatesFetchStatus !== "pending" &&
(templates?.length === 0 ? (
<ComboboxEmpty>
No templates yet.{" "}
<Link asChild showExternalIcon={false}>
<RouterLink to="/templates">Create one now.</RouterLink>
</Link>
</ComboboxEmpty>
) : (
<ComboboxEmpty>No templates found</ComboboxEmpty>
))}
<div className="shrink-0 border-0 border-t border-solid border-border py-1">
<RouterLink
to="/templates"
css={(theme) => ({
display: "flex",
alignItems: "center",
columnGap: 12,
color: theme.palette.primary.main,
})}
className="flex items-center gap-3 px-4 py-2 text-sm text-content-link no-underline outline-none hover:bg-surface-tertiary hover:no-underline focus:bg-surface-tertiary"
>
<ExternalLinkIcon className="size-icon-xs" />
<span>See all templates</span>
</PopoverLink>
</RouterLink>
</div>
</PopoverContent>
</Popover>
</ComboboxContent>
</Combobox>
);
};
interface WorkspaceResultsRowProps {
template: Template;
}
const WorkspaceResultsRow: FC<WorkspaceResultsRowProps> = ({ template }) => {
const getLink = useLinks();
const templateLink = getLink(
linkToTemplate(template.organization_name, template.name),
);
return (
<PopoverLink
to={`${templateLink}/workspace`}
className="flex gap-3 items-center"
>
<Avatar
variant="icon"
src={template.icon}
fallback={template.display_name || template.name}
/>
<div
css={(theme) => ({
color: theme.palette.text.primary,
display: "flex",
flexDirection: "column",
lineHeight: "140%",
fontSize: 14,
overflow: "hidden",
})}
>
<span className="whitespace-nowrap text-ellipsis">
{template.display_name || template.name || "[Unnamed]"}
</span>
<span
css={(theme) => ({
fontSize: 13,
color: 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"}
</span>
</div>
</PopoverLink>
);
};
const PopoverLink: FC<RouterLinkProps> = ({ children, ...linkProps }) => {
return (
<RouterLink
{...linkProps}
css={(theme) => ({
color: theme.palette.text.primary,
padding: "8px 16px",
fontSize: 14,
outline: "none",
textDecoration: "none",
"&:focus": {
backgroundColor: theme.palette.action.focus,
},
"&:hover": {
textDecoration: "none",
backgroundColor: theme.palette.action.hover,
},
})}
>
{children}
</RouterLink>
);
};
function sortTemplatesByUsersDesc(
templates: readonly Template[],
searchTerm: string,
) {
const allWhitespace = /^\s+$/.test(searchTerm);
if (allWhitespace) {
return templates;
function activeDeveloperLabel(count: number): string {
// Some templates report -1 as their user count, which is treated like a
// null. Treat those the same as 0.
if (count <= 0) {
return "No developers";
}
return `${count} developer${count === 1 ? "" : "s"}`;
}
const termMatcher = new RegExp(searchTerm.replaceAll(/[^\w]/g, "."), "i");
return templates
.filter(
(template) =>
termMatcher.test(template.display_name) ||
termMatcher.test(template.name),
)
.sort((t1, t2) => t2.active_user_count - t1.active_user_count)
.slice(0, 10);
function sortTemplatesByUsersDesc(templates: readonly Template[]) {
return templates.toSorted(
(t1, t2) => t2.active_user_count - t1.active_user_count,
);
}