#669 display resource admin (#677)

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-03-20 18:01:04 +07:00
committed by GitHub
parent 2ba92ed194
commit 5f26bef4dc
7 changed files with 252 additions and 52 deletions
@@ -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(
<CalendarAccessRights
calendar={baseCalendar}
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>,
{ ...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(
<CalendarAccessRights
calendar={resourceCalendar}
value={[]}
onChange={mockOnChange}
onInvitesLoaded={mockOnInvitesLoaded}
/>,
{ ...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 = {
@@ -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<typeof addCalendarResourceAsync>[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);
@@ -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<number | undefined>(undefined);
const [accessRight, setAccessRight] = useState<AccessRight>(2);
const [inviteLoading, setInvitesLoading] = useState(false);
const [resourceAdmins, setResourceAdmins] = useState<UserWithAccess[]>([]);
const [adminLoading, setAdminLoading] = useState(false);
const currentUsersRef = useRef<UserWithAccess[]>(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<UserWithAccess>).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({
</Typography>
</Box>
</Box>
{adminLoading ? (
<Box mt={2} display="flex" justifyContent="center">
<CircularProgress size={24} />
</Box>
) : (
resourceAdmins.map((admin) => (
<ResourceAdmin key={admin.email} admin={admin} />
))
)}
{inviteLoading ? (
<Box mt={2} display="flex" justifyContent="center">
<CircularProgress size={24} />
@@ -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 (
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
px={1}
py={0.5}
sx={{
borderRadius: "8px",
"&:hover": { backgroundColor: "action.hover" },
}}
>
<Box display="flex" alignItems="center" gap={1.5} minWidth={0}>
<Avatar
{...stringAvatar(admin.displayName)}
sx={{ width: 28, height: 28, fontSize: "0.875rem" }}
/>
<Box minWidth={0} display="flex" flexDirection="column" gap={0}>
<Typography noWrap>{admin.displayName}</Typography>
<Typography variant="caption" color="text.secondary">
{admin.email}
</Typography>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={0.5} flexShrink={0}>
<Typography variant="caption">
{t("calendarPopover.access.administrator")}
</Typography>
</Box>
</Box>
);
}
@@ -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);
}
}
+8 -5
View File
@@ -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<OpenPaasUserData> => {
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;
}
};
@@ -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(