mirror of https://github.com/coder/coder.git
149 lines
4.1 KiB
TypeScript
149 lines
4.1 KiB
TypeScript
import CircularProgress from "@mui/material/CircularProgress";
|
|
import TextField from "@mui/material/TextField";
|
|
import Autocomplete from "@mui/material/Autocomplete";
|
|
import { type ChangeEvent, type FC, useState } from "react";
|
|
import { css } from "@emotion/react";
|
|
import type { Group, ReducedUser } from "api/typesGenerated";
|
|
import { AvatarData } from "components/AvatarData/AvatarData";
|
|
import { getGroupSubtitle } from "utils/groups";
|
|
import { useDebouncedFunction } from "hooks/debounce";
|
|
import { useQuery } from "react-query";
|
|
import { templaceACLAvailable } from "api/queries/templates";
|
|
import { prepareQuery } from "utils/filters";
|
|
|
|
export type UserOrGroupAutocompleteValue = ReducedUser | Group | null;
|
|
|
|
export type UserOrGroupAutocompleteProps = {
|
|
value: UserOrGroupAutocompleteValue;
|
|
onChange: (value: UserOrGroupAutocompleteValue) => void;
|
|
templateID: string;
|
|
exclude: UserOrGroupAutocompleteValue[];
|
|
};
|
|
|
|
export const UserOrGroupAutocomplete: FC<UserOrGroupAutocompleteProps> = ({
|
|
value,
|
|
onChange,
|
|
templateID,
|
|
exclude,
|
|
}) => {
|
|
const [autoComplete, setAutoComplete] = useState({
|
|
value: "",
|
|
open: false,
|
|
});
|
|
const aclAvailableQuery = useQuery({
|
|
...templaceACLAvailable(templateID, {
|
|
q: prepareQuery(encodeURI(autoComplete.value)),
|
|
limit: 25,
|
|
}),
|
|
enabled: autoComplete.open,
|
|
keepPreviousData: true,
|
|
});
|
|
const options = aclAvailableQuery.data
|
|
? [
|
|
...aclAvailableQuery.data.groups,
|
|
...aclAvailableQuery.data.users,
|
|
].filter((result) => {
|
|
const excludeIds = exclude.map(
|
|
(optionToExclude) => optionToExclude?.id,
|
|
);
|
|
return !excludeIds.includes(result.id);
|
|
})
|
|
: [];
|
|
|
|
const { debounced: handleFilterChange } = useDebouncedFunction(
|
|
(event: ChangeEvent<HTMLInputElement>) => {
|
|
setAutoComplete((state) => ({
|
|
...state,
|
|
value: event.target.value,
|
|
}));
|
|
},
|
|
500,
|
|
);
|
|
|
|
return (
|
|
<Autocomplete
|
|
noOptionsText="No users or groups found"
|
|
value={value}
|
|
id="user-or-group-autocomplete"
|
|
open={autoComplete.open}
|
|
onOpen={() => {
|
|
setAutoComplete((state) => ({
|
|
...state,
|
|
open: true,
|
|
}));
|
|
}}
|
|
onClose={() => {
|
|
setAutoComplete({
|
|
value: isGroup(value) ? value.display_name : value?.email ?? "",
|
|
open: false,
|
|
});
|
|
}}
|
|
onChange={(_, newValue) => {
|
|
onChange(newValue);
|
|
}}
|
|
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
getOptionLabel={(option) =>
|
|
isGroup(option) ? option.display_name || option.name : option.email
|
|
}
|
|
renderOption={(props, option) => {
|
|
const isOptionGroup = isGroup(option);
|
|
|
|
return (
|
|
<li {...props}>
|
|
<AvatarData
|
|
title={
|
|
isOptionGroup
|
|
? option.display_name || option.name
|
|
: option.username
|
|
}
|
|
subtitle={isOptionGroup ? getGroupSubtitle(option) : option.email}
|
|
src={option.avatar_url}
|
|
/>
|
|
</li>
|
|
);
|
|
}}
|
|
options={options}
|
|
loading={aclAvailableQuery.isFetching}
|
|
css={autoCompleteStyles}
|
|
renderInput={(params) => (
|
|
<>
|
|
<TextField
|
|
{...params}
|
|
margin="none"
|
|
size="small"
|
|
placeholder="Search for user or group"
|
|
InputProps={{
|
|
...params.InputProps,
|
|
onChange: handleFilterChange,
|
|
endAdornment: (
|
|
<>
|
|
{aclAvailableQuery.isFetching ? (
|
|
<CircularProgress size={16} />
|
|
) : null}
|
|
{params.InputProps.endAdornment}
|
|
</>
|
|
),
|
|
}}
|
|
/>
|
|
</>
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
const isGroup = (value: UserOrGroupAutocompleteValue): value is Group => {
|
|
return value !== null && "members" in value;
|
|
};
|
|
|
|
const autoCompleteStyles = css`
|
|
width: 300px;
|
|
|
|
& .MuiFormControl-root {
|
|
width: 100%;
|
|
}
|
|
|
|
& .MuiInputBase-root {
|
|
width: 100%;
|
|
}
|
|
`;
|