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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy