diff --git a/site/src/components/Autocomplete/Autocomplete.stories.tsx b/site/src/components/Autocomplete/Autocomplete.stories.tsx new file mode 100644 index 0000000000000..9639e8a89b454 --- /dev/null +++ b/site/src/components/Autocomplete/Autocomplete.stories.tsx @@ -0,0 +1,354 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Avatar } from "components/Avatar/Avatar"; +import { AvatarData } from "components/Avatar/AvatarData"; +import { Check } from "lucide-react"; +import { useState } from "react"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { Autocomplete } from "./Autocomplete"; + +const meta: Meta = { + title: "components/Autocomplete", + component: Autocomplete, + args: { + placeholder: "Select an option", + }, +}; + +export default meta; + +type Story = StoryObj; + +interface SimpleOption { + id: string; + name: string; +} + +const simpleOptions: SimpleOption[] = [ + { id: "1", name: "Mango" }, + { id: "2", name: "Banana" }, + { id: "3", name: "Pineapple" }, + { id: "4", name: "Kiwi" }, + { id: "5", name: "Coconut" }, +]; + +export const Default: Story = { + render: function DefaultStory() { + const [value, setValue] = useState(null); + return ( +
+ opt.id} + getOptionLabel={(opt) => opt.name} + placeholder="Select a fruit" + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button"); + + expect(trigger).toHaveTextContent("Select a fruit"); + await userEvent.click(trigger); + + await waitFor(() => + expect(screen.getByRole("option", { name: "Mango" })).toBeInTheDocument(), + ); + }, +}; + +export const WithSelectedValue: Story = { + render: function WithSelectedValueStory() { + const [value, setValue] = useState(simpleOptions[2]); + return ( +
+ opt.id} + getOptionLabel={(opt) => opt.name} + placeholder="Select a fruit" + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button", { name: /pineapple/i }); + expect(trigger).toHaveTextContent("Pineapple"); + + await userEvent.click(trigger); + + await waitFor(() => + expect( + screen.getByRole("option", { name: "Pineapple" }), + ).toBeInTheDocument(), + ); + + await userEvent.click(screen.getByRole("option", { name: "Mango" })); + await waitFor(() => expect(trigger).toHaveTextContent("Mango")); + }, +}; + +export const NotClearable: Story = { + render: function NotClearableStory() { + const [value, setValue] = useState(simpleOptions[0]); + return ( +
+ opt.id} + getOptionLabel={(opt) => opt.name} + placeholder="Select a fruit" + clearable={false} + /> +
+ ); + }, +}; + +export const Loading: Story = { + render: function LoadingStory() { + const [value, setValue] = useState(null); + return ( +
+ opt.id} + getOptionLabel={(opt) => opt.name} + placeholder="Loading options..." + loading + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + await waitFor(() => { + const spinners = screen.getAllByTitle("Loading spinner"); + expect(spinners.length).toBeGreaterThanOrEqual(1); + }); + }, +}; + +export const Disabled: Story = { + render: function DisabledStory() { + const [value, setValue] = useState(simpleOptions[1]); + return ( +
+ opt.id} + getOptionLabel={(opt) => opt.name} + placeholder="Select a fruit" + disabled + /> +
+ ); + }, +}; + +export const EmptyOptions: Story = { + render: function EmptyOptionsStory() { + const [value, setValue] = useState(null); + return ( +
+ opt.id} + getOptionLabel={(opt) => opt.name} + placeholder="Select a fruit" + noOptionsText="No fruits available" + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click(canvas.getByRole("button")); + await waitFor(() => + expect(screen.getByText("No fruits available")).toBeInTheDocument(), + ); + }, +}; + +export const SearchAndFilter: Story = { + render: function SearchAndFilterStory() { + const [value, setValue] = useState(null); + return ( +
+ opt.id} + getOptionLabel={(opt) => opt.name} + placeholder="Select a fruit" + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: /select a fruit/i }), + ); + const searchInput = screen.getByRole("combobox"); + await userEvent.type(searchInput, "an"); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "Mango" })).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "Banana" }), + ).toBeInTheDocument(); + expect( + screen.queryByRole("option", { name: "Pineapple" }), + ).not.toBeInTheDocument(); + }); + }, +}; + +export const ClearSelection: Story = { + render: function ClearSelectionStory() { + const [value, setValue] = useState(simpleOptions[0]); + return ( +
+ opt.id} + getOptionLabel={(opt) => opt.name} + placeholder="Select a fruit" + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button", { name: /mango/i }); + expect(trigger).toHaveTextContent("Mango"); + + const clearButton = canvas.getByRole("button", { name: "Clear selection" }); + await userEvent.click(clearButton); + + await waitFor(() => + expect( + canvas.getByRole("button", { name: /select a fruit/i }), + ).toBeInTheDocument(), + ); + }, +}; + +interface User { + id: string; + username: string; + email: string; + avatar_url?: string; +} + +const users: User[] = [ + { + id: "1", + username: "alice", + email: "alice@example.com", + avatar_url: "", + }, + { + id: "2", + username: "bob", + email: "bob@example.com", + avatar_url: "", + }, + { + id: "3", + username: "charlie", + email: "charlie@example.com", + avatar_url: "", + }, +]; + +export const WithCustomRenderOption: Story = { + render: function WithCustomRenderOptionStory() { + const [value, setValue] = useState(null); + return ( +
+ user.id} + getOptionLabel={(user) => user.email} + placeholder="Search for a user" + renderOption={(user, isSelected) => ( +
+ + {isSelected && } +
+ )} + /> +
+ ); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button"); + + expect(trigger).toHaveTextContent("Search for a user"); + await userEvent.click(trigger); + }, +}; + +export const WithStartAdornment: Story = { + render: function WithStartAdornmentStory() { + const [value, setValue] = useState(users[0]); + return ( +
+ user.id} + getOptionLabel={(user) => user.email} + placeholder="Search for a user" + startAdornment={ + value && ( + + ) + } + renderOption={(user, isSelected) => ( +
+ + {isSelected && } +
+ )} + /> +
+ ); + }, +}; diff --git a/site/src/components/Autocomplete/Autocomplete.tsx b/site/src/components/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000000000..5872927420569 --- /dev/null +++ b/site/src/components/Autocomplete/Autocomplete.tsx @@ -0,0 +1,252 @@ +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "components/Command/Command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "components/Popover/Popover"; +import { Spinner } from "components/Spinner/Spinner"; +import { Check, ChevronDown, X } from "lucide-react"; +import { + type KeyboardEvent, + type ReactNode, + useCallback, + useState, +} from "react"; +import { cn } from "utils/cn"; + +interface AutocompleteProps { + value: TOption | null; + onChange: (value: TOption | null) => void; + options: readonly TOption[]; + getOptionValue: (option: TOption) => string; + getOptionLabel: (option: TOption) => string; + isOptionEqualToValue?: (option: TOption, value: TOption) => boolean; + renderOption?: (option: TOption, isSelected: boolean) => ReactNode; + loading?: boolean; + placeholder?: string; + noOptionsText?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; + inputValue?: string; + onInputChange?: (value: string) => void; + clearable?: boolean; + disabled?: boolean; + startAdornment?: ReactNode; + className?: string; + id?: string; + "data-testid"?: string; +} + +export function Autocomplete({ + value, + onChange, + options, + getOptionValue, + getOptionLabel, + isOptionEqualToValue, + renderOption, + loading = false, + placeholder = "Select an option", + noOptionsText = "No results found", + open: controlledOpen, + onOpenChange, + inputValue: controlledInputValue, + onInputChange, + clearable = true, + disabled = false, + startAdornment, + className, + id, + "data-testid": testId, +}: AutocompleteProps) { + const [managedOpen, setManagedOpen] = useState(false); + const [managedInputValue, setManagedInputValue] = useState(""); + + const isOpen = controlledOpen ?? managedOpen; + const inputValue = controlledInputValue ?? managedInputValue; + + const handleOpenChange = useCallback( + (newOpen: boolean) => { + setManagedOpen(newOpen); + onOpenChange?.(newOpen); + if (!newOpen && controlledInputValue === undefined) { + setManagedInputValue(""); + } + }, + [onOpenChange, controlledInputValue], + ); + + const handleInputChange = useCallback( + (newValue: string) => { + setManagedInputValue(newValue); + onInputChange?.(newValue); + }, + [onInputChange], + ); + + const isSelected = useCallback( + (option: TOption): boolean => { + if (!value) return false; + if (isOptionEqualToValue) { + return isOptionEqualToValue(option, value); + } + return getOptionValue(option) === getOptionValue(value); + }, + [value, isOptionEqualToValue, getOptionValue], + ); + + const handleSelect = useCallback( + (option: TOption) => { + if (isSelected(option) && clearable) { + onChange(null); + } else { + onChange(option); + } + handleOpenChange(false); + }, + [isSelected, clearable, onChange, handleOpenChange], + ); + + const handleClear = useCallback( + (e: React.SyntheticEvent) => { + e.stopPropagation(); + onChange(null); + handleInputChange(""); + }, + [onChange, handleInputChange], + ); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape") { + handleOpenChange(false); + } + }, + [handleOpenChange], + ); + + const displayValue = value ? getOptionLabel(value) : ""; + const showClearButton = clearable && value && !disabled; + + return ( + + + + + + + + + {loading ? ( +
+ +
+ ) : ( + <> + {noOptionsText} + + {options.map((option) => { + const optionValue = getOptionValue(option); + const optionLabel = getOptionLabel(option); + const selected = isSelected(option); + + return ( + handleSelect(option)} + className="cursor-pointer" + > + {renderOption ? ( + renderOption(option, selected) + ) : ( + <> + {optionLabel} + {selected && } + + )} + + ); + })} + + + )} +
+
+
+
+ ); +} diff --git a/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx b/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx deleted file mode 100644 index dd9506fc7e837..0000000000000 --- a/site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import type { Group, ReducedUser, User } from "api/typesGenerated"; -import { AvatarData } from "components/Avatar/AvatarData"; -import type { HTMLAttributes } from "react"; -import { getGroupSubtitle } from "utils/groups"; - -type UserOrGroupAutocompleteValue = User | ReducedUser | Group | null; - -type UserOption = User | ReducedUser; -type OptionType = UserOption | Group; - -/** - * Type guard to check if the value is a Group. - * Groups have a "members" property that users don't have. - */ -export const isGroup = ( - value: UserOrGroupAutocompleteValue, -): value is Group => { - return value !== null && typeof value === "object" && "members" in value; -}; - -interface UserOrGroupOptionProps { - option: OptionType; - htmlProps: HTMLAttributes; -} - -export const UserOrGroupOption = ({ - option, - htmlProps, -}: UserOrGroupOptionProps) => { - const isOptionGroup = isGroup(option); - - return ( -
  • - -
  • - ); -}; diff --git a/site/src/utils/groups.ts b/site/src/modules/groups.ts similarity index 65% rename from site/src/utils/groups.ts rename to site/src/modules/groups.ts index 4c456b81bf788..fc642d7769f4f 100644 --- a/site/src/utils/groups.ts +++ b/site/src/modules/groups.ts @@ -1,4 +1,16 @@ -import type { Group } from "api/typesGenerated"; +import type { Group, ReducedUser, User } from "api/typesGenerated"; + +type UserOrGroupAutocompleteValue = User | ReducedUser | Group | null; + +/** + * Type guard to check if the value is a Group. + * Groups have a "members" property that users don't have. + */ +export const isGroup = ( + value: UserOrGroupAutocompleteValue, +): value is Group => { + return value !== null && typeof value === "object" && "members" in value; +}; /** * Returns true if the provided group is the 'Everyone' group. diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index 4213a0ac20400..b5a9f1940a705 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -53,10 +53,10 @@ import { TrashIcon, UserPlusIcon, } from "lucide-react"; +import { isEveryoneGroup } from "modules/groups"; import { type FC, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { Link as RouterLink, useNavigate, useParams } from "react-router"; -import { isEveryoneGroup } from "utils/groups"; import { pageTitle } from "utils/page"; const GroupPage: FC = () => { diff --git a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx index c99495d1c92ab..a07d1b4a10ccf 100644 --- a/site/src/pages/GroupsPage/GroupSettingsPageView.tsx +++ b/site/src/pages/GroupsPage/GroupSettingsPageView.tsx @@ -12,13 +12,13 @@ import { Loader } from "components/Loader/Loader"; import { ResourcePageHeader } from "components/PageHeader/PageHeader"; import { Spinner } from "components/Spinner/Spinner"; import { useFormik } from "formik"; +import { isEveryoneGroup } from "modules/groups"; import type { FC } from "react"; import { getFormHelpers, nameValidator, onChangeTrimmed, } from "utils/formUtils"; -import { isEveryoneGroup } from "utils/groups"; import * as Yup from "yup"; type FormData = { diff --git a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx index 6f4cd9dc6630a..091ab4851dc4d 100644 --- a/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx +++ b/site/src/pages/OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx @@ -29,9 +29,9 @@ import { } from "components/Tooltip/Tooltip"; import { useFormik } from "formik"; import { Plus, Trash, TriangleAlert } from "lucide-react"; +import { isEveryoneGroup } from "modules/groups"; import { type FC, type KeyboardEventHandler, useId, useState } from "react"; import { docs } from "utils/docs"; -import { isEveryoneGroup } from "utils/groups"; import { isUUID } from "utils/uuid"; import * as Yup from "yup"; import { ExportPolicyButton } from "./ExportPolicyButton"; diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx index 07047bbc3da97..6ad89ae0dbcd8 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -33,8 +33,8 @@ import { } from "components/Table/Table"; import { TableLoader } from "components/TableLoader/TableLoader"; import { EllipsisVertical, UserPlusIcon } from "lucide-react"; +import { getGroupSubtitle } from "modules/groups"; import { type FC, useState } from "react"; -import { getGroupSubtitle } from "utils/groups"; import { UserOrGroupAutocomplete, type UserOrGroupAutocompleteValue, diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx index 23da3c2784981..8e7747b41c49b 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -1,14 +1,10 @@ -import Autocomplete from "@mui/material/Autocomplete"; -import CircularProgress from "@mui/material/CircularProgress"; -import TextField from "@mui/material/TextField"; import { templaceACLAvailable } from "api/queries/templates"; import type { Group, ReducedUser } from "api/typesGenerated"; -import { - isGroup, - UserOrGroupOption, -} from "components/UserOrGroupAutocomplete/UserOrGroupOption"; -import { useDebouncedFunction } from "hooks/debounce"; -import { type ChangeEvent, type FC, useState } from "react"; +import { Autocomplete } from "components/Autocomplete/Autocomplete"; +import { AvatarData } from "components/Avatar/AvatarData"; +import { Check } from "lucide-react"; +import { getGroupSubtitle, isGroup } from "modules/groups"; +import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { prepareQuery } from "utils/filters"; @@ -28,18 +24,25 @@ export const UserOrGroupAutocomplete: FC = ({ templateID, exclude, }) => { - const [autoComplete, setAutoComplete] = useState({ - value: "", - open: false, - }); + const [inputValue, setInputValue] = useState(""); + const [open, setOpen] = useState(false); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + setInputValue(""); + } + }; + const aclAvailableQuery = useQuery({ ...templaceACLAvailable(templateID, { - q: prepareQuery(encodeURI(autoComplete.value)), + q: prepareQuery(encodeURI(inputValue)), limit: 25, }), - enabled: autoComplete.open, + enabled: open, placeholderData: keepPreviousData, }); + const options: AutocompleteOption[] = aclAvailableQuery.data ? [ ...aclAvailableQuery.data.groups, @@ -52,69 +55,41 @@ export const UserOrGroupAutocomplete: FC = ({ }) : []; - const { debounced: handleFilterChange } = useDebouncedFunction( - (event: ChangeEvent) => { - setAutoComplete((state) => ({ - ...state, - value: event.target.value, - })); - }, - 500, - ); - return ( { - setAutoComplete((state) => ({ - ...state, - open: true, - })); - }} - onClose={() => { - setAutoComplete({ - value: isGroup(value) ? value.display_name : (value?.email ?? ""), - open: false, - }); - }} - onChange={(_, newValue) => { - onChange(newValue); - }} - isOptionEqualToValue={(option, optionValue) => - option.id === optionValue.id - } + onChange={onChange} + options={options} + getOptionValue={(option) => option.id} getOptionLabel={(option) => isGroup(option) ? option.display_name || option.name : option.email } - renderOption={({ key, ...props }, option) => ( - + isOptionEqualToValue={(option, optionValue) => + option.id === optionValue.id + } + renderOption={(option, isSelected) => ( +
    + + {isSelected && } +
    )} - options={options} + open={open} + onOpenChange={handleOpenChange} + inputValue={inputValue} + onInputChange={setInputValue} loading={aclAvailableQuery.isFetching} - className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full" - renderInput={(params) => ( - - {aclAvailableQuery.isFetching ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - }} - /> - )} + placeholder="Search for user or group" + noOptionsText="No users or groups found" + className="w-[300px]" + id="user-or-group-autocomplete" /> ); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx index 8e61f92b2cd41..86b5546d6c5d1 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx @@ -1,15 +1,11 @@ -import Autocomplete from "@mui/material/Autocomplete"; -import CircularProgress from "@mui/material/CircularProgress"; -import TextField from "@mui/material/TextField"; import { groupsByOrganization } from "api/queries/groups"; import { users } from "api/queries/users"; import type { Group, User } from "api/typesGenerated"; -import { - isGroup, - UserOrGroupOption, -} from "components/UserOrGroupAutocomplete/UserOrGroupOption"; -import { useDebouncedFunction } from "hooks/debounce"; -import { type ChangeEvent, type FC, useState } from "react"; +import { Autocomplete } from "components/Autocomplete/Autocomplete"; +import { AvatarData } from "components/Avatar/AvatarData"; +import { Check } from "lucide-react"; +import { getGroupSubtitle, isGroup } from "modules/groups"; +import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { prepareQuery } from "utils/filters"; @@ -31,27 +27,32 @@ export const UserOrGroupAutocomplete: FC = ({ organizationId, exclude, }) => { - const [autoComplete, setAutoComplete] = useState({ - value: "", - open: false, - }); + const [inputValue, setInputValue] = useState(""); + const [open, setOpen] = useState(false); + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen); + if (!newOpen) { + setInputValue(""); + } + }; const usersQuery = useQuery({ ...users({ - q: prepareQuery(encodeURI(autoComplete.value)), + q: prepareQuery(encodeURI(inputValue)), limit: 25, }), - enabled: autoComplete.open, + enabled: open, placeholderData: keepPreviousData, }); const groupsQuery = useQuery({ ...groupsByOrganization(organizationId), - enabled: autoComplete.open, + enabled: open, placeholderData: keepPreviousData, }); - const filterValue = autoComplete.value.trim().toLowerCase(); + const filterValue = inputValue.trim().toLowerCase(); const groupOptions = groupsQuery.data ? groupsQuery.data.filter((group) => { if (!filterValue) { @@ -71,71 +72,41 @@ export const UserOrGroupAutocomplete: FC = ({ ...(usersQuery.data?.users ?? []), ].filter((result) => !excludeIds.includes(result.id)); - const { debounced: handleFilterChange } = useDebouncedFunction( - (event: ChangeEvent) => { - setAutoComplete((state) => ({ - ...state, - value: event.target.value, - })); - }, - 500, - ); - return ( { - setAutoComplete((state) => ({ - ...state, - open: true, - })); - }} - onClose={() => { - setAutoComplete({ - value: isGroup(value) - ? value.display_name || value.name - : (value?.email ?? value?.username ?? ""), - open: false, - }); - }} - onChange={(_, newValue) => { - onChange(newValue ?? null); - }} - isOptionEqualToValue={(option, optionValue) => - option.id === optionValue.id - } + onChange={onChange} + options={options} + getOptionValue={(option) => option.id} getOptionLabel={(option) => isGroup(option) ? option.display_name || option.name : option.email } - renderOption={({ key, ...props }, option) => ( - + isOptionEqualToValue={(option, optionValue) => + option.id === optionValue.id + } + renderOption={(option, isSelected) => ( +
    + + {isSelected && } +
    )} - options={options} + open={open} + onOpenChange={handleOpenChange} + inputValue={inputValue} + onInputChange={setInputValue} loading={usersQuery.isFetching || groupsQuery.isFetching} - className="w-[300px] [&_.MuiFormControl-root]:w-full [&_.MuiInputBase-root]:w-full" - renderInput={(params) => ( - - {(usersQuery.isFetching || groupsQuery.isFetching) && ( - - )} - {params.InputProps.endAdornment} - - ), - }} - /> - )} + placeholder="Search for user or group" + noOptionsText="No users or groups found" + className="w-80" + id="workspace-user-or-group-autocomplete" /> ); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx index 403480d19e1b8..c4fe8a2cc5eff 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/WorkspaceSharingPageView.tsx @@ -35,8 +35,8 @@ import { } from "components/Table/Table"; import { TableLoader } from "components/TableLoader/TableLoader"; import { EllipsisVertical, UserPlusIcon } from "lucide-react"; +import { getGroupSubtitle } from "modules/groups"; import { type FC, useState } from "react"; -import { getGroupSubtitle } from "utils/groups"; import { UserOrGroupAutocomplete, type UserOrGroupAutocompleteValue,