* #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:
@@ -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
|
||||
/>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user