diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 9828ab7884..acd3914c42 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -194,9 +194,12 @@ export const getTokenConfig = async (): Promise => { export const getUsers = async ( options: TypesGen.UsersRequest, + signal?: AbortSignal, ): Promise => { const url = getURLWithSearchParams("/api/v2/users", options); - const response = await axios.get(url.toString()); + const response = await axios.get(url.toString(), { + signal, + }); return response.data; }; diff --git a/site/src/api/queries/users.ts b/site/src/api/queries/users.ts index 1eac49771f..bdb753493c 100644 --- a/site/src/api/queries/users.ts +++ b/site/src/api/queries/users.ts @@ -1,11 +1,15 @@ -import { QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryOptions } from "@tanstack/react-query"; import * as API from "api/api"; -import { UpdateUserPasswordRequest, UsersRequest } from "api/typesGenerated"; +import { + GetUsersResponse, + UpdateUserPasswordRequest, + UsersRequest, +} from "api/typesGenerated"; -export const users = (req: UsersRequest) => { +export const users = (req: UsersRequest): QueryOptions => { return { queryKey: ["users", req], - queryFn: () => API.getUsers(req), + queryFn: ({ signal }) => API.getUsers(req, signal), }; }; diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx index f76b61e605..6d022dac2e 100644 --- a/site/src/components/UserAutocomplete/UserAutocomplete.tsx +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -2,21 +2,15 @@ import CircularProgress from "@mui/material/CircularProgress"; import { makeStyles } from "@mui/styles"; import TextField from "@mui/material/TextField"; import Autocomplete from "@mui/material/Autocomplete"; -import { useMachine } from "@xstate/react"; import { User } from "api/typesGenerated"; import { Avatar } from "components/Avatar/Avatar"; import { AvatarData } from "components/AvatarData/AvatarData"; -import { - ChangeEvent, - ComponentProps, - FC, - useEffect, - useRef, - useState, -} from "react"; -import { searchUserMachine } from "xServices/users/searchUserXService"; +import { ChangeEvent, ComponentProps, FC, useState } from "react"; import Box from "@mui/material/Box"; import { useDebouncedFunction } from "hooks/debounce"; +import { useQuery } from "@tanstack/react-query"; +import { users } from "api/queries/users"; +import { prepareQuery } from "utils/filters"; export type UserAutocompleteProps = { value: User | null; @@ -34,53 +28,57 @@ export const UserAutocomplete: FC = ({ size = "small", }) => { const styles = useStyles(); - const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false); - const [searchState, sendSearch] = useMachine(searchUserMachine); - const { searchResults } = searchState.context; + const [autoComplete, setAutoComplete] = useState<{ + value: string; + open: boolean; + }>({ + value: value?.email ?? "", + open: false, + }); + const usersQuery = useQuery({ + ...users({ + q: prepareQuery(encodeURI(autoComplete.value)), + limit: 25, + }), + enabled: autoComplete.open, + keepPreviousData: true, + }); - // Seed list of options on the first page load if a user passes in a value. - // Since some organizations have long lists of users, we do not want to load - // all options on page load. - const onMountRef = useRef(value); - useEffect(() => { - const mountValue = onMountRef.current; - if (mountValue) { - sendSearch("SEARCH", { query: mountValue.email }); - } - - // This isn't in XState's docs, but its source code guarantees that the - // memory reference of sendSearch will stay stable across renders. This - // useEffect call will behave like an on-mount effect and will not ever need - // to resynchronize - }, [sendSearch]); - - const { debounced: debouncedOnChange } = useDebouncedFunction( + const { debounced: debouncedInputOnChange } = useDebouncedFunction( (event: ChangeEvent) => { - sendSearch("SEARCH", { query: event.target.value }); + setAutoComplete((state) => ({ + ...state, + value: event.target.value, + })); }, - 1000, + 750, ); return ( user} + noOptionsText="No users found" className={className} - options={searchResults ?? []} - loading={searchState.matches("searching")} + options={usersQuery.data?.users ?? []} + loading={usersQuery.isLoading} value={value} id="user-autocomplete" - open={isAutocompleteOpen} + open={autoComplete.open} onOpen={() => { - setIsAutocompleteOpen(true); + setAutoComplete((state) => ({ + ...state, + open: true, + })); }} onClose={() => { - setIsAutocompleteOpen(false); + setAutoComplete({ + value: value?.email ?? "", + open: false, + }); }} onChange={(_, newValue) => { - if (newValue === null) { - sendSearch("CLEAR_RESULTS"); - } - onChange(newValue); }} isOptionEqualToValue={(option: User, value: User) => @@ -97,39 +95,37 @@ export const UserAutocomplete: FC = ({ )} renderInput={(params) => ( - <> - - {value.username} - - ), - endAdornment: ( - <> - {searchState.matches("searching") ? ( - - ) : null} - {params.InputProps.endAdornment} - - ), - classes: { - root: styles.inputRoot, - }, - }} - InputLabelProps={{ - shrink: true, - }} - /> - + + {value.username} + + ), + endAdornment: ( + <> + {usersQuery.isFetching && autoComplete.open ? ( + + ) : null} + {params.InputProps.endAdornment} + + ), + classes: { + root: styles.inputRoot, + }, + }} + InputLabelProps={{ + shrink: true, + }} + /> )} /> ); diff --git a/site/src/xServices/users/searchUserXService.ts b/site/src/xServices/users/searchUserXService.ts deleted file mode 100644 index 62b23a727b..0000000000 --- a/site/src/xServices/users/searchUserXService.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { getUsers } from "api/api"; -import { User } from "api/typesGenerated"; -import { queryToFilter } from "utils/filters"; -import { assign, createMachine } from "xstate"; - -export type AutocompleteEvent = - | { type: "SEARCH"; query: string } - | { type: "CLEAR_RESULTS" }; - -export const searchUserMachine = createMachine( - { - id: "searchUserMachine", - predictableActionArguments: true, - schema: { - context: {} as { - searchResults?: User[]; - }, - events: {} as AutocompleteEvent, - services: {} as { - searchUsers: { - data: User[]; - }; - }, - }, - context: { - searchResults: [], - }, - tsTypes: {} as import("./searchUserXService.typegen").Typegen0, - initial: "idle", - states: { - idle: { - on: { - SEARCH: "searching", - CLEAR_RESULTS: { - actions: ["clearResults"], - target: "idle", - }, - }, - }, - searching: { - invoke: { - src: "searchUsers", - onDone: { - target: "idle", - actions: ["assignSearchResults"], - }, - }, - }, - }, - }, - { - services: { - searchUsers: async (_, { query }) => - (await getUsers(queryToFilter(query))).users, - }, - actions: { - assignSearchResults: assign({ - searchResults: (_, { data }) => data, - }), - clearResults: assign({ - searchResults: (_) => undefined, - }), - }, - }, -);