diff --git a/__test__/features/Calendars/CalendarAccessRights.test.tsx b/__test__/features/Calendars/CalendarAccessRights.test.tsx index 6e86a45..40d43da 100644 --- a/__test__/features/Calendars/CalendarAccessRights.test.tsx +++ b/__test__/features/Calendars/CalendarAccessRights.test.tsx @@ -87,7 +87,7 @@ const baseCalendar: Calendar = { events: {}, invite: [], owner: { emails: ["user1@example.com"] }, - access: { write: true }, + access: { write: true } as any, delegated: true, }; @@ -261,6 +261,82 @@ describe("CalendarAccessRights", () => { expect(document.querySelector('[role="progressbar"]')).toBeInTheDocument(); }); + + it("displays the calendar owner information", () => { + renderWithProviders( + , + { ...userState } + ); + + expect(screen.getAllByText("user1@example.com").length).toBeGreaterThan(0); + expect( + screen.getByText("calendarPopover.access.owner") + ).toBeInTheDocument(); + }); + + it("fetches and displays resource administrators for resource calendars", async () => { + const resourceCalendar: any = { + ...baseCalendar, + owner: { + _id: "resource1", + resource: true, + emails: ["resource1@example.com"], + administrators: [ + { id: "admin1" }, + { id: "admin2" }, + { id: "resource1" }, // Owner shouldn't be loaded as an admin + ], + }, + }; + + (getUserDetails as jest.Mock).mockImplementation((id: string) => { + if (id === "admin1") { + return Promise.resolve({ + preferredEmail: "admin1@example.com", + firstname: "Admin", + lastname: "One", + }); + } + if (id === "admin2") { + return Promise.resolve({ + preferredEmail: "admin2@example.com", + firstname: "Admin", + lastname: "Two", + }); + } + return Promise.resolve(null); + }); + + renderWithProviders( + , + { ...userState } + ); + + await waitFor(() => { + expect(getUserDetails).toHaveBeenCalledWith("admin1"); + expect(getUserDetails).toHaveBeenCalledWith("admin2"); + }); + + expect(getUserDetails).not.toHaveBeenCalledWith("resource1"); + + expect(await screen.findByText("Admin One")).toBeInTheDocument(); + expect(screen.getByText("admin1@example.com")).toBeInTheDocument(); + expect(screen.getByText("Admin Two")).toBeInTheDocument(); + expect(screen.getByText("admin2@example.com")).toBeInTheDocument(); + expect( + screen.getAllByText("calendarPopover.access.administrator") + ).toHaveLength(2); + }); }); const userState = { diff --git a/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx index 5ec1800..4104870 100644 --- a/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx +++ b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx @@ -1,16 +1,15 @@ import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync"; import { addSharedCalendar } from "@/features/Calendars/CalendarApi"; -import { getResourceDetails, getUserDetails } from "@/features/User/userAPI"; +import { fetchOwnerOfResource } from "@/features/Calendars/services/helpers"; import { toRejectedError } from "@/utils/errorUtils"; import { configureStore } from "@reduxjs/toolkit"; jest.mock("@/features/Calendars/CalendarApi"); -jest.mock("@/features/User/userAPI"); +jest.mock("@/features/Calendars/services/helpers"); jest.mock("@/utils/errorUtils"); const mockedAddSharedCalendar = addSharedCalendar as jest.Mock; -const mockedGetResourceDetails = getResourceDetails as jest.Mock; -const mockedGetUserDetails = getUserDetails as jest.Mock; +const mockedFetchOwnerOfResource = fetchOwnerOfResource as jest.Mock; const mockedToRejectedError = toRejectedError as jest.Mock; describe("addCalendarResourceAsync thunk", () => { @@ -55,8 +54,7 @@ describe("addCalendarResourceAsync thunk", () => { }; it("should add shared calendar, fetch resource details, map userData", async () => { - mockedGetResourceDetails.mockResolvedValueOnce(mockResolvedResourceData); - mockedGetUserDetails.mockResolvedValueOnce({ + mockedFetchOwnerOfResource.mockResolvedValueOnce({ firstname: "Creator", lastname: "User", emails: ["creator@example.com"], @@ -72,8 +70,7 @@ describe("addCalendarResourceAsync thunk", () => { mockPayload.calId, mockPayload.cal ); - expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456"); - expect(mockedGetUserDetails).toHaveBeenCalledWith("user-789"); + expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith("res-456"); expect(result.type).toBe("calendars/addCalendarResource/fulfilled"); expect(result.payload).toEqual({ @@ -94,9 +91,12 @@ describe("addCalendarResourceAsync thunk", () => { it("should fallback to name if resource details fetch fails", async () => { mockedAddSharedCalendar.mockResolvedValueOnce({}); const errorDetails = new Error("Fetch failed"); - mockedGetResourceDetails.mockRejectedValueOnce(errorDetails); - const mockRejectedErrorResult = { message: "Fetch failed" }; - mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult); + mockedFetchOwnerOfResource.mockRejectedValueOnce(errorDetails); + + // Silence expected console error in tests + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); const result = await addCalendarResourceAsync( mockPayload as unknown as Parameters[0] @@ -107,9 +107,9 @@ describe("addCalendarResourceAsync thunk", () => { mockPayload.calId, mockPayload.cal ); - expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456"); + expect(mockedFetchOwnerOfResource).toHaveBeenCalledWith("res-456"); - expect(mockedToRejectedError).toHaveBeenCalledWith(errorDetails); + consoleSpy.mockRestore(); expect(result.type).toBe("calendars/addCalendarResource/fulfilled"); expect(result.payload).toEqual({ @@ -142,7 +142,7 @@ describe("addCalendarResourceAsync thunk", () => { mockPayload.calId, mockPayload.cal ); - expect(mockedGetResourceDetails).not.toHaveBeenCalled(); + expect(mockedFetchOwnerOfResource).not.toHaveBeenCalled(); expect(mockedToRejectedError).toHaveBeenCalledWith(errorAdd); diff --git a/src/components/Calendar/CalendarAccessRights.tsx b/src/components/Calendar/CalendarAccessRights.tsx index d37fbe2..d68b963 100644 --- a/src/components/Calendar/CalendarAccessRights.tsx +++ b/src/components/Calendar/CalendarAccessRights.tsx @@ -17,11 +17,12 @@ import { } from "@linagora/twake-mui"; import HighlightOffIcon from "@mui/icons-material/HighlightOff"; import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useI18n } from "twake-i18n"; import { PeopleSearch, User } from "../Attendees/PeopleSearch"; import { FieldWithLabel } from "../Event/components/FieldWithLabel"; import { stringAvatar } from "../Event/utils/eventUtils"; +import { ResourceAdmin } from "./ResourceAdmins"; export interface UserWithAccess extends User { accessRight: AccessRight; @@ -34,6 +35,11 @@ interface CalendarAccessRightsProps { onInvitesLoaded: (users: UserWithAccess[]) => void; } +interface UserInCalendar { + id: string; + access: AccessRight; +} + export function CalendarAccessRights({ calendar, value: usersWithAccess, @@ -57,6 +63,8 @@ export function CalendarAccessRights({ const [searchWidth, setSearchWidth] = useState(undefined); const [accessRight, setAccessRight] = useState(2); const [inviteLoading, setInvitesLoading] = useState(false); + const [resourceAdmins, setResourceAdmins] = useState([]); + const [adminLoading, setAdminLoading] = useState(false); const currentUsersRef = useRef(usersWithAccess); useEffect(() => { @@ -74,6 +82,39 @@ export function CalendarAccessRights({ return () => observer.disconnect(); }, []); + const handleLoadUsers = useCallback( + async (usersInCal: UserInCalendar[], cancelled: boolean) => { + const results = await Promise.allSettled( + usersInCal.map(async (user) => { + const details = await getUserDetails(user.id); + const email = details?.preferredEmail ?? details?.emails?.[0] ?? ""; + return { + openpaasId: user.id, + displayName: + [details?.firstname, details?.lastname] + .filter(Boolean) + .join(" ") + .trim() || email, + email, + accessRight: user.access as AccessRight, + } satisfies UserWithAccess; + }) + ); + + if (cancelled) { + return [] as UserWithAccess[]; + } + + const loaded: UserWithAccess[] = results + .filter((r) => r.status === "fulfilled") + .map((r) => (r as PromiseFulfilledResult).value) + .filter(Boolean) as UserWithAccess[]; + + return loaded; + }, + [] + ); + useEffect(() => { if (!calendar.invite?.length) return; @@ -82,33 +123,19 @@ export function CalendarAccessRights({ async function loadInvitedUsers() { setInvitesLoading(true); try { - const loaded: UserWithAccess[] = ( - await Promise.all( - calendar.invite.map(async (invite) => { - const principalId = invite.principal.split("/").pop(); - if (!principalId) return null; - try { - const details = await getUserDetails(principalId); - const email = - details?.preferredEmail ?? details?.emails?.[0] ?? ""; - return { - openpaasId: principalId, - displayName: - [details?.firstname, details?.lastname] - .filter(Boolean) - .join(" ") - .trim() || email, - email, - accessRight: invite.access as AccessRight, - } satisfies UserWithAccess; - } catch { - return null; - } - }) - ) - ).filter((u) => u !== null && !!u.email); + const usersInCal = (calendar.invite + ?.map((invite) => { + const principalId = invite.principal.split("/").pop(); + if (!principalId) return null; - if (cancelled) return; + return { + id: principalId, + access: invite.access, + }; + }) + ?.filter((invite) => !!invite) || []) satisfies UserInCalendar[]; + + const loaded = await handleLoadUsers(usersInCal, cancelled); const loadedIds = new Set(loaded.map((u) => normalizeEmail(u.email))); const manuallyAdded = currentUsersRef.current.filter( @@ -127,7 +154,42 @@ export function CalendarAccessRights({ return () => { cancelled = true; }; - }, [calendar.invite, onChange, onInvitesLoaded]); + }, [calendar.invite, handleLoadUsers, onChange, onInvitesLoaded]); + + useEffect(() => { + const isResource = calendar.owner.resource; + const resourceAdmins = calendar.owner.administrators || []; + if (!isResource || !resourceAdmins?.length) return; + + let cancelled = false; + + async function loadAdmins() { + try { + setAdminLoading(true); + const resourceAdminsWithoutOwner = resourceAdmins + .filter((admin) => admin.id !== calendar.owner._id) + .map((admin) => ({ + id: admin.id, + access: 5, // ADMIN + })) satisfies UserInCalendar[]; + const admins = await handleLoadUsers( + resourceAdminsWithoutOwner, + cancelled + ); + + setResourceAdmins(admins); + } catch (error) { + console.error(error); + } finally { + if (!cancelled) setAdminLoading(false); + } + } + + loadAdmins(); + return () => { + cancelled = true; + }; + }, [calendar.owner, handleLoadUsers, setResourceAdmins]); const handleUserSelect = (_event: unknown, users: User[]) => { const updated: UserWithAccess[] = users.map((user) => { @@ -309,6 +371,15 @@ export function CalendarAccessRights({ + {adminLoading ? ( + + + + ) : ( + resourceAdmins.map((admin) => ( + + )) + )} {inviteLoading ? ( diff --git a/src/components/Calendar/ResourceAdmins.tsx b/src/components/Calendar/ResourceAdmins.tsx new file mode 100644 index 0000000..3fb35cf --- /dev/null +++ b/src/components/Calendar/ResourceAdmins.tsx @@ -0,0 +1,45 @@ +import { Avatar, Box, Typography } from "@linagora/twake-mui"; +import { useI18n } from "twake-i18n"; +import { stringAvatar } from "../Event/utils/eventUtils"; +import { UserWithAccess } from "./CalendarAccessRights"; + +interface ResourceAdminProps { + admin: UserWithAccess; +} + +export function ResourceAdmin({ admin }: ResourceAdminProps) { + const { t } = useI18n(); + + return ( + + + + + {admin.displayName} + + {admin.email} + + + + + + + {t("calendarPopover.access.administrator")} + + + + ); +} diff --git a/src/features/Calendars/api/addCalendarResourceAsync.ts b/src/features/Calendars/api/addCalendarResourceAsync.ts index 910fa34..0144bbd 100644 --- a/src/features/Calendars/api/addCalendarResourceAsync.ts +++ b/src/features/Calendars/api/addCalendarResourceAsync.ts @@ -1,11 +1,11 @@ import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; -import { getResourceDetails, getUserDetails } from "@/features/User/userAPI"; import { toRejectedError } from "@/utils/errorUtils"; import { createAsyncThunk } from "@reduxjs/toolkit"; import { addSharedCalendar } from "../CalendarApi"; import { CalendarInput } from "../types/CalendarData"; import { RejectedError } from "../types/RejectedError"; import { User } from "@/components/Attendees/PeopleSearch"; +import { fetchOwnerOfResource } from "../services/helpers"; export const addCalendarResourceAsync = createAsyncThunk< { @@ -43,13 +43,12 @@ export const addCalendarResourceAsync = createAsyncThunk< if (resourceId) { try { - const resource = await getResourceDetails(resourceId); owner = { - ...(await getUserDetails(resource.creator)), + ...(await fetchOwnerOfResource(resourceId)), resource: true, }; } catch (e) { - toRejectedError(e); + console.error("Error while fetching owner of resource: ", e); } } diff --git a/src/features/Calendars/services/helpers.ts b/src/features/Calendars/services/helpers.ts index e7b21fd..5c32c2c 100644 --- a/src/features/Calendars/services/helpers.ts +++ b/src/features/Calendars/services/helpers.ts @@ -1,15 +1,18 @@ import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; import { getResourceDetails, getUserDetails } from "@/features/User/userAPI"; -const fetchOwnerOfResource = async ( - ownerId: string +export const fetchOwnerOfResource = async ( + resourceId: string ): Promise => { try { - const data = await getResourceDetails(ownerId); + const data = await getResourceDetails(resourceId); const ownerData = await getUserDetails(data.creator); - return ownerData; + return { + ...ownerData, + administrators: data.administrators, + }; } catch (error) { - console.error(`Failed to fetch resource details for ${ownerId}:`, error); + console.error(`Failed to fetch resource details for ${resourceId}:`, error); throw error; } }; diff --git a/src/features/User/type/OpenPaasUserData.ts b/src/features/User/type/OpenPaasUserData.ts index bfd9908..b422fcd 100644 --- a/src/features/User/type/OpenPaasUserData.ts +++ b/src/features/User/type/OpenPaasUserData.ts @@ -4,12 +4,18 @@ export interface OpenPaasUserData { firstname?: string; lastname?: string; id?: string; + _id?: string; preferredEmail?: string; configurations?: { modules?: ModuleConfiguration[]; }; emails: string[]; resource?: boolean; + administrators?: { + _id: string; + id: string; + objectType: string; + }[]; } export function ToUserData(