mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Kira Pilot
parent
a7e08db16d
commit
5a449bf86f
@@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user