Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user