@@ -1,18 +1,33 @@
|
||||
import { userAttendee } from "@/features/User/models/attendee";
|
||||
import { createAttendee } from "@/features/User/models/attendee.mapper";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { FreeBusyIndicator } from "./FreeBusyIndicator";
|
||||
import {
|
||||
ExtendedAutocompleteRenderInputParams,
|
||||
PeopleSearch,
|
||||
User,
|
||||
} from "./PeopleSearch";
|
||||
import { FreeBusyMap, useAttendeesFreeBusy } from "./useFreeBusy";
|
||||
|
||||
export default function UserSearch({
|
||||
const attendeeToUser = (a: userAttendee, openpaasId = ""): User => ({
|
||||
email: a.cal_address,
|
||||
displayName: a.cn ?? "",
|
||||
avatarUrl: "",
|
||||
openpaasId,
|
||||
});
|
||||
|
||||
const hasCalendar = (u: User) => u.objectType === "user" && !!u.openpaasId;
|
||||
|
||||
export default function AttendeeSearch({
|
||||
attendees,
|
||||
setAttendees,
|
||||
disabled,
|
||||
inputSlot,
|
||||
placeholder,
|
||||
start,
|
||||
end,
|
||||
timezone,
|
||||
eventUid,
|
||||
}: {
|
||||
attendees: userAttendee[];
|
||||
setAttendees: (attendees: userAttendee[]) => void;
|
||||
@@ -21,25 +36,59 @@ export default function UserSearch({
|
||||
params: ExtendedAutocompleteRenderInputParams
|
||||
) => React.ReactNode;
|
||||
placeholder?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
timezone?: string;
|
||||
eventUid?: string | null;
|
||||
}) {
|
||||
const [selectedUsers, setSelectedUsers] = useState<User[]>(
|
||||
attendees.map((attendee) => ({
|
||||
email: attendee.cal_address,
|
||||
displayName: attendee.cn ?? "",
|
||||
avatarUrl: "",
|
||||
openpaasId: "",
|
||||
})) ?? []
|
||||
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 selectedUsers: User[] = [
|
||||
...addedUsers,
|
||||
...attendees
|
||||
.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,
|
||||
});
|
||||
|
||||
const existingAttendees = selectedUsers
|
||||
.filter((u) => initialEmails.has(u.email))
|
||||
.map(toAttendee);
|
||||
const newAttendees = selectedUsers
|
||||
.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])
|
||||
);
|
||||
useEffect(() => {
|
||||
setSelectedUsers(
|
||||
attendees.map((attendee) => ({
|
||||
email: attendee.cal_address,
|
||||
displayName: attendee.cn ?? "",
|
||||
avatarUrl: "",
|
||||
openpaasId: "",
|
||||
}))
|
||||
);
|
||||
}, [attendees]);
|
||||
|
||||
const freeBusyMap = useAttendeesFreeBusy({
|
||||
existingAttendees,
|
||||
newAttendees,
|
||||
start: start ?? "",
|
||||
end: end ?? "",
|
||||
timezone: timezone ?? "",
|
||||
eventUid,
|
||||
enabled: !!(start && end && selectedUsers.length > 0),
|
||||
});
|
||||
|
||||
const statusMap = { ...freeBusyMap, ...contactMap };
|
||||
|
||||
return (
|
||||
<PeopleSearch
|
||||
selectedUsers={selectedUsers}
|
||||
@@ -47,16 +96,27 @@ export default function UserSearch({
|
||||
disabled={disabled}
|
||||
inputSlot={inputSlot}
|
||||
placeholder={placeholder}
|
||||
getChipIcon={
|
||||
start && end
|
||||
? (user) => (
|
||||
<FreeBusyIndicator status={statusMap[user.email] ?? "unknown"} />
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
onChange={(_event, value: User[]) => {
|
||||
setUserIdMap((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const u of value) {
|
||||
if (u.openpaasId && u.email) next[u.email] = u.openpaasId;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setAddedUsers(value.filter((u) => !initialEmails.has(u.email)));
|
||||
setAttendees(
|
||||
value.map((attendee: User) =>
|
||||
createAttendee({
|
||||
cal_address: attendee.email,
|
||||
cn: attendee.displayName,
|
||||
})
|
||||
value.map((u) =>
|
||||
createAttendee({ cal_address: u.email, cn: u.displayName })
|
||||
)
|
||||
);
|
||||
setSelectedUsers(value);
|
||||
}}
|
||||
freeSolo
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export function FreeBusyIndicator({ status }: FreeBusyIndicatorProps) {
|
||||
const { t } = useI18n();
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => {
|
||||
if (status === "busy") setOpen(true);
|
||||
}, [status]);
|
||||
|
||||
if (status !== "busy") return null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<span style={{ display: "flex", alignItems: "center", gap: 4 }}>
|
||||
{t("event.freeBusy.busy")}
|
||||
<CloseIcon
|
||||
fontSize="inherit"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
open={open}
|
||||
disableHoverListener
|
||||
placement="bottom-start"
|
||||
onClose={() => setOpen(false)}
|
||||
slotProps={{ tooltip: { sx: { opacity: 1, bgcolor: "grey.900" } } }}
|
||||
>
|
||||
<AccessTimeFilledIcon
|
||||
aria-label={t("event.freeBusy.busy")}
|
||||
color="warning"
|
||||
style={{
|
||||
margin: "0 -6px 0 5px",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils";
|
||||
import { stringAvatar } from "@/components/Event/utils/eventUtils";
|
||||
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
import { searchUsers } from "@/features/User/userAPI";
|
||||
import {
|
||||
Autocomplete,
|
||||
@@ -33,6 +35,7 @@ export interface User {
|
||||
avatarUrl?: string;
|
||||
openpaasId?: string;
|
||||
color?: Record<string, string>;
|
||||
objectType?: string;
|
||||
}
|
||||
|
||||
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
|
||||
@@ -54,6 +57,7 @@ export function PeopleSearch({
|
||||
inputSlot,
|
||||
customRenderInput,
|
||||
customSlotProps,
|
||||
getChipIcon,
|
||||
}: {
|
||||
selectedUsers: User[];
|
||||
onChange: (event: SyntheticEvent, users: User[]) => void;
|
||||
@@ -75,6 +79,7 @@ export function PeopleSearch({
|
||||
paper?: Partial<PaperProps>;
|
||||
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
|
||||
};
|
||||
getChipIcon?: (user: User) => ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -307,7 +312,6 @@ export function PeopleSearch({
|
||||
...customSlotProps?.popper,
|
||||
},
|
||||
}}
|
||||
// When render input is custom, the adornments should be handled by the custom component
|
||||
forcePopupIcon={false}
|
||||
disableClearable
|
||||
renderInput={(params) =>
|
||||
@@ -341,14 +345,18 @@ export function PeopleSearch({
|
||||
? option
|
||||
: option.displayName || option.email;
|
||||
const chipColor = isString
|
||||
? theme.palette.grey[300]
|
||||
: (option.color?.light ?? theme.palette.grey[300]);
|
||||
? theme.palette.grey[200]
|
||||
: (option.color?.light ?? theme.palette.grey[200]);
|
||||
const textColor = getAccessiblePair(chipColor, theme);
|
||||
|
||||
return (
|
||||
<Chip
|
||||
{...getTagProps({ index })}
|
||||
key={label}
|
||||
icon={
|
||||
!isString && getChipIcon ? getChipIcon(option) : undefined
|
||||
}
|
||||
deleteIcon={<CloseIcon />}
|
||||
style={{
|
||||
backgroundColor: chipColor,
|
||||
color: textColor,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
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>;
|
||||
|
||||
interface Attendee {
|
||||
email: string;
|
||||
userId?: string | null;
|
||||
}
|
||||
|
||||
interface ResolvedAttendee {
|
||||
email: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
// Helpers
|
||||
async function resolveUserId(attendee: Attendee): Promise<string | null> {
|
||||
if (attendee.userId) return attendee.userId;
|
||||
return getUserDataFromEmail(attendee.email)
|
||||
.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;
|
||||
})
|
||||
);
|
||||
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)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isVFreeBusyWithConflict(component: unknown): boolean {
|
||||
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")
|
||||
);
|
||||
}
|
||||
|
||||
function toUtcIcal(datetime: string, timezone: string): string {
|
||||
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 unresolved: FreeBusyMap = Object.fromEntries(
|
||||
attendees
|
||||
.filter((a) => !resolved.find((r) => r.email === a.email))
|
||||
.map((a) => [a.email, "unknown" as FreeBusyStatus])
|
||||
);
|
||||
|
||||
if (resolved.length === 0) return unresolved;
|
||||
|
||||
const fetched = await fetcher(resolved);
|
||||
return { ...unresolved, ...fetched };
|
||||
}
|
||||
|
||||
function toLoadingMap(attendees: Attendee[]): FreeBusyMap {
|
||||
return Object.fromEntries(
|
||||
attendees.map((a) => [a.email, "loading" as FreeBusyStatus])
|
||||
);
|
||||
}
|
||||
|
||||
function toUnknownMap(attendees: Attendee[]): FreeBusyMap {
|
||||
return Object.fromEntries(
|
||||
attendees.map((a) => [a.email, "unknown" as FreeBusyStatus])
|
||||
);
|
||||
}
|
||||
|
||||
function toFreeBusyMap(
|
||||
resolved: ResolvedAttendee[]
|
||||
): (busyByUserId: Record<string, boolean>) => FreeBusyMap {
|
||||
const userIdToEmail = Object.fromEntries(
|
||||
resolved.map(({ email, userId }) => [userId, email])
|
||||
);
|
||||
return (busyByUserId) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(busyByUserId).flatMap(([uid, busy]) => {
|
||||
const email = userIdToEmail[uid];
|
||||
return email
|
||||
? [[email, (busy ? "busy" : "free") as FreeBusyStatus]]
|
||||
: [];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
interface UseAttendeesFreeBusyOptions {
|
||||
existingAttendees: Attendee[];
|
||||
newAttendees: Attendee[];
|
||||
start: string;
|
||||
end: string;
|
||||
timezone: string;
|
||||
eventUid?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function useAttendeesFreeBusy({
|
||||
existingAttendees,
|
||||
newAttendees,
|
||||
start,
|
||||
end,
|
||||
timezone,
|
||||
eventUid,
|
||||
enabled = true,
|
||||
}: UseAttendeesFreeBusyOptions): FreeBusyMap {
|
||||
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(",");
|
||||
|
||||
useEffect(() => {
|
||||
fetchedNewEmailsRef.current = new Set();
|
||||
setStatusMap({});
|
||||
}, [start, end, timezone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!enabled ||
|
||||
!start ||
|
||||
!end ||
|
||||
!eventUid ||
|
||||
existingAttendees.length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
let cancelled = false;
|
||||
setStatusMap((prev) => ({ ...prev, ...toLoadingMap(existingAttendees) }));
|
||||
|
||||
fetchFreeBusyMap(existingAttendees, (resolved) =>
|
||||
getFreeBusyForEventAttendeesPOST(
|
||||
resolved.map((r) => r.userId),
|
||||
toUtcIcal(start, timezone),
|
||||
toUtcIcal(end, timezone),
|
||||
eventUid!
|
||||
).then(toFreeBusyMap(resolved))
|
||||
)
|
||||
.then((updates) => {
|
||||
if (!cancelled) setStatusMap((prev) => ({ ...prev, ...updates }));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled)
|
||||
setStatusMap((prev) => ({
|
||||
...prev,
|
||||
...toUnknownMap(existingAttendees),
|
||||
}));
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [existingKey, start, end, eventUid, enabled, timezone]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !start || !end) return;
|
||||
|
||||
const currentEmails = new Set(newAttendees.map((a) => a.email));
|
||||
const removedEmails = [...fetchedNewEmailsRef.current].filter(
|
||||
(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;
|
||||
});
|
||||
}
|
||||
|
||||
const toFetch = newAttendees.filter(
|
||||
(a) => !fetchedNewEmailsRef.current.has(a.email)
|
||||
);
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
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;
|
||||
} catch {
|
||||
return [email, "unknown" as FreeBusyStatus] as const;
|
||||
}
|
||||
})
|
||||
).then(Object.fromEntries)
|
||||
)
|
||||
.then((updates) => {
|
||||
if (!cancelled) {
|
||||
Object.keys(updates).forEach((e) => {
|
||||
fetchedNewEmailsRef.current.add(e);
|
||||
});
|
||||
setStatusMap((prev) => ({ ...prev, ...updates }));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
toFetch.forEach((a) => {
|
||||
fetchedNewEmailsRef.current.add(a.email);
|
||||
});
|
||||
setStatusMap((prev) => ({ ...prev, ...toUnknownMap(toFetch) }));
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [newKey, start, end, enabled, timezone]);
|
||||
|
||||
return statusMap;
|
||||
}
|
||||
@@ -100,6 +100,7 @@ interface EventFormFieldsProps {
|
||||
browserTz: string;
|
||||
getTimezoneOffset: (tzName: string, date: Date) => string;
|
||||
};
|
||||
eventId?: string | null;
|
||||
|
||||
// Event handlers
|
||||
onStartChange?: (newStart: string) => void;
|
||||
@@ -157,6 +158,7 @@ export default function EventFormFields({
|
||||
isOpen = false,
|
||||
userPersonalCalendars,
|
||||
timezoneList,
|
||||
eventId,
|
||||
onStartChange,
|
||||
onEndChange,
|
||||
onAllDayChange,
|
||||
@@ -633,6 +635,10 @@ export default function EventFormFields({
|
||||
<AttendeeSelector
|
||||
attendees={attendees}
|
||||
setAttendees={setAttendees}
|
||||
start={start}
|
||||
eventUid={eventId}
|
||||
timezone={timezone}
|
||||
end={end}
|
||||
placeholder={t("event.form.addGuestsPlaceholder")}
|
||||
inputSlot={(params) => <TextField {...params} size="small" />}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user