feat: add radix autocomplete component (#21262)

<img width="339" height="330" alt="Screenshot 2025-12-13 at 18 39 30"
src="https://github.com/user-attachments/assets/41bade09-1e2e-4ff4-9b27-a3bdc9cb07f2"
/>
This commit is contained in:
Jaayden Halko
2025-12-16 16:15:06 -05:00
committed by GitHub
parent 55f4efd011
commit 5092645e40
11 changed files with 714 additions and 193 deletions
@@ -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<typeof Autocomplete> = {
title: "components/Autocomplete",
component: Autocomplete,
args: {
placeholder: "Select an option",
},
};
export default meta;
type Story = StoryObj<typeof Autocomplete>;
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<SimpleOption | null>(null);
return (
<div className="w-80">
<Autocomplete
value={value}
onChange={setValue}
options={simpleOptions}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
placeholder="Select a fruit"
/>
</div>
);
},
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<SimpleOption | null>(simpleOptions[2]);
return (
<div className="w-80">
<Autocomplete
value={value}
onChange={setValue}
options={simpleOptions}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
placeholder="Select a fruit"
/>
</div>
);
},
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<SimpleOption | null>(simpleOptions[0]);
return (
<div className="w-80">
<Autocomplete
value={value}
onChange={setValue}
options={simpleOptions}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
placeholder="Select a fruit"
clearable={false}
/>
</div>
);
},
};
export const Loading: Story = {
render: function LoadingStory() {
const [value, setValue] = useState<SimpleOption | null>(null);
return (
<div className="w-80">
<Autocomplete
value={value}
onChange={setValue}
options={[]}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
placeholder="Loading options..."
loading
/>
</div>
);
},
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<SimpleOption | null>(simpleOptions[1]);
return (
<div className="w-80">
<Autocomplete
value={value}
onChange={setValue}
options={simpleOptions}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
placeholder="Select a fruit"
disabled
/>
</div>
);
},
};
export const EmptyOptions: Story = {
render: function EmptyOptionsStory() {
const [value, setValue] = useState<SimpleOption | null>(null);
return (
<div className="w-80">
<Autocomplete
value={value}
onChange={setValue}
options={[]}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
placeholder="Select a fruit"
noOptionsText="No fruits available"
/>
</div>
);
},
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<SimpleOption | null>(null);
return (
<div className="w-80">
<Autocomplete
value={value}
onChange={setValue}
options={simpleOptions}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
placeholder="Select a fruit"
/>
</div>
);
},
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<SimpleOption | null>(simpleOptions[0]);
return (
<div className="w-80">
<Autocomplete
value={value}
onChange={setValue}
options={simpleOptions}
getOptionValue={(opt) => opt.id}
getOptionLabel={(opt) => opt.name}
placeholder="Select a fruit"
/>
</div>
);
},
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<User | null>(null);
return (
<div className="w-[350px]">
<Autocomplete
value={value}
onChange={setValue}
options={users}
getOptionValue={(user) => user.id}
getOptionLabel={(user) => user.email}
placeholder="Search for a user"
renderOption={(user, isSelected) => (
<div className="flex items-center justify-between w-full">
<AvatarData
title={user.username}
subtitle={user.email}
src={user.avatar_url}
/>
{isSelected && <Check className="size-4 shrink-0" />}
</div>
)}
/>
</div>
);
},
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<User | null>(users[0]);
return (
<div className="w-[350px]">
<Autocomplete
value={value}
onChange={setValue}
options={users}
getOptionValue={(user) => user.id}
getOptionLabel={(user) => user.email}
placeholder="Search for a user"
startAdornment={
value && (
<Avatar
size="sm"
src={value.avatar_url}
fallback={value.username}
/>
)
}
renderOption={(user, isSelected) => (
<div className="flex items-center justify-between w-full">
<AvatarData
title={user.username}
subtitle={user.email}
src={user.avatar_url}
/>
{isSelected && <Check className="size-4 shrink-0" />}
</div>
)}
/>
</div>
);
},
};
@@ -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<TOption> {
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<TOption>({
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<TOption>) {
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<HTMLInputElement>) => {
if (e.key === "Escape") {
handleOpenChange(false);
}
},
[handleOpenChange],
);
const displayValue = value ? getOptionLabel(value) : "";
const showClearButton = clearable && value && !disabled;
return (
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild disabled={disabled}>
<button
type="button"
id={id}
data-testid={testId}
aria-expanded={isOpen}
aria-haspopup="listbox"
disabled={disabled}
className={cn(
`flex h-10 w-full items-center justify-between gap-2
rounded-md border border-border border-solid bg-transparent px-3 py-2
text-sm shadow-sm transition-colors
placeholder:text-content-secondary
focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-content-link
disabled:cursor-not-allowed disabled:opacity-50`,
className,
)}
>
<span className="flex items-center gap-2 overflow-hidden flex-1 min-w-0">
{startAdornment}
<span
className={cn(
"truncate text-left",
displayValue
? "text-content-primary"
: "text-content-secondary",
)}
>
{displayValue || placeholder}
</span>
</span>
<span className="flex items-center shrink-0">
{loading && <Spinner size="sm" loading className="mr-1" />}
{showClearButton && (
<span
role="button"
tabIndex={0}
onClick={handleClear}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
handleClear(e);
}
}}
className="flex items-center justify-center size-5 rounded hover:bg-surface-secondary transition-colors"
aria-label="Clear selection"
>
<X className="size-4 text-content-secondary hover:text-content-primary" />
</span>
)}
<span className="flex items-center justify-center size-5">
<ChevronDown
className={cn(
"size-4 text-content-secondary transition-transform",
isOpen && "rotate-180",
)}
/>
</span>
</span>
</button>
</PopoverTrigger>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-0"
align="start"
>
<Command shouldFilter={controlledInputValue === undefined}>
<CommandInput
placeholder={placeholder}
value={inputValue}
onValueChange={handleInputChange}
onKeyDown={handleKeyDown}
/>
<CommandList>
{loading ? (
<div className="flex items-center justify-center py-6">
<Spinner size="sm" loading />
</div>
) : (
<>
<CommandEmpty>{noOptionsText}</CommandEmpty>
<CommandGroup>
{options.map((option) => {
const optionValue = getOptionValue(option);
const optionLabel = getOptionLabel(option);
const selected = isSelected(option);
return (
<CommandItem
key={optionValue}
value={optionValue}
keywords={[optionLabel]}
onSelect={() => handleSelect(option)}
className="cursor-pointer"
>
{renderOption ? (
renderOption(option, selected)
) : (
<>
<span className="flex-1">{optionLabel}</span>
{selected && <Check className="size-4 shrink-0" />}
</>
)}
</CommandItem>
);
})}
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
@@ -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<HTMLLIElement>;
}
export const UserOrGroupOption = ({
option,
htmlProps,
}: UserOrGroupOptionProps) => {
const isOptionGroup = isGroup(option);
return (
<li {...htmlProps}>
<AvatarData
title={
isOptionGroup ? option.display_name || option.name : option.username
}
subtitle={isOptionGroup ? getGroupSubtitle(option) : option.email}
src={option.avatar_url}
/>
</li>
);
};
@@ -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.
+1 -1
View File
@@ -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 = () => {
@@ -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 = {
@@ -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";
@@ -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,
@@ -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<UserOrGroupAutocompleteProps> = ({
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<UserOrGroupAutocompleteProps> = ({
})
: [];
const { debounced: handleFilterChange } = useDebouncedFunction(
(event: ChangeEvent<HTMLInputElement>) => {
setAutoComplete((state) => ({
...state,
value: event.target.value,
}));
},
500,
);
return (
<Autocomplete
noOptionsText="No users or groups found"
value={value}
id="user-or-group-autocomplete"
open={autoComplete.open}
onOpen={() => {
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) => (
<UserOrGroupOption key={key} htmlProps={props} option={option} />
isOptionEqualToValue={(option, optionValue) =>
option.id === optionValue.id
}
renderOption={(option, isSelected) => (
<div className="flex items-center justify-between w-full">
<AvatarData
title={
isGroup(option)
? option.display_name || option.name
: option.username
}
subtitle={isGroup(option) ? getGroupSubtitle(option) : option.email}
src={option.avatar_url}
/>
{isSelected && <Check className="size-4 shrink-0" />}
</div>
)}
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) => (
<TextField
{...params}
margin="none"
size="small"
placeholder="Search for user or group"
InputProps={{
...params.InputProps,
onChange: handleFilterChange,
endAdornment: (
<>
{aclAvailableQuery.isFetching ? (
<CircularProgress size={16} />
) : null}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
placeholder="Search for user or group"
noOptionsText="No users or groups found"
className="w-[300px]"
id="user-or-group-autocomplete"
/>
);
};
@@ -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<UserOrGroupAutocompleteProps> = ({
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<UserOrGroupAutocompleteProps> = ({
...(usersQuery.data?.users ?? []),
].filter((result) => !excludeIds.includes(result.id));
const { debounced: handleFilterChange } = useDebouncedFunction(
(event: ChangeEvent<HTMLInputElement>) => {
setAutoComplete((state) => ({
...state,
value: event.target.value,
}));
},
500,
);
return (
<Autocomplete
noOptionsText="No users or groups found"
value={value}
id="workspace-user-or-group-autocomplete"
open={autoComplete.open}
onOpen={() => {
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) => (
<UserOrGroupOption key={key} htmlProps={props} option={option} />
isOptionEqualToValue={(option, optionValue) =>
option.id === optionValue.id
}
renderOption={(option, isSelected) => (
<div className="flex items-center justify-between w-full">
<AvatarData
title={
isGroup(option)
? option.display_name || option.name
: option.username
}
subtitle={isGroup(option) ? getGroupSubtitle(option) : option.email}
src={option.avatar_url}
/>
{isSelected && <Check className="size-4 shrink-0" />}
</div>
)}
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) => (
<TextField
{...params}
margin="none"
size="small"
placeholder="Search for user or group"
InputProps={{
...params.InputProps,
onChange: handleFilterChange,
endAdornment: (
<>
{(usersQuery.isFetching || groupsQuery.isFetching) && (
<CircularProgress size={16} />
)}
{params.InputProps.endAdornment}
</>
),
}}
/>
)}
placeholder="Search for user or group"
noOptionsText="No users or groups found"
className="w-80"
id="workspace-user-or-group-autocomplete"
/>
);
};
@@ -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,