From 3fca20df65fde39dea39374f63118aed6c386c42 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 5 Mar 2026 14:22:37 +1100 Subject: [PATCH] feat: move `` to `` (#22590) This pull-request takes our super MUI-based `` and migrates it to a common `` so that things will render correctly inline and use the new `shadcn` style system. | Old | New | | --- | --- | | | | --- site/src/components/Combobox/Combobox.tsx | 13 +- .../UserAutocomplete/UserAutocomplete.tsx | 225 ++++++++++-------- .../CreateWorkspacePageView.tsx | 1 - .../pages/GroupsPage/GroupPage.stories.tsx | 20 +- 4 files changed, 152 insertions(+), 107 deletions(-) diff --git a/site/src/components/Combobox/Combobox.tsx b/site/src/components/Combobox/Combobox.tsx index 318f536f67..bee47d5b52 100644 --- a/site/src/components/Combobox/Combobox.tsx +++ b/site/src/components/Combobox/Combobox.tsx @@ -97,12 +97,19 @@ export const ComboboxButton = ({ ); }; +type ComboboxContentProps = React.ComponentPropsWithRef< + typeof PopoverContent +> & { + shouldFilter?: boolean; +}; + export const ComboboxContent = ({ children, className, ref, + shouldFilter, ...props -}: React.ComponentPropsWithRef) => { +}: ComboboxContentProps) => { return ( - {children} + + {children} + ); }; diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx index 0db917b47a..6b9cf5fc25 100644 --- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -1,7 +1,3 @@ -import { css } from "@emotion/css"; -import Autocomplete from "@mui/material/Autocomplete"; -import CircularProgress from "@mui/material/CircularProgress"; -import TextField from "@mui/material/TextField"; import { getErrorMessage } from "api/errors"; import { organizationMembers } from "api/queries/organizations"; import { users, workspaceAvailableUsers } from "api/queries/users"; @@ -10,16 +6,25 @@ import type { OrganizationMemberWithUserData, User, } from "api/typesGenerated"; +import { ChevronDownIcon } from "components/AnimatedIcons/ChevronDown"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/Avatar/AvatarData"; -import { useDebouncedFunction } from "hooks/debounce"; +import { Button } from "components/Button/Button"; import { - type ChangeEvent, - type ComponentProps, - type FC, - useState, -} from "react"; + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxTrigger, +} from "components/Combobox/Combobox"; +import { Label } from "components/Label/Label"; +import { Spinner } from "components/Spinner/Spinner"; +import { useDebouncedFunction, useDebouncedValue } from "hooks/debounce"; +import { type FC, useId, useState } from "react"; import { keepPreviousData, useQuery } from "react-query"; +import { cn } from "utils/cn"; import { prepareQuery } from "utils/filters"; // The common properties between users and org members that we need. @@ -33,8 +38,6 @@ type CommonAutocompleteProps = { className?: string; label?: string; onChange: (user: T | null) => void; - required?: boolean; - size?: ComponentProps["size"]; value: T | null; }; @@ -55,6 +58,7 @@ export const UserAutocomplete: FC = (props) => { error={usersQuery.error} isFetching={usersQuery.isFetching} + shouldFilter={false} setFilter={setFilter} users={usersQuery.data?.users} {...props} @@ -82,6 +86,7 @@ export const MemberAutocomplete: FC = ({ error={membersQuery.error} isFetching={membersQuery.isFetching} + shouldFilter={true} setFilter={setFilter} users={membersQuery.data?.members} {...props} @@ -111,6 +116,7 @@ export const WorkspaceUserAutocomplete: FC = ({ error={availableUsersQuery.error} isFetching={availableUsersQuery.isFetching} + shouldFilter={false} setFilter={setFilter} users={availableUsersQuery.data} {...props} @@ -123,6 +129,8 @@ type InnerAutocompleteProps = /** The error is null if not loaded or no error. */ error: unknown; isFetching: boolean; + /** Enable cmdk filtering for non-API-filtered variants. */ + shouldFilter: boolean; /** Filter is undefined if the autocomplete is closed. */ setFilter: (filter: string | undefined) => void; /** Users are undefined if not loaded or errored. */ @@ -132,100 +140,123 @@ type InnerAutocompleteProps = const InnerAutocomplete = ({ className, error, - isFetching, label, onChange, - required, + shouldFilter, setFilter, - size = "small", + isFetching, users, value, }: InnerAutocompleteProps) => { - const [open, setOpen] = useState(false); + const DEBOUNCE_MS = 750; - const { debounced: debouncedInputOnChange } = useDebouncedFunction( - (event: ChangeEvent) => { - setFilter(event.target.value ?? ""); - }, - 750, - ); + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(""); + const id = useId(); + const debouncedInputValue = useDebouncedValue(inputValue, DEBOUNCE_MS); + const { debounced: debouncedSetFilter, cancelDebounce } = + useDebouncedFunction((nextFilter: string) => { + setFilter(nextFilter); + }, DEBOUNCE_MS); + + const selectedInputValue = value?.email ?? value?.username ?? ""; + const selectedFilterValue = value?.username ?? ""; + // Keep spinner only while typing away from the selected value. + const isLoadingOptions = + selectedInputValue !== inputValue && + ((inputValue !== "" && debouncedInputValue !== inputValue) || isFetching); + + const handleOpenChange = (nextOpen: boolean) => { + setOpen(nextOpen); + cancelDebounce(); + if (nextOpen) { + setInputValue(selectedInputValue); + setFilter(selectedFilterValue); + return; + } + setInputValue(""); + setFilter(undefined); + }; return ( - a.username === b.username} - getOptionLabel={(option) => option.email ?? option.username} - onOpen={() => { - setOpen(true); - setFilter(value?.email ?? value?.username ?? ""); - }} - onClose={() => { - setOpen(false); - setFilter(undefined); - }} - onChange={(_, newValue) => { - onChange(newValue); - }} - renderOption={({ key, ...props }, option) => ( -
  • - + {label && } + { + if (!newValue) { + onChange(null); + return; + } + onChange(users?.find((user) => user.username === newValue) ?? null); + }} + open={open} + onOpenChange={handleOpenChange} + > + + + + + { + setInputValue(nextInputValue); + if (nextInputValue === "") { + // If we're clearing the input, we don't need + // to debounce the filter change. + cancelDebounce(); + setFilter(""); + return; + } + debouncedSetFilter(nextInputValue); + }} /> -
  • - )} - renderInput={(params) => ( - - ), - endAdornment: ( - <> - {isFetching && open && } - {params.InputProps.endAdornment} - - ), - classes: { root }, - }} - InputLabelProps={{ - shrink: true, - }} - /> - )} - /> + + {!isLoadingOptions && + (users ?? []).map((option) => ( + + + + ))} + + + {isLoadingOptions || (!users && !error) ? ( +
    + +
    + ) : error ? ( + getErrorMessage(error, "Unable to fetch users") + ) : ( + "No users found" + )} +
    + +
    + ); }; - -const root = css` - padding-left: 14px !important; // Same padding left as input - gap: 4px; -`; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx index 1732c0a7c4..7bc51ec138 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageView.tsx @@ -509,7 +509,6 @@ export const CreateWorkspacePageView: FC = ({ onChange={(user) => { handleOwnerChange(user ?? defaultOwner); }} - size="medium" /> )} diff --git a/site/src/pages/GroupsPage/GroupPage.stories.tsx b/site/src/pages/GroupsPage/GroupPage.stories.tsx index 80675c91b6..a0448ec2e4 100644 --- a/site/src/pages/GroupsPage/GroupPage.stories.tsx +++ b/site/src/pages/GroupsPage/GroupPage.stories.tsx @@ -106,8 +106,9 @@ export const MembersError: Story = { }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const combobox = await canvas.findByRole("combobox"); - await userEvent.click(combobox); + await userEvent.click( + await canvas.findByRole("button", { name: "Select a user" }), + ); }, }; @@ -119,13 +120,14 @@ export const NoMembers: Story = { members: [], }), permissionsQuery({ canUpdateGroup: true }), - membersQuery([]), + membersQuery({ members: [] }), ], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const combobox = await canvas.findByRole("combobox"); - await userEvent.click(combobox); + await userEvent.click( + await canvas.findByRole("button", { name: "Select a user" }), + ); }, }; @@ -134,11 +136,15 @@ export const FiltersByMembers: Story = { queries: [ groupQuery(MockGroup), permissionsQuery({ canUpdateGroup: true }), - membersQuery([MockOrganizationMember, MockOrganizationMember2]), + membersQuery({ + members: [MockOrganizationMember, MockOrganizationMember2], + }), ], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await userEvent.click(canvas.getByRole("button", { name: "Open" })); + await userEvent.click( + await canvas.findByRole("button", { name: "Select a user" }), + ); }, };