Merge pull request #117 from linagora/UI/grid-view

UI/grid view
This commit is contained in:
lenhanphung
2025-09-22 09:49:59 +07:00
committed by GitHub
12 changed files with 507 additions and 83 deletions
+133
View File
@@ -55,6 +55,139 @@ describe("Calendar App Component Display Tests", () => {
expect(screen.getByText("t")).toBeInTheDocument();
});
// Edge cases for avatar display logic
it("handles user with only family_name", () => {
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: "Smith",
name: null,
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
},
};
renderWithProviders(<Menubar />, preloadedState);
expect(screen.getByText("t")).toBeInTheDocument();
});
it("handles user with only name", () => {
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: null,
name: "John",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
},
};
renderWithProviders(<Menubar />, preloadedState);
expect(screen.getByText("t")).toBeInTheDocument();
});
it("handles user with both name and family_name", () => {
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: "Doe",
name: "John",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
},
};
renderWithProviders(<Menubar />, preloadedState);
expect(screen.getByText("JD")).toBeInTheDocument();
});
it("handles user with no name and family_name", () => {
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
const preloadedState = {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: null,
name: null,
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
},
};
renderWithProviders(<Menubar />, preloadedState);
expect(screen.getByText("t")).toBeInTheDocument();
});
it("handles user with empty email", () => {
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
const preloadedState = {
user: {
userData: {
sub: "test",
email: "",
family_name: null,
name: null,
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
},
};
renderWithProviders(<Menubar />, preloadedState);
// Should not crash and show empty string
const avatar = screen.getByRole("img", { hidden: true });
expect(avatar).toBeInTheDocument();
});
it("handles user with null email", () => {
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
const preloadedState = {
user: {
userData: {
sub: "test",
email: null,
family_name: null,
name: null,
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
},
};
renderWithProviders(<Menubar />, preloadedState);
// Should not crash and show empty string
const avatar = screen.getByRole("img", { hidden: true });
expect(avatar).toBeInTheDocument();
});
it("handles user with undefined email", () => {
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
const preloadedState = {
user: {
userData: {
sub: "test",
email: undefined,
family_name: null,
name: null,
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
},
};
renderWithProviders(<Menubar />, preloadedState);
// Should not crash and show empty string
const avatar = screen.getByRole("img", { hidden: true });
expect(avatar).toBeInTheDocument();
});
it("shows AppsIcon when applist is not empty", () => {
(window as any).appList = [{ name: "test", icon: "test", link: "test" }];
renderWithProviders(<Menubar />, preloadedState);
+198
View File
@@ -465,4 +465,202 @@ describe("calendarEventToJCal", () => {
])
);
});
// Edge cases for timezone fallback
it("handles invalid timezone with UTC fallback", () => {
const mockEvent = {
uid: "event-invalid-tz",
title: "Invalid Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
timezone: "Invalid/Timezone",
allday: false,
attendee: [],
};
const result = calendarEventToJCal(mockEvent as CalendarEvent);
expect(result[0]).toBe("vcalendar");
const [vevent, vtimezone] = result[2];
expect(vevent[0]).toBe("vevent");
expect(vtimezone[0]).toBe("vtimezone");
// Should use UTC timezone as fallback - check for UTC standard timezone
expect(vtimezone[2]).toEqual(
expect.arrayContaining([
[
"standard",
[
["tzoffsetfrom", {}, "utc-offset", "+00:00"],
["tzoffsetto", {}, "utc-offset", "+00:00"],
["tzname", {}, "text", "UTC"],
["dtstart", {}, "date-time", "1970-01-01T00:00:00"],
],
[],
],
])
);
});
it("handles null timezone with UTC fallback", () => {
const mockEvent = {
uid: "event-null-tz",
title: "Null Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
timezone: null,
allday: false,
attendee: [],
};
const result = calendarEventToJCal(mockEvent as CalendarEvent);
expect(result[0]).toBe("vcalendar");
const [vevent, vtimezone] = result[2];
expect(vevent[0]).toBe("vevent");
expect(vtimezone[0]).toBe("vtimezone");
// Should use UTC timezone as fallback
expect(vtimezone[2]).toEqual(
expect.arrayContaining([
[
"standard",
[
["tzoffsetfrom", {}, "utc-offset", "+00:00"],
["tzoffsetto", {}, "utc-offset", "+00:00"],
["tzname", {}, "text", "UTC"],
["dtstart", {}, "date-time", "1970-01-01T00:00:00"],
],
[],
],
])
);
});
it("handles undefined timezone with UTC fallback", () => {
const mockEvent = {
uid: "event-undefined-tz",
title: "Undefined Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
timezone: undefined,
allday: false,
attendee: [],
};
const result = calendarEventToJCal(mockEvent as CalendarEvent);
expect(result[0]).toBe("vcalendar");
const [vevent, vtimezone] = result[2];
expect(vevent[0]).toBe("vevent");
expect(vtimezone[0]).toBe("vtimezone");
// Should use UTC timezone as fallback
expect(vtimezone[2]).toEqual(
expect.arrayContaining([
[
"standard",
[
["tzoffsetfrom", {}, "utc-offset", "+00:00"],
["tzoffsetto", {}, "utc-offset", "+00:00"],
["tzname", {}, "text", "UTC"],
["dtstart", {}, "date-time", "1970-01-01T00:00:00"],
],
[],
],
])
);
});
it("handles empty string timezone with UTC fallback", () => {
const mockEvent = {
uid: "event-empty-tz",
title: "Empty Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
timezone: "",
allday: false,
attendee: [],
};
const result = calendarEventToJCal(mockEvent as CalendarEvent);
expect(result[0]).toBe("vcalendar");
const [vevent, vtimezone] = result[2];
expect(vevent[0]).toBe("vevent");
expect(vtimezone[0]).toBe("vtimezone");
// Should use UTC timezone as fallback
expect(vtimezone[2]).toEqual(
expect.arrayContaining([
[
"standard",
[
["tzoffsetfrom", {}, "utc-offset", "+00:00"],
["tzoffsetto", {}, "utc-offset", "+00:00"],
["tzname", {}, "text", "UTC"],
["dtstart", {}, "date-time", "1970-01-01T00:00:00"],
],
[],
],
])
);
});
it("handles valid timezone correctly", () => {
const mockEvent = {
uid: "event-valid-tz",
title: "Valid Timezone Event",
start: new Date("2025-07-23T10:00:00"),
end: new Date("2025-07-23T11:00:00"),
timezone: "Europe/Paris",
allday: false,
attendee: [],
};
const result = calendarEventToJCal(mockEvent as CalendarEvent);
expect(result[0]).toBe("vcalendar");
const [vevent, vtimezone] = result[2];
expect(vevent[0]).toBe("vevent");
expect(vtimezone[0]).toBe("vtimezone");
// Should use the specified timezone - check for Europe/Paris timezone components
expect(vtimezone[2]).toEqual(
expect.arrayContaining([
[
"daylight",
[
["tzoffsetfrom", {}, "utc-offset", "+01:00"],
["tzoffsetto", {}, "utc-offset", "+02:00"],
["tzname", {}, "text", "CEST"],
["dtstart", {}, "date-time", "1970-03-29T02:00:00"],
[
"rrule",
{},
"recur",
{ byday: "-1SU", bymonth: 3, freq: "YEARLY" },
],
],
[],
],
[
"standard",
[
["tzoffsetfrom", {}, "utc-offset", "+02:00"],
["tzoffsetto", {}, "utc-offset", "+01:00"],
["tzname", {}, "text", "CET"],
["dtstart", {}, "date-time", "1970-10-25T03:00:00"],
[
"rrule",
{},
"recur",
{ byday: "-1SU", bymonth: 10, freq: "YEARLY" },
],
],
[],
],
])
);
});
});
+125 -61
View File
@@ -1,12 +1,21 @@
main {
.App {
background-color: #f3f6f9;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.main-layout {
background-color: #fff;
flex-direction: row;
height: calc(100vh - 90px);
display: flex;
}
.calendar {
flex-grow: 1;
overflow: "hidden";
height: 90vh;
height: 100%;
overflow: hidden;
padding: 0 25px 0 10px;
}
.calendarListHeader {
@@ -16,38 +25,22 @@ main {
margin-left: 25%;
}
.sidebar {
border-right: 2px gray;
border-right: 2px solid #f3f4f6;
width: 20vw;
padding: 16px 25px;
}
.fc .fc-daygrid-day {
min-height: 10px !important; /* Default is ~40px */
height: 10px !important;
font-size: 0.75rem;
}
.fc .fc-timegrid-slot {
height: 60px !important; /* Default is ~40px */
}
.fc-timegrid-slot {
position: relative;
}
.declined-event {
opacity: 0.7;
}
.tentative-event {
}
.needs-action-event {
opacity: 0.7;
border: dashed 1px #ffffff;
}
.fc .fc-timegrid-slot {
height: 30px !important;
min-height: 30px !important;
height: auto;
min-height: 54px !important;
}
.fc .fc-timegrid-slot-label {
@@ -74,14 +67,6 @@ main {
overflow: hidden; /* Important: let FullCalendar handle internal scroll */
}
.fc-scroller-liquid-absolute {
overflow-y: auto !important; /* This targets the scrollable part */
max-height: calc(100vh - 100px); /* Adjust based on your header size */
}
.scrollgrid-section-header {
border: white;
}
/* Base container */
.fc-daygrid-day-top {
display: flex;
@@ -91,36 +76,6 @@ main {
font-family: "Inter", sans-serif;
}
/* Day number */
.fc-daygrid-day-top .fc-daygrid-day-number {
font-size: 1rem;
font-weight: 600;
line-height: 2;
}
/* Day name (e.g., MON) */
.fc-daygrid-day-top small {
font-size: 0.75rem;
letter-spacing: 0.05em;
color: #94a3b8; /* slate gray */
text-transform: uppercase;
}
/* Todays date highlight */
.current-date {
background-color: orange;
color: white;
border-radius: 50%;
padding: 50px;
}
.fc .fc-col-header-cell,
.fc .fc-scrollgrid table,
.fc-theme-standard .fc-scrollgrid {
background: none;
border: none;
}
/* sidebar*/
.sidebar-calendar {
width: 280px;
@@ -207,3 +162,112 @@ main {
margin-left: auto;
margin-right: auto;
}
/* Custom main calendar view */
.fc-header-toolbar.fc-toolbar.fc-toolbar-ltr {
/* display: none; */
}
.fc-theme-standard td,
.fc-theme-standard th {
border: 1px solid #b8c1cc;
border-left: 0;
}
a.fc-timegrid-axis-cushion {
color: #243b55;
text-align: center;
font-family: Roboto;
font-size: 12.507px;
font-style: normal;
font-weight: 400;
line-height: 18.76px;
letter-spacing: 0.391px;
}
.fc-daygrid-day-top small {
color: #8c9caf;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px;
letter-spacing: 0.25px;
}
.fc-daygrid-day-top .fc-daygrid-day-number,
span.fc-daygrid-day-number {
color: #243b55;
margin: 0 8px 0 0;
font-family: Roboto;
font-size: 28px;
font-style: normal;
font-weight: 400;
line-height: 36px;
position: relative;
width: 48px;
height: 48px;
line-height: 39px;
}
.fc-daygrid-day-top .fc-daygrid-day-number:after,
span.fc-daygrid-day-number:after {
content: "";
z-index: -1;
border-radius: 50%;
width: 48px;
height: 48px;
position: absolute;
left: 0;
top: -1px;
}
span.fc-daygrid-day-number.current-date {
color: #fff;
}
span.fc-daygrid-day-number.current-date:after {
background-color: #f67e35;
}
td.fc-timegrid-slot.fc-timegrid-slot-label.fc-scrollgrid-shrink,
td.fc-timegrid-slot.fc-timegrid-slot-label.fc-timegrid-slot-minor {
border: 0;
}
th.fc-timegrid-axis.fc-scrollgrid-shrink {
border: 0;
border-right: 1px solid #b8c1cc;
}
.fc .fc-timegrid-axis-cushion {
min-width: 70px;
max-width: 70px;
padding: 0;
text-align: left;
}
.fc .fc-timegrid-divider {
display: none;
height: 0;
}
.fc-timegrid-slot-label-cushion {
color: #aea9b1;
font-family: Roboto;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: 0.25px;
}
th.fc-col-header-cell.fc-day {
border-right: 0;
}
.fc .fc-scrollgrid {
border: 0;
}
.fc .fc-timegrid-slot-label {
justify-content: flex-end;
}
.fc .fc-timegrid-slot-label .fc-timegrid-slot-label-frame {
position: relative;
transform: translate(-15px, -12px);
}
.fc .fc-timegrid-slot-label .fc-timegrid-slot-label-frame::after {
content: "";
z-index: -1;
background-color: #b8c1cc;
width: 10px;
height: 1px;
position: absolute;
top: 9.5px;
right: -15px;
}
+22 -7
View File
@@ -56,7 +56,8 @@ export default function CalendarApp() {
const calendars = useAppSelector((state) => state.calendars.list);
const pending = useAppSelector((state) => state.calendars.pending);
const userId = useAppSelector((state) => state.user.userData.openpaasId);
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const selectPersonnalCalendars = createSelector(
(state) => state.calendars,
(calendars) =>
@@ -80,9 +81,21 @@ export default function CalendarApp() {
);
}
});
const [selectedCalendars, setSelectedCalendars] = useState<string[]>(
Object.keys(calendars).filter((id) => id.split("/")[0] === userId)
);
const [selectedCalendars, setSelectedCalendars] = useState<string[]>([]);
// Auto-select personal calendars when first loaded
useEffect(() => {
if (
Object.keys(calendars).length > 0 &&
userId &&
selectedCalendars.length === 0
) {
const personalCalendarIds = Object.keys(calendars).filter(
(id) => id.split("/")[0] === userId
);
setSelectedCalendars(personalCalendarIds);
}
}, [calendars, userId, selectedCalendars.length]);
const calendarRange = getCalendarRange(selectedDate);
@@ -163,7 +176,7 @@ export default function CalendarApp() {
}
return (
<main>
<main className="main-layout">
<div className="sidebar">
<div className="calendar-label">
<div className="calendar-label">
@@ -304,8 +317,10 @@ export default function CalendarApp() {
weekNumbers
weekNumberFormat={{ week: "long" }}
slotDuration={"00:30:00"}
slotLabelInterval={"01:00:00"}
scrollTime={"08:00:00"}
slotLabelInterval={"00:30:00"}
scrollTime={new Date(Date.now() - 2 * 60 * 60 * 1000)
.toTimeString()
.slice(0, 5)}
unselectAuto={false}
allDayText=""
slotLabelFormat={{
@@ -10,7 +10,6 @@ import { Calendars } from "../../features/Calendars/CalendarTypes";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import IconButton from "@mui/material/IconButton";
import Checkbox from "@mui/material/Checkbox";
import Button from "@mui/material/Button";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
function CalendarAccordion({
@@ -34,8 +33,8 @@ function CalendarAccordion({
}) {
const allCalendars = useAppSelector((state) => state.calendars.list);
if (calendars.length === 0) return null;
const [expended, setExpended] = useState(defaultExpanded);
if (calendars.length === 0) return null;
return (
<Accordion defaultExpanded={defaultExpanded} expanded={expended}>
@@ -81,7 +80,8 @@ export default function CalendarSelection({
selectedCalendars: string[];
setSelectedCalendars: Function;
}) {
const userId = useAppSelector((state) => state.user.userData.openpaasId);
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const calendars = useAppSelector((state) => state.calendars.list);
const personnalCalendars = Object.keys(calendars).filter(
+2 -1
View File
@@ -42,7 +42,8 @@
padding: 0.5rem;
width: 100%;
z-index: 1000;
height: 7vh;
height: 80px;
background-color: #fff;
}
.menubar-item {
+7 -3
View File
@@ -48,14 +48,18 @@ export function Menubar() {
<Avatar
sx={{
bgcolor: stringToColor(
user.family_name ? user.family_name : user.email
user && user.family_name
? user.family_name
: user && user.email
? user.email
: ""
),
}}
sizes="large"
>
{user.name && user.family_name
{user?.name && user?.family_name
? `${user.name[0]}${user.family_name[0]}`
: user.email[0]}
: (user?.email?.[0] ?? "")}
</Avatar>
</div>
</header>
+1 -1
View File
@@ -30,7 +30,7 @@ function CalendarPopover({
}) {
const dispatch = useAppDispatch();
const userId =
useAppSelector((state) => state.user.userData.openpaasId) ?? "";
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const [name, setName] = useState(calendar?.name ?? "");
const [description, setDescription] = useState(calendar?.description ?? "");
const [color, setColor] = useState(calendar?.color ?? "");
+3 -3
View File
@@ -74,7 +74,7 @@ export default function EventDisplayModal({
);
const userPersonnalCalendars: Calendars[] = calendars.filter(
(c) => c.id?.split("/")[0] === user.userData.openpaasId
(c) => c.id?.split("/")[0] === user.userData?.openpaasId
);
// Form state
@@ -97,7 +97,7 @@ export default function EventDisplayModal({
const [timezone, setTimezone] = useState(event?.timezone ?? "UTC");
const [newCalId, setNewCalId] = useState(event?.calId);
const [calendarid, setCalendarid] = useState(
calId.split("/")[0] === user.userData.openpaasId
calId.split("/")[0] === user.userData?.openpaasId
? userPersonnalCalendars.findIndex((cal) => cal.id === calId)
: calendars.findIndex((cal) => cal.id === calId)
);
@@ -198,7 +198,7 @@ export default function EventDisplayModal({
};
const calList =
calId.split("/")[0] === user.userData.openpaasId
calId.split("/")[0] === user.userData?.openpaasId
? Object.keys(userPersonnalCalendars).map((calendar, index) => (
<MenuItem key={index} value={index}>
<Typography variant="body2">
+2 -1
View File
@@ -46,7 +46,8 @@ function EventPopover({
const dispatch = useAppDispatch();
const organizer = useAppSelector((state) => state.user.organiserData);
const userId = useAppSelector((state) => state.user.userData.openpaasId);
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const selectPersonnalCalendars = createSelector(
(state) => state.calendars,
(calendars) =>
+10 -1
View File
@@ -234,8 +234,17 @@ export function calendarEventToJCal(
]);
});
const timezoneData = TIMEZONES.zones[event.timezone];
if (!timezoneData) {
const vtimezone = new ICAL.Timezone({
component: TIMEZONES.zones["Etc/UTC"].ics,
tzid: "Etc/UTC",
});
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
}
const vtimezone = new ICAL.Timezone({
component: TIMEZONES.zones[event.timezone].ics,
component: timezoneData.ics,
tzid: event.timezone,
});
return ["vcalendar", [], [vevent, vtimezone.component.jCal]];
+1 -2
View File
@@ -2,7 +2,6 @@ import React, { useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { Auth } from "./oidcAuth";
import { Loading } from "../../components/Loading/Loading";
import { Error } from "../../components/Error/Error";
import { push } from "redux-first-history";
import { redirectTo } from "../../utils/apiUtils";
import { getOpenPaasUserDataAsync, setTokens, setUserData } from "./userSlice";
@@ -27,7 +26,7 @@ export function HandleLogin() {
dispatch(setUserData(savedUser));
dispatch(getOpenPaasUserDataAsync());
dispatch(getCalendarsListAsync());
dispatch(push("/"));
dispatch(push("/calendar"));
return;
}