diff --git a/__test__/components/Calendar.test.tsx b/__test__/components/Calendar.test.tsx
index 9fa2866..965c40c 100644
--- a/__test__/components/Calendar.test.tsx
+++ b/__test__/components/Calendar.test.tsx
@@ -2,6 +2,13 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import CalendarApp from "../../src/components/Calendar/Calendar";
import * as eventThunks from "../../src/features/Calendars/CalendarSlice";
import { renderWithProviders } from "../utils/Renderwithproviders";
+import { searchUsers } from "../../src/features/User/userAPI";
+
+import userEvent from "@testing-library/user-event";
+jest.mock("../../src/features/User/userAPI");
+const mockedSearchUsers = searchUsers as jest.MockedFunction<
+ typeof searchUsers
+>;
describe("CalendarSelection", () => {
const today = new Date();
@@ -157,15 +164,93 @@ describe("CalendarSelection", () => {
expect(screen.getByLabelText("Calendar delegated")).toBeInTheDocument();
expect(screen.getByLabelText("Calendar shared")).toBeInTheDocument();
- const delegatedAccordionSummary = screen
- .getByText("Delegated Calendars")
+ const sharedAccordionSummary = screen
+ .getByText("Other Calendars")
.closest(".MuiAccordionSummary-root");
- const addButton = screen.getAllByTestId("AddIcon")[1];
+ const addButton = screen.getAllByTestId("AddIcon")[2];
fireEvent.click(addButton);
- expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "true");
+ expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true");
fireEvent.click(addButton);
- expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "true");
+ expect(sharedAccordionSummary).toHaveAttribute("aria-expanded", "true");
+ });
+});
+
+describe("calendar Availability search", () => {
+ const preloadedState = {
+ user: {
+ userData: {
+ sub: "test",
+ email: "test@test.com",
+ sid: "mockSid",
+ openpaasId: "user1",
+ },
+ tokens: { accessToken: "token" },
+ },
+ calendars: {
+ list: {
+ "user1/cal1": {
+ name: "Calendar personnal",
+ id: "user1/cal1",
+ color: "#FF0000",
+ ownerEmails: ["alice@example.com"],
+ events: {},
+ },
+ },
+ pending: false,
+ templist: {},
+ },
+ };
+
+ it("imports temporary calendars when selecting new users", async () => {
+ const spy = jest
+ .spyOn(eventThunks, "getTempCalendarsListAsync")
+ .mockImplementation((payload) => {
+ return () => Promise.resolve(payload) as any;
+ });
+ mockedSearchUsers.mockResolvedValueOnce([
+ {
+ email: "newuser@example.com",
+ displayName: "New User",
+ avatarUrl: "image.png",
+ openpaasId: "1234567890",
+ },
+ ]);
+
+ renderWithProviders(, preloadedState);
+
+ const input = screen.getByPlaceholderText(/search user/i);
+ userEvent.type(input, "New");
+
+ const option = await screen.findByText("New User");
+ fireEvent.click(option);
+
+ expect(spy).toHaveBeenCalled();
+ });
+
+ it("does not import temp calendars if user already has a calendar but toggles the shared one", async () => {
+ mockedSearchUsers.mockResolvedValueOnce([
+ {
+ email: "alice@example.com",
+ displayName: "Alice",
+ avatarUrl: "image.png",
+ openpaasId: "1234567890",
+ },
+ ]);
+ const spy = jest
+ .spyOn(eventThunks, "getTempCalendarsListAsync")
+ .mockImplementation((payload) => {
+ return () => Promise.resolve(payload) as any;
+ });
+ renderWithProviders(, preloadedState);
+
+ const input = screen.getByPlaceholderText(/search user/i);
+ userEvent.type(input, "Alice");
+
+ const option = await screen.findByText("Alice");
+ fireEvent.click(option);
+
+ expect(spy).not.toHaveBeenCalledWith();
});
});
diff --git a/__test__/components/CalendarSelection.test.tsx b/__test__/components/CalendarSelection.test.tsx
index a91dbb7..e0e59b7 100644
--- a/__test__/components/CalendarSelection.test.tsx
+++ b/__test__/components/CalendarSelection.test.tsx
@@ -190,9 +190,9 @@ describe("CalendarSelection", () => {
.closest(".MuiAccordionSummary-root");
fireEvent.click(delegatedAccordionSummary!);
- expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "false");
+ expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "true");
fireEvent.click(delegatedAccordionSummary!);
- expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "true");
+ expect(delegatedAccordionSummary).toHaveAttribute("aria-expanded", "false");
});
});
diff --git a/__test__/components/DuplicateEvent.test.tsx b/__test__/components/DuplicateEvent.test.tsx
index 32fff1a..71c5def 100644
--- a/__test__/components/DuplicateEvent.test.tsx
+++ b/__test__/components/DuplicateEvent.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, screen } from "@testing-library/react";
+import { fireEvent, screen, waitFor } from "@testing-library/react";
import EventDuplication from "../../src/components/Event/EventDuplicate";
import EventDisplayModal from "../../src/features/Events/EventDisplay";
import EventPopover from "../../src/features/Events/EventModal";
@@ -147,7 +147,7 @@ describe("EventPopover", () => {
});
fireEvent.click(screen.getByRole("button", { name: /Save/i }));
- expect(onClose).toHaveBeenCalled();
+ waitFor(() => expect(onClose).toHaveBeenCalled());
});
});
diff --git a/__test__/components/EventModifications.test.tsx b/__test__/components/EventModifications.test.tsx
index f8a8825..731d303 100644
--- a/__test__/components/EventModifications.test.tsx
+++ b/__test__/components/EventModifications.test.tsx
@@ -2,7 +2,7 @@ import { CalendarApi } from "@fullcalendar/core";
import { jest } from "@jest/globals";
import { ThunkDispatch } from "@reduxjs/toolkit";
import "@testing-library/jest-dom";
-import { act, screen, within } from "@testing-library/react";
+import { act, screen, waitFor, within } from "@testing-library/react";
import * as appHooks from "../../src/app/hooks";
import CalendarApp from "../../src/components/Calendar/Calendar";
import { renderWithProviders } from "../utils/Renderwithproviders";
@@ -97,7 +97,7 @@ describe("CalendarApp integration", () => {
fcEvent?.setEnd(newEnd);
- expect(dispatch).toHaveBeenCalled();
+ waitFor(() => expect(dispatch).toHaveBeenCalled());
}
});
});
diff --git a/__test__/components/PeopleSearch.test.tsx b/__test__/components/PeopleSearch.test.tsx
new file mode 100644
index 0000000..8758697
--- /dev/null
+++ b/__test__/components/PeopleSearch.test.tsx
@@ -0,0 +1,102 @@
+import { screen, fireEvent, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import {
+ User,
+ PeopleSearch,
+} from "../../src/components/Attendees/PeopleSearch";
+import { renderWithProviders } from "../utils/Renderwithproviders";
+import { searchUsers } from "../../src/features/User/userAPI";
+
+jest.mock("../../src/features/User/userAPI");
+const mockedSearchUsers = searchUsers as jest.MockedFunction<
+ typeof searchUsers
+>;
+
+describe("PeopleSearch", () => {
+ const baseUser: User = {
+ email: "test@example.com",
+ displayName: "Test User",
+ avatarUrl: "https://example.com/avatar.png",
+ openpaasId: "1234567890",
+ };
+
+ function setup(
+ selectedUsers: User[] = [],
+ props?: Partial>
+ ) {
+ const onChange = jest.fn();
+ renderWithProviders(
+
+ );
+ return { onChange };
+ }
+
+ beforeEach(() => {
+ jest.useFakeTimers();
+ mockedSearchUsers.mockReset();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it("calls searchUsers after debounce when typing", async () => {
+ mockedSearchUsers.mockResolvedValueOnce([baseUser]);
+ setup();
+
+ const input = screen.getByRole("combobox");
+ await userEvent.type(input, "Test");
+ jest.advanceTimersByTime(300);
+
+ await waitFor(() => {
+ expect(mockedSearchUsers).toHaveBeenCalledWith("Test", ["user"]);
+ });
+ });
+
+ it("renders search results and allows selection", async () => {
+ mockedSearchUsers.mockResolvedValueOnce([baseUser]);
+ const { onChange } = setup();
+
+ const input = screen.getByRole("combobox");
+ await userEvent.type(input, "Test");
+ jest.advanceTimersByTime(300);
+
+ const option = await screen.findByText("Test User");
+ await userEvent.click(option);
+
+ await waitFor(() => {
+ expect(onChange).toHaveBeenCalled();
+ });
+ });
+
+ it("does not show already selected users in options", async () => {
+ mockedSearchUsers.mockResolvedValueOnce([baseUser]);
+ setup([baseUser]);
+ const input = screen.getByRole("combobox");
+ await userEvent.type(input, "Test");
+ jest.advanceTimersByTime(300);
+
+ await waitFor(() => {
+ expect(screen.queryByText("test@example.com")).not.toBeInTheDocument();
+ });
+ });
+
+ it("triggers onToggleEventPreview on Enter key press", () => {
+ const onToggleEventPreview = jest.fn();
+ setup([], { onToggleEventPreview });
+
+ const input = screen.getByRole("combobox");
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(onToggleEventPreview).toHaveBeenCalled();
+ });
+
+ it("respects disabled state", () => {
+ setup([], { disabled: true });
+ expect(screen.getByRole("combobox")).toBeDisabled();
+ });
+});
diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx
index 6406cfa..4ea53af 100644
--- a/src/components/Attendees/PeopleSearch.tsx
+++ b/src/components/Attendees/PeopleSearch.tsx
@@ -17,26 +17,33 @@ import TextField from "@mui/material/TextField";
import Typography from "@mui/material/Typography";
import { useState, useEffect } from "react";
import { searchUsers } from "../../features/User/userAPI";
+import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined";
+import Chip from "@mui/material/Chip";
+import { useTheme } from "@mui/material/styles";
export interface User {
email: string;
displayName: string;
avatarUrl: string;
openpaasId: string;
+ color?: string;
}
export function PeopleSearch({
selectedUsers,
onChange,
disabled,
+ onToggleEventPreview,
}: {
selectedUsers: User[];
onChange: Function;
disabled?: boolean;
+ onToggleEventPreview?: Function;
}) {
const [query, setQuery] = useState("");
const [loading, setLoading] = useState(false);
const [options, setOptions] = useState([]);
+ const theme = useTheme();
useEffect(() => {
const delayDebounceFn = setTimeout(async () => {
@@ -67,10 +74,25 @@ export function PeopleSearch({
renderInput={(params) => (
{
+ if (e.key === "Enter" && onToggleEventPreview) {
+ e.preventDefault();
+ onToggleEventPreview();
+ }
+ }}
slotProps={{
input: {
...params.InputProps,
+ startAdornment: (
+ <>
+
+ {params.InputProps.startAdornment}
+ >
+ ),
endAdornment: (
<>
{loading ? (
@@ -101,6 +123,18 @@ export function PeopleSearch({
);
}}
+ renderValue={(value, getTagProps) =>
+ value.map((option, index) => (
+
+ ))
+ }
/>
);
}
diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx
index 1c55855..00d2146 100644
--- a/src/components/Calendar/Calendar.tsx
+++ b/src/components/Calendar/Calendar.tsx
@@ -6,7 +6,7 @@ import interactionPlugin from "@fullcalendar/interaction";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import ReactCalendar from "react-calendar";
import "./Calendar.css";
-import { useEffect, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import EventPopover from "../../features/Events/EventModal";
import CalendarPopover from "../../features/Calendars/CalendarModal";
@@ -21,6 +21,7 @@ import {
} from "../../features/Calendars/CalendarSlice";
import ImportAlert from "../../features/Events/ImportAlert";
import {
+ computeStartOfTheWeek,
formatDateToYYYYMMDDTHHMMSS,
getCalendarRange,
getDeltaInMilliseconds,
@@ -35,15 +36,9 @@ import AddIcon from "@mui/icons-material/Add";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import LockIcon from "@mui/icons-material/Lock";
import { userAttendee } from "../../features/User/userDataTypes";
+import { TempCalendarsInput } from "./TempCalendarsInput";
import Button from "@mui/material/Button";
-const computeStartOfTheWeek = (date: Date): Date => {
- const startOfWeek = new Date(date);
- startOfWeek.setDate(date.getDate() - ((date.getDay() + 6) % 7)); // Monday
- startOfWeek.setHours(0, 0, 0, 0);
- return startOfWeek;
-};
-
export default function CalendarApp() {
const calendarRef = useRef(null);
const [selectedDate, setSelectedDate] = useState(new Date());
@@ -57,6 +52,8 @@ export default function CalendarApp() {
}
const calendars = useAppSelector((state) => state.calendars.list);
+ const tempcalendars =
+ useAppSelector((state) => state.calendars.templist) ?? {};
const pending = useAppSelector((state) => state.calendars.pending);
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
@@ -106,35 +103,39 @@ export default function CalendarApp() {
calendarRange.start
)}_${formatDateToYYYYMMDDTHHMMSS(calendarRange.end)}`;
- let filteredEvents: CalendarEvent[] = [];
- selectedCalendars.forEach((id) => {
- if (calendars[id].events) {
- filteredEvents = filteredEvents
- .concat(
- Object.keys(calendars[id].events).map(
- (eventid) => calendars[id].events[eventid]
- )
- )
- .filter((event) => !(event.status === "CANCELLED"));
- }
- });
+ let filteredEvents: CalendarEvent[] = extractEvents(
+ selectedCalendars,
+ calendars
+ );
+
+ let filteredTempEvents: CalendarEvent[] = extractEvents(
+ Object.keys(tempcalendars),
+ tempcalendars
+ );
useEffect(() => {
- selectedCalendars.forEach((id) => {
- if (!pending && rangeKey) {
- dispatch(
- getCalendarDetailAsync({
- calId: id,
- match: {
- start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
- end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
- },
- })
- );
- }
- });
+ updateCalsDetails(
+ selectedCalendars,
+ pending,
+ calendars,
+ rangeKey,
+ dispatch,
+ calendarRange
+ );
}, [rangeKey, selectedCalendars]);
+ useEffect(() => {
+ updateCalsDetails(
+ Object.keys(tempcalendars),
+ pending,
+ tempcalendars,
+ rangeKey,
+ dispatch,
+ calendarRange,
+ "temp"
+ );
+ }, [rangeKey, Object.keys(tempcalendars).join(",")]);
+
const [anchorEl, setAnchorEl] = useState(null);
const [anchorPosition, setAnchorPosition] = useState<{
top: number;
@@ -142,11 +143,16 @@ export default function CalendarApp() {
} | null>(null);
const [openEventDisplay, setOpenEventDisplay] = useState(false);
const [eventDisplayedId, setEventDisplayedId] = useState("");
+ const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
const [eventDisplayedCalId, setEventDisplayedCalId] = useState("");
const [selectedRange, setSelectedRange] = useState(
null
);
+ const [tempEvent, setTempEvent] = useState(
+ {} as CalendarEvent
+ );
+
const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedRange(selectInfo);
setAnchorEl(document.body); // fallback: we could use selectInfo.jsEvent.target if from a click
@@ -156,6 +162,29 @@ export default function CalendarApp() {
calendarRef.current?.unselect();
setAnchorEl(null);
setSelectedRange(null);
+ selectedCalendars.forEach((calId) =>
+ dispatch(
+ getCalendarDetailAsync({
+ calId,
+ match: {
+ start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
+ end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
+ },
+ })
+ )
+ );
+ Object.keys(tempcalendars).forEach((calId) =>
+ dispatch(
+ getCalendarDetailAsync({
+ calId,
+ match: {
+ start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
+ end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
+ },
+ calType: "temp",
+ })
+ )
+ );
};
const handleCloseEventDisplay = () => {
setAnchorPosition(null);
@@ -267,6 +296,12 @@ export default function CalendarApp() {
);
}}
/>
+
{
- if (e.calId.split("/")[0] === userId) {
- return { ...e, editable: true };
- }
- return { ...e, editable: false };
- })}
+ events={eventToFullCalendarFormat(
+ filteredEvents,
+ filteredTempEvents,
+ userId
+ )}
weekNumbers
weekNumberFormat={{ week: "long" }}
slotDuration={"00:30:00"}
@@ -379,6 +413,7 @@ export default function CalendarApp() {
});
setEventDisplayedId(info.event.extendedProps.uid);
setEventDisplayedCalId(info.event.extendedProps.calId);
+ setEventDisplayedTemp(info.event._def.extendedProps.temp);
}
}}
eventAllow={(dropInfo, draggedEvent) => {
@@ -439,7 +474,7 @@ export default function CalendarApp() {
start: computedNewStart,
end: computedNewEnd,
} as CalendarEvent;
- console.log(event, newEvent);
+
dispatch(
putEventAsync({ cal: calendars[newEvent.calId], newEvent })
);
@@ -451,7 +486,13 @@ export default function CalendarApp() {
}}
eventContent={(arg) => {
const event = arg.event;
- if (!calendars[arg.event._def.extendedProps.calId]) return;
+ if (
+ (!event._def.extendedProps.temp &&
+ !calendars[arg.event._def.extendedProps.calId]) ||
+ (event._def.extendedProps.temp &&
+ !tempcalendars[arg.event._def.extendedProps.calId])
+ )
+ return;
const attendees = event._def.extendedProps.attendee || [];
const isPrivate =
@@ -460,15 +501,19 @@ export default function CalendarApp() {
let Icon = null;
let titleStyle: React.CSSProperties = {};
const ownerEmails = new Set(
- calendars[arg.event._def.extendedProps.calId].ownerEmails?.map(
- (email) => email.toLowerCase()
- )
+ (event._def.extendedProps.temp ? tempcalendars : calendars)[
+ arg.event._def.extendedProps.calId
+ ].ownerEmails?.map((email) => email.toLowerCase())
);
+
+ const delegated = (
+ event._def.extendedProps.temp ? tempcalendars : calendars
+ )[arg.event._def.extendedProps.calId].delegated;
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
ownerEmails.has(att.cal_address.toLowerCase())
);
- if (!showSpecialDisplay[0]) return;
- switch (showSpecialDisplay[0].partstat) {
+ if (!delegated && showSpecialDisplay.length === 0) return null;
+ switch (showSpecialDisplay?.[0]?.partstat) {
case "DECLINED":
Icon = null;
titleStyle.textDecoration = "line-through";
@@ -545,11 +590,13 @@ export default function CalendarApp() {
selectedRange={selectedRange}
setSelectedRange={setSelectedRange}
calendarRef={calendarRef}
+ event={tempEvent}
/>
{openEventDisplay && eventDisplayedId && eventDisplayedCalId && (
);
}
+
+function eventToFullCalendarFormat(
+ filteredEvents: CalendarEvent[],
+ filteredTempEvents: CalendarEvent[],
+ userId: string | undefined
+) {
+ return filteredEvents
+ .concat(filteredTempEvents.map((e) => ({ ...e, temp: true })))
+ .map((e) => {
+ if (e.calId.split("/")[0] === userId) {
+ return { ...e, editable: true };
+ }
+ return { ...e, editable: false };
+ });
+}
+
+function extractEvents(
+ selectedCalendars: string[],
+ calendars: Record
+) {
+ let filteredEvents: CalendarEvent[] = [];
+ selectedCalendars.forEach((id) => {
+ if (calendars[id].events) {
+ filteredEvents = filteredEvents
+ .concat(
+ Object.keys(calendars[id].events).map(
+ (eventid) => calendars[id].events[eventid]
+ )
+ )
+ .filter((event) => !(event.status === "CANCELLED"));
+ }
+ });
+ return filteredEvents;
+}
+
+function updateCalsDetails(
+ selectedCalendars: string[],
+ pending: boolean,
+ calendars: Record,
+ rangeKey: string,
+ dispatch: Function,
+ calendarRange: { start: Date; end: Date },
+ calType?: "temp"
+) {
+ selectedCalendars.forEach((id) => {
+ if (Object.keys(calendars[id].events).length > 0) {
+ return;
+ }
+ if (!pending && rangeKey) {
+ dispatch(
+ getCalendarDetailAsync({
+ calId: id,
+ match: {
+ start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
+ end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
+ },
+ calType,
+ })
+ );
+ }
+ });
+}
diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx
index 15e6401..c7fa080 100644
--- a/src/components/Calendar/CalendarSelection.tsx
+++ b/src/components/Calendar/CalendarSelection.tsx
@@ -34,7 +34,7 @@ function CalendarAccordion({
const allCalendars = useAppSelector((state) => state.calendars.list);
const [expended, setExpended] = useState(defaultExpanded);
- if (calendars.length === 0) return null;
+ if (calendars.length === 0 && !defaultExpanded) return null;
return (
@@ -129,7 +129,6 @@ export default function CalendarSelection({
calendars={delegatedCalendars}
selectedCalendars={selectedCalendars}
handleToggle={handleCalendarToggle}
- defaultExpanded
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
diff --git a/src/components/Calendar/TempCalendarsInput.tsx b/src/components/Calendar/TempCalendarsInput.tsx
new file mode 100644
index 0000000..89682b2
--- /dev/null
+++ b/src/components/Calendar/TempCalendarsInput.tsx
@@ -0,0 +1,160 @@
+import { useState, useRef, useMemo } from "react";
+import { useAppDispatch, useAppSelector } from "../../app/hooks";
+import {
+ getTempCalendarsListAsync,
+ removeTempCal,
+} from "../../features/Calendars/CalendarSlice";
+import { Calendars } from "../../features/Calendars/CalendarTypes";
+import { CalendarEvent } from "../../features/Events/EventsTypes";
+import { User, PeopleSearch } from "../Attendees/PeopleSearch";
+
+const requestControllers = new Map();
+
+export function TempCalendarsInput({
+ setAnchorEl,
+ setTempEvent,
+ selectedCalendars,
+ setSelectedCalendars,
+}: {
+ setAnchorEl: Function;
+ setTempEvent: Function;
+ selectedCalendars: string[];
+ setSelectedCalendars: Function;
+}) {
+ const [tempUsers, setTempUsers] = useState([]);
+ const dispatch = useAppDispatch();
+ const tempcalendars =
+ useAppSelector((state) => state.calendars.templist) ?? {};
+ const calendars = useAppSelector((state) => state.calendars.list);
+
+ const prevUsersRef = useRef([]);
+ const userColors = new Map();
+
+ const handleUserChange = async (_: any, users: User[]) => {
+ setTempUsers(users);
+
+ const prevUsers = prevUsersRef.current;
+
+ const addedUsers = users.filter(
+ (u) => !prevUsers.some((p) => p.email === u.email)
+ );
+ const removedUsers = prevUsers.filter(
+ (p) => !users.some((u) => u.email === p.email)
+ );
+
+ prevUsersRef.current = users;
+
+ const { calendarsToImport, calendarsToToggle } = getCalendarsFromUsersDelta(
+ addedUsers,
+ buildEmailToCalendarMap(calendars),
+ selectedCalendars
+ );
+
+ if (calendarsToImport.length > 0) {
+ for (const user of calendarsToImport) {
+ const controller = new AbortController();
+ requestControllers.set(user.email, controller);
+
+ if (!userColors.has(user.email)) {
+ const existingColors = new Set(userColors.values());
+ userColors.set(user.email, generateUserColor(existingColors));
+ }
+
+ user.color = userColors.get(user.email)!;
+
+ dispatch(
+ getTempCalendarsListAsync(user, { signal: controller.signal })
+ );
+ }
+ }
+
+ if (calendarsToToggle.length > 0) {
+ setSelectedCalendars((prev: string[]) => [
+ ...new Set([...prev, ...calendarsToToggle]),
+ ]);
+ }
+
+ for (const user of removedUsers) {
+ const controller = requestControllers.get(user.email);
+ if (controller) {
+ controller.abort();
+ requestControllers.delete(user.email);
+ }
+
+ const calIds = buildEmailToCalendarMap(tempcalendars).get(user.email);
+ calIds?.forEach((id) => dispatch(removeTempCal(id)));
+ }
+ };
+
+ const handleToggleEventPreview = () => {
+ const newEvent: CalendarEvent = {
+ title: "New Event",
+ attendee: tempUsers.map((u) => ({
+ cn: u.displayName,
+ cal_address: u.email,
+ partstat: "NEED-ACTION",
+ role: "REQ-PARTICIPANT",
+ rsvp: "TRUE",
+ cutype: "INDIVIDUAL",
+ })),
+ } as CalendarEvent;
+
+ setTempEvent(newEvent);
+ setAnchorEl(document.body);
+ };
+
+ return (
+
+ );
+}
+
+function getCalendarsFromUsersDelta(
+ addedUsers: User[],
+ emailToCalendarId: Map,
+ selectedCalendars: string[]
+) {
+ const selectedSet = new Set(selectedCalendars);
+
+ const calendarsToImport: User[] = [];
+ const calendarsToToggle: string[] = [];
+
+ for (const user of addedUsers) {
+ const calIds = emailToCalendarId.get(user.email) ?? [];
+
+ if (!calIds || calIds.every((calId) => !selectedSet.has(calId))) {
+ calendarsToImport.push(user);
+ } else {
+ // calIds.forEach((calId) => calendarsToToggle.push(calId));
+ }
+ }
+
+ return { calendarsToImport, calendarsToToggle };
+}
+
+function buildEmailToCalendarMap(calRecord: Record) {
+ const map = new Map();
+ for (const [id, cal] of Object.entries(calRecord)) {
+ cal.ownerEmails?.forEach((email) => {
+ const existing = map.get(email);
+ if (existing) {
+ existing.push(id);
+ } else {
+ map.set(email, [id]);
+ }
+ });
+ }
+ return map;
+}
+
+function generateUserColor(existingColors: Set): string {
+ let color: string;
+ do {
+ const hue = Math.floor(Math.random() * 360);
+ color = `hsl(${hue}, 70%, 50%)`;
+ } while (existingColors.has(color));
+ return color;
+}
diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts
index d4bd5c5..cabeed7 100644
--- a/src/features/Calendars/CalendarSlice.ts
+++ b/src/features/Calendars/CalendarSlice.ts
@@ -12,6 +12,7 @@ import { getOpenPaasUser, getUserDetails } from "../User/userAPI";
import { parseCalendarEvent } from "../Events/eventUtils";
import { deleteEvent, getEvent, moveEvent, putEvent } from "../Events/EventApi";
import { formatDateToYYYYMMDDTHHMMSS } from "../../utils/dateUtils";
+import { User } from "../../components/Attendees/PeopleSearch";
export const getCalendarsListAsync = createAsyncThunk<
Record // Return type
@@ -55,10 +56,50 @@ export const getCalendarsListAsync = createAsyncThunk<
return importedCalendars;
});
+export const getTempCalendarsListAsync = createAsyncThunk<
+ Record,
+ User
+>("calendars/getTempCalendars", async (tempUser) => {
+ const importedCalendars: Record = {};
+
+ const calendars = (await getCalendars(
+ tempUser.openpaasId ?? "",
+ "sharedPublic=true&WithRights=true"
+ )) as Record;
+ const rawCalendars = calendars._embedded["dav:calendar"];
+
+ for (const cal of rawCalendars) {
+ const name = cal["dav:name"];
+ const description = cal["caldav:description"];
+ const delegated = cal["calendarserver:delegatedsource"] ? true : false;
+ const source = cal["calendarserver:source"]
+ ? cal["calendarserver:source"]._links.self.href
+ : cal._links.self.href;
+ const link = cal._links.self.href;
+
+ const id = source.replace("/calendars/", "").replace(".json", "");
+ const ownerData: any = await getUserDetails(id.split("/")[0]);
+
+ importedCalendars[id] = {
+ id,
+ name,
+ link,
+ owner: `${ownerData.firstname ? `${ownerData.firstname} ` : ""}${ownerData.lastname}`,
+ ownerEmails: ownerData.emails,
+ description,
+ delegated,
+ color: tempUser.color ?? "#a8a8a8ff",
+ events: {},
+ };
+ }
+
+ return importedCalendars;
+});
+
export const getCalendarDetailAsync = createAsyncThunk<
- { calId: string; events: CalendarEvent[] }, // Return type
- { calId: string; match: { start: string; end: string } } // Arg type
->("calendars/getCalendarDetails", async ({ calId, match }) => {
+ { calId: string; events: CalendarEvent[]; calType?: string }, // Return type
+ { calId: string; match: { start: string; end: string }; calType?: string } // Arg type
+>("calendars/getCalendarDetails", async ({ calId, match, calType }) => {
const calendar = (await getCalendar(calId, match)) as Record;
const color = calendar["apple:color"];
const events: CalendarEvent[] = calendar._embedded["dav:item"].flatMap(
@@ -72,13 +113,13 @@ export const getCalendarDetailAsync = createAsyncThunk<
}
);
- return { calId, events };
+ return { calId, events, calType };
});
export const putEventAsync = createAsyncThunk<
- { calId: string; events: CalendarEvent[] }, // Return type
- { cal: Calendars; newEvent: CalendarEvent } // Arg type
->("calendars/putEvent", async ({ cal, newEvent }) => {
+ { calId: string; events: CalendarEvent[]; calType?: "temp" }, // Return type
+ { cal: Calendars; newEvent: CalendarEvent; calType?: "temp" } // Arg type
+>("calendars/putEvent", async ({ cal, newEvent, calType }) => {
const response = await putEvent(
newEvent,
cal.ownerEmails ? cal.ownerEmails[0] : undefined
@@ -116,6 +157,7 @@ export const putEventAsync = createAsyncThunk<
return {
calId: cal.id,
events,
+ calType,
};
});
@@ -247,7 +289,11 @@ export const addSharedCalendarAsync = createAsyncThunk<
const CalendarSlice = createSlice({
name: "calendars",
- initialState: { list: {} as Record, pending: false },
+ initialState: {
+ list: {} as Record,
+ templist: {} as Record,
+ pending: false,
+ },
reducers: {
createCalendar: (state, action: PayloadAction>) => {
const id = Date.now().toString(36);
@@ -280,6 +326,9 @@ const CalendarSlice = createSlice({
action.payload.eventUid
];
},
+ removeTempCal: (state, action: PayloadAction) => {
+ delete state.templist[action.payload];
+ },
updateEventLocal: (
state,
action: PayloadAction<{ calId: string; event: CalendarEvent }>
@@ -297,56 +346,81 @@ const CalendarSlice = createSlice({
state.list = action.payload;
}
)
+ .addCase(
+ getTempCalendarsListAsync.fulfilled,
+ (state, action: PayloadAction>) => {
+ state.pending = false;
+ Object.keys(action.payload).forEach(
+ (id) => (state.templist[id] = action.payload[id])
+ );
+ }
+ )
.addCase(
getCalendarDetailAsync.fulfilled,
(
state,
- action: PayloadAction<{ calId: string; events: CalendarEvent[] }>
+ action: PayloadAction<{
+ calId: string;
+ events: CalendarEvent[];
+ calType?: string;
+ }>
) => {
state.pending = false;
- if (!state.list[action.payload.calId]) {
- state.list[action.payload.calId] = {
+ const type = action.payload.calType === "temp" ? "templist" : "list";
+
+ if (!state[type][action.payload.calId]) {
+ state[type][action.payload.calId] = {
id: action.payload.calId,
events: {},
} as Calendars;
}
action.payload.events.forEach((event) => {
- state.list[action.payload.calId].events[event.uid] = event;
- });
- Object.keys(state.list[action.payload.calId].events).forEach((id) => {
- state.list[action.payload.calId].events[id].color =
- state.list[action.payload.calId].color;
- state.list[action.payload.calId].events[id].calId =
- action.payload.calId;
- state.list[action.payload.calId].events[id].timezone =
- Intl.DateTimeFormat().resolvedOptions().timeZone;
+ state[type][action.payload.calId].events[event.uid] = event;
});
+ Object.keys(state[type][action.payload.calId].events).forEach(
+ (id) => {
+ state[type][action.payload.calId].events[id].color =
+ state[type][action.payload.calId].color;
+ state[type][action.payload.calId].events[id].calId =
+ action.payload.calId;
+ state[type][action.payload.calId].events[id].timezone =
+ Intl.DateTimeFormat().resolvedOptions().timeZone;
+ }
+ );
}
)
.addCase(
putEventAsync.fulfilled,
(
state,
- action: PayloadAction<{ calId: string; events: CalendarEvent[] }>
+ action: PayloadAction<{
+ calId: string;
+ events: CalendarEvent[];
+ calType?: "temp";
+ }>
) => {
state.pending = false;
- if (!state.list[action.payload.calId]) {
- state.list[action.payload.calId] = {
+ const type = action.payload.calType === "temp" ? "templist" : "list";
+
+ if (!state[type][action.payload.calId]) {
+ state[type][action.payload.calId] = {
id: action.payload.calId,
events: {},
} as Calendars;
}
action.payload.events.forEach((event) => {
- state.list[action.payload.calId].events[event.uid] = event;
- });
- Object.keys(state.list[action.payload.calId].events).forEach((id) => {
- state.list[action.payload.calId].events[id].color =
- state.list[action.payload.calId].color;
- state.list[action.payload.calId].events[id].calId =
- action.payload.calId;
- state.list[action.payload.calId].events[id].timezone =
- Intl.DateTimeFormat().resolvedOptions().timeZone;
+ state[type][action.payload.calId].events[event.uid] = event;
});
+ Object.keys(state[type][action.payload.calId].events).forEach(
+ (id) => {
+ state[type][action.payload.calId].events[id].color =
+ state[type][action.payload.calId].color;
+ state[type][action.payload.calId].events[id].calId =
+ action.payload.calId;
+ state[type][action.payload.calId].events[id].timezone =
+ Intl.DateTimeFormat().resolvedOptions().timeZone;
+ }
+ );
}
)
.addCase(
@@ -484,12 +558,20 @@ const CalendarSlice = createSlice({
.addCase(createCalendarAsync.pending, (state) => {
state.pending = true;
})
+ .addCase(getTempCalendarsListAsync.pending, (state) => {
+ state.pending = true;
+ })
.addCase(addSharedCalendarAsync.pending, (state) => {
state.pending = true;
});
},
});
-export const { addEvent, removeEvent, createCalendar, updateEventLocal } =
- CalendarSlice.actions;
+export const {
+ addEvent,
+ removeEvent,
+ createCalendar,
+ updateEventLocal,
+ removeTempCal,
+} = CalendarSlice.actions;
export default CalendarSlice.reducer;
diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx
index 09b9c44..4583bab 100644
--- a/src/features/Events/EventDisplayPreview.tsx
+++ b/src/features/Events/EventDisplayPreview.tsx
@@ -45,22 +45,24 @@ import { getCalendar } from "../Calendars/CalendarApi";
export default function EventPreviewModal({
eventId,
calId,
+ tempEvent,
anchorPosition,
open,
onClose,
}: {
eventId: string;
calId: string;
+ tempEvent?: boolean;
anchorPosition: PopoverPosition | null;
open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
}) {
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
- const calendar = calendars.list[calId];
- const event = useAppSelector(
- (state) => state.calendars.list[calId]?.events[eventId]
- );
+ const calendar = tempEvent
+ ? calendars.templist[calId]
+ : calendars.list[calId];
+ const event = calendar.events[eventId];
const user = useAppSelector((state) => state.user);
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openFullDisplay, setOpenFullDisplay] = useState(false);
diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx
index d55cb9c..1822927 100644
--- a/src/features/Events/EventModal.tsx
+++ b/src/features/Events/EventModal.tsx
@@ -67,13 +67,16 @@ function EventPopover({
const [showMore, setShowMore] = useState(false);
const [title, setTitle] = useState(event?.title ?? "");
+
const [description, setDescription] = useState(event?.description ?? "");
const [location, setLocation] = useState(event?.location ?? "");
const [start, setStart] = useState(
- event?.start ? new Date(event.start).toISOString() : ""
+ event?.start
+ ? new Date(event.start).toISOString()
+ : new Date().toISOString()
);
const [end, setEnd] = useState(
- event?.end ? new Date(event.end)?.toISOString() : ""
+ event?.end ? new Date(event.end)?.toISOString() : new Date().toISOString()
);
const [calendarid, setCalendarid] = useState(
event?.calId
@@ -104,6 +107,24 @@ function EventPopover({
}
}, [selectedRange]);
+ useEffect(() => {
+ setTitle(event?.title ?? "");
+ setAttendees(
+ event?.attendee
+ ? event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
+ : []
+ );
+ }, [event, organizer?.cal_address]);
+
+ const handleClose = () => {
+ onClose({}, "backdropClick"); // Reset
+ setTitle("");
+ setDescription("");
+ setAttendees([]);
+ setLocation("");
+ setCalendarid(0);
+ };
+
const handleSave = async () => {
const newEventUID = crypto.randomUUID();
@@ -142,7 +163,7 @@ function EventPopover({
newEvent.attendee = newEvent.attendee.concat(attendees);
}
- dispatch(
+ await dispatch(
putEventAsync({
cal: userPersonnalCalendars[calendarid],
newEvent,
@@ -153,6 +174,7 @@ function EventPopover({
// Reset
setTitle("");
setDescription("");
+ setAttendees([]);
setLocation("");
setCalendarid(0);
};
@@ -161,7 +183,7 @@ function EventPopover({
-
+
@@ -359,10 +381,7 @@ function EventPopover({
-