#708 apply strictier linting rules (#717)

* #708 apply strictier linting rules and fix simple eslint bugs

* #708 fix eslint errors relate to promise

* #708 fix eslint import/no-extraneous-dependencies

* #708 fix eslint errors of react-hook

* #708 enable eslint check for typescript

---------

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-04-01 22:15:10 +07:00
committed by GitHub
parent 2bff6aae78
commit cadfa70e60
321 changed files with 27452 additions and 27600 deletions
+75 -64
View File
@@ -1,22 +1,22 @@
import { userAttendee } from "@/features/User/models/attendee";
import { createAttendee } from "@/features/User/models/attendee.mapper";
import { useRef, useState } from "react";
import { FreeBusyIndicator } from "./FreeBusyIndicator";
import { userAttendee } from '@/features/User/models/attendee'
import { createAttendee } from '@/features/User/models/attendee.mapper'
import { useEffect, useRef, useState } from 'react'
import { FreeBusyIndicator } from './FreeBusyIndicator'
import {
ExtendedAutocompleteRenderInputParams,
PeopleSearch,
User,
} from "./PeopleSearch";
import { FreeBusyMap, useAttendeesFreeBusy } from "./useFreeBusy";
User
} from './PeopleSearch'
import { FreeBusyMap, useAttendeesFreeBusy } from './useFreeBusy'
const attendeeToUser = (a: userAttendee, openpaasId = ""): User => ({
const attendeeToUser = (a: userAttendee, openpaasId = ''): User => ({
email: a.cal_address,
displayName: a.cn ?? "",
avatarUrl: "",
openpaasId,
});
displayName: a.cn ?? '',
avatarUrl: '',
openpaasId
})
const hasCalendar = (u: User) => u.objectType === "user" && !!u.openpaasId;
const hasCalendar = (u: User) => u.objectType === 'user' && !!u.openpaasId
export default function AttendeeSearch({
attendees,
@@ -27,98 +27,109 @@ export default function AttendeeSearch({
start,
end,
timezone,
eventUid,
eventUid
}: {
attendees: userAttendee[];
setAttendees: (attendees: userAttendee[]) => void;
disabled?: boolean;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
placeholder?: string;
start?: string;
end?: string;
timezone?: string;
eventUid?: string | null;
attendees: userAttendee[]
setAttendees: (attendees: userAttendee[]) => void
disabled?: boolean
inputSlot?: (params: ExtendedAutocompleteRenderInputParams) => React.ReactNode
placeholder?: string
start?: string
end?: string
timezone?: string
eventUid?: string | null
}) {
const [userIdMap, setUserIdMap] = useState<Record<string, string>>({});
const [addedUsers, setAddedUsers] = useState<User[]>([]);
const initialEmailsRef = useRef<Set<string> | null>(null);
if (initialEmailsRef.current === null && !!eventUid && attendees.length > 0) {
initialEmailsRef.current = new Set(attendees.map((a) => a.cal_address));
}
const initialEmails = eventUid
? (initialEmailsRef.current ?? new Set<string>())
: new Set<string>();
const [userIdMap, setUserIdMap] = useState<Record<string, string>>({})
const [addedUsers, setAddedUsers] = useState<User[]>([])
const initialEmailsRef = useRef<Set<string> | null>(null)
const [initialEmailsSet, setInitialEmailsSet] = useState<Set<string>>(
new Set()
)
useEffect(() => {
const updateInitialEmailsSet = () => {
if (
initialEmailsRef.current === null &&
!!eventUid &&
attendees.length > 0
) {
initialEmailsRef.current = new Set(attendees.map(a => a.cal_address))
setInitialEmailsSet(initialEmailsRef.current)
}
}
updateInitialEmailsSet()
}, [eventUid, attendees])
const initialEmails = eventUid ? initialEmailsSet : new Set<string>()
const selectedUsers: User[] = [
...addedUsers,
...attendees
.map((a) => attendeeToUser(a, userIdMap[a.cal_address]))
.filter((a) => !addedUsers.find((u) => a.email === u.email)),
];
.map(a => attendeeToUser(a, userIdMap[a.cal_address]))
.filter(a => !addedUsers.find(u => a.email === u.email))
]
const toAttendee = (u: User) => ({
email: u.email,
userId: u.openpaasId || userIdMap[u.email] || null,
});
userId: u.openpaasId || userIdMap[u.email] || null
})
const existingAttendees = selectedUsers
.filter((u) => initialEmails.has(u.email))
.map(toAttendee);
.filter(u => initialEmails.has(u.email))
.map(toAttendee)
const newAttendees = selectedUsers
.filter((u) => !initialEmails.has(u.email) && hasCalendar(u))
.map(toAttendee);
.filter(u => !initialEmails.has(u.email) && hasCalendar(u))
.map(toAttendee)
// Contacts and freeSolo users get a static "contact" status — no API call needed
const contactMap: FreeBusyMap = Object.fromEntries(
selectedUsers
.filter((u) => !initialEmails.has(u.email) && !hasCalendar(u))
.map((u) => [u.email, "contact" as const])
);
.filter(u => !initialEmails.has(u.email) && !hasCalendar(u))
.map(u => [u.email, 'contact' as const])
)
const freeBusyMap = useAttendeesFreeBusy({
existingAttendees,
newAttendees,
start: start ?? "",
end: end ?? "",
timezone: timezone ?? "",
start: start ?? '',
end: end ?? '',
timezone: timezone ?? '',
eventUid,
enabled: !!(start && end && selectedUsers.length > 0),
});
enabled: !!(start && end && selectedUsers.length > 0)
})
const statusMap = { ...freeBusyMap, ...contactMap };
const statusMap = { ...freeBusyMap, ...contactMap }
return (
<PeopleSearch
selectedUsers={selectedUsers}
objectTypes={["user", "contact"]}
objectTypes={['user', 'contact']}
disabled={disabled}
inputSlot={inputSlot}
placeholder={placeholder}
getChipIcon={
start && end
? (user) => (
<FreeBusyIndicator status={statusMap[user.email] ?? "unknown"} />
? user => (
<FreeBusyIndicator status={statusMap[user.email] ?? 'unknown'} />
)
: undefined
}
onChange={(_event, value: User[]) => {
setUserIdMap((prev) => {
const next = { ...prev };
setUserIdMap(prev => {
const next = { ...prev }
for (const u of value) {
if (u.openpaasId && u.email) next[u.email] = u.openpaasId;
if (u.openpaasId && u.email) next[u.email] = u.openpaasId
}
return next;
});
setAddedUsers(value.filter((u) => !initialEmails.has(u.email)));
return next
})
setAddedUsers(value.filter(u => !initialEmails.has(u.email)))
setAttendees(
value.map((u) =>
value.map(u =>
createAttendee({ cal_address: u.email, cn: u.displayName })
)
);
)
}}
freeSolo
/>
);
)
}
+24 -21
View File
@@ -1,32 +1,35 @@
import { Tooltip } from "@linagora/twake-mui";
import AccessTimeFilledIcon from "@mui/icons-material/AccessTimeFilled";
import CloseIcon from "@mui/icons-material/Close";
import { useEffect, useState } from "react";
import { useI18n } from "twake-i18n";
import { FreeBusyStatus } from "./useFreeBusy";
import { Tooltip } from '@linagora/twake-mui'
import AccessTimeFilledIcon from '@mui/icons-material/AccessTimeFilled'
import CloseIcon from '@mui/icons-material/Close'
import { useEffect, useState } from 'react'
import { useI18n } from 'twake-i18n'
import { FreeBusyStatus } from './useFreeBusy'
interface FreeBusyIndicatorProps {
status: FreeBusyStatus;
size?: number;
status: FreeBusyStatus
size?: number
}
export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const { t } = useI18n()
const [open, setOpen] = useState(false)
useEffect(() => {
if (status === "busy") setOpen(true);
}, [status]);
const triggerOpen = () => {
if (status === 'busy') setOpen(true)
}
triggerOpen()
}, [status])
if (status !== "busy") return null;
if (status !== 'busy') return null
return (
<Tooltip
title={
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
{t("event.freeBusy.busy")}
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{t('event.freeBusy.busy')}
<CloseIcon
fontSize="inherit"
style={{ cursor: "pointer" }}
style={{ cursor: 'pointer' }}
onClick={() => setOpen(false)}
/>
</span>
@@ -35,16 +38,16 @@ export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) {
disableHoverListener
placement="bottom-start"
onClose={() => setOpen(false)}
slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: "grey.900" } } }}
slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: 'grey.900' } } }}
>
<AccessTimeFilledIcon
aria-label={t("event.freeBusy.busy")}
aria-label={t('event.freeBusy.busy')}
color="warning"
style={{
margin: "0 -6px 0 5px",
flexShrink: 0,
margin: '0 -6px 0 5px',
flexShrink: 0
}}
/>
</Tooltip>
);
)
}
+128 -132
View File
@@ -1,8 +1,8 @@
import { getAccessiblePair } from "@/utils/getAccessiblePair";
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 { getAccessiblePair } from '@/utils/getAccessiblePair'
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 {
Autocomplete,
Avatar,
@@ -15,35 +15,35 @@ import {
PopperProps,
TextField,
useTheme,
type AutocompleteRenderInputParams,
} from "@linagora/twake-mui";
import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
type AutocompleteRenderInputParams
} from '@linagora/twake-mui'
import PeopleOutlineOutlinedIcon from '@mui/icons-material/PeopleOutlineOutlined'
import {
HTMLAttributes,
useCallback,
type ReactNode,
type SyntheticEvent,
} from "react";
import { useI18n } from "twake-i18n";
import { ResourceIcon } from "./ResourceIcon";
import { isValidEmail } from "../../utils/isValidEmail";
import { usePasteHandler } from "./usePasteHandler";
type SyntheticEvent
} from 'react'
import { useI18n } from 'twake-i18n'
import { ResourceIcon } from './ResourceIcon'
import { isValidEmail } from '../../utils/isValidEmail'
import { usePasteHandler } from './usePasteHandler'
export interface User {
email: string;
displayName: string;
avatarUrl?: string;
openpaasId?: string;
color?: Record<string, string>;
objectType?: string;
email: string
displayName: string
avatarUrl?: string
openpaasId?: string
color?: Record<string, string>
objectType?: string
}
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
error?: boolean;
helperText?: string | null;
placeholder?: string;
label?: string;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
error?: boolean
helperText?: string | null
placeholder?: string
label?: string
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
export function PeopleSearch({
@@ -57,33 +57,31 @@ export function PeopleSearch({
inputSlot,
customRenderInput,
customSlotProps,
getChipIcon,
getChipIcon
}: {
selectedUsers: User[];
onChange: (event: SyntheticEvent, users: User[]) => void;
objectTypes: string[];
disabled?: boolean;
freeSolo?: boolean;
onToggleEventPreview?: () => void;
placeholder?: string;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
selectedUsers: User[]
onChange: (event: SyntheticEvent, users: User[]) => 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;
) => ReactNode
customSlotProps?: {
popper?: Partial<PopperProps>;
paper?: Partial<PaperProps>;
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
};
getChipIcon?: (user: User) => ReactNode;
popper?: Partial<PopperProps>
paper?: Partial<PaperProps>
listbox?: Partial<HTMLAttributes<HTMLUListElement>>
}
getChipIcon?: (user: User) => ReactNode
}) {
const { t } = useI18n();
const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder");
const errorMessage = t("peopleSearch.searchError");
const { t } = useI18n()
const searchPlaceholder = placeholder ?? t('peopleSearch.placeholder')
const errorMessage = t('peopleSearch.searchError')
const {
query,
@@ -98,32 +96,32 @@ export function PeopleSearch({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage,
} = useUserSearch<User>({ objectTypes, errorMessage });
setSnackbarMessage
} = useUserSearch<User>({ objectTypes, errorMessage })
const theme = useTheme();
const theme = useTheme()
const handleBlurCommit = useCallback(
(event: React.SyntheticEvent) => {
const trimmed = query.trim();
if (!trimmed) return;
const trimmed = query.trim()
if (!trimmed) return
if (!isValidEmail(trimmed)) {
setInputError(
t("peopleSearch.invalidEmail").replace("%{email}", trimmed)
);
return;
t('peopleSearch.invalidEmail').replace('%{email}', trimmed)
)
return
}
if (selectedUsers.find((u) => u.email === trimmed)) {
setQuery("");
return;
if (selectedUsers.find(u => u.email === trimmed)) {
setQuery('')
return
}
setInputError(null);
const newUser: User = { email: trimmed, displayName: trimmed };
onChange(event, [...selectedUsers, newUser]);
setQuery("");
setInputError(null)
const newUser: User = { email: trimmed, displayName: trimmed }
onChange(event, [...selectedUsers, newUser])
setQuery('')
},
[query, selectedUsers, onChange, t, setInputError, setQuery]
);
)
const handlePaste = usePasteHandler({
freeSolo,
@@ -131,8 +129,8 @@ export function PeopleSearch({
onChange,
setQuery,
setInputError,
t,
});
t
})
const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => {
@@ -143,7 +141,7 @@ export function PeopleSearch({
) : (
<PeopleOutlineOutlinedIcon
fontSize="small"
sx={{ mr: 1, color: "action.active" }}
sx={{ mr: 1, color: 'action.active' }}
/>
),
endAdornment: (
@@ -151,61 +149,61 @@ export function PeopleSearch({
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{params.InputProps.endAdornment}
</>
),
};
)
}
const enhancedParams = {
...params,
InputProps: inputProps,
inputProps: {
...params.inputProps,
autoComplete: "off",
onPaste: handlePaste,
},
};
autoComplete: 'off',
onPaste: handlePaste
}
}
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
if (e.key === 'Enter' && onToggleEventPreview) {
e.preventDefault()
onToggleEventPreview()
}
};
}
const defaultTextFieldProps = {
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
label: '',
onKeyDown: handleEnterKey,
slotProps: {
input: {
...inputProps,
},
},
};
...inputProps
}
}
}
if (inputSlot) {
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
{t('peopleSearch.label')}
</label>
{inputSlot({
...enhancedParams,
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: handleEnterKey,
label: '',
onKeyDown: handleEnterKey
})}
</>
);
)
}
return (
<>
<label htmlFor={params.id} className="visually-hidden">
{t("peopleSearch.label")}
{t('peopleSearch.label')}
</label>
<TextField
{...enhancedParams}
@@ -214,7 +212,7 @@ export function PeopleSearch({
size="medium"
/>
</>
);
)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -223,9 +221,9 @@ export function PeopleSearch({
onToggleEventPreview,
loading,
searchPlaceholder,
handlePaste,
handlePaste
]
);
)
return (
<>
@@ -246,68 +244,68 @@ export function PeopleSearch({
onClose={() => setIsOpen(false)}
disabled={disabled}
loading={loading}
filterOptions={(x) => x}
filterOptions={x => x}
fullWidth
noOptionsText={t("peopleSearch.noResults")}
loadingText={t("peopleSearch.loading")}
getOptionLabel={(option) => {
if (typeof option === "object") {
return option.displayName || option.email;
noOptionsText={t('peopleSearch.noResults')}
loadingText={t('peopleSearch.loading')}
getOptionLabel={option => {
if (typeof option === 'object') {
return option.displayName || option.email
} else {
return option;
return option
}
}}
sx={{
"& .MuiAutocomplete-inputRoot": {
py: 0,
},
'& .MuiAutocomplete-inputRoot': {
py: 0
}
}}
filterSelectedOptions
value={selectedUsers}
inputValue={query}
onInputChange={(_event, value) => setQuery(value)}
onChange={(event, value) => {
const last = value[value.length - 1];
if (typeof last === "string" && !isValidEmail(last.trim())) {
const invalidEmailMessage = t("peopleSearch.invalidEmail").replace(
"%{email}",
const last = value[value.length - 1]
if (typeof last === 'string' && !isValidEmail(last.trim())) {
const invalidEmailMessage = t('peopleSearch.invalidEmail').replace(
'%{email}',
last
);
setInputError(invalidEmailMessage);
return;
)
setInputError(invalidEmailMessage)
return
}
setInputError(null);
setInputError(null)
const mapped = value
.map((v: string | User) =>
typeof v === "string"
typeof v === 'string'
? { email: v.trim(), displayName: v.trim() }
: v
)
.filter(
(user, index, self) =>
self.findIndex((u) => u.email === user.email) === index
);
onChange(event, mapped);
self.findIndex(u => u.email === user.email) === index
)
onChange(event, mapped)
}}
slotProps={{
...customSlotProps,
popper: {
placement: "bottom-start",
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx },
...customSlotProps?.popper,
},
placement: 'bottom-start',
sx: { minWidth: '300px', ...customSlotProps?.popper?.sx },
...customSlotProps?.popper
}
}}
forcePopupIcon={false}
disableClearable
renderInput={(params) =>
renderInput={params =>
customRenderInput
? customRenderInput(params, query, setQuery)
: defaultRenderInput(params)
}
renderOption={(props, option) => {
if (selectedUsers.find((u) => u.email === option.email)) return null;
const { key, ...otherProps } = props;
const isResource = option.objectType === "resource";
if (selectedUsers.find(u => u.email === option.email)) return null
const { key, ...otherProps } = props
const isResource = option.objectType === 'resource'
return (
<ListItem key={key + option?.email} {...otherProps} disableGutters>
<ListItemAvatar>
@@ -321,25 +319,23 @@ export function PeopleSearch({
</ListItemAvatar>
<ListItemText
primary={option.displayName}
secondary={isResource ? "" : option.email}
secondary={isResource ? '' : option.email}
slotProps={{
primary: { variant: "body2" },
secondary: { variant: "caption" },
primary: { variant: 'body2' },
secondary: { variant: 'caption' }
}}
/>
</ListItem>
);
)
}}
renderValue={(value, getTagProps) =>
value.map((option, index) => {
const isString = typeof option === "string";
const label = isString
? option
: option.displayName || option.email;
const isString = typeof option === 'string'
const label = isString ? option : option.displayName || option.email
const chipColor = isString
? theme.palette.grey[200]
: (option.color?.light ?? theme.palette.grey[200]);
const textColor = getAccessiblePair(chipColor, theme);
: (option.color?.light ?? theme.palette.grey[200])
const textColor = getAccessiblePair(chipColor, theme)
return (
<Chip
@@ -351,25 +347,25 @@ export function PeopleSearch({
deleteIcon={<CloseIcon />}
style={{
backgroundColor: chipColor,
color: textColor,
color: textColor
}}
label={label}
/>
);
)
})
}
/>
<SnackbarAlert
open={snackbarOpen}
setOpen={(open: boolean) => {
setSnackbarOpen(open);
setSnackbarOpen(open)
if (!open) {
setSnackbarMessage("");
setSnackbarMessage('')
}
}}
message={snackbarMessage}
severity="error"
/>
</>
);
)
}
+13 -13
View File
@@ -1,41 +1,41 @@
import { Avatar, Box } from "@linagora/twake-mui";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import { Avatar, Box } from '@linagora/twake-mui'
import LayersOutlinedIcon from '@mui/icons-material/LayersOutlined'
interface ResourceIconProps {
avatarUrl?: string;
colorIcon?: boolean;
color?: string;
avatarUrl?: string
colorIcon?: boolean
color?: string
}
export function ResourceIcon({
avatarUrl,
colorIcon,
color,
color
}: ResourceIconProps) {
if (colorIcon && avatarUrl) {
return (
<Box
sx={{
width: "24px",
height: "24px",
width: '24px',
height: '24px',
backgroundColor: color,
maskImage: `url(${avatarUrl})`,
maskSize: "cover",
maskSize: 'cover'
}}
/>
);
)
}
return avatarUrl ? (
<Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }}
sx={{ backgroundColor: 'transparent', width: '24px', height: '24px' }}
src={avatarUrl}
/>
) : (
<Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }}
sx={{ backgroundColor: 'transparent', width: '24px', height: '24px' }}
>
<LayersOutlinedIcon />
</Avatar>
);
)
}
+106 -110
View File
@@ -1,7 +1,7 @@
import { getAccessiblePair } from "@/utils/getAccessiblePair";
import { useUserSearch } from "./useUserSearch";
import { ResourceIcon } from "./ResourceIcon";
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
import { getAccessiblePair } from '@/utils/getAccessiblePair'
import { useUserSearch } from './useUserSearch'
import { ResourceIcon } from './ResourceIcon'
import { SnackbarAlert } from '@/components/Loading/SnackBarAlert'
import {
Autocomplete,
Chip,
@@ -14,31 +14,31 @@ import {
TextField,
useTheme,
Typography,
type AutocompleteRenderInputParams,
} from "@linagora/twake-mui";
import SearchIcon from "@mui/icons-material/Search";
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";
type SyntheticEvent
} from 'react'
import { useI18n } from 'twake-i18n'
export interface Resource {
email?: string;
displayName: string;
avatarUrl?: string;
openpaasId?: string;
color?: Record<string, string>;
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;
error?: boolean
helperText?: string | null
placeholder?: string
label?: string
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void
}
export function ResourceSearch({
@@ -52,33 +52,31 @@ export function ResourceSearch({
inputSlot,
customRenderInput,
customSlotProps,
hideLabel,
hideLabel
}: {
selectedResources: Resource[];
onChange: (event: SyntheticEvent, users: Resource[]) => void;
objectTypes: string[];
disabled?: boolean;
freeSolo?: boolean;
onToggleEventPreview?: () => void;
placeholder?: string;
inputSlot?: (
params: ExtendedAutocompleteRenderInputParams
) => React.ReactNode;
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;
) => ReactNode
customSlotProps?: {
popper?: Partial<PopperProps>;
paper?: Partial<PaperProps>;
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
};
hideLabel?: boolean;
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 { t } = useI18n()
const searchPlaceholder = placeholder ?? t('resourceSearch.placeholder')
const errorMessage = t('resourceSearch.searchError')
const {
query,
@@ -93,26 +91,26 @@ export function ResourceSearch({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage,
} = useUserSearch<Resource>({ objectTypes, errorMessage });
setSnackbarMessage
} = useUserSearch<Resource>({ objectTypes, errorMessage })
const theme = useTheme();
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;
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("");
setInputError(null)
const newResource: Resource = { displayName: trimmed }
onChange(event, [...selectedResources, newResource])
setQuery('')
},
[query, selectedResources, onChange, setInputError, setQuery]
);
)
const defaultRenderInput = useCallback(
(params: AutocompleteRenderInputParams) => {
@@ -123,7 +121,7 @@ export function ResourceSearch({
{!selectedResources?.length ? (
<SearchIcon
fontSize="small"
sx={{ mr: 1, color: "action.active" }}
sx={{ mr: 1, color: 'action.active' }}
/>
) : null}
{params.InputProps.startAdornment}
@@ -134,44 +132,44 @@ export function ResourceSearch({
{loading ? <CircularProgress color="inherit" size={20} /> : null}
{!selectedResources?.length ? params.InputProps.endAdornment : null}
</>
),
};
)
}
const enhancedParams = {
...params,
InputProps: inputProps,
inputProps: {
...params.inputProps,
autoComplete: "off",
},
};
autoComplete: 'off'
}
}
const handleEnterKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
if (e.key === 'Enter' && onToggleEventPreview) {
e.preventDefault()
onToggleEventPreview()
}
};
}
const defaultTextFieldProps = {
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
label: '',
onKeyDown: handleEnterKey,
slotProps: {
input: {
...inputProps,
},
},
};
...inputProps
}
}
}
if (inputSlot) {
return (
<>
{!hideLabel && (
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
{t("resourceSearch.label")}
<Typography variant="h6" sx={{ marginBottom: '10px' }}>
{t('resourceSearch.label')}
</Typography>
)}
{inputSlot({
@@ -179,18 +177,18 @@ export function ResourceSearch({
error: !!inputError,
helperText: inputError,
placeholder: searchPlaceholder,
label: "",
onKeyDown: handleEnterKey,
label: '',
onKeyDown: handleEnterKey
})}
</>
);
)
}
return (
<>
{!hideLabel && (
<Typography variant="h6" sx={{ marginBottom: "10px" }}>
{t("resourceSearch.label")}
<Typography variant="h6" sx={{ marginBottom: '10px' }}>
{t('resourceSearch.label')}
</Typography>
)}
<TextField
@@ -200,7 +198,7 @@ export function ResourceSearch({
size="medium"
/>
</>
);
)
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -209,9 +207,9 @@ export function ResourceSearch({
onToggleEventPreview,
loading,
searchPlaceholder,
selectedResources?.length,
selectedResources?.length
]
);
)
return (
<>
@@ -234,55 +232,53 @@ export function ResourceSearch({
loading={loading}
filterOptions={(options: Resource[]) => options}
fullWidth
noOptionsText={t("resourceSearch.noResults")}
loadingText={t("resourceSearch.loading")}
noOptionsText={t('resourceSearch.noResults')}
loadingText={t('resourceSearch.loading')}
getOptionLabel={(option: Resource | string) => {
if (typeof option === "object") {
return option.displayName;
if (typeof option === 'object') {
return option.displayName
} else {
return option;
return option
}
}}
sx={{
"& .MuiAutocomplete-inputRoot": {
py: 0,
},
'& .MuiAutocomplete-inputRoot': {
py: 0
}
}}
filterSelectedOptions
value={selectedResources}
inputValue={query}
onInputChange={(_event, value: string) => setQuery(value)}
onChange={(event, value: string[] | Resource[]) => {
setInputError(null);
setInputError(null)
const mapped = value
.map((v: string | Resource) =>
typeof v === "string" ? { displayName: v.trim() } : v
typeof v === 'string' ? { displayName: v.trim() } : v
)
.filter((v) => v.displayName.trim().length > 0);
onChange(event, mapped);
.filter(v => v.displayName.trim().length > 0)
onChange(event, mapped)
}}
slotProps={{
...customSlotProps,
popper: {
placement: "bottom-start",
sx: { minWidth: "300px", ...customSlotProps?.popper?.sx },
...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) =>
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;
if (selectedResources.find(u => u.displayName === option.displayName))
return null
const { key, ...otherProps } = props
return (
<ListItem
key={key + option?.displayName}
@@ -294,16 +290,16 @@ export function ResourceSearch({
</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 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);
: (option.color?.light ?? theme.palette.grey[300])
const textColor = getAccessiblePair(chipColor, theme)
return (
<Chip
@@ -311,25 +307,25 @@ export function ResourceSearch({
key={label}
style={{
backgroundColor: chipColor,
color: textColor,
color: textColor
}}
label={label}
/>
);
)
})
}
/>
<SnackbarAlert
open={snackbarOpen}
setOpen={(open: boolean) => {
setSnackbarOpen(open);
setSnackbarOpen(open)
if (!open) {
setSnackbarMessage("");
setSnackbarMessage('')
}
}}
message={snackbarMessage}
severity="error"
/>
</>
);
)
}
+119 -122
View File
@@ -1,99 +1,92 @@
import { getFreeBusyForAddedAttendeesREPORT } from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT";
import { getFreeBusyForEventAttendeesPOST } from "@/features/Events/api/getFreeBusyForEventAttendeesPOST";
import { getUserDataFromEmail } from "@/features/Events/api/getUserDataFromEmail";
import moment from "moment-timezone";
import { useEffect, useRef, useState } from "react";
import { getFreeBusyForAddedAttendeesREPORT } from '@/features/Events/api/getFreeBusyForAddedAttendeesREPORT'
import { getFreeBusyForEventAttendeesPOST } from '@/features/Events/api/getFreeBusyForEventAttendeesPOST'
import { getUserDataFromEmail } from '@/features/Events/api/getUserDataFromEmail'
import moment from 'moment-timezone'
import { useEffect, useRef, useState } from 'react'
export type FreeBusyStatus =
| "free"
| "busy"
| "loading"
| "contact"
| "unknown";
export type FreeBusyMap = Record<string, FreeBusyStatus>;
export type FreeBusyStatus = 'free' | 'busy' | 'loading' | 'contact' | 'unknown'
export type FreeBusyMap = Record<string, FreeBusyStatus>
interface Attendee {
email: string;
userId?: string | null;
email: string
userId?: string | null
}
interface ResolvedAttendee {
email: string;
userId: string;
email: string
userId: string
}
// Helpers
async function resolveUserId(attendee: Attendee): Promise<string | null> {
if (attendee.userId) return attendee.userId;
if (attendee.userId) return attendee.userId
return getUserDataFromEmail(attendee.email)
.then((u) => u[0]?._id ?? null)
.catch(() => null);
.then(u => u[0]?._id ?? null)
.catch(() => null)
}
async function resolveAll(attendees: Attendee[]): Promise<ResolvedAttendee[]> {
const results = await Promise.all(
attendees.map(async (a) => {
const userId = await resolveUserId(a);
return userId ? { email: a.email, userId } : null;
attendees.map(async a => {
const userId = await resolveUserId(a)
return userId ? { email: a.email, userId } : null
})
);
return results.filter((r): r is ResolvedAttendee => r !== null);
)
return results.filter((r): r is ResolvedAttendee => r !== null)
}
export function hasFreeBusyConflict(data: unknown): boolean {
try {
const jcal = (data as { data: unknown[] }).data;
if (!Array.isArray(jcal) || jcal[0] !== "vcalendar") return false;
const components = jcal[2] as unknown[][];
return (
Array.isArray(components) && components.some(isVFreeBusyWithConflict)
);
const jcal = (data as { data: unknown[] }).data
if (!Array.isArray(jcal) || jcal[0] !== 'vcalendar') return false
const components = jcal[2] as unknown[][]
return Array.isArray(components) && components.some(isVFreeBusyWithConflict)
} catch {
return false;
return false
}
}
function isVFreeBusyWithConflict(component: unknown): boolean {
if (!Array.isArray(component) || component[0] !== "vfreebusy") return false;
const props = component[1] as unknown[][];
if (!Array.isArray(component) || component[0] !== 'vfreebusy') return false
const props = component[1] as unknown[][]
return (
Array.isArray(props) &&
props.some((p) => Array.isArray(p) && p[0] === "freebusy")
);
props.some(p => Array.isArray(p) && p[0] === 'freebusy')
)
}
function toUtcIcal(datetime: string, timezone: string): string {
return moment.tz(datetime, timezone).utc().format("YYYYMMDDTHHmmss");
return moment.tz(datetime, timezone).utc().format('YYYYMMDDTHHmmss')
}
async function fetchFreeBusyMap(
attendees: Attendee[],
fetcher: (resolved: ResolvedAttendee[]) => Promise<FreeBusyMap>
): Promise<FreeBusyMap> {
const resolved = await resolveAll(attendees);
const resolved = await resolveAll(attendees)
const unresolved: FreeBusyMap = Object.fromEntries(
attendees
.filter((a) => !resolved.find((r) => r.email === a.email))
.map((a) => [a.email, "unknown" as FreeBusyStatus])
);
.filter(a => !resolved.find(r => r.email === a.email))
.map(a => [a.email, 'unknown' as FreeBusyStatus])
)
if (resolved.length === 0) return unresolved;
if (resolved.length === 0) return unresolved
const fetched = await fetcher(resolved);
return { ...unresolved, ...fetched };
const fetched = await fetcher(resolved)
return { ...unresolved, ...fetched }
}
function toLoadingMap(attendees: Attendee[]): FreeBusyMap {
return Object.fromEntries(
attendees.map((a) => [a.email, "loading" as FreeBusyStatus])
);
attendees.map(a => [a.email, 'loading' as FreeBusyStatus])
)
}
function toUnknownMap(attendees: Attendee[]): FreeBusyMap {
return Object.fromEntries(
attendees.map((a) => [a.email, "unknown" as FreeBusyStatus])
);
attendees.map(a => [a.email, 'unknown' as FreeBusyStatus])
)
}
function toFreeBusyMap(
@@ -101,26 +94,26 @@ function toFreeBusyMap(
): (busyByUserId: Record<string, boolean>) => FreeBusyMap {
const userIdToEmail = Object.fromEntries(
resolved.map(({ email, userId }) => [userId, email])
);
return (busyByUserId) =>
)
return busyByUserId =>
Object.fromEntries(
Object.entries(busyByUserId).flatMap(([uid, busy]) => {
const email = userIdToEmail[uid];
const email = userIdToEmail[uid]
return email
? [[email, (busy ? "busy" : "free") as FreeBusyStatus]]
: [];
? [[email, (busy ? 'busy' : 'free') as FreeBusyStatus]]
: []
})
);
)
}
interface UseAttendeesFreeBusyOptions {
existingAttendees: Attendee[];
newAttendees: Attendee[];
start: string;
end: string;
timezone: string;
eventUid?: string | null;
enabled?: boolean;
existingAttendees: Attendee[]
newAttendees: Attendee[]
start: string
end: string
timezone: string
eventUid?: string | null
enabled?: boolean
}
export function useAttendeesFreeBusy({
@@ -130,18 +123,18 @@ export function useAttendeesFreeBusy({
end,
timezone,
eventUid,
enabled = true,
enabled = true
}: UseAttendeesFreeBusyOptions): FreeBusyMap {
const [statusMap, setStatusMap] = useState<FreeBusyMap>({});
const fetchedNewEmailsRef = useRef<Set<string>>(new Set());
const [statusMap, setStatusMap] = useState<FreeBusyMap>({})
const fetchedNewEmailsRef = useRef<Set<string>>(new Set())
const existingKey = existingAttendees.map((a) => a.email).join(",");
const newKey = newAttendees.map((a) => a.email).join(",");
const existingKey = existingAttendees.map(a => a.email).join(',')
const newKey = newAttendees.map(a => a.email).join(',')
useEffect(() => {
fetchedNewEmailsRef.current = new Set();
setStatusMap({});
}, [start, end, timezone]);
fetchedNewEmailsRef.current = new Set()
setStatusMap({})
}, [start, end, timezone])
useEffect(() => {
if (
@@ -151,101 +144,105 @@ export function useAttendeesFreeBusy({
!eventUid ||
existingAttendees.length === 0
)
return;
return
let cancelled = false;
setStatusMap((prev) => ({ ...prev, ...toLoadingMap(existingAttendees) }));
let cancelled = false
setStatusMap(prev => ({ ...prev, ...toLoadingMap(existingAttendees) }))
fetchFreeBusyMap(existingAttendees, (resolved) =>
fetchFreeBusyMap(existingAttendees, resolved =>
getFreeBusyForEventAttendeesPOST(
resolved.map((r) => r.userId),
resolved.map(r => r.userId),
toUtcIcal(start, timezone),
toUtcIcal(end, timezone),
eventUid!
eventUid
).then(toFreeBusyMap(resolved))
)
.then((updates) => {
if (!cancelled) setStatusMap((prev) => ({ ...prev, ...updates }));
.then(updates => {
if (!cancelled) {
setStatusMap(prev => ({ ...prev, ...updates }))
}
return
})
.catch(() => {
if (!cancelled)
setStatusMap((prev) => ({
setStatusMap(prev => ({
...prev,
...toUnknownMap(existingAttendees),
}));
});
...toUnknownMap(existingAttendees)
}))
})
return () => {
cancelled = true;
};
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [existingKey, start, end, eventUid, enabled, timezone]);
}, [existingKey, start, end, eventUid, enabled, timezone])
useEffect(() => {
if (!enabled || !start || !end) return;
if (!enabled || !start || !end) return
const currentEmails = new Set(newAttendees.map((a) => a.email));
const currentEmails = new Set(newAttendees.map(a => a.email))
const removedEmails = [...fetchedNewEmailsRef.current].filter(
(e) => !currentEmails.has(e)
);
e => !currentEmails.has(e)
)
if (removedEmails.length > 0) {
removedEmails.forEach((e) => {
fetchedNewEmailsRef.current.delete(e);
});
setStatusMap((prev) => {
const next = { ...prev };
removedEmails.forEach((e) => {
delete next[e];
});
return next;
});
removedEmails.forEach(e => {
fetchedNewEmailsRef.current.delete(e)
})
setStatusMap(prev => {
const next = { ...prev }
removedEmails.forEach(e => {
delete next[e]
})
return next
})
}
const toFetch = newAttendees.filter(
(a) => !fetchedNewEmailsRef.current.has(a.email)
);
if (toFetch.length === 0) return;
a => !fetchedNewEmailsRef.current.has(a.email)
)
if (toFetch.length === 0) return
let cancelled = false;
setStatusMap((prev) => ({ ...prev, ...toLoadingMap(toFetch) }));
fetchFreeBusyMap(toFetch, (resolved) =>
let cancelled = false
setStatusMap(prev => ({ ...prev, ...toLoadingMap(toFetch) }))
fetchFreeBusyMap(toFetch, resolved =>
Promise.all(
resolved.map(async ({ email, userId }) => {
try {
const busy = await getFreeBusyForAddedAttendeesREPORT(
userId,
moment.tz(start, timezone).utc().format("YYYYMMDDTHHmmss"),
moment.tz(end, timezone).utc().format("YYYYMMDDTHHmmss")
);
return [email, (busy ? "busy" : "free") as FreeBusyStatus] as const;
moment.tz(start, timezone).utc().format('YYYYMMDDTHHmmss'),
moment.tz(end, timezone).utc().format('YYYYMMDDTHHmmss')
)
return [email, (busy ? 'busy' : 'free') as FreeBusyStatus] as const
} catch {
return [email, "unknown" as FreeBusyStatus] as const;
return [email, 'unknown' as FreeBusyStatus] as const
}
})
).then(Object.fromEntries)
)
.then((updates) => {
.then(updates => {
if (!cancelled) {
Object.keys(updates).forEach((e) => {
fetchedNewEmailsRef.current.add(e);
});
setStatusMap((prev) => ({ ...prev, ...updates }));
Object.keys(updates).forEach(e => {
fetchedNewEmailsRef.current.add(e)
})
setStatusMap(prev => ({ ...prev, ...updates }))
}
return
})
.catch(() => {
if (!cancelled) {
toFetch.forEach((a) => {
fetchedNewEmailsRef.current.add(a.email);
});
setStatusMap((prev) => ({ ...prev, ...toUnknownMap(toFetch) }));
toFetch.forEach(a => {
fetchedNewEmailsRef.current.add(a.email)
})
setStatusMap(prev => ({ ...prev, ...toUnknownMap(toFetch) }))
}
});
})
return () => {
cancelled = true;
};
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newKey, start, end, enabled, timezone]);
}, [newKey, start, end, enabled, timezone])
return statusMap;
return statusMap
}
+31 -31
View File
@@ -1,7 +1,7 @@
import { useCallback } from "react";
import type { SyntheticEvent } from "react";
import { isValidEmail } from "../../utils/isValidEmail";
import type { User } from "./PeopleSearch";
import { useCallback } from 'react'
import type { SyntheticEvent } from 'react'
import { isValidEmail } from '../../utils/isValidEmail'
import type { User } from './PeopleSearch'
export function usePasteHandler({
freeSolo,
@@ -9,62 +9,62 @@ export function usePasteHandler({
onChange,
setQuery,
setInputError,
t,
t
}: {
freeSolo?: boolean;
selectedUsers: User[];
onChange: (event: SyntheticEvent, users: User[]) => void;
setQuery: (value: string) => void;
setInputError: (error: string | null) => void;
t: (key: string) => string;
freeSolo?: boolean
selectedUsers: User[]
onChange: (event: SyntheticEvent, users: User[]) => void
setQuery: (value: string) => void
setInputError: (error: string | null) => void
t: (key: string) => string
}) {
return useCallback(
(event: React.ClipboardEvent<HTMLInputElement>) => {
if (!freeSolo) return;
if (!freeSolo) return
const pasted = event.clipboardData.getData("text/plain");
if (!pasted) return;
const pasted = event.clipboardData.getData('text/plain')
if (!pasted) return
// Split by comma, semicolon, newline, or whitespace
const chunks = pasted
.split(/[,;\n\r\s]+/)
.map((s) => s.trim())
.filter(Boolean);
.map(s => s.trim())
.filter(Boolean)
// If there is only one chunk, let the default Autocomplete behaviour handle it
if (chunks.length <= 1) return;
if (chunks.length <= 1) return
event.preventDefault();
event.preventDefault()
const existingEmails = new Set(selectedUsers.map((u) => u.email));
const validUsers: User[] = [];
const invalid: string[] = [];
const existingEmails = new Set(selectedUsers.map(u => u.email))
const validUsers: User[] = []
const invalid: string[] = []
for (const chunk of chunks) {
if (!isValidEmail(chunk)) {
invalid.push(chunk);
invalid.push(chunk)
} else if (!existingEmails.has(chunk)) {
existingEmails.add(chunk);
validUsers.push({ email: chunk, displayName: chunk });
existingEmails.add(chunk)
validUsers.push({ email: chunk, displayName: chunk })
}
// silently skip duplicates
}
if (validUsers.length > 0) {
onChange(event, [...selectedUsers, ...validUsers]);
onChange(event, [...selectedUsers, ...validUsers])
}
if (invalid.length > 0) {
// Leave the invalid text in the input for manual correction
setQuery(invalid.join(", "));
setQuery(invalid.join(', '))
setInputError(
t("peopleSearch.invalidEmail").replace("%{email}", invalid.join(", "))
);
t('peopleSearch.invalidEmail').replace('%{email}', invalid.join(', '))
)
} else {
setQuery("");
setInputError(null);
setQuery('')
setInputError(null)
}
},
[freeSolo, selectedUsers, onChange, setQuery, setInputError, t]
);
)
}
+34 -34
View File
@@ -1,67 +1,67 @@
import { useState, useEffect } from "react";
import { searchUsers } from "@/features/User/userAPI";
import { useState, useEffect } from 'react'
import { searchUsers } from '@/features/User/userAPI'
export interface UseUserSearchProps {
objectTypes: string[];
errorMessage: string;
objectTypes: string[]
errorMessage: string
}
export function useUserSearch<T>({
objectTypes,
errorMessage,
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 [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("");
const [inputError, setInputError] = useState<string | null>(null)
const [snackbarOpen, setSnackbarOpen] = useState(false)
const [snackbarMessage, setSnackbarMessage] = useState('')
useEffect(() => {
let cancelled = false;
let cancelled = false
const delayDebounceFn = setTimeout(async () => {
if (!query.trim()) {
if (!cancelled) {
setOptions([]);
setLoading(false);
setHasSearched(false);
setOptions([])
setLoading(false)
setHasSearched(false)
}
return;
return
}
if (!cancelled) {
setLoading(true);
setHasSearched(false);
setLoading(true)
setHasSearched(false)
}
try {
const res = await searchUsers(query, objectTypes);
const res = await searchUsers(query, objectTypes)
if (!cancelled) {
setOptions(res as unknown as T[]);
setHasSearched(true);
setOptions(res as unknown as T[])
setHasSearched(true)
}
} catch {
if (!cancelled) {
setHasSearched(false);
setSnackbarMessage(errorMessage);
setSnackbarOpen(true);
setHasSearched(false)
setSnackbarMessage(errorMessage)
setSnackbarOpen(true)
}
} finally {
if (!cancelled) {
setLoading(false);
setLoading(false)
}
}
}, 300);
}, 300)
return () => {
cancelled = true;
clearTimeout(delayDebounceFn);
};
}, [objectTypes, query, errorMessage]);
cancelled = true
clearTimeout(delayDebounceFn)
}
}, [objectTypes, query, errorMessage])
return {
query,
@@ -76,6 +76,6 @@ export function useUserSearch<T>({
snackbarOpen,
setSnackbarOpen,
snackbarMessage,
setSnackbarMessage,
};
setSnackbarMessage
}
}