mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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.
|
||||
@@ -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";
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+45
-70
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+45
-74
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user