chore: add UserCombobox component for tasks sidebar (#19920)

This PR is part of a series of PRs aimed at completing the tasks
sidebar.

Reference:
[https://github.com/coder/coder/issues/19573](https://github.com/coder/coder/issues/19573)
This commit is contained in:
Bruno Quaresma
2025-09-24 10:27:53 -03:00
committed by GitHub
parent 78b1ec9698
commit 50a92147e3
3 changed files with 270 additions and 1 deletions
+1 -1
View File
@@ -54,7 +54,7 @@ export const CommandInput = forwardRef<
ref={ref}
className={cn(
`flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none border-none
placeholder:text-content-secondary
placeholder:text-content-secondary text-content-primary
disabled:cursor-not-allowed disabled:opacity-50`,
className,
)}
@@ -0,0 +1,102 @@
import { MockUserOwner } from "testHelpers/entities";
import { withAuthProvider } from "testHelpers/storybook";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { waitFor } from "@testing-library/react";
import { API } from "api/api";
import { MockUsers } from "pages/UsersPage/storybookData/users";
import { useState } from "react";
import { expect, spyOn, userEvent, within } from "storybook/test";
import { UserCombobox } from "./UserCombobox";
const meta: Meta<typeof UserCombobox> = {
title: "modules/tasks/TasksSidebar/UserCombobox",
component: UserCombobox,
decorators: [withAuthProvider],
parameters: {
user: MockUserOwner,
},
render: (args) => {
const [value, setValue] = useState("");
return <UserCombobox {...args} value={value} onValueChange={setValue} />;
},
};
export default meta;
type Story = StoryObj<typeof UserCombobox>;
export const Loading: Story = {
beforeEach: () => {
spyOn(API, "getUsers").mockImplementation(() => {
return new Promise(() => {
// never resolves
});
});
},
};
export const AllUsers: Story = {
parameters: {
queries: [{ key: ["users"], data: MockUsers }],
},
};
export const SelectUser: Story = {
beforeEach: () => {
spyOn(API, "getUsers").mockResolvedValue({
count: MockUsers.length,
users: MockUsers,
});
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const user = userEvent.setup();
await step("open combobox", async () => {
const trigger = await canvas.findByText(/all users/i, { exact: false });
await user.click(trigger);
});
await step("select user", async () => {
const option = await body.findByText(MockUsers[1].name!, {
exact: false,
});
await user.click(option);
});
},
};
export const SearchUser: Story = {
beforeEach: () => {
spyOn(API, "getUsers").mockImplementation((options) => {
let users = MockUsers;
if (options.q?.includes("Ivan")) {
users = users.filter((u) => u.name?.includes("Ivan"));
}
return Promise.resolve({
count: MockUsers.length,
users: MockUsers,
});
});
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);
const body = within(canvasElement.ownerDocument.body);
const user = userEvent.setup();
await step("open combobox", async () => {
const trigger = await canvas.findByText(/all users/i, { exact: false });
await user.click(trigger);
});
await step("search user", async () => {
const searchInput = await body.findByLabelText("Search user");
await user.type(searchInput, "Ivan");
await waitFor(() => {
expect(API.getUsers).toHaveBeenCalledTimes(2);
});
});
},
};
@@ -0,0 +1,167 @@
import { users } from "api/queries/users";
import type { User } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar";
import { Button } from "components/Button/Button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "components/Command/Command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "components/Popover/Popover";
import { useAuthenticated } from "hooks";
import { useDebouncedValue } from "hooks/debounce";
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
import { type FC, useState } from "react";
import { keepPreviousData, useQuery } from "react-query";
import { cn } from "utils/cn";
type UserOption = {
label: string;
/**
* The username of the user.
*/
value: string;
avatarUrl?: string;
};
type UserComboboxProps = {
value: string;
onValueChange: (value: string) => void;
};
export const UserCombobox: FC<UserComboboxProps> = ({
value,
onValueChange,
}) => {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const debouncedSearch = useDebouncedValue(search, 250);
const { user } = useAuthenticated();
const { data: options, isFetched } = useQuery({
...users({ q: debouncedSearch }),
select: (res) => mapUsersToOptions(res.users, user, value),
placeholderData: keepPreviousData,
});
const selectedOption = options?.find((o) => o.value === value);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
disabled={!isFetched}
role="combobox"
aria-expanded={open}
className="justify-between rounded-full bg-surface-tertiary border border-border hover:bg-surface-quaternary text-content-primary pl-3 w-full"
size="sm"
>
{isFetched ? (
selectedOption ? (
<UserItem option={selectedOption} className="-ml-2" />
) : (
"All users"
)
) : (
"Loading users..."
)}
<ChevronsUpDownIcon className="h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0 " side="bottom" align="start">
<Command>
<CommandInput
placeholder="Search user..."
value={search}
onValueChange={setSearch}
aria-label="Search user"
/>
<CommandList>
<CommandEmpty>No users found.</CommandEmpty>
<CommandGroup>
{options?.map((option) => (
<CommandItem
keywords={[option.label]}
key={option.value}
value={option.value}
onSelect={() => {
onValueChange(option.value);
setOpen(false);
}}
>
<UserItem option={option} />
<CheckIcon
className={cn(
"ml-2 h-4 w-4",
option.value === selectedOption?.value
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
};
type UserItemProps = {
option: UserOption;
className?: string;
};
const UserItem: FC<UserItemProps> = ({ option, className }) => {
return (
<div className={cn("flex flex-1 items-center gap-2", className)}>
<Avatar
src={option.avatarUrl}
fallback={option.label}
className="rounded-full"
/>
{option.label}
</div>
);
};
function mapUsersToOptions(
users: readonly User[],
/**
* Includes the authenticated user in the list if they are not already
* present. So the current user can always select themselves easily.
*/
authUser: User,
/**
* Username of the currently selected user.
*/
selectedValue: string,
): UserOption[] {
const includeAuthenticatedUser = (users: readonly User[]) => {
const hasAuthenticatedUser = users.some(
(u) => u.username === authUser.username,
);
if (hasAuthenticatedUser) {
return users;
}
return [authUser, ...users];
};
const sortSelectedFirst = (a: User) =>
selectedValue && a.username === selectedValue ? -1 : 0;
return includeAuthenticatedUser(users)
.toSorted(sortSelectedFirst)
.map((user) => ({
label: user.name || user.username,
value: user.username,
avatarUrl: user.avatar_url,
}));
}