#598 resource field in event (#634)

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-03-17 20:11:12 +07:00
committed by GitHub
parent e4fb209147
commit 387e64d58c
21 changed files with 503 additions and 91 deletions
@@ -1,6 +1,6 @@
import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync";
import { addSharedCalendar } from "@/features/Calendars/CalendarApi";
import { getResourceDetails } from "@/features/User/userAPI";
import { getResourceDetails, getUserDetails } from "@/features/User/userAPI";
import { toRejectedError } from "@/utils/errorUtils";
import { configureStore } from "@reduxjs/toolkit";
@@ -10,6 +10,7 @@ jest.mock("@/utils/errorUtils");
const mockedAddSharedCalendar = addSharedCalendar as jest.Mock;
const mockedGetResourceDetails = getResourceDetails as jest.Mock;
const mockedGetUserDetails = getUserDetails as jest.Mock;
const mockedToRejectedError = toRejectedError as jest.Mock;
describe("addCalendarResourceAsync thunk", () => {
@@ -55,6 +56,11 @@ describe("addCalendarResourceAsync thunk", () => {
it("should add shared calendar, fetch resource details, map userData", async () => {
mockedGetResourceDetails.mockResolvedValueOnce(mockResolvedResourceData);
mockedGetUserDetails.mockResolvedValueOnce({
firstname: "Creator",
lastname: "User",
emails: ["creator@example.com"],
});
mockedAddSharedCalendar.mockResolvedValueOnce({});
const result = await addCalendarResourceAsync(
@@ -67,6 +73,7 @@ describe("addCalendarResourceAsync thunk", () => {
mockPayload.cal
);
expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456");
expect(mockedGetUserDetails).toHaveBeenCalledWith("user-789");
expect(result.type).toBe("calendars/addCalendarResource/fulfilled");
expect(result.payload).toEqual({
@@ -75,7 +82,12 @@ describe("addCalendarResourceAsync thunk", () => {
desc: "A meeting room",
link: "/calendars/user-123/cal-123.json",
name: "Resource Room A",
owner: mockResolvedResourceData,
owner: {
firstname: "Creator",
lastname: "User",
emails: ["creator@example.com"],
resource: true,
},
});
});
@@ -5,7 +5,7 @@ import {
getUserDetails,
} from "@/features/User/userAPI";
import { getCalendars } from "@/features/Calendars/CalendarApi";
import { formatReduxError, toRejectedError } from "@/utils/errorUtils";
import { formatReduxError } from "@/utils/errorUtils";
import { normalizeCalendar } from "@/features/Calendars/utils/normalizeCalendar";
jest.mock("@/features/User/userAPI");
@@ -176,16 +176,20 @@ describe("getCalendarsListAsync", () => {
ownerId: "resource-123",
});
// getUserDetails fails with 404 for the resource ID
mockedGetUserDetails.mockRejectedValueOnce({ response: { status: 404 } });
// getUserDetails fails with 404 for the resource ID, succeeds for the creator
mockedGetUserDetails.mockImplementation((id: string) => {
if (id === "resource-123")
return Promise.reject({ response: { status: 404 } });
if (id === "creator-456")
return Promise.resolve({
firstname: "Creator",
lastname: "User",
emails: [],
});
return Promise.resolve({ firstname: "", lastname: "", emails: [] });
});
// Then getResourceDetails is called and succeeds
mockedGetResourceDetails.mockResolvedValueOnce({ creator: "creator-456" });
// Then getUserDetails is called for the creator and succeeds
mockedGetUserDetails.mockResolvedValueOnce({
firstname: "Creator",
lastname: "User",
emails: [],
});
const thunk = getCalendarsListAsync();
const result = await thunk(dispatch, getState, undefined);
@@ -65,6 +65,14 @@ describe("Event Preview Display", () => {
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
{
cn: "Projector Room",
cal_address: "room1@test.com",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "RESOURCE",
},
],
},
event2: {
@@ -163,6 +171,8 @@ describe("Event Preview Display", () => {
expect(screen.getByText(/ 10:00/)).toBeInTheDocument();
expect(screen.getByText("Calendar")).toBeInTheDocument();
expect(screen.getByText("Projector Room")).toBeInTheDocument();
});
it("calls onClose when Cancel clicked", () => {
renderWithProviders(
+137 -55
View File
@@ -81,6 +81,22 @@ describe("EventPopover", () => {
},
],
},
{
id: "room1@example.com",
objectType: "resource",
emailAddresses: [
{
value: "room1@example.com",
type: "default",
},
],
names: [
{
displayName: "Room 1",
type: "default",
},
],
},
];
(api.post as jest.Mock).mockReturnValue({
json: jest.fn().mockResolvedValue(mockUsers),
@@ -211,71 +227,137 @@ describe("EventPopover", () => {
});
it("adds a attendee", async () => {
jest.useFakeTimers();
jest
.spyOn(calendarsApi, "getCalendars")
.mockReturnValue({ json: jest.fn() });
renderPopover();
fireEvent.change(screen.getByLabelText("event.form.title"), {
target: { value: "newEvent" },
});
const select = screen.getByLabelText("peopleSearch.label");
try {
jest
.spyOn(calendarsApi, "getCalendars")
.mockReturnValue({ json: jest.fn() });
renderPopover();
fireEvent.change(screen.getByLabelText("event.form.title"), {
target: { value: "newEvent" },
});
const select = screen.getByLabelText("peopleSearch.label");
act(() => {
select.focus();
fireEvent.mouseDown(select);
userEvent.type(select, "john");
});
await act(async () => {
jest.advanceTimersByTime(400);
});
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1));
act(() => {
select.focus();
fireEvent.mouseDown(select);
userEvent.type(select, "john");
});
await act(async () => {
jest.advanceTimersByTime(400);
});
await waitFor(() => expect(api.post).toHaveBeenCalledTimes(1));
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
await act(async () => {
userEvent.click(screen.getByText("John Doe"));
});
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
await waitFor(() => {
expect(screen.getByText("John Doe")).toBeInTheDocument();
});
await act(async () => {
userEvent.click(screen.getByText("John Doe"));
});
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
const receivedPayload = spy.mock.calls[0][0];
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
expect(receivedPayload.cal).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
);
const receivedPayload = spy.mock.calls[0][0];
expect(receivedPayload.newEvent.attendee).toHaveLength(2);
expect(receivedPayload.newEvent.attendee).toStrictEqual([
{
cn: "test",
cal_address: "test@test.com",
partstat: "ACCEPTED",
rsvp: "FALSE",
role: "CHAIR",
cutype: "INDIVIDUAL",
},
{
cn: "John Doe",
cal_address: "john@example.com",
expect(receivedPayload.cal).toEqual(
preloadedState.calendars.list["667037022b752d0026472254/cal1"]
);
expect(receivedPayload.newEvent.attendee).toHaveLength(2);
expect(receivedPayload.newEvent.attendee).toStrictEqual([
{
cn: "test",
cal_address: "test@test.com",
partstat: "ACCEPTED",
rsvp: "FALSE",
role: "CHAIR",
cutype: "INDIVIDUAL",
},
{
cn: "John Doe",
cal_address: "john@example.com",
partstat: "NEEDS-ACTION",
rsvp: "FALSE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
]);
} finally {
jest.useRealTimers();
}
});
it("adds a resource", async () => {
jest.useFakeTimers();
try {
renderPopover();
fireEvent.change(screen.getByLabelText("event.form.title"), {
target: { value: "newEventWithResource" },
});
fireEvent.click(
screen.getByRole("button", { name: "common.moreOptions" })
);
const resourceCombobox = screen.getByPlaceholderText(
"resourceSearch.placeholder"
);
act(() => {
resourceCombobox.focus();
fireEvent.mouseDown(resourceCombobox);
});
await userEvent.type(resourceCombobox, "room");
await act(async () => {
jest.advanceTimersByTime(400);
});
await waitFor(() => {
expect(screen.getByText("Room 1")).toBeInTheDocument();
});
await userEvent.click(screen.getByText("Room 1"));
const spy = jest
.spyOn(eventThunks, "putEventAsync")
.mockImplementation((payload) => {
const promise = Promise.resolve(payload);
(promise as any).unwrap = () => promise;
return () => promise as any;
});
fireEvent.click(screen.getByRole("button", { name: "actions.save" }));
await waitFor(() => {
expect(spy).toHaveBeenCalled();
});
const receivedPayload = spy.mock.calls[0][0];
expect(receivedPayload.newEvent.attendee).toHaveLength(2); // Organizer + 1 resource
expect(receivedPayload.newEvent.attendee[1]).toStrictEqual({
cn: "Room 1",
cal_address: "room1@example.com",
partstat: "NEEDS-ACTION",
rsvp: "FALSE",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "INDIVIDUAL",
},
]);
cutype: "RESOURCE",
});
} finally {
jest.useRealTimers();
}
});
it("dispatches putEventAsync and calls onClose when Save is clicked", async () => {
@@ -157,6 +157,89 @@ describe("EventUpdateModal Timezone Handling", () => {
// Verify the timezone is still preserved (should be Asia/Bangkok)
expect(titleInput).toHaveValue("Updated Event");
});
it("preserves resources when editing an event", async () => {
const eventDateUTC = new Date("2025-01-15T07:00:00.000Z");
const eventData = {
uid: "test-event-resource",
title: "Resource Event",
calId: "667037022b752d0026472254/cal1",
start: eventDateUTC.toISOString(),
end: new Date(eventDateUTC.getTime() + 3600000).toISOString(),
timezone: "Asia/Bangkok",
allday: false,
organizer: { cn: "test", cal_address: "test@test.com" },
attendee: [
{ cn: "test", cal_address: "test@test.com" },
{
cn: "Conference Room",
cal_address: "room@test.com",
partstat: "ACCEPTED",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "RESOURCE",
},
],
};
const stateWithEvent = {
...preloadedState,
calendars: {
...preloadedState.calendars,
list: {
"667037022b752d0026472254/cal1": {
...preloadedState.calendars.list["667037022b752d0026472254/cal1"],
events: {
"test-event-resource": eventData,
},
},
},
},
};
const mockPutEvent = jest.spyOn(EventApi, "putEvent").mockResolvedValue({
status: 201,
url: `/calendars/667037022b752d0026472254/cal1/test-event-resource.ics`,
} as any);
renderWithProviders(
<EventUpdateModal
open={true}
onClose={mockOnClose}
calId={"667037022b752d0026472254/cal1"}
eventId={"test-event-resource"}
eventData={eventData}
/>,
stateWithEvent
);
// Edit the title
const titleInput = screen.getByDisplayValue("Resource Event");
fireEvent.change(titleInput, {
target: { value: "Updated Resource Event" },
});
// Click Save
const saveButton = screen.getByRole("button", { name: "actions.save" });
await act(async () => {
fireEvent.click(saveButton);
});
await waitFor(() => {
expect(mockPutEvent).toHaveBeenCalled();
});
const putEventCall = mockPutEvent.mock.calls[0][0];
expect(putEventCall.title).toBe("Updated Resource Event");
// Check that the resource is still in the attendee list!
const attendees = putEventCall.attendee;
const resource = attendees.find((a: any) => a.cutype === "RESOURCE");
expect(resource).toBeDefined();
expect(resource!.cn).toBe("Conference Room");
expect(resource!.cal_address).toBe("room@test.com");
});
});
describe("EventUpdateModal Recurring to Non-Recurring Conversion", () => {
@@ -291,7 +291,10 @@ export default function CalendarResources({
const successfulCals = results
.filter((result) => result.status === "fulfilled")
.map((result) => (result as PromiseFulfilledResult<unknown>).value)
.map(
(result) =>
(result as PromiseFulfilledResult<CalendarWithOwner[]>).value
)
.flat()
.filter(Boolean);
+29
View File
@@ -50,6 +50,7 @@ import RepeatEvent from "./EventRepeat";
import { useAllDayToggle } from "./hooks/useAllDayToggle";
import { combineDateTime, splitDateTime } from "./utils/dateTimeHelpers";
import { validateEventForm } from "./utils/formValidation";
import { Resource, ResourceSearch } from "../Attendees/ResourceSearch";
interface EventFormFieldsProps {
// Form state
@@ -84,6 +85,8 @@ interface EventFormFieldsProps {
setHasVideoConference: (hasVideoConference: boolean) => void;
meetingLink: string | null;
setMeetingLink: (meetingLink: string | null) => void;
selectedResources: Resource[];
setSelectedResources: (resources: Resource[]) => void;
// UI state
showMore: boolean;
@@ -166,6 +169,8 @@ export default function EventFormFields({
onValidationChange,
showValidationErrors = false,
onHasEndDateChangedChange,
selectedResources,
setSelectedResources,
}: EventFormFieldsProps) {
const { t } = useI18n();
@@ -478,6 +483,10 @@ export default function EventFormFields({
onCalendarChange?.(newCalendarId);
};
const handleResourceChange = (resources: Resource[]) => {
setSelectedResources(resources);
};
return (
<>
<FieldWithLabel
@@ -858,6 +867,26 @@ export default function EventFormFields({
{showMore && (
<>
<FieldWithLabel
label={t("event.form.resource")}
isExpanded={showMore}
>
<FormControl fullWidth margin="dense" size="small">
<ResourceSearch
objectTypes={["resource"]}
selectedResources={selectedResources}
inputSlot={(params) => <TextField {...params} size="small" />}
onChange={async (
_event: React.SyntheticEvent,
value: Resource[]
) => {
handleResourceChange(value);
}}
hideLabel={true}
/>
</FormControl>
</FieldWithLabel>
<FieldWithLabel
label={t("event.form.notification")}
isExpanded={showMore}
+1
View File
@@ -72,6 +72,7 @@ export function InfoRow({
alignItems,
gap: 1,
marginBottom: 1,
flexWrap: "wrap",
}}
>
{icon}
@@ -1,5 +1,5 @@
import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData";
import { getResourceDetails } from "@/features/User/userAPI";
import { getResourceDetails, getUserDetails } from "@/features/User/userAPI";
import { toRejectedError } from "@/utils/errorUtils";
import { createAsyncThunk } from "@reduxjs/toolkit";
import { addSharedCalendar } from "../CalendarApi";
@@ -27,14 +27,24 @@ export const addCalendarResourceAsync = createAsyncThunk<
>(
"calendars/addCalendarResource",
async ({ userId, calId, cal }, { rejectWithValue }) => {
const resourceId = cal.cal._links.self?.href
?.replace("/calendars/", "")
?.replace(".json", "")
?.split("/")[0];
let owner: OpenPaasUserData = {
firstname: "",
lastname: cal.cal["dav:name"] ?? "",
emails: [],
resource: true,
};
try {
await addSharedCalendar(userId, calId, cal);
const ownerData = await getResourceDetails(
cal.cal._links.self.href
.replace("/calendars/", "")
.replace(".json", "")
.split("/")[0]
);
const resource = await getResourceDetails(resourceId!);
owner = {
...(await getUserDetails(resource.creator)),
resource: true,
};
return {
calId: cal.cal._links.self?.href
@@ -44,7 +54,7 @@ export const addCalendarResourceAsync = createAsyncThunk<
link: `/calendars/${userId}/${calId}.json`,
desc: cal.cal["caldav:description"] ?? "",
name: cal.cal["dav:name"] ?? "",
owner: ownerData,
owner,
};
} catch (err) {
return rejectWithValue(toRejectedError(err));
+52 -5
View File
@@ -43,6 +43,7 @@ import { userAttendee } from "../User/models/attendee";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { useEventOrganizer } from "./useEventOrganizer";
import { buildDelegatedEventURL } from "./utils/buildDelegatedEventURL";
import { Resource } from "@/components/Attendees/ResourceSearch";
function EventPopover({
open,
@@ -86,6 +87,16 @@ function EventPopover({
return resolveTimezone(tz);
}, [calendarTimezone]);
const resources: Resource[] = useMemo(() => {
const resourcesInEvent =
event?.attendee?.filter((attendee) => attendee.cutype === "RESOURCE") ??
[];
return resourcesInEvent.map((resource) => ({
email: resource.cal_address,
displayName: resource.cn,
}));
}, [event?.attendee]);
const [showMore, setShowMore] = useState(false);
const [showDescription, setShowDescription] = useState(
event?.description ? true : false
@@ -127,6 +138,7 @@ function EventPopover({
const [repetition, setRepetition] = useState<RepetitionObject>(
event?.repetition ?? ({} as RepetitionObject)
);
const [selectedResources, setSelectedResources] = useState(resources ?? []);
// Derive the effective organizer based on the selected calendar.
// When a delegated calendar is selected, the organizer must be the
@@ -139,11 +151,16 @@ function EventPopover({
const [attendees, setAttendees] = useState<userAttendee[]>(
event?.attendee
? event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
? event.attendee.filter(
(a) =>
a.cal_address !== organizer?.cal_address && a.cutype !== "RESOURCE"
)
: []
);
const [alarm, setAlarm] = useState(event?.alarm?.trigger ?? "");
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
const [eventClass, setEventClass] = useState<string>(
event?.class ?? "PUBLIC"
);
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
const [timezone, setTimezone] = useState(
event?.timezone ? resolveTimezone(event.timezone) : resolvedCalendarTimezone
@@ -200,6 +217,7 @@ function EventPopover({
setHasVideoConference(false);
setMeetingLink(null);
setHasEndDateChanged(false);
setSelectedResources([]);
}, [resolvedCalendarTimezone, defaultCalendarId]);
// Track if we should sync from selectedRange (only on initial selection, not on toggle)
@@ -466,7 +484,9 @@ function EventPopover({
setAttendees(
event.attendee
? event.attendee.filter(
(a) => a.cal_address !== organizer?.cal_address
(a) =>
a.cal_address !== organizer?.cal_address &&
a.cutype !== "RESOURCE"
)
: []
);
@@ -491,17 +511,23 @@ function EventPopover({
setDescription(event.description);
}
}
setSelectedResources(resources ?? []);
} else if (event && event.attendee && event.attendee.length > 0) {
// Handle tempEvent case (no uid but has attendees from temp calendar search)
setAttendees(
event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
event.attendee.filter(
(a) =>
a.cal_address !== organizer?.cal_address && a.cutype !== "RESOURCE"
)
);
setSelectedResources(resources ?? []);
}
}, [
event,
organizer?.cal_address,
resolvedCalendarTimezone,
defaultCalendarId,
resources,
]);
// Reset state when creating new event (event is empty object or undefined)
@@ -535,6 +561,7 @@ function EventPopover({
setHasVideoConference(false);
setMeetingLink(null);
setHasEndDateChanged(false);
setSelectedResources([]);
}
if (!isCreatingNew) {
@@ -640,6 +667,7 @@ function EventPopover({
resetAllStateToDefault();
setStart("");
setEnd("");
setSelectedResources([]);
shouldSyncFromRangeRef.current = true; // Reset for next time
isCalendarIdUserSelectedRef.current = false; // Reset so next open gets fresh default
};
@@ -666,6 +694,7 @@ function EventPopover({
showDescription,
showRepeat,
hasEndDateChanged,
resources: selectedResources,
};
return buildEventFormTempData(formState);
}, [
@@ -688,6 +717,7 @@ function EventPopover({
showDescription,
showRepeat,
hasEndDateChanged,
selectedResources,
]);
// Check for temp data when modal opens
@@ -722,6 +752,7 @@ function EventPopover({
setShowDescription,
setShowRepeat,
setHasEndDateChanged,
setSelectedResources,
});
// Clear the error flag but keep data until successful save
const updatedTempData = { ...tempData, fromError: false };
@@ -766,7 +797,7 @@ function EventPopover({
uid: newEventUID,
description,
location,
class: eventClass,
class: eventClass as "PUBLIC" | "PRIVATE" | "CONFIDENTIAL",
repetition,
organizer,
timezone,
@@ -787,6 +818,20 @@ function EventPopover({
x_openpass_videoconference: meetingLink || undefined,
};
// Map data of resources to attendee before creating event
if (selectedResources?.length) {
selectedResources.forEach((resource: Resource) => {
newEvent.attendee.push({
cn: resource?.displayName ?? "",
cal_address: resource?.email ?? "",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "RESOURCE",
});
});
}
if (allday) {
const startDateOnly = (start || "").split("T")[0];
const endDateOnlyUI = (end || start || "").split("T")[0];
@@ -957,6 +1002,8 @@ function EventPopover({
onValidationChange={setIsFormValid}
showValidationErrors={showValidationErrors}
onHasEndDateChangedChange={setHasEndDateChanged}
setSelectedResources={setSelectedResources}
selectedResources={selectedResources}
/>
</ResponsiveDialog>
);
@@ -28,7 +28,9 @@ export function EventPreviewActionMenu({
const mailSpaUrl = window.MAIL_SPA_URL ?? null;
const attendees = event.attendee ?? [];
const otherAttendees = attendees.filter((a) => a.cal_address !== userEmail);
const otherAttendees = attendees.filter(
(a) => a.cal_address !== userEmail && a.cutype !== "RESOURCE"
);
return (
<Menu open={Boolean(anchorEl)} onClose={onClose} anchorEl={anchorEl}>
@@ -1,5 +1,6 @@
import { InfoRow } from "@/components/Event/InfoRow";
import { Box, Button, Typography } from "@linagora/twake-mui";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
@@ -11,6 +12,7 @@ import { useI18n } from "twake-i18n";
import { CalendarEvent } from "../EventsTypes";
import { EventPreviewAttendees } from "./EventPreviewAttendees";
import { makeRecurrenceString } from "./utils/makeRecurrenceString";
import { useMemo } from "react";
interface EventPreviewDetailsProps {
event: CalendarEvent;
@@ -28,13 +30,25 @@ export function EventPreviewDetails({
const infoIconColor = alpha(theme.palette.grey[900], 0.9);
const infoIconSx = { minWidth: "25px", marginRight: 2, color: infoIconColor };
const resources = useMemo(
() => event?.attendee?.filter((attendee) => attendee.cutype === "RESOURCE"),
[event?.attendee]
);
const eventAttendees = useMemo(
() =>
event?.attendee?.filter((attendee) => attendee.cutype !== "RESOURCE") ??
[],
[event?.attendee]
);
const attendees =
event.attendee?.filter(
eventAttendees?.filter(
(a) => a.cal_address !== event.organizer?.cal_address
) || [];
const organizer = event.attendee?.find(
const organizer = eventAttendees?.find(
(a) => a.cal_address === event.organizer?.cal_address
);
const showDetails = isNotPrivate || isOwn;
if (!showDetails) {
@@ -91,7 +105,7 @@ export function EventPreviewDetails({
<EventPreviewAttendees
attendees={attendees}
organizer={organizer}
allAttendees={event.attendee ?? []}
allAttendees={eventAttendees ?? []}
start={event.start}
end={event.end}
timezone={event.timezone}
@@ -112,6 +126,56 @@ export function EventPreviewDetails({
/>
)}
{/* Resource */}
{resources && (
<InfoRow
alignItems="flex-start"
icon={
<Box sx={infoIconSx}>
<LayersOutlinedIcon />
</Box>
}
content={resources.map((resource, index) => (
<Box
sx={{
marginRight: "5px",
}}
>
<Typography
variant="body2"
color="textPrimary"
sx={{
wordBreak: "break-word",
whiteSpace: "pre-line",
maxHeight: "33vh",
overflowY: "auto",
width: "100%",
}}
>
{resource.cn}
{index < resources.length - 1 ? "," : ""}
</Typography>
<Typography
sx={{
wordBreak: "break-word",
whiteSpace: "pre-line",
overflowY: "auto",
width: "100%",
fontSize: "13px",
color: "#717D96",
}}
>
{t(`eventPreview.${resource.partstat}`)}
</Typography>
</Box>
))}
style={{
fontSize: "16px",
fontFamily: "'Inter', sans-serif",
}}
/>
)}
{/* Description */}
{event.description && (
<InfoRow
+47 -1
View File
@@ -44,6 +44,7 @@ import { deleteEvent, getEvent, putEvent } from "./EventApi";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { moveEventBetweenCalendars } from "./updateEventHelpers/moveEventBetweenCalendars";
import { detectRecurringEventChanges } from "./utils/detectRecurringEventChanges";
import { Resource } from "@/components/Attendees/ResourceSearch";
function EventUpdateModal({
eventId,
@@ -91,6 +92,16 @@ function EventUpdateModal({
return { zones, browserTz, getTimezoneOffset };
}, []);
const resources: Resource[] = useMemo(() => {
const resourcesInEvent =
event?.attendee?.filter((attendee) => attendee.cutype === "RESOURCE") ??
[];
return resourcesInEvent.map((resource) => ({
email: resource.cal_address,
displayName: resource.cn,
}));
}, [event?.attendee]);
const [showMore, setShowMore] = useState(false);
const [showDescription, setShowDescription] = useState(
event?.description ? true : false
@@ -129,6 +140,7 @@ function EventUpdateModal({
const [isFormValid, setIsFormValid] = useState(false);
const [showValidationErrors, setShowValidationErrors] = useState(false);
const [hasEndDateChanged, setHasEndDateChanged] = useState(false);
const [selectedResources, setSelectedResources] = useState(resources ?? []);
const resetAllStateToDefault = useCallback(() => {
setShowMore(false);
@@ -151,6 +163,7 @@ function EventUpdateModal({
setTimezone(resolveTimezone(browserDefaultTimeZone));
setHasVideoConference(false);
setMeetingLink(null);
setSelectedResources([]);
}, [defaultCalendarId]);
// Prevent repeated initialization loops
@@ -297,7 +310,8 @@ function EventUpdateModal({
eventToDisplay.attendee
? eventToDisplay.attendee.filter(
(a: userAttendee) =>
a.cal_address !== eventToDisplay.organizer?.cal_address
a.cal_address !== eventToDisplay.organizer?.cal_address &&
a.cutype !== "RESOURCE"
)
: []
);
@@ -335,6 +349,15 @@ function EventUpdateModal({
setDescription(eventToDisplay.description);
}
}
setSelectedResources(
(eventToDisplay.attendee ?? [])
.filter((a: userAttendee) => a.cutype === "RESOURCE")
.map((a) => ({
email: a.cal_address,
displayName: a.cn,
}))
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
@@ -345,6 +368,7 @@ function EventUpdateModal({
calList,
masterEvent,
isLoadingMasterEvent,
resources,
]);
// Helper to close modal(s) - use onCloseAll if available to close preview modal too
@@ -387,6 +411,7 @@ function EventUpdateModal({
showDescription,
showRepeat,
hasEndDateChanged,
resources: selectedResources,
};
const context: EventFormContext = {
eventId,
@@ -417,6 +442,7 @@ function EventUpdateModal({
eventId,
calId,
typeOfAction,
selectedResources,
]);
// Check for temp data when modal opens
@@ -454,6 +480,7 @@ function EventUpdateModal({
setShowDescription,
setShowRepeat,
setHasEndDateChanged,
setSelectedResources,
});
// Clear the error flag but keep data until successful save
const updatedTempData = { ...tempData, fromError: false };
@@ -612,6 +639,23 @@ function EventUpdateModal({
x_openpass_videoconference: meetingLink || undefined,
};
// Map data of resources to attendee before creating event
if (selectedResources?.length) {
if (!newEvent.attendee) {
newEvent.attendee = [];
}
selectedResources.forEach((resource: Resource) => {
newEvent.attendee.push({
cn: resource?.displayName ?? "",
cal_address: resource?.email ?? "",
partstat: "NEEDS-ACTION",
rsvp: "TRUE",
role: "REQ-PARTICIPANT",
cutype: "RESOURCE",
});
});
}
// Special case: When converting recurring event to non-recurring
if (
recurrenceId &&
@@ -1088,6 +1132,8 @@ function EventUpdateModal({
onValidationChange={setIsFormValid}
showValidationErrors={showValidationErrors}
onHasEndDateChangedChange={setHasEndDateChanged}
selectedResources={selectedResources}
setSelectedResources={setSelectedResources}
/>
</ResponsiveDialog>
);
+2
View File
@@ -1,3 +1,4 @@
import { Resource } from "@/components/Attendees/ResourceSearch";
import { Calendar } from "../Calendars/CalendarTypes";
import { VObjectProperty } from "../Calendars/types/CalendarData";
import { userAttendee } from "../User/models/attendee";
@@ -29,6 +30,7 @@ export interface CalendarEvent {
alarm?: AlarmObject;
exdates?: string[];
passthroughProps?: VObjectProperty[];
selectedResources?: Resource[];
}
export interface RepetitionObject {
+1 -1
View File
@@ -1,5 +1,5 @@
export type AttendeeRole = "CHAIR" | "REQ-PARTICIPANT" | "OPT-PARTICIPANT";
export type CuType = "INDIVIDUAL" | "GROUP";
export type CuType = "INDIVIDUAL" | "GROUP" | "RESOURCE";
export type PartStat = "ACCEPTED" | "DECLINED" | "TENTATIVE" | "NEEDS-ACTION";
export interface userAttendee {
+1 -1
View File
@@ -45,7 +45,7 @@ export async function getUserDetails(id: string): Promise<OpenPaasUserData> {
export async function getResourceDetails(id: string): Promise<ResourceData> {
const resource = await api
.get(`linagora.esn.resource/api/resources/${id}`)
.get(`linagora.esn.resource/api/resources/${encodeURIComponent(id)}`)
.json();
return resource as ResourceData;
}
+4 -1
View File
@@ -187,7 +187,9 @@
"busy": "Busy",
"visibleTo": "Visible to",
"visibleAll": "All",
"visibleParticipants": "Participants"
"visibleParticipants": "Participants",
"resource": "Resource",
"select_resource_placeholder": "Add resource"
},
"validation": {
"titleRequired": "Title is required",
@@ -281,6 +283,7 @@
"ACCEPTED": "Accept",
"TENTATIVE": "Maybe",
"DECLINED": "Decline",
"NEEDS-ACTION": "Pending",
"showMore": "Show more",
"showLess": "Show less",
"joinVideo": "Join the video conference",
+4 -1
View File
@@ -188,7 +188,9 @@
"busy": "Occupé",
"visibleTo": "Visible par",
"visibleAll": "Tous",
"visibleParticipants": "Participants"
"visibleParticipants": "Participants",
"resource": "Ressource",
"select_resource_placeholder": "Ajouter une ressource"
},
"validation": {
"titleRequired": "Le titre est obligatoire",
@@ -282,6 +284,7 @@
"ACCEPTED": "Accepter",
"TENTATIVE": "Peut-être",
"DECLINED": "Décliner",
"NEEDS-ACTION": "En attente",
"showMore": "Afficher plus",
"showLess": "Afficher moins",
"joinVideo": "Rejoindre la visioconférence",
+4 -1
View File
@@ -188,7 +188,9 @@
"busy": "Занят",
"visibleTo": "Видно для",
"visibleAll": "Всех",
"visibleParticipants": "Участников"
"visibleParticipants": "Участников",
"resource": "Ресурс",
"select_resource_placeholder": "Добавить ресурс"
},
"validation": {
"titleRequired": "Укажите название",
@@ -282,6 +284,7 @@
"ACCEPTED": "Да",
"TENTATIVE": "Возможно",
"DECLINED": "Нет",
"NEEDS-ACTION": "В ожидании",
"showMore": "Показать больше",
"showLess": "Показать меньше",
"joinVideo": "Присоединиться к видеоконференции",
+4 -1
View File
@@ -186,7 +186,9 @@
"busy": "Bận",
"visibleTo": "Hiển thị với",
"visibleAll": "Tất cả",
"visibleParticipants": "Người tham gia"
"visibleParticipants": "Người tham gia",
"resource": "Tài nguyên",
"select_resource_placeholder": "Thêm tài nguyên"
},
"validation": {
"titleRequired": "Tiêu đề là bắt buộc",
@@ -280,6 +282,7 @@
"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",
+5
View File
@@ -1,3 +1,4 @@
import { Resource } from "@/components/Attendees/ResourceSearch";
import { RepetitionObject } from "@/features/Events/EventsTypes";
import { userAttendee } from "@/features/User/models/attendee";
@@ -29,6 +30,7 @@ export interface EventFormTempData {
typeOfAction?: "solo" | "all";
// Flag to indicate this is from an error
fromError?: boolean;
resources?: Resource[];
}
const STORAGE_KEY_PREFIX = "eventFormTempData_";
@@ -94,6 +96,7 @@ export interface EventFormState {
showDescription?: boolean;
showRepeat?: boolean;
hasEndDateChanged?: boolean;
resources?: Resource[];
}
export interface EventFormContext {
@@ -133,6 +136,7 @@ export interface EventFormSetters {
setShowDescription?: (value: boolean) => void;
setShowRepeat?: (value: boolean) => void;
setHasEndDateChanged?: (value: boolean) => void;
setSelectedResources?: (value: Resource[]) => void;
}
export function restoreFormDataFromTemp(
@@ -169,4 +173,5 @@ export function restoreFormDataFromTemp(
) {
setters.setHasEndDateChanged(tempData.hasEndDateChanged);
}
setters.setSelectedResources?.(tempData.resources ?? []);
}