@@ -1,3 +1,4 @@
|
|||||||
|
import * as calendarsApi from "@/features/Calendars/CalendarApi";
|
||||||
import * as eventThunks from "@/features/Calendars/services";
|
import * as eventThunks from "@/features/Calendars/services";
|
||||||
import EventPopover from "@/features/Events/EventModal";
|
import EventPopover from "@/features/Events/EventModal";
|
||||||
import { api } from "@/utils/apiUtils";
|
import { api } from "@/utils/apiUtils";
|
||||||
@@ -210,6 +211,9 @@ describe("EventPopover", () => {
|
|||||||
});
|
});
|
||||||
it("adds a attendee", async () => {
|
it("adds a attendee", async () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
jest
|
||||||
|
.spyOn(calendarsApi, "getCalendars")
|
||||||
|
.mockReturnValue({ json: jest.fn() });
|
||||||
renderPopover();
|
renderPopover();
|
||||||
fireEvent.change(screen.getByLabelText("event.form.title"), {
|
fireEvent.change(screen.getByLabelText("event.form.title"), {
|
||||||
target: { value: "newEvent" },
|
target: { value: "newEvent" },
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
import {
|
||||||
|
hasFreeBusyConflict,
|
||||||
|
useAttendeesFreeBusy,
|
||||||
|
} from "@/components/Attendees/useFreeBusy";
|
||||||
|
import * as getFreeBusyREPORT from "@/features/Events/api/getFreeBusyForAddedAttendeesREPORT";
|
||||||
|
import * as getFreeBusyPOST from "@/features/Events/api/getFreeBusyForEventAttendeesPOST";
|
||||||
|
import * as getUserData from "@/features/Events/api/getUserDataFromEmail";
|
||||||
|
import { renderHook, waitFor } from "@testing-library/react";
|
||||||
|
|
||||||
|
jest.mock("moment-timezone", () => {
|
||||||
|
const actual = jest.requireActual("moment-timezone");
|
||||||
|
return actual;
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.mock("@/features/Events/api/getUserDataFromEmail");
|
||||||
|
jest.mock("@/features/Events/api/getFreeBusyForAddedAttendeesREPORT");
|
||||||
|
jest.mock("@/features/Events/api/getFreeBusyForEventAttendeesPOST");
|
||||||
|
|
||||||
|
const mockGetUserData = getUserData.getUserDataFromEmail as jest.MockedFunction<
|
||||||
|
typeof getUserData.getUserDataFromEmail
|
||||||
|
>;
|
||||||
|
const mockREPORT =
|
||||||
|
getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT as jest.MockedFunction<
|
||||||
|
typeof getFreeBusyREPORT.getFreeBusyForAddedAttendeesREPORT
|
||||||
|
>;
|
||||||
|
const mockPOST =
|
||||||
|
getFreeBusyPOST.getFreeBusyForEventAttendeesPOST as jest.MockedFunction<
|
||||||
|
typeof getFreeBusyPOST.getFreeBusyForEventAttendeesPOST
|
||||||
|
>;
|
||||||
|
|
||||||
|
const START = "2026-03-14T14:00:00";
|
||||||
|
const END = "2026-03-14T15:00:00";
|
||||||
|
const TZ = "Europe/Paris";
|
||||||
|
|
||||||
|
describe("hasFreeBusyConflict", () => {
|
||||||
|
it("returns false for non-vcalendar data", () => {
|
||||||
|
expect(hasFreeBusyConflict({ data: ["not-vcalendar", [], []] })).toBe(
|
||||||
|
false
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false when vfreebusy has no freebusy property", () => {
|
||||||
|
const data = {
|
||||||
|
data: [
|
||||||
|
"vcalendar",
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"vfreebusy",
|
||||||
|
[["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"]],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(hasFreeBusyConflict(data)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns true when vfreebusy contains a freebusy property", () => {
|
||||||
|
const data = {
|
||||||
|
data: [
|
||||||
|
"vcalendar",
|
||||||
|
[],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"vfreebusy",
|
||||||
|
[
|
||||||
|
["dtstart", {}, "date-time", "2026-03-14T14:00:00Z"],
|
||||||
|
["freebusy", {}, "period", "2026-03-14T14:00:00Z/PT1H"],
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(hasFreeBusyConflict(data)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false for malformed data without throwing", () => {
|
||||||
|
expect(hasFreeBusyConflict(null)).toBe(false);
|
||||||
|
expect(hasFreeBusyConflict("garbage")).toBe(false);
|
||||||
|
expect(hasFreeBusyConflict({ data: null })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAttendeesFreeBusy — Flow B (new attendees)", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
const newAttendee = { email: "alice@example.com", userId: "user-alice" };
|
||||||
|
|
||||||
|
it("shows loading then free for a new attendee", async () => {
|
||||||
|
mockREPORT.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [],
|
||||||
|
newAttendees: [newAttendee],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current[newAttendee.email]).toBe("loading");
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows busy when REPORT returns true", async () => {
|
||||||
|
mockREPORT.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [],
|
||||||
|
newAttendees: [newAttendee],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe("busy"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unknown when REPORT throws", async () => {
|
||||||
|
mockREPORT.mockRejectedValue(new Error("network error"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [],
|
||||||
|
newAttendees: [newAttendee],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current[newAttendee.email]).toBe("unknown")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves userId via getUserDataFromEmail when not provided", async () => {
|
||||||
|
mockGetUserData.mockResolvedValue([{ _id: "resolved-id" }] as never);
|
||||||
|
mockREPORT.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const attendeeWithoutId = { email: "bob@example.com" };
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [],
|
||||||
|
newAttendees: [attendeeWithoutId],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current[attendeeWithoutId.email]).toBe("free")
|
||||||
|
);
|
||||||
|
expect(mockGetUserData).toHaveBeenCalledWith("bob@example.com");
|
||||||
|
expect(mockREPORT).toHaveBeenCalledWith(
|
||||||
|
"resolved-id",
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unknown when userId cannot be resolved", async () => {
|
||||||
|
mockGetUserData.mockResolvedValue([]);
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [],
|
||||||
|
newAttendees: [{ email: "ghost@example.com" }],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current["ghost@example.com"]).toBe("unknown")
|
||||||
|
);
|
||||||
|
expect(mockREPORT).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not re-fetch an attendee already fetched", async () => {
|
||||||
|
mockREPORT.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ attendees }) =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [],
|
||||||
|
newAttendees: attendees,
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
}),
|
||||||
|
{ initialProps: { attendees: [newAttendee] } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
|
||||||
|
|
||||||
|
rerender({ attendees: [newAttendee] });
|
||||||
|
expect(mockREPORT).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes departed attendee from the map", async () => {
|
||||||
|
mockREPORT.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ attendees }) =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [],
|
||||||
|
newAttendees: attendees,
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
}),
|
||||||
|
{ initialProps: { attendees: [newAttendee] } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
|
||||||
|
|
||||||
|
rerender({ attendees: [] });
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current[newAttendee.email]).toBeUndefined()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates cache and re-fetches when time window changes", async () => {
|
||||||
|
mockREPORT.mockResolvedValue(false);
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ start, end }) =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [],
|
||||||
|
newAttendees: [newAttendee],
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
timezone: TZ,
|
||||||
|
}),
|
||||||
|
{ initialProps: { start: START, end: END } }
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
|
||||||
|
|
||||||
|
rerender({ start: "2026-03-15T14:00:00", end: "2026-03-15T15:00:00" });
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current[newAttendee.email]).toBe("free"));
|
||||||
|
|
||||||
|
expect(mockREPORT).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes UTC-converted iCal times to the API", async () => {
|
||||||
|
mockREPORT.mockResolvedValue(false);
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [],
|
||||||
|
newAttendees: [newAttendee],
|
||||||
|
start: "2026-03-14T16:00:00", // 16:00 Paris = 15:00 UTC in winter
|
||||||
|
end: "2026-03-14T17:00:00",
|
||||||
|
timezone: "Europe/Paris",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockREPORT).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Paris is UTC+1 in March (before DST), so 16:00 → 15:00 UTC
|
||||||
|
expect(mockREPORT).toHaveBeenCalledWith(
|
||||||
|
"user-alice",
|
||||||
|
"20260314T150000",
|
||||||
|
"20260314T160000"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAttendeesFreeBusy — Flow A (existing attendees)", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
const existingAttendee = { email: "carol@example.com", userId: "user-carol" };
|
||||||
|
|
||||||
|
it("does not fetch when eventUid is missing", () => {
|
||||||
|
renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [existingAttendee],
|
||||||
|
newAttendees: [],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPOST).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading then free for existing attendees", async () => {
|
||||||
|
mockPOST.mockResolvedValue({ "user-carol": false });
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [existingAttendee],
|
||||||
|
newAttendees: [],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
eventUid: "event-123",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.current[existingAttendee.email]).toBe("loading");
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current[existingAttendee.email]).toBe("free")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows busy when POST returns busy for userId", async () => {
|
||||||
|
mockPOST.mockResolvedValue({ "user-carol": true });
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [existingAttendee],
|
||||||
|
newAttendees: [],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
eventUid: "event-123",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current[existingAttendee.email]).toBe("busy")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows unknown when POST throws", async () => {
|
||||||
|
mockPOST.mockRejectedValue(new Error("server error"));
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [existingAttendee],
|
||||||
|
newAttendees: [],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
eventUid: "event-123",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(result.current[existingAttendee.email]).toBe("unknown")
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes the eventUid to the POST API", async () => {
|
||||||
|
mockPOST.mockResolvedValue({ "user-carol": false });
|
||||||
|
|
||||||
|
renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [existingAttendee],
|
||||||
|
newAttendees: [],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
eventUid: "event-abc",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockPOST).toHaveBeenCalled());
|
||||||
|
expect(mockPOST).toHaveBeenCalledWith(
|
||||||
|
["user-carol"],
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
"event-abc"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useAttendeesFreeBusy — enabled flag", () => {
|
||||||
|
it("does not fetch when disabled", () => {
|
||||||
|
renderHook(() =>
|
||||||
|
useAttendeesFreeBusy({
|
||||||
|
existingAttendees: [{ email: "x@x.com", userId: "uid" }],
|
||||||
|
newAttendees: [{ email: "y@y.com", userId: "uid2" }],
|
||||||
|
start: START,
|
||||||
|
end: END,
|
||||||
|
timezone: TZ,
|
||||||
|
eventUid: "event-123",
|
||||||
|
enabled: false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockPOST).not.toHaveBeenCalled();
|
||||||
|
expect(mockREPORT).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,18 +1,33 @@
|
|||||||
import { userAttendee } from "@/features/User/models/attendee";
|
import { userAttendee } from "@/features/User/models/attendee";
|
||||||
import { createAttendee } from "@/features/User/models/attendee.mapper";
|
import { createAttendee } from "@/features/User/models/attendee.mapper";
|
||||||
import { useEffect, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
|
import { FreeBusyIndicator } from "./FreeBusyIndicator";
|
||||||
import {
|
import {
|
||||||
ExtendedAutocompleteRenderInputParams,
|
ExtendedAutocompleteRenderInputParams,
|
||||||
PeopleSearch,
|
PeopleSearch,
|
||||||
User,
|
User,
|
||||||
} from "./PeopleSearch";
|
} 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,
|
attendees,
|
||||||
setAttendees,
|
setAttendees,
|
||||||
disabled,
|
disabled,
|
||||||
inputSlot,
|
inputSlot,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
timezone,
|
||||||
|
eventUid,
|
||||||
}: {
|
}: {
|
||||||
attendees: userAttendee[];
|
attendees: userAttendee[];
|
||||||
setAttendees: (attendees: userAttendee[]) => void;
|
setAttendees: (attendees: userAttendee[]) => void;
|
||||||
@@ -21,25 +36,59 @@ export default function UserSearch({
|
|||||||
params: ExtendedAutocompleteRenderInputParams
|
params: ExtendedAutocompleteRenderInputParams
|
||||||
) => React.ReactNode;
|
) => React.ReactNode;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
start?: string;
|
||||||
|
end?: string;
|
||||||
|
timezone?: string;
|
||||||
|
eventUid?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [selectedUsers, setSelectedUsers] = useState<User[]>(
|
const [userIdMap, setUserIdMap] = useState<Record<string, string>>({});
|
||||||
attendees.map((attendee) => ({
|
const [addedUsers, setAddedUsers] = useState<User[]>([]);
|
||||||
email: attendee.cal_address,
|
const initialEmailsRef = useRef<Set<string> | null>(null);
|
||||||
displayName: attendee.cn ?? "",
|
if (initialEmailsRef.current === null && !!eventUid && attendees.length > 0) {
|
||||||
avatarUrl: "",
|
initialEmailsRef.current = new Set(attendees.map((a) => a.cal_address));
|
||||||
openpaasId: "",
|
}
|
||||||
})) ?? []
|
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(
|
const freeBusyMap = useAttendeesFreeBusy({
|
||||||
attendees.map((attendee) => ({
|
existingAttendees,
|
||||||
email: attendee.cal_address,
|
newAttendees,
|
||||||
displayName: attendee.cn ?? "",
|
start: start ?? "",
|
||||||
avatarUrl: "",
|
end: end ?? "",
|
||||||
openpaasId: "",
|
timezone: timezone ?? "",
|
||||||
}))
|
eventUid,
|
||||||
);
|
enabled: !!(start && end && selectedUsers.length > 0),
|
||||||
}, [attendees]);
|
});
|
||||||
|
|
||||||
|
const statusMap = { ...freeBusyMap, ...contactMap };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PeopleSearch
|
<PeopleSearch
|
||||||
selectedUsers={selectedUsers}
|
selectedUsers={selectedUsers}
|
||||||
@@ -47,16 +96,27 @@ export default function UserSearch({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
inputSlot={inputSlot}
|
inputSlot={inputSlot}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
getChipIcon={
|
||||||
|
start && end
|
||||||
|
? (user) => (
|
||||||
|
<FreeBusyIndicator status={statusMap[user.email] ?? "unknown"} />
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
onChange={(_event, value: User[]) => {
|
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(
|
setAttendees(
|
||||||
value.map((attendee: User) =>
|
value.map((u) =>
|
||||||
createAttendee({
|
createAttendee({ cal_address: u.email, cn: u.displayName })
|
||||||
cal_address: attendee.email,
|
|
||||||
cn: attendee.displayName,
|
|
||||||
})
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
setSelectedUsers(value);
|
|
||||||
}}
|
}}
|
||||||
freeSolo
|
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 { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils";
|
||||||
import { stringAvatar } from "@/components/Event/utils/eventUtils";
|
import { stringAvatar } from "@/components/Event/utils/eventUtils";
|
||||||
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
|
import { SnackbarAlert } from "@/components/Loading/SnackBarAlert";
|
||||||
|
import CloseIcon from "@mui/icons-material/Close";
|
||||||
|
|
||||||
import { searchUsers } from "@/features/User/userAPI";
|
import { searchUsers } from "@/features/User/userAPI";
|
||||||
import {
|
import {
|
||||||
Autocomplete,
|
Autocomplete,
|
||||||
@@ -33,6 +35,7 @@ export interface User {
|
|||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
openpaasId?: string;
|
openpaasId?: string;
|
||||||
color?: Record<string, string>;
|
color?: Record<string, string>;
|
||||||
|
objectType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
|
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
|
||||||
@@ -54,6 +57,7 @@ export function PeopleSearch({
|
|||||||
inputSlot,
|
inputSlot,
|
||||||
customRenderInput,
|
customRenderInput,
|
||||||
customSlotProps,
|
customSlotProps,
|
||||||
|
getChipIcon,
|
||||||
}: {
|
}: {
|
||||||
selectedUsers: User[];
|
selectedUsers: User[];
|
||||||
onChange: (event: SyntheticEvent, users: User[]) => void;
|
onChange: (event: SyntheticEvent, users: User[]) => void;
|
||||||
@@ -75,6 +79,7 @@ export function PeopleSearch({
|
|||||||
paper?: Partial<PaperProps>;
|
paper?: Partial<PaperProps>;
|
||||||
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
|
listbox?: Partial<HTMLAttributes<HTMLUListElement>>;
|
||||||
};
|
};
|
||||||
|
getChipIcon?: (user: User) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -307,7 +312,6 @@ export function PeopleSearch({
|
|||||||
...customSlotProps?.popper,
|
...customSlotProps?.popper,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
// When render input is custom, the adornments should be handled by the custom component
|
|
||||||
forcePopupIcon={false}
|
forcePopupIcon={false}
|
||||||
disableClearable
|
disableClearable
|
||||||
renderInput={(params) =>
|
renderInput={(params) =>
|
||||||
@@ -341,14 +345,18 @@ export function PeopleSearch({
|
|||||||
? option
|
? option
|
||||||
: option.displayName || option.email;
|
: option.displayName || option.email;
|
||||||
const chipColor = isString
|
const chipColor = isString
|
||||||
? theme.palette.grey[300]
|
? theme.palette.grey[200]
|
||||||
: (option.color?.light ?? theme.palette.grey[300]);
|
: (option.color?.light ?? theme.palette.grey[200]);
|
||||||
const textColor = getAccessiblePair(chipColor, theme);
|
const textColor = getAccessiblePair(chipColor, theme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
{...getTagProps({ index })}
|
{...getTagProps({ index })}
|
||||||
key={label}
|
key={label}
|
||||||
|
icon={
|
||||||
|
!isString && getChipIcon ? getChipIcon(option) : undefined
|
||||||
|
}
|
||||||
|
deleteIcon={<CloseIcon />}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: chipColor,
|
backgroundColor: chipColor,
|
||||||
color: textColor,
|
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;
|
browserTz: string;
|
||||||
getTimezoneOffset: (tzName: string, date: Date) => string;
|
getTimezoneOffset: (tzName: string, date: Date) => string;
|
||||||
};
|
};
|
||||||
|
eventId?: string | null;
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
onStartChange?: (newStart: string) => void;
|
onStartChange?: (newStart: string) => void;
|
||||||
@@ -157,6 +158,7 @@ export default function EventFormFields({
|
|||||||
isOpen = false,
|
isOpen = false,
|
||||||
userPersonalCalendars,
|
userPersonalCalendars,
|
||||||
timezoneList,
|
timezoneList,
|
||||||
|
eventId,
|
||||||
onStartChange,
|
onStartChange,
|
||||||
onEndChange,
|
onEndChange,
|
||||||
onAllDayChange,
|
onAllDayChange,
|
||||||
@@ -633,6 +635,10 @@ export default function EventFormFields({
|
|||||||
<AttendeeSelector
|
<AttendeeSelector
|
||||||
attendees={attendees}
|
attendees={attendees}
|
||||||
setAttendees={setAttendees}
|
setAttendees={setAttendees}
|
||||||
|
start={start}
|
||||||
|
eventUid={eventId}
|
||||||
|
timezone={timezone}
|
||||||
|
end={end}
|
||||||
placeholder={t("event.form.addGuestsPlaceholder")}
|
placeholder={t("event.form.addGuestsPlaceholder")}
|
||||||
inputSlot={(params) => <TextField {...params} size="small" />}
|
inputSlot={(params) => <TextField {...params} size="small" />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1092,6 +1092,7 @@ function EventUpdateModal({
|
|||||||
setEventClass={setEventClass}
|
setEventClass={setEventClass}
|
||||||
timezone={timezone}
|
timezone={timezone}
|
||||||
setTimezone={setTimezone}
|
setTimezone={setTimezone}
|
||||||
|
eventId={event.uid}
|
||||||
calendarid={calendarid}
|
calendarid={calendarid}
|
||||||
setCalendarid={setCalendarid}
|
setCalendarid={setCalendarid}
|
||||||
hasVideoConference={hasVideoConference}
|
hasVideoConference={hasVideoConference}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getCalendars } from "@/features/Calendars/CalendarApi";
|
||||||
|
|
||||||
|
import { api } from "@/utils/apiUtils";
|
||||||
|
import { extractCalendarHrefs } from "@/utils/extractCalendarHrefs";
|
||||||
|
import { hasFreeBusyConflict } from "../../../components/Attendees/useFreeBusy";
|
||||||
|
|
||||||
|
export async function getFreeBusyForAddedAttendeesREPORT(
|
||||||
|
userId: string,
|
||||||
|
start: string,
|
||||||
|
end: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const calendars = await getCalendars(
|
||||||
|
userId,
|
||||||
|
"withFreeBusy=true&withRights=true"
|
||||||
|
);
|
||||||
|
const hrefs = extractCalendarHrefs(calendars);
|
||||||
|
if (hrefs.length === 0) return false;
|
||||||
|
|
||||||
|
const freeBusyBody = JSON.stringify({
|
||||||
|
type: "free-busy-query",
|
||||||
|
match: { start, end },
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await Promise.all(
|
||||||
|
hrefs.map((href) =>
|
||||||
|
api(`dav${href}`, {
|
||||||
|
method: "REPORT",
|
||||||
|
headers: { Accept: "application/json, text/plain, */*" },
|
||||||
|
body: freeBusyBody,
|
||||||
|
})
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((data) => (data ? hasFreeBusyConflict(data) : false))
|
||||||
|
.catch(() => false)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return results.some(Boolean);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { api } from "@/utils/apiUtils";
|
||||||
|
|
||||||
|
export async function getFreeBusyForEventAttendeesPOST(
|
||||||
|
userIds: string[],
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
eventUid: string
|
||||||
|
): Promise<Record<string, boolean>> {
|
||||||
|
const r = await api("dav/calendars/freebusy", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { Accept: "application/json, text/plain, */*" },
|
||||||
|
body: JSON.stringify({ start, end, users: userIds, uids: [eventUid] }),
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||||
|
|
||||||
|
const { users } = (await r.json()) as {
|
||||||
|
users: { id: string; calendars: { busy: unknown[] }[] }[];
|
||||||
|
};
|
||||||
|
return Object.fromEntries(
|
||||||
|
users.map((u) => [u.id, u.calendars.some((cal) => cal.busy.length > 0)])
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { api } from "@/utils/apiUtils";
|
||||||
|
|
||||||
|
export async function getUserDataFromEmail(email: string) {
|
||||||
|
const r = await api(`api/users?email=${encodeURIComponent(email)}`);
|
||||||
|
const result: Array<Record<string, string>> = await r.json();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -29,9 +29,10 @@ export async function searchUsers(
|
|||||||
return response.map((user) => ({
|
return response.map((user) => ({
|
||||||
email: user.emailAddresses?.[0]?.value || "",
|
email: user.emailAddresses?.[0]?.value || "",
|
||||||
displayName:
|
displayName:
|
||||||
user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value,
|
user.names?.[0]?.displayName || user.emailAddresses?.[0]?.value || "",
|
||||||
avatarUrl: user.photos?.[0]?.url || "",
|
avatarUrl: user.photos?.[0]?.url || "",
|
||||||
openpaasId: user.id || "",
|
openpaasId: user.id || "",
|
||||||
|
objectType: user.objectType,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export interface SearchResponseItem {
|
|||||||
emailAddresses?: Array<{ value?: string }>;
|
emailAddresses?: Array<{ value?: string }>;
|
||||||
names?: Array<{ displayName?: string }>;
|
names?: Array<{ displayName?: string }>;
|
||||||
photos?: Array<{ url?: string }>;
|
photos?: Array<{ url?: string }>;
|
||||||
|
objectType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Type for configuration item
|
// Type for configuration item
|
||||||
|
|||||||
@@ -130,6 +130,12 @@
|
|||||||
"occurrences": "occurrences"
|
"occurrences": "occurrences"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"freeBusy": {
|
||||||
|
"unknown": "Unknown status",
|
||||||
|
"busy": "This person is busy",
|
||||||
|
"free": "User is free",
|
||||||
|
"loading": "Status is loading"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Title",
|
"title": "Title",
|
||||||
"titlePlaceholder": "Add title",
|
"titlePlaceholder": "Add title",
|
||||||
|
|||||||
@@ -131,6 +131,12 @@
|
|||||||
"occurrences": "occurrences"
|
"occurrences": "occurrences"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"freeBusy": {
|
||||||
|
"unknown": "Statut inconnu",
|
||||||
|
"busy": "Cette personne est occupée",
|
||||||
|
"free": "L'utilisateur est disponible",
|
||||||
|
"loading": "Chargement du statut"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Titre",
|
"title": "Titre",
|
||||||
"titlePlaceholder": "Ajouter un titre",
|
"titlePlaceholder": "Ajouter un titre",
|
||||||
|
|||||||
@@ -131,6 +131,12 @@
|
|||||||
"occurrences": "повторений"
|
"occurrences": "повторений"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"freeBusy": {
|
||||||
|
"unknown": "Статус неизвестен",
|
||||||
|
"busy": "Пользователь занят",
|
||||||
|
"free": "Пользователь свободен",
|
||||||
|
"loading": "Загрузка статуса"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Название",
|
"title": "Название",
|
||||||
"titlePlaceholder": "Добавить название",
|
"titlePlaceholder": "Добавить название",
|
||||||
|
|||||||
@@ -129,6 +129,12 @@
|
|||||||
"occurrences": "lần"
|
"occurrences": "lần"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"freeBusy": {
|
||||||
|
"unknown": "Trạng thái không rõ",
|
||||||
|
"busy": "Người này đang bận",
|
||||||
|
"free": "Người dùng đang rảnh",
|
||||||
|
"loading": "Đang tải trạng thái"
|
||||||
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"title": "Tiêu đề",
|
"title": "Tiêu đề",
|
||||||
"titlePlaceholder": "Thêm tiêu đề",
|
"titlePlaceholder": "Thêm tiêu đề",
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function extractCalendarHrefs(data: unknown): string[] {
|
||||||
|
try {
|
||||||
|
const calendars = (
|
||||||
|
data as {
|
||||||
|
_embedded: { "dav:calendar": { _links: { self: { href: string } } }[] };
|
||||||
|
}
|
||||||
|
)._embedded["dav:calendar"];
|
||||||
|
|
||||||
|
return Array.isArray(calendars)
|
||||||
|
? calendars.map((cal) => cal?._links?.self?.href ?? "").filter(Boolean)
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user