diff --git a/site/src/components/Command/Command.tsx b/site/src/components/Command/Command.tsx index 8973154f1d..ff26e960f5 100644 --- a/site/src/components/Command/Command.tsx +++ b/site/src/components/Command/Command.tsx @@ -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, )} diff --git a/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx b/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx new file mode 100644 index 0000000000..3f4900000a --- /dev/null +++ b/site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx @@ -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 = { + title: "modules/tasks/TasksSidebar/UserCombobox", + component: UserCombobox, + decorators: [withAuthProvider], + parameters: { + user: MockUserOwner, + }, + render: (args) => { + const [value, setValue] = useState(""); + return ; + }, +}; + +export default meta; +type Story = StoryObj; + +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); + }); + }); + }, +}; diff --git a/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx b/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx new file mode 100644 index 0000000000..af5c199c99 --- /dev/null +++ b/site/src/modules/tasks/TasksSidebar/UserCombobox.tsx @@ -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 = ({ + 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 ( + + + + + + + + + No users found. + + {options?.map((option) => ( + { + onValueChange(option.value); + setOpen(false); + }} + > + + + + ))} + + + + + + ); +}; + +type UserItemProps = { + option: UserOption; + className?: string; +}; + +const UserItem: FC = ({ option, className }) => { + return ( +
+ + {option.label} +
+ ); +}; + +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, + })); +}