diff --git a/__test__/components/DuplicateEvent.test.tsx b/__test__/components/DuplicateEvent.test.tsx
index e118d7a..bbae6ac 100644
--- a/__test__/components/DuplicateEvent.test.tsx
+++ b/__test__/components/DuplicateEvent.test.tsx
@@ -25,6 +25,7 @@ const preloadedState = {
id: "667037022b752d0026472254/cal1",
name: "Calendar",
color: "#FF0000",
+ owner: { emails: ["test@test.com"] },
events: {
event1: {
uid: "event1",
diff --git a/__test__/features/Calendars/services/helpers.test.tsx b/__test__/features/Calendars/services/helpers.test.tsx
index eca326b..255a224 100644
--- a/__test__/features/Calendars/services/helpers.test.tsx
+++ b/__test__/features/Calendars/services/helpers.test.tsx
@@ -51,7 +51,12 @@ describe("helpers", () => {
expect(mockedGetUserDetails).toHaveBeenNthCalledWith(1, "resource-123");
expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123");
expect(mockedGetUserDetails).toHaveBeenNthCalledWith(2, "creator-456");
- expect(result).toEqual({ ...mockCreator, resource: true });
+ expect(result).toEqual({
+ ...mockCreator,
+ resource: true,
+ administrators: undefined,
+ resourceIcon: undefined,
+ });
});
it("should throw error when getUserDetails fails with non-404 error", async () => {
diff --git a/__test__/features/Events/AttendanceValidation.delegated.test.tsx b/__test__/features/Events/AttendanceValidation.delegated.test.tsx
index bad587c..cb44029 100644
--- a/__test__/features/Events/AttendanceValidation.delegated.test.tsx
+++ b/__test__/features/Events/AttendanceValidation.delegated.test.tsx
@@ -129,4 +129,55 @@ describe("AttendanceValidation - delegation", () => {
expect(container.firstChild).not.toBeNull();
});
});
+
+ describe("resource calendar", () => {
+ const resourceContext = makeContext({
+ isOwn: false,
+ calendar: {
+ id: "res1/cal1",
+ name: "Resource Cal",
+ delegated: false,
+ owner: {
+ emails: ["resource@example.com"],
+ resource: true,
+ administrators: [{ id: "admin1" }],
+ },
+ events: {},
+ } as Calendar,
+ });
+
+ it("renders when user is an administrator of the resource", () => {
+ const { getByText } = render(
+
+ );
+ expect(getByText("eventPreview.authorizeQuestion")).toBeTruthy();
+ });
+
+ it("returns null when user is not an administrator of the resource", () => {
+ const { container } = render(
+
+ );
+ expect(container.firstChild).toBeNull();
+ });
+ });
});
diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx
index ce6badf..829f0d6 100644
--- a/__test__/features/Events/EventDisplay.test.tsx
+++ b/__test__/features/Events/EventDisplay.test.tsx
@@ -103,6 +103,7 @@ describe("Event Preview Display", () => {
id: "otherCal/cal",
name: "Calendar 1",
color: "#FF0000",
+ owner: { emails: ["other@test.com"] },
events: {
event1: {
uid: "event1",
@@ -901,7 +902,7 @@ describe("Event Preview Display", () => {
name: "Calendar 1",
id: "667037022b752d0026472254/cal1",
color: "#FF0000",
- // ownerEmails missing
+ owner: {},
events: {
event1: {
calId: "667037022b752d0026472254/cal1",
diff --git a/__test__/features/Events/EventUpdateModal.test.tsx b/__test__/features/Events/EventUpdateModal.test.tsx
index 5b1fc09..5a1a725 100644
--- a/__test__/features/Events/EventUpdateModal.test.tsx
+++ b/__test__/features/Events/EventUpdateModal.test.tsx
@@ -61,7 +61,8 @@ describe("EventUpdateModal Timezone Handling", () => {
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
- };
+ URL: "/calendars/667037022b752d0026472254/cal1/test-event-1.ics",
+ } as CalendarEvent;
const stateWithEvent = {
...preloadedState,
@@ -122,7 +123,8 @@ describe("EventUpdateModal Timezone Handling", () => {
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
- };
+ URL: "/calendars/667037022b752d0026472254/cal1/test-event-2.ics",
+ } as CalendarEvent;
const stateWithEvent = {
...preloadedState,
@@ -181,7 +183,8 @@ describe("EventUpdateModal Timezone Handling", () => {
cutype: "RESOURCE",
},
],
- };
+ URL: "/calendars/667037022b752d0026472254/cal1/test-event-resource.ics",
+ } as CalendarEvent;
const stateWithEvent = {
...preloadedState,
@@ -239,6 +242,10 @@ describe("EventUpdateModal Timezone Handling", () => {
expect(resource).toBeDefined();
expect(resource!.cn).toBe("Conference Room");
expect(resource!.cal_address).toBe("room@test.com");
+ expect(resource!.partstat).toBe("ACCEPTED");
+ expect(resource!.rsvp).toBe("TRUE");
+ expect(resource!.role).toBe("REQ-PARTICIPANT");
+ expect(resource!.cutype).toBe("RESOURCE");
});
});
@@ -294,7 +301,8 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: `/calendars/${calId}/${baseUID}.ics`,
- };
+ timezone: "UTC",
+ } as CalendarEvent;
const instance1 = {
...masterEvent,
@@ -445,6 +453,7 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: `/calendars/${calId}/${baseUID}.ics`,
+ timezone: "UTC",
} as CalendarEvent;
const instance1 = {
@@ -582,7 +591,8 @@ describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [{ cn: "test", cal_address: "test@test.com" }],
URL: `/calendars/${calId}/${baseUID}.ics`,
- };
+ timezone: "UTC",
+ } as CalendarEvent;
const instance1 = {
...masterEvent,
diff --git a/src/components/Attendees/ResourceIcon.tsx b/src/components/Attendees/ResourceIcon.tsx
index 6fe17d3..081833b 100644
--- a/src/components/Attendees/ResourceIcon.tsx
+++ b/src/components/Attendees/ResourceIcon.tsx
@@ -1,11 +1,31 @@
-import { Avatar } from "@linagora/twake-mui";
+import { Avatar, Box } from "@linagora/twake-mui";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
interface ResourceIconProps {
avatarUrl?: string;
+ colorIcon?: boolean;
+ color?: string;
}
-export function ResourceIcon({ avatarUrl }: ResourceIconProps) {
+export function ResourceIcon({
+ avatarUrl,
+ colorIcon,
+ color,
+}: ResourceIconProps) {
+ if (colorIcon && avatarUrl) {
+ return (
+
+ );
+ }
+
return avatarUrl ? (
!!invite) || []) satisfies UserInCalendar[];
+ ?.filter(
+ (invite) =>
+ !!invite &&
+ !calendar.owner.administrators?.some(
+ (admin) => admin.id === invite.id
+ )
+ ) || []) as UserInCalendar[];
const loaded = await handleLoadUsers(usersInCal, cancelled);
@@ -154,7 +160,13 @@ export function CalendarAccessRights({
return () => {
cancelled = true;
};
- }, [calendar.invite, handleLoadUsers, onChange, onInvitesLoaded]);
+ }, [
+ calendar.invite,
+ calendar.owner.administrators,
+ handleLoadUsers,
+ onChange,
+ onInvitesLoaded,
+ ]);
useEffect(() => {
const isResource = calendar.owner.resource;
diff --git a/src/components/Calendar/CalendarName.tsx b/src/components/Calendar/CalendarName.tsx
index d564065..855fa21 100644
--- a/src/components/Calendar/CalendarName.tsx
+++ b/src/components/Calendar/CalendarName.tsx
@@ -7,6 +7,7 @@ import { Box, Typography } from "@linagora/twake-mui";
import SquareRoundedIcon from "@mui/icons-material/SquareRounded";
import { useI18n } from "twake-i18n";
import { OwnerCaption } from "./OwnerCaption";
+import { ResourceIcon } from "../Attendees/ResourceIcon";
export function CalendarName({ calendar }: { calendar: Calendar }) {
const userData = useAppSelector((state) => state.user.userData);
@@ -15,8 +16,9 @@ export function CalendarName({ calendar }: { calendar: Calendar }) {
const ownerId = calendar.id.split("/")[0];
const ownerDisplayName = makeDisplayName(calendar) ?? "";
const isOwnCalendar = userData.openpaasId === ownerId;
+ const isResource = calendar.owner?.resource;
const showCaption =
- calendar.name !== "#default" && !isOwnCalendar && !calendar.owner?.resource;
+ calendar.name !== "#default" && !isOwnCalendar && !isResource;
return (
-
+ {isResource ? (
+
+ ) : (
+
+ )}
{renameDefault(calendar.name, ownerDisplayName, t, isOwnCalendar)}
diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx
index 0afd2f0..15a9151 100644
--- a/src/components/Calendar/CalendarSelection.tsx
+++ b/src/components/Calendar/CalendarSelection.tsx
@@ -137,7 +137,10 @@ export default function CalendarSelection({
(id) => extractEventBaseUuid(id) === userId
);
const delegatedCalendars = Object.keys(calendars || {}).filter(
- (id) => extractEventBaseUuid(id) !== userId && calendars[id]?.delegated
+ (id) =>
+ extractEventBaseUuid(id) !== userId &&
+ calendars[id]?.delegated &&
+ !calendars?.[id]?.owner?.resource
);
const sharedCalendars = Object.keys(calendars || {}).filter(
(id) =>
@@ -147,9 +150,7 @@ export default function CalendarSelection({
);
const resourceCalendars = Object.keys(calendars || {}).filter(
(id) =>
- extractEventBaseUuid(id) !== userId &&
- !calendars?.[id]?.delegated &&
- calendars?.[id]?.owner?.resource
+ extractEventBaseUuid(id) !== userId && calendars?.[id]?.owner?.resource
);
const handleCalendarToggle = (name: string) => {
diff --git a/src/components/Event/eventHandlers/eventHandlers.ts b/src/components/Event/eventHandlers/eventHandlers.ts
index e4542cf..217cd9d 100644
--- a/src/components/Event/eventHandlers/eventHandlers.ts
+++ b/src/components/Event/eventHandlers/eventHandlers.ts
@@ -15,10 +15,22 @@ import { buildFamilyName } from "@/utils/buildFamilyName";
import { isEventOrganiser } from "@/utils/isEventOrganiser";
function updateEventAttendees(
+ calendar: Calendar,
event: CalendarEvent,
user: userData | undefined,
rsvp: PartStat
) {
+ if (calendar.owner?.resource) {
+ const updatedAttendees =
+ event.attendee?.map((attendeeData) =>
+ attendeeData.cutype === "RESOURCE" && attendeeData.cn === calendar.name
+ ? { ...attendeeData, partstat: rsvp }
+ : attendeeData
+ ) || [];
+
+ return { attendee: updatedAttendees };
+ }
+
if (!user) {
throw new Error("Cannot update attendees without user data");
}
@@ -100,7 +112,7 @@ export async function handleRSVP(
) {
const newEvent = {
...event,
- ...updateEventAttendees(event, user, rsvp),
+ ...updateEventAttendees(calendar, event, user, rsvp),
};
if (typeOfAction === "solo") {
diff --git a/src/features/Calendars/services/helpers.ts b/src/features/Calendars/services/helpers.ts
index 5c32c2c..cf91102 100644
--- a/src/features/Calendars/services/helpers.ts
+++ b/src/features/Calendars/services/helpers.ts
@@ -10,6 +10,11 @@ export const fetchOwnerOfResource = async (
return {
...ownerData,
administrators: data.administrators,
+ // The `icon` from resource detail contains the name only, so we must map with URL to have full URL of icon
+ resourceIcon:
+ window.CALENDAR_BASE_URL && data.icon
+ ? `${window.CALENDAR_BASE_URL}/images/icon/${data.icon}.svg`
+ : undefined,
};
} catch (error) {
console.error(`Failed to fetch resource details for ${resourceId}:`, error);
diff --git a/src/features/Events/AttendanceValidation/AttendanceValidation.tsx b/src/features/Events/AttendanceValidation/AttendanceValidation.tsx
index f2f8789..d01a9ab 100644
--- a/src/features/Events/AttendanceValidation/AttendanceValidation.tsx
+++ b/src/features/Events/AttendanceValidation/AttendanceValidation.tsx
@@ -21,7 +21,7 @@ export function AttendanceValidation({
setAfterChoiceFunc,
setOpenEditModePopup,
}: AttendanceValidationProps) {
- const { currentUserAttendee, isOwn } = contextualizedEvent;
+ const { currentUserAttendee, isOwn, calendar } = contextualizedEvent;
const { t } = useI18n();
const [isLoading, setIsLoading] = useState(false);
const [loadingValue, setLoadingValue] = useState(null);
@@ -38,7 +38,16 @@ export function AttendanceValidation({
(!contextualizedEvent.event.class ||
contextualizedEvent.event.class === "PUBLIC");
- if (!(editRightInSelfCalendar || isDelegatedPublicEvent)) {
+ const { owner: resourceOwner } = calendar;
+ const isAdminOfResource =
+ resourceOwner?.resource &&
+ resourceOwner?.administrators?.some(
+ (admin) => admin.id === user?.openpaasId
+ );
+
+ if (
+ !(editRightInSelfCalendar || isDelegatedPublicEvent || isAdminOfResource)
+ ) {
return null;
}
@@ -60,7 +69,9 @@ export function AttendanceValidation({
return (
<>
- {t("eventPreview.attendingQuestion")}
+ {calendar.owner?.resource
+ ? t("eventPreview.authorizeQuestion")
+ : t("eventPreview.attendingQuestion")}
diff --git a/src/features/Events/EventPreview/EventPreviewDetails.tsx b/src/features/Events/EventPreview/EventPreviewDetails.tsx
index efde77c..7bfe2f8 100644
--- a/src/features/Events/EventPreview/EventPreviewDetails.tsx
+++ b/src/features/Events/EventPreview/EventPreviewDetails.tsx
@@ -13,17 +13,22 @@ import { useI18n } from "twake-i18n";
import { CalendarEvent } from "../EventsTypes";
import { EventPreviewAttendees } from "./EventPreviewAttendees";
import { makeRecurrenceString } from "./utils/makeRecurrenceString";
+import { renderAttendeeBadge } from "@/components/Event/utils/eventUtils";
interface EventPreviewDetailsProps {
event: CalendarEvent;
isOwn: boolean;
isNotPrivate: boolean;
+ isResourceEventPreview?: boolean;
+ calendarName?: string;
}
export function EventPreviewDetails({
event,
isOwn,
isNotPrivate,
+ isResourceEventPreview,
+ calendarName,
}: EventPreviewDetailsProps) {
const { t } = useI18n();
const theme = useTheme();
@@ -31,8 +36,14 @@ export function EventPreviewDetails({
const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor };
const resources = useMemo(
- () => event?.attendee?.filter((attendee) => attendee.cutype === "RESOURCE"),
- [event?.attendee]
+ () =>
+ event?.attendee?.filter(
+ (attendee) =>
+ attendee.cutype === "RESOURCE" &&
+ ((isResourceEventPreview && calendarName !== attendee.cn) ||
+ !isResourceEventPreview)
+ ),
+ [calendarName, event?.attendee, isResourceEventPreview]
);
const eventAttendees = useMemo(
() =>
@@ -106,8 +117,12 @@ export function EventPreviewDetails({
/>
)}
+ {isResourceEventPreview &&
+ organizer &&
+ renderAttendeeBadge(organizer, "org", t, true, true)}
+
{/* Attendees */}
- {attendees.length > 0 && (
+ {!isResourceEventPreview && attendees.length > 0 && (
(
- {t(`eventPreview.${resource.partstat}`)}
+ {t(`eventPreview.attendingStatus.${resource.partstat}`)}
))}
diff --git a/src/features/Events/EventPreview/EventPreviewModal.tsx b/src/features/Events/EventPreview/EventPreviewModal.tsx
index 1b26e11..1834fbf 100644
--- a/src/features/Events/EventPreview/EventPreviewModal.tsx
+++ b/src/features/Events/EventPreview/EventPreviewModal.tsx
@@ -75,6 +75,14 @@ export default function EventPreviewModal({
if (!user || !event || !calendar) return null;
+ const isAdminOfResource = Boolean(
+ calendar.owner?.resource &&
+ user.openpaasId &&
+ calendar.owner?.administrators?.some(
+ (admin) => admin.id === user.openpaasId
+ )
+ );
+
return (
<>
onClose({}, "backdropClick")}
showHeaderActions={false}
actionsBorderTop={
- !!(event.attendee?.find((p) => p.cal_address === user.email) && isOwn)
+ !!(
+ event.attendee?.find((p) => p.cal_address === user.email) && isOwn
+ ) || isAdminOfResource
}
actionsJustifyContent="center"
style={{ overflow: "auto" }}
@@ -163,6 +173,8 @@ export default function EventPreviewModal({
event={event}
isOwn={isOwn}
isNotPrivate={isNotPrivate}
+ isResourceEventPreview={calendar.owner?.resource}
+ calendarName={calendar.name}
/>
{/* Calendar label */}
diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx
index 770311d..1c573a9 100644
--- a/src/features/Events/EventUpdateModal.tsx
+++ b/src/features/Events/EventUpdateModal.tsx
@@ -648,7 +648,10 @@ function EventUpdateModal({
newEvent.attendee.push({
cn: resource?.displayName ?? "",
cal_address: resource?.email ?? "",
- partstat: "NEEDS-ACTION",
+ partstat:
+ event.attendee?.find(
+ (a) => a.cutype === "RESOURCE" && a.cn === resource.displayName
+ )?.partstat || "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "RESOURCE",
diff --git a/src/features/Events/createEventContext.ts b/src/features/Events/createEventContext.ts
index 0cf7c62..6d8e0cd 100644
--- a/src/features/Events/createEventContext.ts
+++ b/src/features/Events/createEventContext.ts
@@ -15,9 +15,11 @@ export function createEventContext(
const attendeeEmail = calendar.delegated
? calendar.owner?.emails?.[0]
: user.email;
- const currentUserAttendee = event.attendee?.find(
- (person) => person.cal_address === attendeeEmail
- );
+ const currentUserAttendee = calendar.owner?.resource
+ ? event.attendee?.find(
+ (person) => person.cutype === "RESOURCE" && person.cn === calendar.name
+ )
+ : event.attendee?.find((person) => person.cal_address === attendeeEmail);
return {
event,
calendar,
diff --git a/src/features/User/type/OpenPaasUserData.ts b/src/features/User/type/OpenPaasUserData.ts
index b422fcd..03e8ae3 100644
--- a/src/features/User/type/OpenPaasUserData.ts
+++ b/src/features/User/type/OpenPaasUserData.ts
@@ -16,6 +16,7 @@ export interface OpenPaasUserData {
id: string;
objectType: string;
}[];
+ resourceIcon?: string;
}
export function ToUserData(
diff --git a/src/locales/en.json b/src/locales/en.json
index 9a5589c..3740ada 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -281,10 +281,17 @@
"tooltip": "Others see you as available during the time range of this event."
},
"attendingQuestion": "Attending?",
+ "authorizeQuestion": "Authorize?",
"ACCEPTED": "Accept",
"TENTATIVE": "Maybe",
"DECLINED": "Decline",
"NEEDS-ACTION": "Pending",
+ "attendingStatus": {
+ "ACCEPTED": "Accepted",
+ "TENTATIVE": "Maybe",
+ "DECLINED": "Declined",
+ "NEEDS-ACTION": "Pending"
+ },
"showMore": "Show more",
"showLess": "Show less",
"joinVideo": "Join the video conference",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 21c5a13..26603e9 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -282,10 +282,17 @@
"tooltip": "Les autres vous voient comme disponible pendant la plage horaire de cet événement."
},
"attendingQuestion": "Participer ?",
+ "authorizeQuestion": "Autoriser ?",
"ACCEPTED": "Accepter",
"TENTATIVE": "Peut-être",
"DECLINED": "Décliner",
"NEEDS-ACTION": "En attente",
+ "attendingStatus": {
+ "ACCEPTED": "Accepté",
+ "TENTATIVE": "Peut-être",
+ "DECLINED": "Refusé",
+ "NEEDS-ACTION": "En attente"
+ },
"showMore": "Afficher plus",
"showLess": "Afficher moins",
"joinVideo": "Rejoindre la visioconférence",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index 885834e..5308464 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -282,10 +282,17 @@
"tooltip": "Другие видят вас свободным."
},
"attendingQuestion": "Присоединитесь?",
+ "authorizeQuestion": "Авторизовать?",
"ACCEPTED": "Да",
"TENTATIVE": "Возможно",
"DECLINED": "Нет",
"NEEDS-ACTION": "В ожидании",
+ "attendingStatus": {
+ "ACCEPTED": "Принято",
+ "TENTATIVE": "Возможно",
+ "DECLINED": "Отклонено",
+ "NEEDS-ACTION": "В ожидании"
+ },
"showMore": "Показать больше",
"showLess": "Показать меньше",
"joinVideo": "Присоединиться к видеоконференции",
diff --git a/src/locales/vi.json b/src/locales/vi.json
index 359099d..d723468 100644
--- a/src/locales/vi.json
+++ b/src/locales/vi.json
@@ -280,10 +280,17 @@
"tooltip": "Người khác sẽ thấy bạn rảnh trong khoảng thời gian này."
},
"attendingQuestion": "Tham gia?",
+ "authorizeQuestion": "Cho phép?",
"ACCEPTED": "Chấp nhận",
"TENTATIVE": "Có thể",
"DECLINED": "Từ chối",
"NEEDS-ACTION": "Đang chờ",
+ "attendingStatus": {
+ "ACCEPTED": "Đã chấp nhận",
+ "TENTATIVE": "Có thể",
+ "DECLINED": "Đã từ chối",
+ "NEEDS-ACTION": "Đang chờ"
+ },
"showMore": "Xem thêm",
"showLess": "Thu gọn",
"joinVideo": "Tham gia cuộc họp video",