From 43424607d4b99a557380c0ac58dade19ff49e5b8 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Wed, 25 Mar 2026 14:28:24 +0700 Subject: [PATCH] #602 adminstrator can manage calendar resources (#703) Co-authored-by: lethemanh --- __test__/components/DuplicateEvent.test.tsx | 1 + .../Calendars/services/helpers.test.tsx | 7 ++- .../AttendanceValidation.delegated.test.tsx | 51 +++++++++++++++++++ .../features/Events/EventDisplay.test.tsx | 3 +- .../features/Events/EventUpdateModal.test.tsx | 20 ++++++-- src/components/Attendees/ResourceIcon.tsx | 24 ++++++++- .../Calendar/CalendarAccessRights.tsx | 16 +++++- src/components/Calendar/CalendarName.tsx | 26 +++++++--- src/components/Calendar/CalendarSelection.tsx | 9 ++-- .../Event/eventHandlers/eventHandlers.ts | 14 ++++- src/features/Calendars/services/helpers.ts | 5 ++ .../AttendanceValidation.tsx | 17 +++++-- .../EventPreview/EventPreviewDetails.tsx | 24 +++++++-- .../Events/EventPreview/EventPreviewModal.tsx | 14 ++++- src/features/Events/EventUpdateModal.tsx | 5 +- src/features/Events/createEventContext.ts | 8 +-- src/features/User/type/OpenPaasUserData.ts | 1 + src/locales/en.json | 7 +++ src/locales/fr.json | 7 +++ src/locales/ru.json | 7 +++ src/locales/vi.json | 7 +++ 21 files changed, 237 insertions(+), 36 deletions(-) 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",