From c47d0f47d2e920dbb19988d6e9ab235ad72e1885 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sat, 13 Dec 2025 18:38:47 +0000 Subject: [PATCH 1/9] feat: add radix autocomplete component --- .../Autocomplete/Autocomplete.stories.tsx | 335 ++++++++++++++++++ .../components/Autocomplete/Autocomplete.tsx | 255 +++++++++++++ .../UserOrGroupAutocomplete.tsx | 116 +++--- .../UserOrGroupAutocomplete.tsx | 120 +++---- 4 files changed, 682 insertions(+), 144 deletions(-) create mode 100644 site/src/components/Autocomplete/Autocomplete.stories.tsx create mode 100644 site/src/components/Autocomplete/Autocomplete.tsx diff --git a/site/src/components/Autocomplete/Autocomplete.stories.tsx b/site/src/components/Autocomplete/Autocomplete.stories.tsx new file mode 100644 index 0000000000000..0299dd7f61cdd --- /dev/null +++ b/site/src/components/Autocomplete/Autocomplete.stories.tsx @@ -0,0 +1,335 @@ +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 { 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" + /> +
+ ); + }, +}; + +export const WithSelectedValue: Story = { + render: function WithSelectedValueStory() { + const [value, setValue] = useState(simpleOptions[2]); + return ( +
+ opt.id} + getOptionLabel={(opt) => opt.name} + placeholder="Select a fruit" + /> +
+ ); + }, +}; + +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 + /> +
+ ); + }, +}; + +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" + /> +
+ ); + }, +}; + +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 && } +
+ )} + /> +
+ ); + }, +}; + +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 && } +
+ )} + /> +
+ ); + }, +}; + +export const AsyncSearch: Story = { + render: function AsyncSearchStory() { + const [value, setValue] = useState(null); + const [inputValue, setInputValue] = useState(""); + const [loading, setLoading] = useState(false); + const [filteredUsers, setFilteredUsers] = useState([]); + + const handleInputChange = (newValue: string) => { + setInputValue(newValue); + setLoading(true); + setTimeout(() => { + const filtered = users.filter( + (user) => + user.username.toLowerCase().includes(newValue.toLowerCase()) || + user.email.toLowerCase().includes(newValue.toLowerCase()), + ); + setFilteredUsers(filtered); + setLoading(false); + }, 500); + }; + + const handleOpenChange = (open: boolean) => { + if (open) { + handleInputChange(""); + } + }; + + return ( +
+ user.id} + getOptionLabel={(user) => user.email} + placeholder="Search for a user" + inputValue={inputValue} + onInputChange={handleInputChange} + onOpenChange={handleOpenChange} + loading={loading} + noOptionsText="No users found" + renderOption={(user, isSelected) => ( +
+ + {isSelected && } +
+ )} + /> +
+ ); + }, +}; + +interface Country { + code: string; + name: string; + flag: string; +} + +const countries: Country[] = [ + { code: "US", name: "United States", flag: "πŸ‡ΊπŸ‡Έ" }, + { code: "GB", name: "United Kingdom", flag: "πŸ‡¬πŸ‡§" }, + { code: "CA", name: "Canada", flag: "πŸ‡¨πŸ‡¦" }, + { code: "AU", name: "Australia", flag: "πŸ‡¦πŸ‡Ί" }, + { code: "DE", name: "Germany", flag: "πŸ‡©πŸ‡ͺ" }, + { code: "FR", name: "France", flag: "πŸ‡«πŸ‡·" }, + { code: "JP", name: "Japan", flag: "πŸ‡―πŸ‡΅" }, +]; + +export const CountrySelector: Story = { + render: function CountrySelectorStory() { + const [value, setValue] = useState(null); + return ( +
+ country.code} + getOptionLabel={(country) => country.name} + placeholder="Select a country" + renderOption={(country, isSelected) => ( +
+ + {country.flag} {country.name} + + {isSelected && } +
+ )} + /> +
+ ); + }, +}; diff --git a/site/src/components/Autocomplete/Autocomplete.tsx b/site/src/components/Autocomplete/Autocomplete.tsx new file mode 100644 index 0000000000000..781fa9d339b9c --- /dev/null +++ b/site/src/components/Autocomplete/Autocomplete.tsx @@ -0,0 +1,255 @@ +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"; + +export interface AutocompleteOption { + value: string; + label: string; +} + +export 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/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx index 23da3c2784981..a48c541c85c23 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -1,16 +1,13 @@ -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 { isGroup } from "components/UserOrGroupAutocomplete/UserOrGroupOption"; +import { Check } from "lucide-react"; +import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { prepareQuery } from "utils/filters"; +import { getGroupSubtitle } from "utils/groups"; export type UserOrGroupAutocompleteValue = ReducedUser | Group | null; type AutocompleteOption = Exclude; @@ -28,18 +25,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 +56,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..346591734e06b 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx @@ -1,17 +1,14 @@ -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 { isGroup } from "components/UserOrGroupAutocomplete/UserOrGroupOption"; +import { Check } from "lucide-react"; +import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { prepareQuery } from "utils/filters"; +import { getGroupSubtitle } from "utils/groups"; type AutocompleteOption = User | Group; export type UserOrGroupAutocompleteValue = AutocompleteOption | null; @@ -31,27 +28,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 +73,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" /> ); }; From 9ca529cb4d38e07fc40b09bb5929c379eee7c299 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sat, 13 Dec 2025 18:42:33 +0000 Subject: [PATCH 2/9] fix: set color for displayvalue --- site/src/components/Autocomplete/Autocomplete.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/components/Autocomplete/Autocomplete.tsx b/site/src/components/Autocomplete/Autocomplete.tsx index 781fa9d339b9c..b3d7e98c632a6 100644 --- a/site/src/components/Autocomplete/Autocomplete.tsx +++ b/site/src/components/Autocomplete/Autocomplete.tsx @@ -165,7 +165,9 @@ export function Autocomplete({ {displayValue || placeholder} From 86981a098cc676669f72ec5713898ec577e37c54 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 14 Dec 2025 09:25:14 +0000 Subject: [PATCH 3/9] chore: add play to stories --- .../Autocomplete/Autocomplete.stories.tsx | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/site/src/components/Autocomplete/Autocomplete.stories.tsx b/site/src/components/Autocomplete/Autocomplete.stories.tsx index 0299dd7f61cdd..edab89be91c1e 100644 --- a/site/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/site/src/components/Autocomplete/Autocomplete.stories.tsx @@ -3,6 +3,7 @@ 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 = { @@ -46,6 +47,20 @@ export const Default: Story = { ); }, + 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(), + ); + + await userEvent.click(screen.getByRole("option", { name: "Mango" })); + await waitFor(() => expect(trigger).toHaveTextContent("Mango")); + }, }; export const WithSelectedValue: Story = { @@ -64,6 +79,26 @@ export const WithSelectedValue: Story = { ); }, + 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: "Pineapple" })); + await waitFor(() => + expect( + canvas.getByRole("button", { name: /select a fruit/i }), + ).toBeInTheDocument(), + ); + }, }; export const NotClearable: Story = { @@ -102,6 +137,14 @@ export const Loading: Story = { ); }, + 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 = { @@ -140,6 +183,85 @@ export const EmptyOptions: Story = { ); }, + 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")); + 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(); + }); + + await userEvent.click(screen.getByRole("option", { name: "Banana" })); + + await waitFor(() => + expect(canvas.getByRole("button")).toHaveTextContent("Banana"), + ); + }, +}; + +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 { @@ -332,4 +454,18 @@ export const CountrySelector: Story = { ); }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button", { name: /select a country/i }); + await userEvent.click(trigger); + + await waitFor(() => expect(screen.getByText(/Japan/)).toBeInTheDocument()); + await userEvent.click(screen.getByText(/Japan/)); + + await waitFor(() => + expect( + canvas.getByRole("button", { name: /japan/i }), + ).toBeInTheDocument(), + ); + }, }; From e83f25ebd7d10e5aecba744dd116782165311b71 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 14 Dec 2025 09:30:20 +0000 Subject: [PATCH 4/9] chore: cleanup --- .../UserOrGroupOption.tsx | 43 ------------------- .../UserOrGroupAutocomplete.tsx | 3 +- .../UserOrGroupAutocomplete.tsx | 3 +- site/src/utils/groups.ts | 14 +++++- 4 files changed, 15 insertions(+), 48 deletions(-) delete mode 100644 site/src/components/UserOrGroupAutocomplete/UserOrGroupOption.tsx 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/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx index a48c541c85c23..2ac3064630819 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -2,12 +2,11 @@ import { templaceACLAvailable } from "api/queries/templates"; import type { Group, ReducedUser } from "api/typesGenerated"; import { Autocomplete } from "components/Autocomplete/Autocomplete"; import { AvatarData } from "components/Avatar/AvatarData"; -import { isGroup } from "components/UserOrGroupAutocomplete/UserOrGroupOption"; import { Check } from "lucide-react"; import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { prepareQuery } from "utils/filters"; -import { getGroupSubtitle } from "utils/groups"; +import { getGroupSubtitle, isGroup } from "utils/groups"; export type UserOrGroupAutocompleteValue = ReducedUser | Group | null; type AutocompleteOption = Exclude; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx index 346591734e06b..8974db9604f3c 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx @@ -3,12 +3,11 @@ import { users } from "api/queries/users"; import type { Group, User } from "api/typesGenerated"; import { Autocomplete } from "components/Autocomplete/Autocomplete"; import { AvatarData } from "components/Avatar/AvatarData"; -import { isGroup } from "components/UserOrGroupAutocomplete/UserOrGroupOption"; import { Check } from "lucide-react"; import { type FC, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; import { prepareQuery } from "utils/filters"; -import { getGroupSubtitle } from "utils/groups"; +import { getGroupSubtitle, isGroup } from "utils/groups"; type AutocompleteOption = User | Group; export type UserOrGroupAutocompleteValue = AutocompleteOption | null; diff --git a/site/src/utils/groups.ts b/site/src/utils/groups.ts index 4c456b81bf788..fc642d7769f4f 100644 --- a/site/src/utils/groups.ts +++ b/site/src/utils/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. From 05310221ead52457e2024682440e3460f74afe8b Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Sun, 14 Dec 2025 09:30:59 +0000 Subject: [PATCH 5/9] fix: cleanup --- site/src/components/Autocomplete/Autocomplete.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/site/src/components/Autocomplete/Autocomplete.tsx b/site/src/components/Autocomplete/Autocomplete.tsx index b3d7e98c632a6..5872927420569 100644 --- a/site/src/components/Autocomplete/Autocomplete.tsx +++ b/site/src/components/Autocomplete/Autocomplete.tsx @@ -21,12 +21,7 @@ import { } from "react"; import { cn } from "utils/cn"; -export interface AutocompleteOption { - value: string; - label: string; -} - -export interface AutocompleteProps { +interface AutocompleteProps { value: TOption | null; onChange: (value: TOption | null) => void; options: readonly TOption[]; From 7078c7670a4b2ffd6bf6858398221d9f59517dcc Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 15 Dec 2025 12:56:33 +0000 Subject: [PATCH 6/9] fix: fix storybook test --- site/src/components/Autocomplete/Autocomplete.stories.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/site/src/components/Autocomplete/Autocomplete.stories.tsx b/site/src/components/Autocomplete/Autocomplete.stories.tsx index edab89be91c1e..e0f1455b3f359 100644 --- a/site/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/site/src/components/Autocomplete/Autocomplete.stories.tsx @@ -210,7 +210,7 @@ export const SearchAndFilter: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button")); + await userEvent.click(canvas.getByRole("button", { name: /select a fruit/i })); const searchInput = screen.getByRole("combobox"); await userEvent.type(searchInput, "an"); @@ -227,7 +227,9 @@ export const SearchAndFilter: Story = { await userEvent.click(screen.getByRole("option", { name: "Banana" })); await waitFor(() => - expect(canvas.getByRole("button")).toHaveTextContent("Banana"), + expect( + canvas.getByRole("button", { name: /banana/i }), + ).toBeInTheDocument(), ); }, }; From 2360d1a1dfc5925c1c71fcc0781dbf8d742bd9ba Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 15 Dec 2025 13:28:36 +0000 Subject: [PATCH 7/9] fix: fix storybook test --- site/src/components/Autocomplete/Autocomplete.stories.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/site/src/components/Autocomplete/Autocomplete.stories.tsx b/site/src/components/Autocomplete/Autocomplete.stories.tsx index e0f1455b3f359..e71e7142e3337 100644 --- a/site/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/site/src/components/Autocomplete/Autocomplete.stories.tsx @@ -210,7 +210,9 @@ export const SearchAndFilter: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button", { name: /select a fruit/i })); + await userEvent.click( + canvas.getByRole("button", { name: /select a fruit/i }), + ); const searchInput = screen.getByRole("combobox"); await userEvent.type(searchInput, "an"); From 029ec0aa82ddce47ea1df0306964e543a0c514e0 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Mon, 15 Dec 2025 13:39:08 +0000 Subject: [PATCH 8/9] chore: cleanup stories --- .../Autocomplete/Autocomplete.stories.tsx | 139 ++---------------- 1 file changed, 9 insertions(+), 130 deletions(-) diff --git a/site/src/components/Autocomplete/Autocomplete.stories.tsx b/site/src/components/Autocomplete/Autocomplete.stories.tsx index e71e7142e3337..9639e8a89b454 100644 --- a/site/src/components/Autocomplete/Autocomplete.stories.tsx +++ b/site/src/components/Autocomplete/Autocomplete.stories.tsx @@ -57,9 +57,6 @@ export const Default: Story = { await waitFor(() => expect(screen.getByRole("option", { name: "Mango" })).toBeInTheDocument(), ); - - await userEvent.click(screen.getByRole("option", { name: "Mango" })); - await waitFor(() => expect(trigger).toHaveTextContent("Mango")); }, }; @@ -92,12 +89,8 @@ export const WithSelectedValue: Story = { ).toBeInTheDocument(), ); - await userEvent.click(screen.getByRole("option", { name: "Pineapple" })); - await waitFor(() => - expect( - canvas.getByRole("button", { name: /select a fruit/i }), - ).toBeInTheDocument(), - ); + await userEvent.click(screen.getByRole("option", { name: "Mango" })); + await waitFor(() => expect(trigger).toHaveTextContent("Mango")); }, }; @@ -225,14 +218,6 @@ export const SearchAndFilter: Story = { screen.queryByRole("option", { name: "Pineapple" }), ).not.toBeInTheDocument(); }); - - await userEvent.click(screen.getByRole("option", { name: "Banana" })); - - await waitFor(() => - expect( - canvas.getByRole("button", { name: /banana/i }), - ).toBeInTheDocument(), - ); }, }; @@ -322,6 +307,13 @@ export const WithCustomRenderOption: Story = { ); }, + 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 = { @@ -360,116 +352,3 @@ export const WithStartAdornment: Story = { ); }, }; - -export const AsyncSearch: Story = { - render: function AsyncSearchStory() { - const [value, setValue] = useState(null); - const [inputValue, setInputValue] = useState(""); - const [loading, setLoading] = useState(false); - const [filteredUsers, setFilteredUsers] = useState([]); - - const handleInputChange = (newValue: string) => { - setInputValue(newValue); - setLoading(true); - setTimeout(() => { - const filtered = users.filter( - (user) => - user.username.toLowerCase().includes(newValue.toLowerCase()) || - user.email.toLowerCase().includes(newValue.toLowerCase()), - ); - setFilteredUsers(filtered); - setLoading(false); - }, 500); - }; - - const handleOpenChange = (open: boolean) => { - if (open) { - handleInputChange(""); - } - }; - - return ( -
    - user.id} - getOptionLabel={(user) => user.email} - placeholder="Search for a user" - inputValue={inputValue} - onInputChange={handleInputChange} - onOpenChange={handleOpenChange} - loading={loading} - noOptionsText="No users found" - renderOption={(user, isSelected) => ( -
    - - {isSelected && } -
    - )} - /> -
    - ); - }, -}; - -interface Country { - code: string; - name: string; - flag: string; -} - -const countries: Country[] = [ - { code: "US", name: "United States", flag: "πŸ‡ΊπŸ‡Έ" }, - { code: "GB", name: "United Kingdom", flag: "πŸ‡¬πŸ‡§" }, - { code: "CA", name: "Canada", flag: "πŸ‡¨πŸ‡¦" }, - { code: "AU", name: "Australia", flag: "πŸ‡¦πŸ‡Ί" }, - { code: "DE", name: "Germany", flag: "πŸ‡©πŸ‡ͺ" }, - { code: "FR", name: "France", flag: "πŸ‡«πŸ‡·" }, - { code: "JP", name: "Japan", flag: "πŸ‡―πŸ‡΅" }, -]; - -export const CountrySelector: Story = { - render: function CountrySelectorStory() { - const [value, setValue] = useState(null); - return ( -
    - country.code} - getOptionLabel={(country) => country.name} - placeholder="Select a country" - renderOption={(country, isSelected) => ( -
    - - {country.flag} {country.name} - - {isSelected && } -
    - )} - /> -
    - ); - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const trigger = canvas.getByRole("button", { name: /select a country/i }); - await userEvent.click(trigger); - - await waitFor(() => expect(screen.getByText(/Japan/)).toBeInTheDocument()); - await userEvent.click(screen.getByText(/Japan/)); - - await waitFor(() => - expect( - canvas.getByRole("button", { name: /japan/i }), - ).toBeInTheDocument(), - ); - }, -}; From a204764f596f9971e32de2c74406c6ab8a147abf Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Tue, 16 Dec 2025 15:22:15 +0000 Subject: [PATCH 9/9] chore: move groups.ts --- site/src/{utils => modules}/groups.ts | 0 site/src/pages/GroupsPage/GroupPage.tsx | 2 +- site/src/pages/GroupsPage/GroupSettingsPageView.tsx | 2 +- .../OrganizationSettingsPage/IdpSyncPage/IdpGroupSyncForm.tsx | 2 +- .../TemplatePermissionsPage/TemplatePermissionsPageView.tsx | 2 +- .../TemplatePermissionsPage/UserOrGroupAutocomplete.tsx | 2 +- .../WorkspaceSharingPage/UserOrGroupAutocomplete.tsx | 2 +- .../WorkspaceSharingPage/WorkspaceSharingPageView.tsx | 2 +- 8 files changed, 7 insertions(+), 7 deletions(-) rename site/src/{utils => modules}/groups.ts (100%) diff --git a/site/src/utils/groups.ts b/site/src/modules/groups.ts similarity index 100% rename from site/src/utils/groups.ts rename to site/src/modules/groups.ts 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 2ac3064630819..8e7747b41c49b 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/UserOrGroupAutocomplete.tsx @@ -3,10 +3,10 @@ import type { Group, ReducedUser } from "api/typesGenerated"; 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"; -import { getGroupSubtitle, isGroup } from "utils/groups"; export type UserOrGroupAutocompleteValue = ReducedUser | Group | null; type AutocompleteOption = Exclude; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx index 8974db9604f3c..86b5546d6c5d1 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSharingPage/UserOrGroupAutocomplete.tsx @@ -4,10 +4,10 @@ import type { Group, User } from "api/typesGenerated"; 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"; -import { getGroupSubtitle, isGroup } from "utils/groups"; type AutocompleteOption = User | Group; export type UserOrGroupAutocompleteValue = AutocompleteOption | null; 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,