chore: Add user autocomplete (#4210)

* chore: Add user autocomplete

* Update value type

* fix initial load and option updates

* cleaned up styling

* PR comments

* prettier

Co-authored-by: Kira Pilot <kira.pilot23@gmail.com>
This commit is contained in:
Bruno Quaresma
2022-09-27 10:23:38 -04:00
committed by GitHub
co-authored by Kira Pilot
parent a7e08db16d
commit 5a449bf86f
2 changed files with 186 additions and 0 deletions
@@ -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<UserAutocompleteProps> = ({ value, onChange }) => {
const styles = useStyles()
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
const [searchState, sendSearch] = useMachine(searchUserMachine)
const { searchResults } = searchState.context
const [selectedValue, setSelectedValue] = useState<User | null>(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<HTMLInputElement>) => {
sendSearch("SEARCH", { query: event.target.value })
}, 1000)
return (
<Autocomplete
value={selectedValue}
id="user-autocomplete"
open={isAutocompleteOpen}
onOpen={() => {
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) => (
<AvatarData
title={option.username}
subtitle={option.email}
highlightTitle
avatar={
option.avatar_url ? (
<img
className={styles.avatar}
alt={`${option.username}'s Avatar`}
src={option.avatar_url}
/>
) : null
}
/>
)}
options={searchResults}
loading={searchState.matches("searching")}
className={styles.autocomplete}
renderInput={(params) => (
<TextField
{...params}
margin="none"
variant="outlined"
placeholder="User email or username"
InputProps={{
...params.InputProps,
onChange: handleFilterChange,
endAdornment: (
<>
{searchState.matches("searching") ? <CircularProgress size={16} /> : 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%",
},
}
})
@@ -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,
}),
},
},
)