From 9ff75056c7e2bf08c8390420eebf1e93736ad710 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:45:17 +0100 Subject: [PATCH] [#397 bis] added participation management on event from thunderbird (#432) Co-authored-by: Camille Moussu --- .../features/Calendars/CalendarModal.test.tsx | 6 +- .../features/Calendars/CalendarSlice.test.tsx | 14 +- .../features/Events/EventDisplay.test.tsx | 24 +-- __test__/features/Events/EventModal.test.tsx | 2 +- .../features/Events/EventRepetition.test.tsx | 9 +- __test__/features/Events/TempUpdate.test.tsx | 8 +- __test__/features/Events/eventUtils.test.ts | 20 +- src/components/Attendees/AttendeeSearch.tsx | 29 ++- src/components/Calendar/AccessTab.tsx | 4 +- src/components/Calendar/CalendarItemList.tsx | 4 +- src/components/Calendar/CalendarModal.tsx | 4 +- src/components/Calendar/CalendarName.tsx | 4 +- src/components/Calendar/CalendarSearch.tsx | 4 +- src/components/Calendar/CalendarSelection.tsx | 4 +- .../Calendar/DeleteCalendarDialog.tsx | 4 +- src/components/Calendar/SettingsTab.tsx | 4 +- .../Calendar/TempCalendarsInput.tsx | 4 +- .../Calendar/handlers/eventHandlers.ts | 24 +-- .../Calendar/handlers/viewHandlers.ts | 8 +- .../Calendar/utils/calendarColorsUtils.tsx | 4 +- .../Calendar/utils/calendarUtils.ts | 6 +- .../Event/EventChip/EventChipUtils.tsx | 8 +- src/components/Event/EventFormFields.tsx | 6 +- .../Event/eventHandlers/eventHandlers.ts | 127 ++++++++++--- src/components/Event/utils/eventUtils.tsx | 8 +- src/components/Menubar/EventSearchBar.tsx | 31 ++-- src/features/Calendars/CalendarSlice.ts | 44 ++--- src/features/Calendars/CalendarTypes.ts | 2 +- .../AttendanceValidation.tsx | 56 ++++++ .../AttendanceValidation/RSVPButton.tsx | 67 +++++++ .../AttendanceValidation/handleRSVPClick.tsx | 43 +++++ src/features/Events/EventDisplayPreview.tsx | 171 ++---------------- src/features/Events/EventModal.tsx | 12 +- src/features/Events/EventUpdateModal.tsx | 10 +- src/features/Events/EventsTypes.ts | 13 +- src/features/Events/createEventContext.ts | 27 +++ src/features/Events/eventUtils.ts | 21 ++- src/features/Events/formHelpers.ts | 2 +- src/features/User/models/attendee.mapper.ts | 19 ++ src/features/User/models/attendee.ts | 12 ++ src/features/User/userDataTypes.ts | 9 - src/locales/en.json | 6 +- src/locales/fr.json | 6 +- src/locales/ru.json | 6 +- src/locales/vi.json | 6 +- src/utils/buildFamilyName.ts | 12 ++ src/utils/eventFormTempStorage.ts | 2 +- 47 files changed, 550 insertions(+), 366 deletions(-) create mode 100644 src/features/Events/AttendanceValidation/AttendanceValidation.tsx create mode 100644 src/features/Events/AttendanceValidation/RSVPButton.tsx create mode 100644 src/features/Events/AttendanceValidation/handleRSVPClick.tsx create mode 100644 src/features/Events/createEventContext.ts create mode 100644 src/features/User/models/attendee.mapper.ts create mode 100644 src/features/User/models/attendee.ts create mode 100644 src/utils/buildFamilyName.ts diff --git a/__test__/features/Calendars/CalendarModal.test.tsx b/__test__/features/Calendars/CalendarModal.test.tsx index 8290254..8e4518c 100644 --- a/__test__/features/Calendars/CalendarModal.test.tsx +++ b/__test__/features/Calendars/CalendarModal.test.tsx @@ -2,7 +2,7 @@ import { screen, fireEvent, waitFor } from "@testing-library/react"; import CalendarPopover from "../../../src/components/Calendar/CalendarModal"; import { renderWithProviders } from "../../utils/Renderwithproviders"; import * as eventThunks from "../../../src/features/Calendars/CalendarSlice"; -import { Calendars } from "../../../src/features/Calendars/CalendarTypes"; +import { Calendar } from "../../../src/features/Calendars/CalendarTypes"; import { getSecretLink } from "../../../src/features/Calendars/CalendarApi"; jest.mock("../../../src/features/Calendars/CalendarApi", () => ({ @@ -103,7 +103,7 @@ describe("CalendarPopover (editing mode)", () => { }, }; - const existingCalendar: Calendars = { + const existingCalendar: Calendar = { id: "user1/cal1", link: "/calendars/user/cal1", name: "Work Calendar", @@ -208,7 +208,7 @@ describe("CalendarPopover - Tabs Scenarios", () => { }, }); - const existingCalendar: Calendars = { + const existingCalendar: Calendar = { id: "user1/cal1", link: "/calendars/user1/cal1.json", name: "Work Calendar", diff --git a/__test__/features/Calendars/CalendarSlice.test.tsx b/__test__/features/Calendars/CalendarSlice.test.tsx index ef94ee7..0d9c324 100644 --- a/__test__/features/Calendars/CalendarSlice.test.tsx +++ b/__test__/features/Calendars/CalendarSlice.test.tsx @@ -22,7 +22,7 @@ import * as calAPI from "../../../src/features/Calendars/CalendarApi"; import * as userAPI from "../../../src/features/User/userAPI"; import { configureStore } from "@reduxjs/toolkit"; -import { Calendars } from "../../../src/features/Calendars/CalendarTypes"; +import { Calendar } from "../../../src/features/Calendars/CalendarTypes"; import { CalendarEvent } from "../../../src/features/Events/EventsTypes"; jest.mock("../../../src/features/Calendars/CalendarApi"); @@ -79,7 +79,7 @@ describe("CalendarSlice", () => { [calId]: { id: calId, events: { e1: { uid: "e1" } }, - } as unknown as Calendars, + } as unknown as Calendar, }, }; const state = reducer( @@ -97,7 +97,7 @@ describe("CalendarSlice", () => { [calId]: { id: calId, events: { e1: { uid: "e1", title: "Old" } }, - } as unknown as Calendars, + } as unknown as Calendar, }, }; const state = reducer( @@ -276,7 +276,7 @@ describe("CalendarSlice", () => { id: "u1/cal1", name: "Existing Calendar", events: {}, - } as Calendars, + } as Calendar, }; const store = storeFactory(); @@ -447,7 +447,7 @@ describe("CalendarSlice", () => { [calId]: { id: calId, events: { e1: { uid: "e1" } }, - } as unknown as Calendars, + } as unknown as Calendar, }, }; const state = reducer( @@ -473,7 +473,7 @@ describe("CalendarSlice", () => { ownerEmails: ["o@o.com"], link: "/calendars/t1.json", description: "desc", - } as Calendars, + } as Calendar, }; const state = reducer( initialState, @@ -533,7 +533,7 @@ describe("CalendarSlice", () => { ["c1"]: { id: "c1", events: {}, - } as unknown as Calendars, + } as unknown as Calendar, }, }, getCalendarDetailAsync.fulfilled(payload, "req11", { diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index a8da0d7..e67ab99 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -300,13 +300,13 @@ describe("Event Preview Display", () => { screen.getByText("eventPreview.attendingQuestion") ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "eventPreview.accept" }) + screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "eventPreview.maybe" }) + screen.getByRole("button", { name: "eventPreview.TENTATIVE" }) ).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "eventPreview.decline" }) + screen.getByRole("button", { name: "eventPreview.DECLINED" }) ).toBeInTheDocument(); }); it("doesnt renders RSVP buttons when user isnt an attendee", () => { @@ -354,13 +354,13 @@ describe("Event Preview Display", () => { screen.queryByText("eventPreview.attendingQuestion") ).not.toBeInTheDocument(); expect( - screen.queryByRole("button", { name: "eventPreview.accept" }) + screen.queryByRole("button", { name: "eventPreview.ACCEPTED" }) ).not.toBeInTheDocument(); expect( - screen.queryByRole("button", { name: "eventPreview.maybe" }) + screen.queryByRole("button", { name: "eventPreview.TENTATIVE" }) ).not.toBeInTheDocument(); expect( - screen.queryByRole("button", { name: "eventPreview.decline" }) + screen.queryByRole("button", { name: "eventPreview.DECLINED" }) ).not.toBeInTheDocument(); }); @@ -414,7 +414,7 @@ describe("Event Preview Display", () => { ); fireEvent.click( - screen.getByRole("button", { name: "eventPreview.accept" }) + screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) ); await waitFor(() => { @@ -474,7 +474,9 @@ describe("Event Preview Display", () => { rsvpState ); - fireEvent.click(screen.getByRole("button", { name: "eventPreview.maybe" })); + fireEvent.click( + screen.getByRole("button", { name: "eventPreview.TENTATIVE" }) + ); await waitFor(() => { expect(spy).toHaveBeenCalled(); @@ -534,7 +536,7 @@ describe("Event Preview Display", () => { ); fireEvent.click( - screen.getByRole("button", { name: "eventPreview.decline" }) + screen.getByRole("button", { name: "eventPreview.DECLINED" }) ); await waitFor(() => { @@ -1020,7 +1022,7 @@ describe("Event Preview Display", () => { ).not.toBeInTheDocument(); }); - it("displays only maybe count when all attendees tentative", () => { + it("displays only TENTATIVE count when all attendees tentative", () => { const attendees = [ { cn: "organizer", @@ -1262,7 +1264,7 @@ describe("Event Preview Display", () => { ).toBeInTheDocument(); }); - it("displays correct counts with multiple yes and maybe", () => { + it("displays correct counts with multiple yes and TENTATIVE", () => { const attendees = [ { cn: "Guest 1", diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 543ba6a..275a0a0 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -258,7 +258,7 @@ describe("EventPopover", () => { { cn: "John Doe", cal_address: "john@example.com", - partstat: "NEED_ACTION", + partstat: "NEEDS-ACTION", rsvp: "FALSE", role: "REQ-PARTICIPANT", cutype: "INDIVIDUAL", diff --git a/__test__/features/Events/EventRepetition.test.tsx b/__test__/features/Events/EventRepetition.test.tsx index 5554ab5..22592e2 100644 --- a/__test__/features/Events/EventRepetition.test.tsx +++ b/__test__/features/Events/EventRepetition.test.tsx @@ -315,7 +315,7 @@ describe("EventPreviewModal - Recurring Event Interactions", () => { ); fireEvent.click( - screen.getByRole("button", { name: "eventPreview.accept" }) + screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) ); await waitFor(() => { @@ -505,7 +505,7 @@ describe("RSVP to Recurring Event", () => { ); fireEvent.click( - screen.getByRole("button", { name: "eventPreview.accept" }) + screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) ); await waitFor(() => { @@ -543,7 +543,7 @@ describe("RSVP to Recurring Event", () => { ); fireEvent.click( - screen.getByRole("button", { name: "eventPreview.accept" }) + screen.getByRole("button", { name: "eventPreview.ACCEPTED" }) ); await waitFor(() => { @@ -975,8 +975,7 @@ describe("handleRSVP function", () => { basePreloadedState.calendars.list["667037022b752d0026472254/cal1"], basePreloadedState.user, nonRecurringEvent, - "ACCEPTED", - mockOnClose + "ACCEPTED" ); expect(mockDispatch).toHaveBeenCalled(); diff --git a/__test__/features/Events/TempUpdate.test.tsx b/__test__/features/Events/TempUpdate.test.tsx index 2d9e064..f1b0094 100644 --- a/__test__/features/Events/TempUpdate.test.tsx +++ b/__test__/features/Events/TempUpdate.test.tsx @@ -13,7 +13,7 @@ import EventUpdateModal from "../../../src/features/Events/EventUpdateModal"; import CalendarLayout from "../../../src/components/Calendar/CalendarLayout"; import { renderWithProviders } from "../../utils/Renderwithproviders"; import { SpiedFunction } from "jest-mock"; -import { Calendars } from "../../../src/features/Calendars/CalendarTypes"; +import { Calendar } from "../../../src/features/Calendars/CalendarTypes"; import { CalendarEvent } from "../../../src/features/Events/EventsTypes"; import { DateSelectArg } from "@fullcalendar/core"; @@ -27,7 +27,7 @@ describe("Update tempcalendars called with correct params", () => { let refreshCalendarsSpy: SpiedFunction< ( dispatch: ThunkDispatch, - calendars: Calendars[], + calendars: Calendar[], calendarRange: { start: Date; end: Date }, calType?: "temp" ) => Promise @@ -35,14 +35,14 @@ describe("Update tempcalendars called with correct params", () => { let refreshSingularCalendarSpy: SpiedFunction< ( dispatch: ThunkDispatch, - calendar: Calendars, + calendar: Calendar, calendarRange: { start: Date; end: Date }, calType?: "temp" ) => Promise >; let updateTempCalendarSpy: SpiedFunction< ( - tempcalendars: Record, + tempcalendars: Record, event: CalendarEvent, dispatch: ThunkDispatch, calendarRange: { start: Date; end: Date } diff --git a/__test__/features/Events/eventUtils.test.ts b/__test__/features/Events/eventUtils.test.ts index 53d9170..492e869 100644 --- a/__test__/features/Events/eventUtils.test.ts +++ b/__test__/features/Events/eventUtils.test.ts @@ -71,9 +71,9 @@ describe("parseCalendarEvent", () => { cn: "Bob", cal_address: "bob@example.com", partstat: "ACCEPTED", - rsvp: "", - role: "", - cutype: "", + cutype: "INDIVIDUAL", + role: "REQ-PARTICIPANT", + rsvp: "FALSE", }, ]); }); @@ -133,9 +133,9 @@ describe("parseCalendarEvent", () => { cn: "Bob", cal_address: "bob@example.com", partstat: "ACCEPTED", - rsvp: "", - role: "", - cutype: "", + cutype: "INDIVIDUAL", + role: "REQ-PARTICIPANT", + rsvp: "FALSE", }, ]); }); @@ -285,10 +285,10 @@ describe("parseCalendarEvent", () => { { cn: "", cal_address: "john@example.com", - partstat: "", - rsvp: "", - role: "", - cutype: "", + partstat: "NEEDS-ACTION", + cutype: "INDIVIDUAL", + role: "REQ-PARTICIPANT", + rsvp: "FALSE", }, ]); diff --git a/src/components/Attendees/AttendeeSearch.tsx b/src/components/Attendees/AttendeeSearch.tsx index 94f602f..4837f3b 100644 --- a/src/components/Attendees/AttendeeSearch.tsx +++ b/src/components/Attendees/AttendeeSearch.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; -import { userAttendee } from "../../features/User/userDataTypes"; +import { userAttendee } from "../../features/User/models/attendee"; +import { createAttendee } from "../../features/User/models/attendee.mapper"; import { PeopleSearch, User, @@ -20,18 +21,18 @@ export default function UserSearch({ ) => React.ReactNode; }) { const [selectedUsers, setSelectedUsers] = useState( - attendees.map((a) => ({ - email: a.cal_address, - displayName: a.cn ?? "", + attendees.map((attendee) => ({ + email: attendee.cal_address, + displayName: attendee.cn ?? "", avatarUrl: "", openpaasId: "", })) ?? [] ); useEffect(() => { setSelectedUsers( - attendees.map((a) => ({ - email: a.cal_address, - displayName: a.cn ?? "", + attendees.map((attendee) => ({ + email: attendee.cal_address, + displayName: attendee.cn ?? "", avatarUrl: "", openpaasId: "", })) @@ -45,14 +46,12 @@ export default function UserSearch({ inputSlot={inputSlot} onChange={(event: any, value: User[]) => { setAttendees( - value.map((a: User) => ({ - cn: a.displayName, - cal_address: a.email, - partstat: "NEED_ACTION", - rsvp: "FALSE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - })) + value.map((attendee: User) => + createAttendee({ + cal_address: attendee.email, + cn: attendee.displayName, + }) + ) ); setSelectedUsers(value); }} diff --git a/src/components/Calendar/AccessTab.tsx b/src/components/Calendar/AccessTab.tsx index 27b4d51..d14e679 100644 --- a/src/components/Calendar/AccessTab.tsx +++ b/src/components/Calendar/AccessTab.tsx @@ -15,13 +15,13 @@ import { exportCalendar, getSecretLink, } from "../../features/Calendars/CalendarApi"; -import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../features/Calendars/CalendarTypes"; import { FieldWithLabel } from "../Event/components/FieldWithLabel"; import { SnackbarAlert } from "../Loading/SnackBarAlert"; import { useI18n } from "twake-i18n"; import { ErrorSnackbar } from "../Error/ErrorSnackbar"; -export function AccessTab({ calendar }: { calendar: Calendars }) { +export function AccessTab({ calendar }: { calendar: Calendar }) { const { t } = useI18n(); const calDAVLink = `${(window as any).CALENDAR_BASE_URL}${calendar.link.replace(".json", "")}`; diff --git a/src/components/Calendar/CalendarItemList.tsx b/src/components/Calendar/CalendarItemList.tsx index de3eed6..cd6b5b3 100644 --- a/src/components/Calendar/CalendarItemList.tsx +++ b/src/components/Calendar/CalendarItemList.tsx @@ -1,10 +1,10 @@ import React from "react"; import { MenuItem } from "@mui/material"; -import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../features/Calendars/CalendarTypes"; import { CalendarName } from "./CalendarName"; export function CalendarItemList( - userPersonalCalendars: Calendars[] + userPersonalCalendars: Calendar[] ): React.ReactNode { return Object.values(userPersonalCalendars).map((calendar) => ( diff --git a/src/components/Calendar/CalendarModal.tsx b/src/components/Calendar/CalendarModal.tsx index fd7ff23..bc16138 100644 --- a/src/components/Calendar/CalendarModal.tsx +++ b/src/components/Calendar/CalendarModal.tsx @@ -7,7 +7,7 @@ import { patchACLCalendarAsync, patchCalendarAsync, } from "../../features/Calendars/CalendarSlice"; -import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../features/Calendars/CalendarTypes"; import { ResponsiveDialog } from "../Dialog"; import { AccessTab } from "./AccessTab"; import { ImportTab } from "./ImportTab"; @@ -25,7 +25,7 @@ function CalendarPopover({ event: object | null, reason: "backdropClick" | "escapeKeyDown" ) => void; - calendar?: Calendars; + calendar?: Calendar; }) { const { t } = useI18n(); const dispatch = useAppDispatch(); diff --git a/src/components/Calendar/CalendarName.tsx b/src/components/Calendar/CalendarName.tsx index 1f7d8bd..8c64c5a 100644 --- a/src/components/Calendar/CalendarName.tsx +++ b/src/components/Calendar/CalendarName.tsx @@ -1,7 +1,7 @@ import { Box, Typography } from "@mui/material"; -import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../features/Calendars/CalendarTypes"; import SquareRoundedIcon from "@mui/icons-material/SquareRounded"; -export function CalendarName({ calendar }: { calendar: Calendars }) { +export function CalendarName({ calendar }: { calendar: Calendar }) { return ( ; + calendars: Record; selectedCal: CalendarWithOwner[]; onRemove: (cal: CalendarWithOwner) => void; onColorChange: ( diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index 0499a09..d6c32c0 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -6,7 +6,7 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks"; import AddIcon from "@mui/icons-material/Add"; import { useState, useMemo, useEffect } from "react"; import CalendarPopover from "./CalendarModal"; -import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../features/Calendars/CalendarTypes"; import MoreVertIcon from "@mui/icons-material/MoreVert"; import IconButton from "@mui/material/IconButton"; import Checkbox from "@mui/material/Checkbox"; @@ -206,7 +206,7 @@ function CalendarSelector({ handleCalendarToggle, setOpen, }: { - calendars: Record; + calendars: Record; id: string; isPersonal: boolean; selectedCalendars: string[]; diff --git a/src/components/Calendar/DeleteCalendarDialog.tsx b/src/components/Calendar/DeleteCalendarDialog.tsx index 7bee1af..502ef2f 100644 --- a/src/components/Calendar/DeleteCalendarDialog.tsx +++ b/src/components/Calendar/DeleteCalendarDialog.tsx @@ -1,5 +1,5 @@ import Dialog from "@mui/material/Dialog"; -import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../features/Calendars/CalendarTypes"; import DialogTitle from "@mui/material/DialogTitle"; import DialogContent from "@mui/material/DialogContent"; import DialogContentText from "@mui/material/DialogContentText"; @@ -17,7 +17,7 @@ export function DeleteCalendarDialog({ }: { deletePopupOpen: boolean; setDeletePopupOpen: (e: boolean) => void; - calendars: Record; + calendars: Record; id: string; isPersonal: boolean; handleDeleteConfirm: () => void; diff --git a/src/components/Calendar/SettingsTab.tsx b/src/components/Calendar/SettingsTab.tsx index 04afb27..99d305b 100644 --- a/src/components/Calendar/SettingsTab.tsx +++ b/src/components/Calendar/SettingsTab.tsx @@ -12,7 +12,7 @@ import { import { useI18n } from "twake-i18n"; import { useState, useEffect } from "react"; import { useAppSelector } from "../../app/hooks"; -import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../features/Calendars/CalendarTypes"; import { AddDescButton } from "../Event/AddDescButton"; import { ColorPicker } from "./CalendarColorPicker"; @@ -35,7 +35,7 @@ export function SettingsTab({ setColor: Function; visibility: "public" | "private"; setVisibility: Function; - calendar?: Calendars; + calendar?: Calendar; }) { const { t } = useI18n(); const [toggleDesc, setToggleDesc] = useState(Boolean(description)); diff --git a/src/components/Calendar/TempCalendarsInput.tsx b/src/components/Calendar/TempCalendarsInput.tsx index 224c8c6..12ca7c0 100644 --- a/src/components/Calendar/TempCalendarsInput.tsx +++ b/src/components/Calendar/TempCalendarsInput.tsx @@ -6,7 +6,7 @@ import { getTempCalendarsListAsync, removeTempCal, } from "../../features/Calendars/CalendarSlice"; -import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../features/Calendars/CalendarTypes"; import { setView } from "../../features/Settings/SettingsSlice"; import TextField from "@mui/material/TextField"; import { User, PeopleSearch } from "../Attendees/PeopleSearch"; @@ -134,7 +134,7 @@ function getCalendarsFromUsersDelta( return { calendarsToImport, calendarsToToggle }; } -function buildEmailToCalendarMap(calRecord: Record) { +function buildEmailToCalendarMap(calRecord: Record) { const map = new Map(); for (const [id, cal] of Object.entries(calRecord)) { cal.ownerEmails?.forEach((email) => { diff --git a/src/components/Calendar/handlers/eventHandlers.ts b/src/components/Calendar/handlers/eventHandlers.ts index 3a6ac22..c00c83d 100644 --- a/src/components/Calendar/handlers/eventHandlers.ts +++ b/src/components/Calendar/handlers/eventHandlers.ts @@ -1,7 +1,7 @@ import { DateSelectArg } from "@fullcalendar/core"; import { CalendarApi } from "@fullcalendar/core"; import { CalendarEvent } from "../../../features/Events/EventsTypes"; -import { Calendars } from "../../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../../features/Calendars/CalendarTypes"; import { getDeltaInMilliseconds } from "../../../utils/dateUtils"; import { getCalendarDetailAsync, @@ -17,21 +17,22 @@ import { refreshCalendars } from "../../Event/utils/eventUtils"; import { updateTempCalendar } from "../utils/calendarUtils"; import { User } from "../../Attendees/PeopleSearch"; import { formatLocalDateTime } from "../../Event/utils/dateTimeFormatters"; -import { userAttendee } from "../../../features/User/userDataTypes"; +import { userAttendee } from "../../../features/User/models/attendee"; +import { createAttendee } from "../../../features/User/models/attendee.mapper"; export interface EventHandlersProps { setSelectedRange: (range: DateSelectArg | null) => void; setAnchorEl: (el: HTMLElement | null) => void; calendarRef: React.RefObject; selectedCalendars: string[]; - tempcalendars: Record; + tempcalendars: Record; calendarRange: { start: Date; end: Date }; dispatch: any; setOpenEventDisplay: (open: boolean) => void; setEventDisplayedId: (id: string) => void; setEventDisplayedCalId: (id: string) => void; setEventDisplayedTemp: (temp: boolean) => void; - calendars: Record; + calendars: Record; setSelectedEvent: (event: CalendarEvent) => void; setAfterChoiceFunc: (func: Function) => void; setOpenEditModePopup: (open: string) => void; @@ -72,14 +73,13 @@ export const createEventHandlers = (props: EventHandlersProps) => { end: selectInfo?.end ? formatLocalDateTime(selectInfo?.end, timezone) : "", - attendee: tempUsers.map((u) => ({ - cn: u.displayName, - cal_address: u.email, - partstat: "NEED-ACTION", - role: "REQ-PARTICIPANT", - rsvp: "TRUE", - cutype: "INDIVIDUAL", - })), + attendee: tempUsers.map((user) => + createAttendee({ + cal_address: user.email, + cn: user.displayName, + rsvp: "TRUE", + }) + ), } as CalendarEvent; setTempEvent(newEvent); diff --git a/src/components/Calendar/handlers/viewHandlers.ts b/src/components/Calendar/handlers/viewHandlers.ts index 1003a8b..6fecc2f 100644 --- a/src/components/Calendar/handlers/viewHandlers.ts +++ b/src/components/Calendar/handlers/viewHandlers.ts @@ -1,8 +1,8 @@ import React from "react"; import { CalendarApi, NowIndicatorContentArg } from "@fullcalendar/core"; import { createMouseHandlers } from "./mouseHandlers"; -import { userAttendee } from "../../../features/User/userDataTypes"; -import { Calendars } from "../../../features/Calendars/CalendarTypes"; +import { userAttendee } from "../../../features/User/models/attendee"; +import { Calendar } from "../../../features/Calendars/CalendarTypes"; import { EventErrorHandler } from "../../Error/EventErrorHandler"; import { EventChip } from "../../Event/EventChip/EventChip"; @@ -11,8 +11,8 @@ export interface ViewHandlersProps { setSelectedDate: (date: Date) => void; setSelectedMiniDate: (date: Date) => void; onViewChange?: (view: string) => void; - calendars: Record; - tempcalendars: Record; + calendars: Record; + tempcalendars: Record; errorHandler: EventErrorHandler; } diff --git a/src/components/Calendar/utils/calendarColorsUtils.tsx b/src/components/Calendar/utils/calendarColorsUtils.tsx index 7092406..f5e97ab 100644 --- a/src/components/Calendar/utils/calendarColorsUtils.tsx +++ b/src/components/Calendar/utils/calendarColorsUtils.tsx @@ -1,10 +1,10 @@ import { darken, getContrastRatio, lighten, Theme } from "@mui/material"; import { ThunkDispatch } from "@reduxjs/toolkit"; import { updateCalColor } from "../../../features/Calendars/CalendarSlice"; -import { Calendars } from "../../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../../features/Calendars/CalendarTypes"; export function updateDarkColor( - calendars: Record, + calendars: Record, theme: Theme, dispatch: ThunkDispatch ) { diff --git a/src/components/Calendar/utils/calendarUtils.ts b/src/components/Calendar/utils/calendarUtils.ts index 0c0877a..50b12e9 100644 --- a/src/components/Calendar/utils/calendarUtils.ts +++ b/src/components/Calendar/utils/calendarUtils.ts @@ -1,5 +1,5 @@ import { CalendarEvent } from "../../../features/Events/EventsTypes"; -import { Calendars } from "../../../features/Calendars/CalendarTypes"; +import { Calendar } from "../../../features/Calendars/CalendarTypes"; import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils"; import { getCalendarDetailAsync } from "../../../features/Calendars/CalendarSlice"; import { SlotLabelContentArg } from "@fullcalendar/core"; @@ -140,7 +140,7 @@ export const eventToFullCalendarFormat = ( export const extractEvents = ( selectedCalendars: string[], - calendars: Record, + calendars: Record, userAddress?: string, hideDeclinedEvents?: boolean | null ) => { @@ -272,7 +272,7 @@ export function getCalendarVisibility(acl: AclEntry[]): "private" | "public" { } export async function updateTempCalendar( - tempcalendars: Record, + tempcalendars: Record, event: CalendarEvent, dispatch: ThunkDispatch, calendarRange: { start: Date; end: Date } diff --git a/src/components/Event/EventChip/EventChipUtils.tsx b/src/components/Event/EventChip/EventChipUtils.tsx index 9359aff..7b5f97c 100644 --- a/src/components/Event/EventChip/EventChipUtils.tsx +++ b/src/components/Event/EventChip/EventChipUtils.tsx @@ -4,8 +4,8 @@ import LockOutlineIcon from "@mui/icons-material/LockOutline"; import { Box, getContrastRatio } from "@mui/material"; import moment from "moment"; import React, { useLayoutEffect, useState } from "react"; -import { Calendars } from "../../../features/Calendars/CalendarTypes"; -import { userAttendee } from "../../../features/User/userDataTypes"; +import { Calendar } from "../../../features/Calendars/CalendarTypes"; +import { userAttendee } from "../../../features/User/models/attendee"; import { EventErrorHandler } from "../../Error/EventErrorHandler"; import { EVENT_DURATION } from "./EventChip"; @@ -13,8 +13,8 @@ const COMPACT_WIDTH_THRESHOLD = 100; export interface EventChipProps { arg: any; - calendars: Record; - tempcalendars: Record; + calendars: Record; + tempcalendars: Record; errorHandler: EventErrorHandler; } export interface IconDisplayConfig { diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index cfac8d8..70d6bba 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -25,8 +25,8 @@ import { import AttendeeSelector from "../Attendees/AttendeeSearch"; import RepeatEvent from "./EventRepeat"; import { RepetitionObject } from "../../features/Events/EventsTypes"; -import { userAttendee } from "../../features/User/userDataTypes"; -import { Calendars } from "../../features/Calendars/CalendarTypes"; +import { userAttendee } from "../../features/User/models/attendee"; +import { Calendar } from "../../features/Calendars/CalendarTypes"; import { generateMeetingLink, addVideoConferenceToDescription, @@ -87,7 +87,7 @@ interface EventFormFieldsProps { isOpen?: boolean; // Data - userPersonalCalendars: Calendars[]; + userPersonalCalendars: Calendar[]; timezoneList: { zones: string[]; browserTz: string; diff --git a/src/components/Event/eventHandlers/eventHandlers.ts b/src/components/Event/eventHandlers/eventHandlers.ts index 64a4c72..9a21928 100644 --- a/src/components/Event/eventHandlers/eventHandlers.ts +++ b/src/components/Event/eventHandlers/eventHandlers.ts @@ -1,51 +1,128 @@ import { ThunkDispatch } from "@reduxjs/toolkit"; -import { useAppSelector } from "../../../app/hooks"; import { updateEventInstanceAsync, - updateSeriesAsync, putEventAsync, deleteEventInstanceAsync, deleteEventAsync, } from "../../../features/Calendars/CalendarSlice"; -import { Calendars } from "../../../features/Calendars/CalendarTypes"; -import { - getEvent, - updateSeriesPartstat, -} from "../../../features/Events/EventApi"; +import { Calendar } from "../../../features/Calendars/CalendarTypes"; +import { updateSeriesPartstat } from "../../../features/Events/EventApi"; import { CalendarEvent } from "../../../features/Events/EventsTypes"; +import { PartStat } from "../../../features/User/models/attendee"; +import { createAttendee } from "../../../features/User/models/attendee.mapper"; import { userData } from "../../../features/User/userDataTypes"; +import { buildFamilyName } from "../../../utils/buildFamilyName"; import { getCalendarRange } from "../../../utils/dateUtils"; import { refreshCalendars } from "../utils/eventUtils"; +function updateEventAttendees( + event: CalendarEvent, + user: userData | undefined, + rsvp: PartStat +) { + if (!user) { + throw new Error("Cannot update attendees without user data"); + } + + const eventHasNoAttendees = !event?.attendee || event.attendee.length === 0; + const isOrganizer = + !event.organizer || + event.organizer.cal_address?.toLowerCase() === user.email?.toLowerCase(); + if (eventHasNoAttendees) { + const userdata = createAttendee({ + cal_address: user.email, + cn: buildFamilyName(user.given_name, user.family_name, user.email), + role: isOrganizer ? "CHAIR" : "REQ-PARTICIPANT", + partstat: rsvp, + }); + return { + organizer: isOrganizer ? userdata : event.organizer, + attendee: [userdata], + }; + } + + return { + attendee: (() => { + const userEmailLower = user.email?.toLowerCase(); + const userExists = event.attendee.some( + (attendee) => attendee.cal_address?.toLowerCase() === userEmailLower + ); + + const updatedAttendees = event.attendee.map((attendeeData) => + attendeeData.cal_address?.toLowerCase() === userEmailLower + ? { ...attendeeData, partstat: rsvp } + : attendeeData + ); + + if (!userExists) { + const newUserAttendee = createAttendee({ + cal_address: user.email, + cn: buildFamilyName(user.given_name, user.family_name, user.email), + role: "REQ-PARTICIPANT", + partstat: rsvp, + }); + return [...updatedAttendees, newUserAttendee]; + } + + return updatedAttendees; + })(), + }; +} + +async function handleSoloRSVP( + dispatch: ThunkDispatch, + calendar: Calendar, + event: CalendarEvent +) { + dispatch(updateEventInstanceAsync({ cal: calendar, event })); +} + +async function handleAllRSVP( + dispatch: ThunkDispatch, + event: CalendarEvent, + userEmail: string, + rsvp: PartStat, + calendars: Calendar[] +) { + const calendarRange = getCalendarRange(new Date(event.start)); + await updateSeriesPartstat(event, userEmail, rsvp); + await refreshCalendars(dispatch, calendars, calendarRange); +} + +async function handleDefaultRSVP( + dispatch: ThunkDispatch, + calendar: Calendar, + newEvent: CalendarEvent +) { + dispatch(putEventAsync({ cal: calendar, newEvent })); +} + export async function handleRSVP( dispatch: ThunkDispatch, - calendar: Calendars, - user: { userData: userData }, + calendar: Calendar, + user: userData | undefined, event: CalendarEvent, - rsvp: string, - onClose?: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void, + rsvp: PartStat, typeOfAction?: string, - calendars?: Calendars[] + calendars?: Calendar[] ) { const newEvent = { ...event, - attendee: event.attendee?.map((a) => - a.cal_address === user.userData?.email ? { ...a, partstat: rsvp } : a - ), + ...updateEventAttendees(event, user, rsvp), }; + if (typeOfAction === "solo") { - dispatch(updateEventInstanceAsync({ cal: calendar, event: newEvent })); + await handleSoloRSVP(dispatch, calendar, newEvent); } else if (typeOfAction === "all") { - const calendarRange = getCalendarRange(new Date(event.start)); - - // Update PARTSTAT on ALL VEVENTs (master + exceptions) - await updateSeriesPartstat(event, user.userData?.email, rsvp); - - if (calendars) { - await refreshCalendars(dispatch, calendars, calendarRange); + if (!calendars || calendars.length === 0) { + throw new Error("Cannot update all occurrences without calendar list"); } + if (!user?.email) { + throw new Error("Cannot update all occurrences without user email"); + } + await handleAllRSVP(dispatch, event, user.email, rsvp, calendars); } else { - dispatch(putEventAsync({ cal: calendar, newEvent })); + await handleDefaultRSVP(dispatch, calendar, newEvent); } } @@ -54,7 +131,7 @@ export function handleDelete( typeOfAction: "solo" | "all" | undefined, onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void, dispatch: Function, - calendar: Calendars, + calendar: Calendar, event: CalendarEvent, calId: string, eventId: string diff --git a/src/components/Event/utils/eventUtils.tsx b/src/components/Event/utils/eventUtils.tsx index fc1fdbf..d02252f 100644 --- a/src/components/Event/utils/eventUtils.tsx +++ b/src/components/Event/utils/eventUtils.tsx @@ -10,8 +10,8 @@ import { getCalendarDetailAsync, getCalendarsListAsync, } from "../../../features/Calendars/CalendarSlice"; -import { Calendars } from "../../../features/Calendars/CalendarTypes"; -import { userAttendee } from "../../../features/User/userDataTypes"; +import { Calendar } from "../../../features/Calendars/CalendarTypes"; +import { userAttendee } from "../../../features/User/models/attendee"; import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils"; export function renderAttendeeBadge( @@ -111,7 +111,7 @@ export function stringAvatar(name: string) { export async function refreshCalendars( dispatch: ThunkDispatch, - calendars: Calendars[], + calendars: Calendar[], calendarRange: { start: Date; end: Date }, calType?: "temp" ) { @@ -163,7 +163,7 @@ export async function refreshCalendars( export async function refreshSingularCalendar( dispatch: ThunkDispatch, - calendar: Calendars, + calendar: Calendar, calendarRange: { start: Date; end: Date }, calType?: "temp" ) { diff --git a/src/components/Menubar/EventSearchBar.tsx b/src/components/Menubar/EventSearchBar.tsx index c9dd23e..e3de6f3 100644 --- a/src/components/Menubar/EventSearchBar.tsx +++ b/src/components/Menubar/EventSearchBar.tsx @@ -24,10 +24,11 @@ import { useI18n } from "twake-i18n"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { searchEventsAsync } from "../../features/Search/SearchSlice"; import { setView } from "../../features/Settings/SettingsSlice"; -import { userAttendee } from "../../features/User/userDataTypes"; +import { userAttendee } from "../../features/User/models/attendee"; import UserSearch from "../Attendees/AttendeeSearch"; import { CalendarItemList } from "../Calendar/CalendarItemList"; import { PeopleSearch, User } from "../Attendees/PeopleSearch"; +import { createAttendee } from "../../features/User/models/attendee.mapper"; export default function SearchBar() { const { t } = useI18n(); @@ -151,14 +152,12 @@ export default function SearchBar() { if (contacts.length > 0) { handleSearch("", { ...filters, - organizers: contacts.map((c) => ({ - cal_address: c.email || c.displayName || "", - cutype: "INDIVIDUAL", - cn: c.displayName || c.email, - role: "Participant", - rsvp: "TRUE", - partstat: "", - })), + organizers: contacts.map((contact) => + createAttendee({ + cal_address: contact.email, + cn: contact.displayName, + }) + ), }); } }; @@ -334,14 +333,12 @@ export default function SearchBar() { handleFilterChange("keywords", query); handleFilterChange( "organizers", - selectedContacts.map((a: User) => ({ - cn: a.displayName, - cal_address: a.email || "", - partstat: "NEEDS-ACTION", - rsvp: "FALSE", - role: "REQ-PARTICIPANT", - cutype: "INDIVIDUAL", - })) + selectedContacts.map((attendee: User) => + createAttendee({ + cal_address: attendee.email, + cn: attendee.displayName, + }) + ) ); }} > diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index fc9c75e..069f7fb 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -1,5 +1,5 @@ import { createAsyncThunk, createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { Calendars } from "./CalendarTypes"; +import { Calendar } from "./CalendarTypes"; import { CalendarEvent } from "../Events/EventsTypes"; import { addSharedCalendar, @@ -39,7 +39,7 @@ interface RejectedError { } export const getCalendarsListAsync = createAsyncThunk< - { importedCalendars: Record; errors: string }, // Return type + { importedCalendars: Record; errors: string }, // Return type void, // Arg type { rejectValue: RejectedError; state: any } // ThunkAPI config >("calendars/getCalendars", async (_, { rejectWithValue, getState }) => { @@ -52,7 +52,7 @@ export const getCalendarsListAsync = createAsyncThunk< } try { - const importedCalendars: Record = {}; + const importedCalendars: Record = {}; const user = (await getOpenPaasUser()) as Record; const calendars = (await getCalendars(user.id)) as Record; const rawCalendars = calendars._embedded["dav:calendar"] as Record< @@ -159,12 +159,12 @@ export const getCalendarsListAsync = createAsyncThunk< }); export const getTempCalendarsListAsync = createAsyncThunk< - Record, + Record, User, { rejectValue: RejectedError } >("calendars/getTempCalendars", async (tempUser, { rejectWithValue }) => { try { - const importedCalendars: Record = {}; + const importedCalendars: Record = {}; const calendars = (await getCalendars( tempUser.openpaasId ?? "", @@ -267,7 +267,7 @@ export const getCalendarDetailAsync = createAsyncThunk< export const putEventAsync = createAsyncThunk< { calId: string; events: CalendarEvent[]; calType?: "temp" }, - { cal: Calendars; newEvent: CalendarEvent; calType?: "temp" }, + { cal: Calendar; newEvent: CalendarEvent; calType?: "temp" }, { rejectValue: RejectedError } >( "calendars/putEvent", @@ -395,7 +395,7 @@ export const removeCalendarAsync = createAsyncThunk< export const moveEventAsync = createAsyncThunk< { calId: string; events: CalendarEvent[] }, - { cal: Calendars; newEvent: CalendarEvent; newURL: string }, + { cal: Calendar; newEvent: CalendarEvent; newURL: string }, { rejectValue: RejectedError } >( "calendars/moveEvent", @@ -490,7 +490,7 @@ export const deleteEventAsync = createAsyncThunk< export const deleteEventInstanceAsync = createAsyncThunk< { calId: string; eventId: string }, - { cal: Calendars; event: CalendarEvent }, + { cal: Calendar; event: CalendarEvent }, { rejectValue: RejectedError } >("calendars/delEventInstance", async ({ cal, event }, { rejectWithValue }) => { try { @@ -506,7 +506,7 @@ export const deleteEventInstanceAsync = createAsyncThunk< export const updateEventInstanceAsync = createAsyncThunk< { calId: string; event: CalendarEvent }, - { cal: Calendars; event: CalendarEvent }, + { cal: Calendar; event: CalendarEvent }, { rejectValue: RejectedError } >( "calendars/updateEventInstance", @@ -525,7 +525,7 @@ export const updateEventInstanceAsync = createAsyncThunk< export const updateSeriesAsync = createAsyncThunk< void, - { cal: Calendars; event: CalendarEvent; removeOverrides?: boolean }, + { cal: Calendar; event: CalendarEvent; removeOverrides?: boolean }, { rejectValue: RejectedError } >( "calendars/updateSeries", @@ -659,13 +659,13 @@ export const importEventFromFileAsync = createAsyncThunk< const CalendarSlice = createSlice({ name: "calendars", initialState: { - list: {} as Record, - templist: {} as Record, + list: {} as Record, + templist: {} as Record, pending: false, error: null as string | null, } as { - list: Record; - templist: Record; + list: Record; + templist: Record; pending: boolean; error: string | null; }, @@ -675,7 +675,7 @@ const CalendarSlice = createSlice({ action: PayloadAction>> ) => { const id = Date.now().toString(36); - state.list[id] = {} as Calendars; + state.list[id] = {} as Calendar; state.list[id].name = action.payload.name as string; state.list[id].color = action.payload.color as Record; state.list[id].description = action.payload.description as string; @@ -753,7 +753,7 @@ const CalendarSlice = createSlice({ ( state, action: PayloadAction<{ - importedCalendars: Record; + importedCalendars: Record; errors: string; }> ) => { @@ -766,7 +766,7 @@ const CalendarSlice = createSlice({ ) .addCase( getTempCalendarsListAsync.fulfilled, - (state, action: PayloadAction>) => { + (state, action: PayloadAction>) => { state.pending = false; Object.keys(action.payload).forEach( (id) => (state.templist[id] = action.payload[id]) @@ -823,7 +823,7 @@ const CalendarSlice = createSlice({ state[type][action.payload.calId] = { id: action.payload.calId, events: {}, - } as Calendars; + } as Calendar; } action.payload.events.forEach((event) => { state[type][action.payload.calId].events[event.uid] = event; @@ -853,7 +853,7 @@ const CalendarSlice = createSlice({ state.list[action.payload.calId] = { id: action.payload.calId, events: {}, - } as Calendars; + } as Calendar; } state.list[action.payload.calId].events[action.payload.event.uid] = @@ -871,7 +871,7 @@ const CalendarSlice = createSlice({ state.list[action.payload.calId] = { id: action.payload.calId, events: {}, - } as Calendars; + } as Calendar; } action.payload.events.forEach((event) => { state.list[action.payload.calId].events[event.uid] = event; @@ -932,7 +932,7 @@ const CalendarSlice = createSlice({ owner: action.payload.owner, ownerEmails: action.payload.ownerEmails, events: {}, - } as Calendars; + } as Calendar; state.error = null; }) .addCase(patchCalendarAsync.fulfilled, (state, action) => { @@ -973,7 +973,7 @@ const CalendarSlice = createSlice({ events: {}, owner: action.payload.owner, ownerEmails: action.payload.ownerEmails, - } as Calendars; + } as Calendar; state.error = null; }) .addCase(removeCalendarAsync.fulfilled, (state, action) => { diff --git a/src/features/Calendars/CalendarTypes.ts b/src/features/Calendars/CalendarTypes.ts index 2ecbc98..53a2fa4 100644 --- a/src/features/Calendars/CalendarTypes.ts +++ b/src/features/Calendars/CalendarTypes.ts @@ -1,6 +1,6 @@ import { CalendarEvent } from "../Events/EventsTypes"; -export interface Calendars { +export interface Calendar { id: string; link: string; name: string; diff --git a/src/features/Events/AttendanceValidation/AttendanceValidation.tsx b/src/features/Events/AttendanceValidation/AttendanceValidation.tsx new file mode 100644 index 0000000..e3dce1f --- /dev/null +++ b/src/features/Events/AttendanceValidation/AttendanceValidation.tsx @@ -0,0 +1,56 @@ +import { Box, Typography } from "@mui/material"; +import { Dispatch, SetStateAction } from "react"; +import { useI18n } from "twake-i18n"; +import { Calendar } from "../../Calendars/CalendarTypes"; +import { userData } from "../../User/userDataTypes"; +import { ContextualizedEvent } from "../EventsTypes"; +import { RSVPButton } from "./RSVPButton"; + +interface AttendanceValidationProps { + contextualizedEvent: ContextualizedEvent; + calendarList: Calendar[]; + user: userData | undefined; + setAfterChoiceFunc: Dispatch>; + setOpenEditModePopup: Dispatch>; +} + +export function AttendanceValidation({ + contextualizedEvent, + calendarList, + user, + setAfterChoiceFunc, + setOpenEditModePopup, +}: AttendanceValidationProps) { + const { currentUserAttendee, isOwn } = contextualizedEvent; + const { t } = useI18n(); + + // Check if we should show RSVP buttons + const hasNoAttendeesOrOrganizer = + !(contextualizedEvent.event?.attendee?.length > 0) && + !contextualizedEvent.event?.organizer; + + if (!((currentUserAttendee || hasNoAttendeesOrOrganizer) && isOwn)) { + return null; + } + + const commonButtonProps = { + contextualizedEvent, + user, + calendarList, + setAfterChoiceFunc, + setOpenEditModePopup, + }; + + return ( + <> + + {t("eventPreview.attendingQuestion")} + + + + + + + + ); +} diff --git a/src/features/Events/AttendanceValidation/RSVPButton.tsx b/src/features/Events/AttendanceValidation/RSVPButton.tsx new file mode 100644 index 0000000..2c29c53 --- /dev/null +++ b/src/features/Events/AttendanceValidation/RSVPButton.tsx @@ -0,0 +1,67 @@ +import { Button } from "@mui/material"; +import { Dispatch, SetStateAction } from "react"; +import { useI18n } from "twake-i18n"; +import { useAppDispatch } from "../../../app/hooks"; +import { Calendar } from "../../Calendars/CalendarTypes"; +import { PartStat } from "../../User/models/attendee"; +import { userData } from "../../User/userDataTypes"; +import { ContextualizedEvent } from "../EventsTypes"; +import { handleRSVPClick } from "./handleRSVPClick"; + +const rsvpColor: Record = + { + ACCEPTED: "success", + DECLINED: "error", + TENTATIVE: "warning", + "NEEDS-ACTION": "primary", + } as const; + +interface RSVPButtonProps { + rsvpValue: PartStat; + contextualizedEvent: ContextualizedEvent; + user: userData | undefined; + calendarList: Calendar[]; + setAfterChoiceFunc: Dispatch>; + setOpenEditModePopup: Dispatch>; +} + +export function RSVPButton({ + rsvpValue, + contextualizedEvent, + user, + calendarList, + setAfterChoiceFunc, + setOpenEditModePopup, +}: RSVPButtonProps) { + const { t } = useI18n(); + const dispatch = useAppDispatch(); + const { currentUserAttendee } = contextualizedEvent; + + return ( + + ); +} diff --git a/src/features/Events/AttendanceValidation/handleRSVPClick.tsx b/src/features/Events/AttendanceValidation/handleRSVPClick.tsx new file mode 100644 index 0000000..5079990 --- /dev/null +++ b/src/features/Events/AttendanceValidation/handleRSVPClick.tsx @@ -0,0 +1,43 @@ +import { Dispatch, SetStateAction } from "react"; +import { AppDispatch } from "../../../app/store"; +import { handleRSVP } from "../../../components/Event/eventHandlers/eventHandlers"; +import { Calendar } from "../../Calendars/CalendarTypes"; +import { PartStat } from "../../User/models/attendee"; +import { userData } from "../../User/userDataTypes"; +import { ContextualizedEvent } from "../EventsTypes"; + +export async function handleRSVPClick( + rsvp: PartStat, + contextualizedEvent: ContextualizedEvent, + user: userData | undefined, + calendarList: Calendar[], + setAfterChoiceFunc: Dispatch>, + setOpenEditModePopup: Dispatch>, + dispatch: AppDispatch +) { + const { isRecurring, calendar, event } = contextualizedEvent; + if (isRecurring) { + setAfterChoiceFunc(() => async (type: string) => { + try { + await handleRSVP( + dispatch, + calendar, + user, + event, + rsvp, + type, + calendarList + ); + } catch (error) { + console.error("Error handling RSVP:", error); + } + }); + setOpenEditModePopup("attendance"); + } else { + try { + await handleRSVP(dispatch, calendar, user, event, rsvp); + } catch (error) { + console.error("Error handling RSVP:", error); + } + } +} diff --git a/src/features/Events/EventDisplayPreview.tsx b/src/features/Events/EventDisplayPreview.tsx index 475bce4..757104d 100644 --- a/src/features/Events/EventDisplayPreview.tsx +++ b/src/features/Events/EventDisplayPreview.tsx @@ -36,10 +36,7 @@ import { import ResponsiveDialog from "../../components/Dialog/ResponsiveDialog"; import { EditModeDialog } from "../../components/Event/EditModeDialog"; import EventDuplication from "../../components/Event/EventDuplicate"; -import { - handleDelete, - handleRSVP, -} from "../../components/Event/eventHandlers/eventHandlers"; +import { handleDelete } from "../../components/Event/eventHandlers/eventHandlers"; import { InfoRow } from "../../components/Event/InfoRow"; import { renderAttendeeBadge } from "../../components/Event/utils/eventUtils"; import { getCalendarRange } from "../../utils/dateUtils"; @@ -48,8 +45,12 @@ import { dlEvent } from "./EventApi"; import { CalendarEvent } from "./EventsTypes"; import EventUpdateModal from "./EventUpdateModal"; import { useI18n } from "twake-i18n"; -import { userAttendee } from "../User/userDataTypes"; +import { userAttendee } from "../User/models/attendee"; import { browserDefaultTimeZone } from "../../utils/timezone"; +import { AttendanceValidation } from "./AttendanceValidation/AttendanceValidation"; +import { Calendar } from "../Calendars/CalendarTypes"; +import { userData } from "../User/userDataTypes"; +import { createEventContext } from "./createEventContext"; export default function EventPreviewModal({ eventId, @@ -77,12 +78,13 @@ export default function EventPreviewModal({ ? calendars.templist[calId] : calendars.list[calId]; const event = calendar.events[eventId]; - const user = useAppSelector((state) => state.user); + const user = useAppSelector((state) => state.user.userData); + if (!user) return null; const isRecurring = event?.uid?.includes("/"); - const isOwn = calendar.ownerEmails?.includes(user.userData?.email); + const isOwn = calendar.ownerEmails?.includes(user.email); const isOrganizer = event.organizer - ? user.userData?.email === event.organizer.cal_address + ? user.email === event.organizer.cal_address : isOwn; const [showAllAttendees, setShowAllAttendees] = useState(false); const [openUpdateModal, setOpenUpdateModal] = useState(false); @@ -274,8 +276,9 @@ export default function EventPreviewModal({ : attendees.slice(0, attendeeDisplayLimit); const currentUserAttendee = event.attendee?.find( - (person) => person.cal_address === user.userData?.email + (person) => person.cal_address === user.email ); + const contextualizedEvent = createEventContext(event, calendar, user); const organizer = event.attendee?.find( (a) => a.cal_address === event.organizer?.cal_address @@ -365,7 +368,7 @@ export default function EventPreviewModal({ window.open( `${mailSpaUrl}/mailto/?uri=mailto:${event.attendee .map((a) => a.cal_address) - .filter((mail) => mail !== user.userData?.email) + .filter((mail) => mail !== user.email) .join(",")}?subject=${event.title}` ) } @@ -467,147 +470,13 @@ export default function EventPreviewModal({ } actions={ - currentUserAttendee && - isOwn && ( - <> - <> - - {t("eventPreview.attendingQuestion")} - - - - - - - - - ) + } > {((event.class !== "PRIVATE" && !isOwn) || isOwn) && ( diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 3e2b275..b737700 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -12,8 +12,8 @@ import React, { import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { ResponsiveDialog } from "../../components/Dialog"; import { putEventAsync } from "../Calendars/CalendarSlice"; -import { Calendars } from "../Calendars/CalendarTypes"; -import { userAttendee } from "../User/userDataTypes"; +import { Calendar } from "../Calendars/CalendarTypes"; +import { userAttendee } from "../User/models/attendee"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { createSelector } from "@reduxjs/toolkit"; import { TIMEZONES } from "../../utils/timezone-data"; @@ -75,11 +75,11 @@ function EventPopover({ if (id.split("/")[0] === userId) { return calendars.list?.[id]; } - return {} as Calendars; + return {} as Calendar; }) .filter((calendar) => calendar.id) ); - const userPersonalCalendars: Calendars[] = useAppSelector( + const userPersonalCalendars: Calendar[] = useAppSelector( selectPersonalCalendars ); @@ -722,10 +722,10 @@ function EventPopover({ const newEventUID = crypto.randomUUID(); // Resolve target calendar safely - const targetCalendar: Calendars | undefined = + const targetCalendar: Calendar | undefined = calList[calendarid] || userPersonalCalendars[0] || - (Object.values(calList)[0] as Calendars | undefined); + (Object.values(calList)[0] as Calendar | undefined); if (!targetCalendar || !targetCalendar.id) { console.error("No target calendar available to save event"); return; diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 5ea7cd3..2a1bba4 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -12,8 +12,8 @@ import { updateEventLocal, clearFetchCache, } from "../Calendars/CalendarSlice"; -import { Calendars } from "../Calendars/CalendarTypes"; -import { userAttendee } from "../User/userDataTypes"; +import { Calendar } from "../Calendars/CalendarTypes"; +import { userAttendee } from "../User/models/attendee"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { TIMEZONES } from "../../utils/timezone-data"; import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils"; @@ -95,10 +95,10 @@ function EventUpdateModal({ const calendarsList = useAppSelector((state) => state.calendars.list); - const userPersonalCalendars: Calendars[] = useMemo(() => { - const allCalendars = Object.values(calendarsList) as Calendars[]; + const userPersonalCalendars: Calendar[] = useMemo(() => { + const allCalendars = Object.values(calendarsList) as Calendar[]; return allCalendars.filter( - (c: Calendars) => c.id?.split("/")[0] === user.userData?.openpaasId + (c: Calendar) => c.id?.split("/")[0] === user.userData?.openpaasId ); }, [calendarsList, user.userData?.openpaasId]); diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index fa68998..bb477f1 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -1,4 +1,6 @@ -import { userAttendee, userOrganiser } from "../User/userDataTypes"; +import { Calendar } from "../Calendars/CalendarTypes"; +import { userAttendee } from "../User/models/attendee"; +import { userOrganiser } from "../User/userDataTypes"; export interface CalendarEvent { URL: string; @@ -39,3 +41,12 @@ export interface AlarmObject { trigger: string; action: string; } + +export interface ContextualizedEvent { + event: CalendarEvent; + calendar: Calendar; + currentUserAttendee: userAttendee | undefined; + isOwn: boolean; + isRecurring: boolean; + isOrganizer: boolean; +} diff --git a/src/features/Events/createEventContext.ts b/src/features/Events/createEventContext.ts new file mode 100644 index 0000000..b0f5a72 --- /dev/null +++ b/src/features/Events/createEventContext.ts @@ -0,0 +1,27 @@ +import { Calendar } from "../Calendars/CalendarTypes"; +import { userData } from "../User/userDataTypes"; +import { CalendarEvent, ContextualizedEvent } from "./EventsTypes"; + +export function createEventContext( + event: CalendarEvent, + calendar: Calendar, + user: userData +): ContextualizedEvent { + const isOwn = calendar.ownerEmails?.includes(user.email) ?? false; + const isRecurring = event?.uid?.includes("/") ?? false; + const isOrganizer = event.organizer + ? user?.email === event.organizer.cal_address + : isOwn; + const currentUserAttendee = event.attendee?.find( + (person) => person.cal_address === user.email + ); + + return { + event, + calendar, + currentUserAttendee, + isOwn, + isRecurring, + isOrganizer, + }; +} diff --git a/src/features/Events/eventUtils.ts b/src/features/Events/eventUtils.ts index bf9c0cb..37f0c8a 100644 --- a/src/features/Events/eventUtils.ts +++ b/src/features/Events/eventUtils.ts @@ -1,4 +1,4 @@ -import { userAttendee } from "../User/userDataTypes"; +import { userAttendee } from "../User/models/attendee"; import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes"; import ICAL from "ical.js"; import { TIMEZONES } from "../../utils/timezone-data"; @@ -7,6 +7,7 @@ import { convertFormDateTimeToISO, detectDateTimeFormat, } from "../../components/Event/utils/dateTimeHelpers"; +import { createAttendee } from "../User/models/attendee.mapper"; type RawEntry = [string, Record, string, any]; function resolveTimezoneId(tzid?: string): string | undefined { @@ -119,14 +120,16 @@ export function parseCalendarEvent( }; break; case "attendee": - (event.attendee as userAttendee[]).push({ - cn: params?.cn ?? "", - cal_address: value.replace(/^mailto:/i, ""), - partstat: params?.partstat ?? "", - rsvp: params?.rsvp ?? "", - role: params?.role ?? "", - cutype: params?.cutype ?? "", - }); + (event.attendee as userAttendee[]).push( + createAttendee({ + cn: params?.cn, + cal_address: value.replace(/^mailto:/i, ""), + partstat: params?.partstat as userAttendee["partstat"], + rsvp: params?.rsvp as userAttendee["rsvp"], + role: params?.role as userAttendee["role"], + cutype: params?.cutype as userAttendee["cutype"], + }) + ); break; case "dtstamp": event.stamp = value; diff --git a/src/features/Events/formHelpers.ts b/src/features/Events/formHelpers.ts index 994e472..875ac26 100644 --- a/src/features/Events/formHelpers.ts +++ b/src/features/Events/formHelpers.ts @@ -1,7 +1,7 @@ import { TIMEZONES } from "../../utils/timezone-data"; import { resolveTimezone } from "../../components/Calendar/TimezoneSelector"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; -import { userAttendee } from "../User/userDataTypes"; +import { userAttendee } from "../User/models/attendee"; import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters"; import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils"; import { browserDefaultTimeZone } from "../../utils/timezone"; diff --git a/src/features/User/models/attendee.mapper.ts b/src/features/User/models/attendee.mapper.ts new file mode 100644 index 0000000..befceb1 --- /dev/null +++ b/src/features/User/models/attendee.mapper.ts @@ -0,0 +1,19 @@ +import { userAttendee } from "./attendee"; + +export function createAttendee(options?: { + cal_address?: string; + cn?: string; + role?: userAttendee["role"]; + partstat?: userAttendee["partstat"]; + rsvp?: userAttendee["rsvp"]; + cutype?: userAttendee["cutype"]; +}): userAttendee { + return { + cal_address: options?.cal_address ?? "", + cn: options?.cn ?? "", + cutype: options?.cutype ?? "INDIVIDUAL", + role: options?.role ?? "REQ-PARTICIPANT", + partstat: options?.partstat ?? "NEEDS-ACTION", + rsvp: options?.rsvp ?? "FALSE", + }; +} diff --git a/src/features/User/models/attendee.ts b/src/features/User/models/attendee.ts new file mode 100644 index 0000000..3471415 --- /dev/null +++ b/src/features/User/models/attendee.ts @@ -0,0 +1,12 @@ +export type AttendeeRole = "CHAIR" | "REQ-PARTICIPANT" | "OPT-PARTICIPANT"; +export type CuType = "INDIVIDUAL" | "GROUP"; +export type PartStat = "ACCEPTED" | "DECLINED" | "TENTATIVE" | "NEEDS-ACTION"; + +export interface userAttendee { + cal_address: string; + partstat: PartStat; + role: AttendeeRole; + cutype: CuType; + rsvp: "TRUE" | "FALSE"; + cn: string; +} diff --git a/src/features/User/userDataTypes.ts b/src/features/User/userDataTypes.ts index 5f3b426..44d7f14 100644 --- a/src/features/User/userDataTypes.ts +++ b/src/features/User/userDataTypes.ts @@ -33,12 +33,3 @@ export interface userOrganiser { cn: string; cal_address: string; } - -export interface userAttendee { - cn?: string; - cal_address: string; - partstat: string; - rsvp: string; - role: string; - cutype: string; -} diff --git a/src/locales/en.json b/src/locales/en.json index 491ceb9..8c36452 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -245,9 +245,9 @@ "tooltip": "Others see you as available during the time range of this event." }, "attendingQuestion": "Attending?", - "accept": "Accept", - "maybe": "Maybe", - "decline": "Decline", + "ACCEPTED": "Accept", + "TENTATIVE": "Maybe", + "DECLINED": "Decline", "showMore": "Show more", "showLess": "Show less", "joinVideo": "Join the video conference", diff --git a/src/locales/fr.json b/src/locales/fr.json index aa1f4d8..860042e 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -247,9 +247,9 @@ "tooltip": "Les autres vous voient comme disponible pendant la plage horaire de cet événement." }, "attendingQuestion": "Vous participez ?", - "accept": "Accepter", - "maybe": "Peut-être", - "decline": "Décliner", + "ACCEPTED": "Accepter", + "TENTATIVE": "Peut-être", + "DECLINED": "Décliner", "showMore": "Afficher plus", "showLess": "Afficher moins", "joinVideo": "Rejoindre la visioconférence", diff --git a/src/locales/ru.json b/src/locales/ru.json index f1274e6..c0351b8 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -247,9 +247,9 @@ "tooltip": "Другие видят вас свободным." }, "attendingQuestion": "Присоединитесь?", - "accept": "Да", - "maybe": "Возможно", - "decline": "Нет", + "ACCEPTED": "Да", + "TENTATIVE": "Возможно", + "DECLINED": "Нет", "showMore": "Показать больше", "showLess": "Показать меньше", "joinVideo": "Присоединиться к видеоконференции", diff --git a/src/locales/vi.json b/src/locales/vi.json index 2cb9a9d..b9e5e4e 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -245,9 +245,9 @@ "tooltip": "Người khác sẽ thấy bạn rảnh trong khoảng thời gian này." }, "attendingQuestion": "Tham gia?", - "accept": "Chấp nhận", - "maybe": "Có thể", - "decline": "Từ chối", + "ACCEPTED": "Chấp nhận", + "TENTATIVE": "Có thể", + "DECLINED": "Từ chối", "showMore": "Xem thêm", "showLess": "Thu gọn", "joinVideo": "Tham gia cuộc họp video", diff --git a/src/utils/buildFamilyName.ts b/src/utils/buildFamilyName.ts new file mode 100644 index 0000000..0606d0b --- /dev/null +++ b/src/utils/buildFamilyName.ts @@ -0,0 +1,12 @@ +export function buildFamilyName( + firstName: string | undefined, + lastName: string | undefined, + email: string +): string { + const trimmedFirstName = firstName?.trim() || ""; + const trimmedLastName = lastName?.trim() || ""; + const fullName = [trimmedFirstName, trimmedLastName] + .filter(Boolean) + .join(" "); + return fullName || email; +} diff --git a/src/utils/eventFormTempStorage.ts b/src/utils/eventFormTempStorage.ts index cb53a0a..ea7c932 100644 --- a/src/utils/eventFormTempStorage.ts +++ b/src/utils/eventFormTempStorage.ts @@ -1,5 +1,5 @@ import { RepetitionObject } from "../features/Events/EventsTypes"; -import { userAttendee } from "../features/User/userDataTypes"; +import { userAttendee } from "../features/User/models/attendee"; export interface EventFormTempData { // Form fields