#602 adminstrator can manage calendar resources (#703)

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-03-25 14:28:24 +07:00
committed by GitHub
parent fdc9d7651b
commit 43424607d4
21 changed files with 237 additions and 36 deletions
@@ -25,6 +25,7 @@ const preloadedState = {
id: "667037022b752d0026472254/cal1",
name: "Calendar",
color: "#FF0000",
owner: { emails: ["test@test.com"] },
events: {
event1: {
uid: "event1",
@@ -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 () => {
@@ -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(
<AttendanceValidation
contextualizedEvent={resourceContext}
user={
{
...makeUser("admin@example.com"),
openpaasId: "admin1",
} as userData
}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
expect(getByText("eventPreview.authorizeQuestion")).toBeTruthy();
});
it("returns null when user is not an administrator of the resource", () => {
const { container } = render(
<AttendanceValidation
contextualizedEvent={resourceContext}
user={
{
...makeUser("other@example.com"),
openpaasId: "other1",
} as userData
}
setAfterChoiceFunc={noopSetFunc}
setOpenEditModePopup={noopSetFunc}
/>
);
expect(container.firstChild).toBeNull();
});
});
});
@@ -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",
@@ -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,
+22 -2
View File
@@ -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 (
<Box
sx={{
width: "24px",
height: "24px",
backgroundColor: color,
maskImage: `url(${avatarUrl})`,
maskSize: "cover",
}}
/>
);
}
return avatarUrl ? (
<Avatar
sx={{ backgroundColor: "transparent", width: "24px", height: "24px" }}
@@ -133,7 +133,13 @@ export function CalendarAccessRights({
access: invite.access,
};
})
?.filter((invite) => !!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;
+18 -8
View File
@@ -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 (
<Box
@@ -27,13 +29,21 @@ export function CalendarName({ calendar }: { calendar: Calendar }) {
alignItems: "center",
}}
>
<SquareRoundedIcon
style={{
color: calendar.color?.light ?? defaultColors[0].light,
width: 24,
height: 24,
}}
/>
{isResource ? (
<ResourceIcon
colorIcon
avatarUrl={calendar.owner.resourceIcon}
color={calendar.color?.light ?? defaultColors[0].light}
/>
) : (
<SquareRoundedIcon
style={{
color: calendar.color?.light ?? defaultColors[0].light,
width: 24,
height: 24,
}}
/>
)}
<Box style={{ display: "flex", flexDirection: "column" }}>
<Typography variant="body2" sx={{ wordBreak: "break-word" }}>
{renameDefault(calendar.name, ownerDisplayName, t, isOwnCalendar)}
@@ -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) => {
@@ -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") {
@@ -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);
@@ -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<PartStat | null>(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 (
<>
<Typography sx={{ marginRight: 2 }}>
{t("eventPreview.attendingQuestion")}
{calendar.owner?.resource
? t("eventPreview.authorizeQuestion")
: t("eventPreview.attendingQuestion")}
</Typography>
<Box display="flex" gap="15px" alignItems="center">
<RSVPButton rsvpValue="ACCEPTED" {...commonButtonProps} />
@@ -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 && (
<EventPreviewAttendees
attendees={attendees}
organizer={organizer}
@@ -144,6 +159,7 @@ export function EventPreviewDetails({
}
content={resources.map((resource, index) => (
<Box
key={resource.cn}
sx={{
marginRight: "5px",
}}
@@ -172,7 +188,7 @@ export function EventPreviewDetails({
color: "#717D96",
}}
>
{t(`eventPreview.${resource.partstat}`)}
{t(`eventPreview.attendingStatus.${resource.partstat}`)}
</Typography>
</Box>
))}
@@ -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 (
<>
<ResponsiveDialog
@@ -82,7 +90,9 @@ export default function EventPreviewModal({
onClose={() => 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 */}
+4 -1
View File
@@ -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",
+5 -3
View File
@@ -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,
@@ -16,6 +16,7 @@ export interface OpenPaasUserData {
id: string;
objectType: string;
}[];
resourceIcon?: string;
}
export function ToUserData(
+7
View File
@@ -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",
+7
View File
@@ -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",
+7
View File
@@ -282,10 +282,17 @@
"tooltip": "Другие видят вас свободным."
},
"attendingQuestion": "Присоединитесь?",
"authorizeQuestion": "Авторизовать?",
"ACCEPTED": "Да",
"TENTATIVE": "Возможно",
"DECLINED": "Нет",
"NEEDS-ACTION": "В ожидании",
"attendingStatus": {
"ACCEPTED": "Принято",
"TENTATIVE": "Возможно",
"DECLINED": "Отклонено",
"NEEDS-ACTION": "В ожидании"
},
"showMore": "Показать больше",
"showLess": "Показать меньше",
"joinVideo": "Присоединиться к видеоконференции",
+7
View File
@@ -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",