mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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} />;
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user