From 00c0b58032543d9b64d794abff80449e486a91a7 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Wed, 24 Sep 2025 17:05:45 +0200 Subject: [PATCH] sidebar availability search (#128) * [123] added people search in left side bar * [#123] added toggle temp calendars * [#123] added tests] * fixup! [123] added people search in left side bar * fixup! [#123] added toggle temp calendars * [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * fixup! [#123] fixed event creation on Enter * [#123] added color diff for temp calendars * fixup! [#123] fixed event creation on Enter * fixup! [#123] added tests] * fixup! [#123] added toggle temp calendars --------- Co-authored-by: Camille Moussu --- __test__/components/Calendar.test.tsx | 95 ++++++++- .../components/CalendarSelection.test.tsx | 4 +- __test__/components/DuplicateEvent.test.tsx | 4 +- .../components/EventModifications.test.tsx | 4 +- __test__/components/PeopleSearch.test.tsx | 102 +++++++++ src/components/Attendees/PeopleSearch.tsx | 34 +++ src/components/Calendar/Calendar.tsx | 201 ++++++++++++++---- src/components/Calendar/CalendarSelection.tsx | 3 +- .../Calendar/TempCalendarsInput.tsx | 160 ++++++++++++++ src/features/Calendars/CalendarSlice.ts | 150 ++++++++++--- src/features/Events/EventDisplayPreview.tsx | 10 +- src/features/Events/EventModal.tsx | 37 +++- src/features/User/userAPI.ts | 10 +- src/utils/dateUtils.ts | 7 + 14 files changed, 707 insertions(+), 114 deletions(-) create mode 100644 __test__/components/PeopleSearch.test.tsx create mode 100644 src/components/Calendar/TempCalendarsInput.tsx 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({ -