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 <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-09-24 17:05:45 +02:00
committed by GitHub
parent 1db93efed9
commit 00c0b58032
14 changed files with 707 additions and 114 deletions
+90 -5
View File
@@ -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(<CalendarApp />, 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(<CalendarApp />, 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();
});
});
@@ -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");
});
});
+2 -2
View File
@@ -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());
});
});
@@ -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());
}
});
});
+102
View File
@@ -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<React.ComponentProps<typeof PeopleSearch>>
) {
const onChange = jest.fn();
renderWithProviders(
<PeopleSearch
selectedUsers={selectedUsers}
onChange={onChange}
{...props}
/>
);
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();
});
});
+34
View File
@@ -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<User[]>([]);
const theme = useTheme();
useEffect(() => {
const delayDebounceFn = setTimeout(async () => {
@@ -67,10 +74,25 @@ export function PeopleSearch({
renderInput={(params) => (
<TextField
{...params}
placeholder="Search user"
label="Search user"
onKeyDown={(e) => {
if (e.key === "Enter" && onToggleEventPreview) {
e.preventDefault();
onToggleEventPreview();
}
}}
slotProps={{
input: {
...params.InputProps,
startAdornment: (
<>
<PeopleOutlineOutlinedIcon
sx={{ mr: 1, color: "action.active" }}
/>
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<>
{loading ? (
@@ -101,6 +123,18 @@ export function PeopleSearch({
</ListItem>
);
}}
renderValue={(value, getTagProps) =>
value.map((option, index) => (
<Chip
{...getTagProps({ index })}
sx={{
backgroundColor: option.color,
color: theme.palette.getContrastText(option.color ?? "#ffffffff"),
}}
label={option.displayName}
/>
))
}
/>
);
}
+155 -46
View File
@@ -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<CalendarApi | null>(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<HTMLElement | null>(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<DateSelectArg | null>(
null
);
const [tempEvent, setTempEvent] = useState<CalendarEvent>(
{} 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() {
);
}}
/>
<TempCalendarsInput
setAnchorEl={setAnchorEl}
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
setTempEvent={setTempEvent}
/>
<CalendarSelection
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
@@ -316,12 +351,11 @@ export default function CalendarApp() {
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
}}
dayMaxEvents={true}
events={filteredEvents.map((e) => {
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 && (
<EventPreviewModal
eventId={eventDisplayedId}
calId={eventDisplayedCalId}
tempEvent={eventDisplayedTemp}
anchorPosition={anchorPosition}
open={openEventDisplay}
onClose={handleCloseEventDisplay}
@@ -559,3 +606,65 @@ export default function CalendarApp() {
</main>
);
}
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<string, Calendars>
) {
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<string, Calendars>,
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,
})
);
}
});
}
@@ -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 (
<Accordion defaultExpanded={defaultExpanded} expanded={expended}>
@@ -129,7 +129,6 @@ export default function CalendarSelection({
calendars={delegatedCalendars}
selectedCalendars={selectedCalendars}
handleToggle={handleCalendarToggle}
defaultExpanded
setOpen={(id: string) => {
setAnchorElCal(document.body);
setSelectedCalId(id);
@@ -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<string, AbortController>();
export function TempCalendarsInput({
setAnchorEl,
setTempEvent,
selectedCalendars,
setSelectedCalendars,
}: {
setAnchorEl: Function;
setTempEvent: Function;
selectedCalendars: string[];
setSelectedCalendars: Function;
}) {
const [tempUsers, setTempUsers] = useState<User[]>([]);
const dispatch = useAppDispatch();
const tempcalendars =
useAppSelector((state) => state.calendars.templist) ?? {};
const calendars = useAppSelector((state) => state.calendars.list);
const prevUsersRef = useRef<User[]>([]);
const userColors = new Map<string, string>();
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 (
<PeopleSearch
selectedUsers={tempUsers}
onChange={handleUserChange}
onToggleEventPreview={handleToggleEventPreview}
/>
);
}
function getCalendarsFromUsersDelta(
addedUsers: User[],
emailToCalendarId: Map<string, string[]>,
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<string, Calendars>) {
const map = new Map<string, string[]>();
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>): string {
let color: string;
do {
const hue = Math.floor(Math.random() * 360);
color = `hsl(${hue}, 70%, 50%)`;
} while (existingColors.has(color));
return color;
}
+116 -34
View File
@@ -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<string, Calendars> // Return type
@@ -55,10 +56,50 @@ export const getCalendarsListAsync = createAsyncThunk<
return importedCalendars;
});
export const getTempCalendarsListAsync = createAsyncThunk<
Record<string, Calendars>,
User
>("calendars/getTempCalendars", async (tempUser) => {
const importedCalendars: Record<string, Calendars> = {};
const calendars = (await getCalendars(
tempUser.openpaasId ?? "",
"sharedPublic=true&WithRights=true"
)) as Record<string, any>;
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<string, any>;
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<string, Calendars>, pending: false },
initialState: {
list: {} as Record<string, Calendars>,
templist: {} as Record<string, Calendars>,
pending: false,
},
reducers: {
createCalendar: (state, action: PayloadAction<Record<string, string>>) => {
const id = Date.now().toString(36);
@@ -280,6 +326,9 @@ const CalendarSlice = createSlice({
action.payload.eventUid
];
},
removeTempCal: (state, action: PayloadAction<string>) => {
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<Record<string, Calendars>>) => {
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;
+6 -4
View File
@@ -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);
+28 -9
View File
@@ -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({
<Popover
open={open}
anchorEl={anchorEl}
onClose={onClose}
onClose={handleClose}
anchorOrigin={{
vertical: "center",
horizontal: "center",
@@ -172,7 +194,7 @@ function EventPopover({
}}
>
<Card>
<CardHeader title={event ? "Duplicate Event" : "Create Event"} />
<CardHeader title={event?.uid ? "Duplicate Event" : "Create Event"} />
<CardContent
sx={{ maxHeight: "85vh", maxWidth: "40vw", overflow: "auto" }}
>
@@ -359,10 +381,7 @@ function EventPopover({
<CardActions>
<Box mt={2} display="flex" justifyContent="flex-end" gap={1}>
<Button
variant="outlined"
onClick={() => onClose({}, "backdropClick")}
>
<Button variant="outlined" onClick={handleClose}>
Cancel
</Button>
<Button size="small" onClick={() => setShowMore(!showMore)}>
+2 -8
View File
@@ -1,3 +1,4 @@
import { User } from "../../components/Attendees/PeopleSearch";
import { api } from "../../utils/apiUtils";
export async function getOpenPaasUser() {
@@ -8,14 +9,7 @@ export async function getOpenPaasUser() {
export async function searchUsers(
query: string,
objectTypes: string[] = ["user", "contact"]
): Promise<
{
email: string;
displayName: string;
avatarUrl: string;
openpaasId: string;
}[]
> {
): Promise<User[]> {
const response: any[] = await api
.post(`api/people/search`, {
body: JSON.stringify({
+7
View File
@@ -45,3 +45,10 @@ export function getDeltaInMilliseconds(delta: {
(delta.milliseconds || 0)
);
}
export 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;
};