Skip to content

Commit 5a449bf

Browse files
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>
1 parent a7e08db commit 5a449bf

File tree

2 files changed

+186
-0
lines changed

2 files changed

+186
-0
lines changed
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import CircularProgress from "@material-ui/core/CircularProgress"
2+
import { makeStyles } from "@material-ui/core/styles"
3+
import TextField from "@material-ui/core/TextField"
4+
import Autocomplete from "@material-ui/lab/Autocomplete"
5+
import { useMachine } from "@xstate/react"
6+
import { User } from "api/typesGenerated"
7+
import { AvatarData } from "components/AvatarData/AvatarData"
8+
import debounce from "just-debounce-it"
9+
import { ChangeEvent, useEffect, useState } from "react"
10+
import { searchUserMachine } from "xServices/users/searchUserXService"
11+
12+
export type UserAutocompleteProps = {
13+
value?: User
14+
onChange: (user: User | null) => void
15+
}
16+
17+
export const UserAutocomplete: React.FC<UserAutocompleteProps> = ({ value, onChange }) => {
18+
const styles = useStyles()
19+
const [isAutocompleteOpen, setIsAutocompleteOpen] = useState(false)
20+
const [searchState, sendSearch] = useMachine(searchUserMachine)
21+
const { searchResults } = searchState.context
22+
const [selectedValue, setSelectedValue] = useState<User | null>(value || null)
23+
24+
// seed list of options on the first page load if a user pases in a value
25+
// since some organizations have long lists of users, we do not load all options on page load.
26+
useEffect(() => {
27+
if (value) {
28+
sendSearch("SEARCH", { query: value.email })
29+
}
30+
// eslint-disable-next-line react-hooks/exhaustive-deps
31+
}, [])
32+
33+
const handleFilterChange = debounce((event: ChangeEvent<HTMLInputElement>) => {
34+
sendSearch("SEARCH", { query: event.target.value })
35+
}, 1000)
36+
37+
return (
38+
<Autocomplete
39+
value={selectedValue}
40+
id="user-autocomplete"
41+
open={isAutocompleteOpen}
42+
onOpen={() => {
43+
setIsAutocompleteOpen(true)
44+
}}
45+
onClose={() => {
46+
setIsAutocompleteOpen(false)
47+
}}
48+
onChange={(_, newValue) => {
49+
if (newValue === null) {
50+
sendSearch("CLEAR_RESULTS")
51+
}
52+
53+
setSelectedValue(newValue)
54+
onChange(newValue)
55+
}}
56+
getOptionSelected={(option: User, value: User) => option.username === value.username}
57+
getOptionLabel={(option) => option.email}
58+
renderOption={(option: User) => (
59+
<AvatarData
60+
title={option.username}
61+
subtitle={option.email}
62+
highlightTitle
63+
avatar={
64+
option.avatar_url ? (
65+
<img
66+
className={styles.avatar}
67+
alt={`${option.username}'s Avatar`}
68+
src={option.avatar_url}
69+
/>
70+
) : null
71+
}
72+
/>
73+
)}
74+
options={searchResults}
75+
loading={searchState.matches("searching")}
76+
className={styles.autocomplete}
77+
renderInput={(params) => (
78+
<TextField
79+
{...params}
80+
margin="none"
81+
variant="outlined"
82+
placeholder="User email or username"
83+
InputProps={{
84+
...params.InputProps,
85+
onChange: handleFilterChange,
86+
endAdornment: (
87+
<>
88+
{searchState.matches("searching") ? <CircularProgress size={16} /> : null}
89+
{params.InputProps.endAdornment}
90+
</>
91+
),
92+
}}
93+
/>
94+
)}
95+
/>
96+
)
97+
}
98+
export const useStyles = makeStyles((theme) => {
99+
return {
100+
autocomplete: {
101+
width: "100%",
102+
103+
"& .MuiFormControl-root": {
104+
width: "100%",
105+
},
106+
107+
"& .MuiInputBase-root": {
108+
width: "100%",
109+
// Match button small height
110+
height: 40,
111+
},
112+
113+
"& input": {
114+
fontSize: 14,
115+
padding: `${theme.spacing(0, 0.5, 0, 0.5)} !important`,
116+
},
117+
},
118+
119+
avatar: {
120+
width: theme.spacing(4.5),
121+
height: theme.spacing(4.5),
122+
borderRadius: "100%",
123+
},
124+
}
125+
})
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { getUsers } from "api/api"
2+
import { User } from "api/typesGenerated"
3+
import { queryToFilter } from "util/filters"
4+
import { assign, createMachine } from "xstate"
5+
6+
export type AutocompleteEvent = { type: "SEARCH"; query: string } | { type: "CLEAR_RESULTS" }
7+
8+
export const searchUserMachine = createMachine(
9+
{
10+
id: "searchUserMachine",
11+
schema: {
12+
context: {} as {
13+
searchResults?: User[]
14+
},
15+
events: {} as AutocompleteEvent,
16+
services: {} as {
17+
searchUsers: {
18+
data: User[]
19+
}
20+
},
21+
},
22+
context: {
23+
searchResults: [],
24+
},
25+
tsTypes: {} as import("./searchUserXService.typegen").Typegen0,
26+
initial: "idle",
27+
states: {
28+
idle: {
29+
on: {
30+
SEARCH: "searching",
31+
CLEAR_RESULTS: {
32+
actions: ["clearResults"],
33+
target: "idle",
34+
},
35+
},
36+
},
37+
searching: {
38+
invoke: {
39+
src: "searchUsers",
40+
onDone: {
41+
target: "idle",
42+
actions: ["assignSearchResults"],
43+
},
44+
},
45+
},
46+
},
47+
},
48+
{
49+
services: {
50+
searchUsers: (_, { query }) => getUsers(queryToFilter(query)),
51+
},
52+
actions: {
53+
assignSearchResults: assign({
54+
searchResults: (_, { data }) => data,
55+
}),
56+
clearResults: assign({
57+
searchResults: (_) => undefined,
58+
}),
59+
},
60+
},
61+
)

0 commit comments

Comments
 (0)