mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: move <UserAutocomplete /> to <Combobox /> (#22590)
This pull-request takes our super MUI-based `<UserAutocomplete />` and migrates it to a common `<Combobox />` so that things will render correctly inline and use the new `shadcn` style system. | Old | New | | --- | --- | | <img width="1139" height="220" src="https://github.com/user-attachments/assets/046b32d0-702c-4f03-b039-91f4b1e30cd1" /> | <img width="1139" height="220" src="https://github.com/user-attachments/assets/74244768-198a-4a28-acb9-a93ff570e010" /> |
This commit is contained in:
@@ -97,12 +97,19 @@ export const ComboboxButton = ({
|
||||
);
|
||||
};
|
||||
|
||||
type ComboboxContentProps = React.ComponentPropsWithRef<
|
||||
typeof PopoverContent
|
||||
> & {
|
||||
shouldFilter?: boolean;
|
||||
};
|
||||
|
||||
export const ComboboxContent = ({
|
||||
children,
|
||||
className,
|
||||
ref,
|
||||
shouldFilter,
|
||||
...props
|
||||
}: React.ComponentPropsWithRef<typeof PopoverContent>) => {
|
||||
}: ComboboxContentProps) => {
|
||||
return (
|
||||
<PopoverContent
|
||||
ref={ref}
|
||||
@@ -112,7 +119,9 @@ export const ComboboxContent = ({
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Command className="bg-surface-secondary">{children}</Command>
|
||||
<Command className="bg-surface-secondary" shouldFilter={shouldFilter}>
|
||||
{children}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
import { css } from "@emotion/css";
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { getErrorMessage } from "api/errors";
|
||||
import { organizationMembers } from "api/queries/organizations";
|
||||
import { users, workspaceAvailableUsers } from "api/queries/users";
|
||||
@@ -10,16 +6,25 @@ import type {
|
||||
OrganizationMemberWithUserData,
|
||||
User,
|
||||
} from "api/typesGenerated";
|
||||
import { ChevronDownIcon } from "components/AnimatedIcons/ChevronDown";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/Avatar/AvatarData";
|
||||
import { useDebouncedFunction } from "hooks/debounce";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type ComponentProps,
|
||||
type FC,
|
||||
useState,
|
||||
} from "react";
|
||||
Combobox,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxTrigger,
|
||||
} from "components/Combobox/Combobox";
|
||||
import { Label } from "components/Label/Label";
|
||||
import { Spinner } from "components/Spinner/Spinner";
|
||||
import { useDebouncedFunction, useDebouncedValue } from "hooks/debounce";
|
||||
import { type FC, useId, useState } from "react";
|
||||
import { keepPreviousData, useQuery } from "react-query";
|
||||
import { cn } from "utils/cn";
|
||||
import { prepareQuery } from "utils/filters";
|
||||
|
||||
// The common properties between users and org members that we need.
|
||||
@@ -33,8 +38,6 @@ type CommonAutocompleteProps<T extends SelectedUser> = {
|
||||
className?: string;
|
||||
label?: string;
|
||||
onChange: (user: T | null) => void;
|
||||
required?: boolean;
|
||||
size?: ComponentProps<typeof TextField>["size"];
|
||||
value: T | null;
|
||||
};
|
||||
|
||||
@@ -55,6 +58,7 @@ export const UserAutocomplete: FC<UserAutocompleteProps> = (props) => {
|
||||
<InnerAutocomplete<User>
|
||||
error={usersQuery.error}
|
||||
isFetching={usersQuery.isFetching}
|
||||
shouldFilter={false}
|
||||
setFilter={setFilter}
|
||||
users={usersQuery.data?.users}
|
||||
{...props}
|
||||
@@ -82,6 +86,7 @@ export const MemberAutocomplete: FC<MemberAutocompleteProps> = ({
|
||||
<InnerAutocomplete<OrganizationMemberWithUserData>
|
||||
error={membersQuery.error}
|
||||
isFetching={membersQuery.isFetching}
|
||||
shouldFilter={true}
|
||||
setFilter={setFilter}
|
||||
users={membersQuery.data?.members}
|
||||
{...props}
|
||||
@@ -111,6 +116,7 @@ export const WorkspaceUserAutocomplete: FC<WorkspaceUserAutocompleteProps> = ({
|
||||
<InnerAutocomplete<MinimalUser>
|
||||
error={availableUsersQuery.error}
|
||||
isFetching={availableUsersQuery.isFetching}
|
||||
shouldFilter={false}
|
||||
setFilter={setFilter}
|
||||
users={availableUsersQuery.data}
|
||||
{...props}
|
||||
@@ -123,6 +129,8 @@ type InnerAutocompleteProps<T extends SelectedUser> =
|
||||
/** The error is null if not loaded or no error. */
|
||||
error: unknown;
|
||||
isFetching: boolean;
|
||||
/** Enable cmdk filtering for non-API-filtered variants. */
|
||||
shouldFilter: boolean;
|
||||
/** Filter is undefined if the autocomplete is closed. */
|
||||
setFilter: (filter: string | undefined) => void;
|
||||
/** Users are undefined if not loaded or errored. */
|
||||
@@ -132,100 +140,123 @@ type InnerAutocompleteProps<T extends SelectedUser> =
|
||||
const InnerAutocomplete = <T extends SelectedUser>({
|
||||
className,
|
||||
error,
|
||||
isFetching,
|
||||
label,
|
||||
onChange,
|
||||
required,
|
||||
shouldFilter,
|
||||
setFilter,
|
||||
size = "small",
|
||||
isFetching,
|
||||
users,
|
||||
value,
|
||||
}: InnerAutocompleteProps<T>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const DEBOUNCE_MS = 750;
|
||||
|
||||
const { debounced: debouncedInputOnChange } = useDebouncedFunction(
|
||||
(event: ChangeEvent<HTMLInputElement>) => {
|
||||
setFilter(event.target.value ?? "");
|
||||
},
|
||||
750,
|
||||
);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
const id = useId();
|
||||
const debouncedInputValue = useDebouncedValue(inputValue, DEBOUNCE_MS);
|
||||
const { debounced: debouncedSetFilter, cancelDebounce } =
|
||||
useDebouncedFunction((nextFilter: string) => {
|
||||
setFilter(nextFilter);
|
||||
}, DEBOUNCE_MS);
|
||||
|
||||
const selectedInputValue = value?.email ?? value?.username ?? "";
|
||||
const selectedFilterValue = value?.username ?? "";
|
||||
// Keep spinner only while typing away from the selected value.
|
||||
const isLoadingOptions =
|
||||
selectedInputValue !== inputValue &&
|
||||
((inputValue !== "" && debouncedInputValue !== inputValue) || isFetching);
|
||||
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
setOpen(nextOpen);
|
||||
cancelDebounce();
|
||||
if (nextOpen) {
|
||||
setInputValue(selectedInputValue);
|
||||
setFilter(selectedFilterValue);
|
||||
return;
|
||||
}
|
||||
setInputValue("");
|
||||
setFilter(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
noOptionsText={
|
||||
error
|
||||
? getErrorMessage(error, "Unable to fetch users")
|
||||
: "No users found"
|
||||
}
|
||||
className={className}
|
||||
options={users ?? []}
|
||||
loading={!users && !error}
|
||||
value={value}
|
||||
data-testid="user-autocomplete"
|
||||
open={open}
|
||||
isOptionEqualToValue={(a, b) => a.username === b.username}
|
||||
getOptionLabel={(option) => option.email ?? option.username}
|
||||
onOpen={() => {
|
||||
setOpen(true);
|
||||
setFilter(value?.email ?? value?.username ?? "");
|
||||
}}
|
||||
onClose={() => {
|
||||
setOpen(false);
|
||||
setFilter(undefined);
|
||||
}}
|
||||
onChange={(_, newValue) => {
|
||||
onChange(newValue);
|
||||
}}
|
||||
renderOption={({ key, ...props }, option) => (
|
||||
<li key={key} {...props}>
|
||||
<AvatarData
|
||||
title={option.username}
|
||||
subtitle={option.email}
|
||||
src={option.avatar_url}
|
||||
<div className={cn("flex flex-col gap-2", className)}>
|
||||
{label && <Label htmlFor={id}>{label}</Label>}
|
||||
<Combobox
|
||||
value={value?.username}
|
||||
onValueChange={(newValue) => {
|
||||
if (!newValue) {
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
onChange(users?.find((user) => user.username === newValue) ?? null);
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<ComboboxTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
{value && (
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={value.avatar_url}
|
||||
fallback={value.username}
|
||||
/>
|
||||
)}
|
||||
{value?.email ?? value?.username ?? "Select a user"}
|
||||
</span>
|
||||
<ChevronDownIcon className="p-0.5" />
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent
|
||||
className="w-[var(--radix-popover-trigger-width)] p-0"
|
||||
shouldFilter={shouldFilter}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Username or email"
|
||||
value={inputValue}
|
||||
onValueChange={(nextInputValue) => {
|
||||
setInputValue(nextInputValue);
|
||||
if (nextInputValue === "") {
|
||||
// If we're clearing the input, we don't need
|
||||
// to debounce the filter change.
|
||||
cancelDebounce();
|
||||
setFilter("");
|
||||
return;
|
||||
}
|
||||
debouncedSetFilter(nextInputValue);
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
required={required}
|
||||
fullWidth
|
||||
size={size}
|
||||
label={label}
|
||||
placeholder="Username or email"
|
||||
css={{
|
||||
"&:not(:has(label))": {
|
||||
margin: 0,
|
||||
},
|
||||
}}
|
||||
InputProps={{
|
||||
...params.InputProps,
|
||||
onChange: debouncedInputOnChange,
|
||||
startAdornment: value && (
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={value.avatar_url}
|
||||
fallback={value.username}
|
||||
/>
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
{isFetching && open && <CircularProgress size={16} />}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
classes: { root },
|
||||
}}
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<ComboboxList className="py-0.5">
|
||||
{!isLoadingOptions &&
|
||||
(users ?? []).map((option) => (
|
||||
<ComboboxItem
|
||||
key={option.username}
|
||||
value={option.username}
|
||||
keywords={[option.username, option.email ?? ""]}
|
||||
className="m-1"
|
||||
>
|
||||
<AvatarData
|
||||
title={option.username}
|
||||
subtitle={option.email}
|
||||
src={option.avatar_url}
|
||||
/>
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
<ComboboxEmpty>
|
||||
{isLoadingOptions || (!users && !error) ? (
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<Spinner size="sm" loading />
|
||||
</div>
|
||||
) : error ? (
|
||||
getErrorMessage(error, "Unable to fetch users")
|
||||
) : (
|
||||
"No users found"
|
||||
)}
|
||||
</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const root = css`
|
||||
padding-left: 14px !important; // Same padding left as input
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
@@ -509,7 +509,6 @@ export const CreateWorkspacePageView: FC<CreateWorkspacePageViewProps> = ({
|
||||
onChange={(user) => {
|
||||
handleOwnerChange(user ?? defaultOwner);
|
||||
}}
|
||||
size="medium"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -106,8 +106,9 @@ export const MembersError: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const combobox = await canvas.findByRole("combobox");
|
||||
await userEvent.click(combobox);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: "Select a user" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -119,13 +120,14 @@ export const NoMembers: Story = {
|
||||
members: [],
|
||||
}),
|
||||
permissionsQuery({ canUpdateGroup: true }),
|
||||
membersQuery([]),
|
||||
membersQuery({ members: [] }),
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const combobox = await canvas.findByRole("combobox");
|
||||
await userEvent.click(combobox);
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: "Select a user" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -134,11 +136,15 @@ export const FiltersByMembers: Story = {
|
||||
queries: [
|
||||
groupQuery(MockGroup),
|
||||
permissionsQuery({ canUpdateGroup: true }),
|
||||
membersQuery([MockOrganizationMember, MockOrganizationMember2]),
|
||||
membersQuery({
|
||||
members: [MockOrganizationMember, MockOrganizationMember2],
|
||||
}),
|
||||
],
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Open" }));
|
||||
await userEvent.click(
|
||||
await canvas.findByRole("button", { name: "Select a user" }),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user