diff --git a/site/src/components/UserAutocomplete/UserAutocomplete.tsx b/site/src/components/UserAutocomplete/UserAutocomplete.tsx new file mode 100644 index 0000000000000..2cd30da85c32b --- /dev/null +++ b/site/src/components/UserAutocomplete/UserAutocomplete.tsx @@ -0,0 +1,125 @@ +import CircularProgress from "@material-ui/core/CircularProgress" +import { makeStyles } from "@material-ui/core/styles" +import TextField from "@material-ui/core/TextField" +import Autocomplete from "@material-ui/lab/Autocomplete" +import { useMachine } from "@xstate/react" +import { User } from "api/typesGenerated" +import { AvatarData } from "components/AvatarData/AvatarData" +import debounce from "just-debounce-it" +import { ChangeEvent, useEffect, useState } from "react" +import { searchUserMachine } from "xServices/users/searchUserXService" + +export type UserAutocompleteProps = { + value?: User + onChange: (user: User | null) => void +} + +export const UserAutocomplete: React.FC = ({ value, onChange }) => { + const styles = useStyles() + const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false) + const [searchState, sendSearch] = useMachine(searchUserMachine) + const { searchResults } = searchState.context + const [selectedValue, setSelectedValue] = useState(value || null) + + // seed list of options on the first page load if a user pases in a value + // since some organizations have long lists of users, we do not load all options on page load. + useEffect(() => { + if (value) { + sendSearch("SEARCH", { query: value.email }) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const handleFilterChange = debounce((event: ChangeEvent) => { + sendSearch("SEARCH", { query: event.target.value }) + }, 1000) + + return ( + { + setIsAutocompleteOpen(true) + }} + onClose={() => { + setIsAutocompleteOpen(false) + }} + onChange={(_, newValue) => { + if (newValue === null) { + sendSearch("CLEAR_RESULTS") + } + + setSelectedValue(newValue) + onChange(newValue) + }} + getOptionSelected={(option: User, value: User) => option.username === value.username} + getOptionLabel={(option) => option.email} + renderOption={(option: User) => ( + + ) : null + } + /> + )} + options={searchResults} + loading={searchState.matches("searching")} + className={styles.autocomplete} + renderInput={(params) => ( + + {searchState.matches("searching") ? : null} + {params.InputProps.endAdornment} + + ), + }} + /> + )} + /> + ) +} +export const useStyles = makeStyles((theme) => { + return { + autocomplete: { + width: "100%", + + "& .MuiFormControl-root": { + width: "100%", + }, + + "& .MuiInputBase-root": { + width: "100%", + // Match button small height + height: 40, + }, + + "& input": { + fontSize: 14, + padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`, + }, + }, + + avatar: { + width: theme.spacing(4.5), + height: theme.spacing(4.5), + borderRadius: "100%", + }, + } +}) diff --git a/site/src/xServices/users/searchUserXService.ts b/site/src/xServices/users/searchUserXService.ts new file mode 100644 index 0000000000000..28fcb043d8e00 --- /dev/null +++ b/site/src/xServices/users/searchUserXService.ts @@ -0,0 +1,61 @@ +import { getUsers } from "api/api" +import { User } from "api/typesGenerated" +import { queryToFilter } from "util/filters" +import { assign, createMachine } from "xstate" + +export type AutocompleteEvent = { type: "SEARCH"; query: string } | { type: "CLEAR_RESULTS" } + +export const searchUserMachine = createMachine( + { + id: "searchUserMachine", + 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: (_, { query }) => getUsers(queryToFilter(query)), + }, + actions: { + assignSearchResults: assign({ + searchResults: (_, { data }) => data, + }), + clearResults: assign({ + searchResults: (_) => undefined, + }), + }, + }, +)