Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
@@ -1,9 +1,8 @@
|
||||
import { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils";
|
||||
import { stringAvatar } from "@/components/Event/utils/eventUtils";
|
||||
import { useUserSearch } from "./useUserSearch";
|
||||
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
import { searchUsers } from "@/features/User/userAPI";
|
||||
import {
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
@@ -22,8 +21,6 @@ import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined
|
||||
import {
|
||||
HTMLAttributes,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
} from "react";
|
||||
@@ -82,21 +79,29 @@ export function PeopleSearch({
|
||||
getChipIcon?: (user: User) => ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [options, setOptions] = useState<User[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder");
|
||||
const errorMessage = t("peopleSearch.searchError");
|
||||
|
||||
const {
|
||||
query,
|
||||
setQuery,
|
||||
loading,
|
||||
options,
|
||||
hasSearched,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
inputError,
|
||||
setInputError,
|
||||
snackbarOpen,
|
||||
setSnackbarOpen,
|
||||
snackbarMessage,
|
||||
setSnackbarMessage,
|
||||
} = useUserSearch<User>({ objectTypes, errorMessage });
|
||||
|
||||
const isValidEmail = (email: string) =>
|
||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
const [inputError, setInputError] = useState<string | null>(null);
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState("");
|
||||
const theme = useTheme();
|
||||
|
||||
const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder");
|
||||
|
||||
const handleBlurCommit = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
const trimmed = query.trim();
|
||||
@@ -116,52 +121,9 @@ export function PeopleSearch({
|
||||
onChange(event, [...selectedUsers, newUser]);
|
||||
setQuery("");
|
||||
},
|
||||
[query, selectedUsers, onChange, t]
|
||||
[query, selectedUsers, onChange, t, setInputError, setQuery]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const delayDebounceFn = setTimeout(async () => {
|
||||
if (!query.trim()) {
|
||||
if (!cancelled) {
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
setHasSearched(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setLoading(true);
|
||||
setHasSearched(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await searchUsers(query, objectTypes);
|
||||
if (!cancelled) {
|
||||
setOptions(res);
|
||||
setHasSearched(true);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setHasSearched(false);
|
||||
setSnackbarMessage(t("peopleSearch.searchError"));
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(delayDebounceFn);
|
||||
};
|
||||
}, [objectTypes, query, t]);
|
||||
|
||||
const defaultRenderInput = useCallback(
|
||||
(params: AutocompleteRenderInputParams) => {
|
||||
const inputProps = {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Avatar } from "@linagora/twake-mui";
|
||||
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
|
||||
import CalendarMonthOutlinedIcon from "@mui/icons-material/CalendarMonthOutlined";
|
||||
import PhoneIphoneOutlinedIcon from "@mui/icons-material/PhoneIphoneOutlined";
|
||||
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
import VideoCameraBackOutlinedIcon from "@mui/icons-material/VideoCameraBackOutlined";
|
||||
import MeetingRoomOutlinedIcon from "@mui/icons-material/MeetingRoomOutlined";
|
||||
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
|
||||
import ViewComfyAltOutlinedIcon from "@mui/icons-material/ViewComfyAltOutlined";
|
||||
import TvOutlinedIcon from "@mui/icons-material/TvOutlined";
|
||||
import type { SvgIconComponent } from "@mui/icons-material";
|
||||
|
||||
/**
|
||||
* displayName → MUI icon mapping.
|
||||
* Add new entries here when new resources are created.
|
||||
*/
|
||||
const RESOURCE_ICON_MAP: Record<string, SvgIconComponent> = {
|
||||
"Astreinte OSSA": PeopleAltOutlinedIcon,
|
||||
"Chômage Partiel": CalendarMonthOutlinedIcon,
|
||||
Congés: CalendarMonthOutlinedIcon,
|
||||
COPIL: CalendarMonthOutlinedIcon,
|
||||
"Iphone 11 Pro Max": PhoneIphoneOutlinedIcon,
|
||||
"Note de Service": DescriptionOutlinedIcon,
|
||||
"OSS Events": CalendarMonthOutlinedIcon,
|
||||
"Permanence OSSA": PeopleAltOutlinedIcon,
|
||||
"Projo salle 404": VideoCameraBackOutlinedIcon,
|
||||
"Salle-bat-A-S215-visio-15p": MeetingRoomOutlinedIcon,
|
||||
"Salle-bat-A-S216-ecran-5p": MeetingRoomOutlinedIcon,
|
||||
"Salle-bat-B-S217-10p": MeetingRoomOutlinedIcon,
|
||||
"Salle-bat-C-S218-ecran-20p": MeetingRoomOutlinedIcon,
|
||||
"Salle-bat-D-S218-visio-amphi-200p": MeetingRoomOutlinedIcon,
|
||||
Télétravail: LayersOutlinedIcon,
|
||||
"TV - VN": TvOutlinedIcon,
|
||||
"Veille COPIL": LayersOutlinedIcon,
|
||||
"White Board - VN": ViewComfyAltOutlinedIcon,
|
||||
};
|
||||
|
||||
function getIconForDisplayName(displayName: string): SvgIconComponent {
|
||||
return RESOURCE_ICON_MAP[displayName] ?? LayersOutlinedIcon;
|
||||
}
|
||||
|
||||
interface ResourceIconProps {
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
export function ResourceIcon({ displayName }: ResourceIconProps) {
|
||||
const IconComponent = getIconForDisplayName(displayName);
|
||||
|
||||
return (
|
||||
<Avatar sx={{ backgroundColor: "transparent" }}>
|
||||
<IconComponent fontSize="medium" />
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils";
|
||||
import { useUserSearch } from "./useUserSearch";
|
||||
import { ResourceIcon } from "./ResourceIcon";
|
||||
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
|
||||
import {
|
||||
Autocomplete,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
PaperProps,
|
||||
PopperProps,
|
||||
TextField,
|
||||
useTheme,
|
||||
Typography,
|
||||
type AutocompleteRenderInputParams,
|
||||
} from "@linagora/twake-mui";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import {
|
||||
HTMLAttributes,
|
||||
useCallback,
|
||||
type ReactNode,
|
||||
type SyntheticEvent,
|
||||
} from "react";
|
||||
import { useI18n } from "twake-i18n";
|
||||
|
||||
export interface Resource {
|
||||
email?: string;
|
||||
displayName: string;
|
||||
avatarUrl?: string;
|
||||
openpaasId?: string;
|
||||
color?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
|
||||
error?: boolean;
|
||||
helperText?: string | null;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function ResourceSearch({
|
||||
selectedResources,
|
||||
onChange,
|
||||
objectTypes,
|
||||
disabled,
|
||||
freeSolo,
|
||||
onToggleEventPreview,
|
||||
placeholder,
|
||||
inputSlot,
|
||||
customRenderInput,
|
||||
customSlotProps,
|
||||
hideLabel,
|
||||
}: {
|
||||
selectedResources: Resource[];
|
||||
onChange: (event: SyntheticEvent, users: Resource[]) => void;
|
||||
objectTypes: string[];
|
||||
disabled?: boolean;
|
||||
freeSolo?: boolean;
|
||||
onToggleEventPreview?: () => void;
|
||||
placeholder?: string;
|
||||
inputSlot?: (
|
||||
params: ExtendedAutocompleteRenderInputParams
|
||||
) => React.ReactNode;
|
||||
customRenderInput?: (
|
||||
params: AutocompleteRenderInputParams,
|
||||
query: string,
|
||||
setQuery: (value: string) => void
|
||||
) => ReactNode;
|
||||
customSlotProps?: {
|
||||
popper?: Partial<PopperProps>;
|
||||
paper?: Partial<PaperProps>;
|
||||
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
|
||||
};
|
||||
hideLabel?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const searchPlaceholder = placeholder ?? t("resourceSearch.placeholder");
|
||||
const errorMessage = t("resourceSearch.searchError");
|
||||
|
||||
const {
|
||||
query,
|
||||
setQuery,
|
||||
loading,
|
||||
options,
|
||||
hasSearched,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
inputError,
|
||||
setInputError,
|
||||
snackbarOpen,
|
||||
setSnackbarOpen,
|
||||
snackbarMessage,
|
||||
setSnackbarMessage,
|
||||
} = useUserSearch<Resource>({ objectTypes, errorMessage });
|
||||
|
||||
const theme = useTheme();
|
||||
|
||||
const handleBlurCommit = useCallback(
|
||||
(event: React.SyntheticEvent) => {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
if (selectedResources.find((u) => u.displayName === trimmed)) {
|
||||
setQuery("");
|
||||
return;
|
||||
}
|
||||
setInputError(null);
|
||||
const newResource: Resource = { displayName: trimmed };
|
||||
onChange(event, [...selectedResources, newResource]);
|
||||
setQuery("");
|
||||
},
|
||||
[query, selectedResources, onChange, setInputError, setQuery]
|
||||
);
|
||||
|
||||
const defaultRenderInput = useCallback(
|
||||
(params: AutocompleteRenderInputParams) => {
|
||||
const inputProps = {
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<>
|
||||
{!selectedResources?.length ? (
|
||||
<SearchIcon
|
||||
fontSize="small"
|
||||
sx={{ mr: 1, color: "action.active" }}
|
||||
/>
|
||||
) : null}
|
||||
{params.InputProps.startAdornment}
|
||||
</>
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
{loading ? <CircularProgress color="inherit" size={20} /> : null}
|
||||
{!selectedResources?.length ? params.InputProps.endAdornment : null}
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
const enhancedParams = {
|
||||
...params,
|
||||
InputProps: inputProps,
|
||||
inputProps: {
|
||||
...params.inputProps,
|
||||
autoComplete: "off",
|
||||
},
|
||||
};
|
||||
|
||||
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && onToggleEventPreview) {
|
||||
e.preventDefault();
|
||||
onToggleEventPreview();
|
||||
}
|
||||
};
|
||||
|
||||
const defaultTextFieldProps = {
|
||||
error: !!inputError,
|
||||
helperText: inputError,
|
||||
placeholder: searchPlaceholder,
|
||||
label: "",
|
||||
onKeyDown: handleEnterKey,
|
||||
slotProps: {
|
||||
input: {
|
||||
...inputProps,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (inputSlot) {
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && (
|
||||
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
|
||||
{t("resourceSearch.label")}
|
||||
</Typography>
|
||||
)}
|
||||
{inputSlot({
|
||||
...enhancedParams,
|
||||
error: !!inputError,
|
||||
helperText: inputError,
|
||||
placeholder: searchPlaceholder,
|
||||
label: "",
|
||||
onKeyDown: handleEnterKey,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideLabel && (
|
||||
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
|
||||
{t("resourceSearch.label")}
|
||||
</Typography>
|
||||
)}
|
||||
<TextField
|
||||
{...enhancedParams}
|
||||
{...defaultTextFieldProps}
|
||||
InputProps={inputProps}
|
||||
size="medium"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
inputError,
|
||||
t,
|
||||
onToggleEventPreview,
|
||||
loading,
|
||||
searchPlaceholder,
|
||||
selectedResources?.length,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Autocomplete
|
||||
popupIcon={null}
|
||||
freeSolo={freeSolo}
|
||||
multiple
|
||||
options={options}
|
||||
autoComplete={false}
|
||||
clearOnBlur={false}
|
||||
onBlur={freeSolo ? handleBlurCommit : undefined}
|
||||
open={
|
||||
customRenderInput
|
||||
? isOpen && !!query && (loading || options.length > 0)
|
||||
: isOpen && !!query && (loading || hasSearched)
|
||||
}
|
||||
onOpen={() => setIsOpen(true)}
|
||||
onClose={() => setIsOpen(false)}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
filterOptions={(options: Resource[]) => options}
|
||||
fullWidth
|
||||
noOptionsText={t("resourceSearch.noResults")}
|
||||
loadingText={t("resourceSearch.loading")}
|
||||
getOptionLabel={(option: Resource | string) => {
|
||||
if (typeof option === "object") {
|
||||
return option.displayName;
|
||||
} else {
|
||||
return option;
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
"& .MuiAutocomplete-inputRoot": {
|
||||
py: 0,
|
||||
},
|
||||
}}
|
||||
filterSelectedOptions
|
||||
value={selectedResources}
|
||||
inputValue={query}
|
||||
onInputChange={(_event, value: string) => setQuery(value)}
|
||||
onChange={(event, value: string[] | Resource[]) => {
|
||||
setInputError(null);
|
||||
const mapped = value
|
||||
.map((v: string | Resource) =>
|
||||
typeof v === "string" ? { displayName: v.trim() } : v
|
||||
)
|
||||
.filter((v) => v.displayName.trim().length > 0);
|
||||
onChange(event, mapped);
|
||||
}}
|
||||
slotProps={{
|
||||
...customSlotProps,
|
||||
popper: {
|
||||
placement: "bottom-start",
|
||||
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx },
|
||||
...customSlotProps?.popper,
|
||||
},
|
||||
}}
|
||||
// When render input is custom, the adornments should be handled by the custom component
|
||||
forcePopupIcon={!customRenderInput}
|
||||
disableClearable={!!customRenderInput}
|
||||
renderInput={(params) =>
|
||||
customRenderInput
|
||||
? customRenderInput(params, query, setQuery)
|
||||
: defaultRenderInput(params)
|
||||
}
|
||||
renderOption={(props, option: Resource) => {
|
||||
if (
|
||||
selectedResources.find((u) => u.displayName === option.displayName)
|
||||
)
|
||||
return null;
|
||||
const { key, ...otherProps } = props;
|
||||
return (
|
||||
<ListItem
|
||||
key={key + option?.displayName}
|
||||
{...otherProps}
|
||||
disableGutters
|
||||
>
|
||||
<ListItemAvatar>
|
||||
<ResourceIcon displayName={option.displayName} />
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary={option.displayName} />
|
||||
</ListItem>
|
||||
);
|
||||
}}
|
||||
renderValue={(value: string[] | Resource[], getTagProps) =>
|
||||
value.map((option: string | Resource, index) => {
|
||||
const isString = typeof option === "string";
|
||||
const label = isString ? option : option.displayName;
|
||||
const chipColor = isString
|
||||
? theme.palette.grey[300]
|
||||
: (option.color?.light ?? theme.palette.grey[300]);
|
||||
const textColor = getAccessiblePair(chipColor, theme);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={label}
|
||||
style={{
|
||||
backgroundColor: chipColor,
|
||||
color: textColor,
|
||||
}}
|
||||
label={label}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
/>
|
||||
<SnackbarAlert
|
||||
open={snackbarOpen}
|
||||
setOpen={(open: boolean) => {
|
||||
setSnackbarOpen(open);
|
||||
if (!open) {
|
||||
setSnackbarMessage("");
|
||||
}
|
||||
}}
|
||||
message={snackbarMessage}
|
||||
severity="error"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { searchUsers } from "@/features/User/userAPI";
|
||||
|
||||
export interface UseUserSearchProps {
|
||||
objectTypes: string[];
|
||||
errorMessage: string;
|
||||
}
|
||||
|
||||
export function useUserSearch<T>({
|
||||
objectTypes,
|
||||
errorMessage,
|
||||
}: UseUserSearchProps) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [options, setOptions] = useState<T[]>([]);
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const [inputError, setInputError] = useState<string | null>(null);
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const delayDebounceFn = setTimeout(async () => {
|
||||
if (!query.trim()) {
|
||||
if (!cancelled) {
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
setHasSearched(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setLoading(true);
|
||||
setHasSearched(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await searchUsers(query, objectTypes);
|
||||
if (!cancelled) {
|
||||
setOptions(res as unknown as T[]);
|
||||
setHasSearched(true);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setHasSearched(false);
|
||||
setSnackbarMessage(errorMessage);
|
||||
setSnackbarOpen(true);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(delayDebounceFn);
|
||||
};
|
||||
}, [objectTypes, query, errorMessage]);
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
loading,
|
||||
options,
|
||||
hasSearched,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
inputError,
|
||||
setInputError,
|
||||
snackbarOpen,
|
||||
setSnackbarOpen,
|
||||
snackbarMessage,
|
||||
setSnackbarMessage,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user