diff --git a/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx index 5260d05..2599a00 100644 --- a/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx +++ b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx @@ -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, + }, }); }); diff --git a/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx b/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx index c51b899..dd95c85 100644 --- a/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx +++ b/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx @@ -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); diff --git a/__test__/features/Events/EventDisplay.test.tsx b/__test__/features/Events/EventDisplay.test.tsx index f6cc215..ce6badf 100644 --- a/__test__/features/Events/EventDisplay.test.tsx +++ b/__test__/features/Events/EventDisplay.test.tsx @@ -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( diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index cf716a3..680b6b8 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -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 () => { diff --git a/__test__/features/Events/EventUpdateModal.test.tsx b/__test__/features/Events/EventUpdateModal.test.tsx index f7340cd..5b1fc09 100644 --- a/__test__/features/Events/EventUpdateModal.test.tsx +++ b/__test__/features/Events/EventUpdateModal.test.tsx @@ -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( + , + 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", () => { diff --git a/src/components/Calendar/CalendarResources.tsx b/src/components/Calendar/CalendarResources.tsx index e4cdfa3..9032d69 100644 --- a/src/components/Calendar/CalendarResources.tsx +++ b/src/components/Calendar/CalendarResources.tsx @@ -291,7 +291,10 @@ export default function CalendarResources({ const successfulCals = results .filter((result) => result.status === "fulfilled") - .map((result) => (result as PromiseFulfilledResult).value) + .map( + (result) => + (result as PromiseFulfilledResult).value + ) .flat() .filter(Boolean); diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index 552cf9c..08cc530 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -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 ( <> + + + } + onChange={async ( + _event: React.SyntheticEvent, + value: Resource[] + ) => { + handleResourceChange(value); + }} + hideLabel={true} + /> + + + {icon} diff --git a/src/features/Calendars/api/addCalendarResourceAsync.ts b/src/features/Calendars/api/addCalendarResourceAsync.ts index 5dfc8bb..684cbd0 100644 --- a/src/features/Calendars/api/addCalendarResourceAsync.ts +++ b/src/features/Calendars/api/addCalendarResourceAsync.ts @@ -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)); diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 9c8fb1c..6a493da 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -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( 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( 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( + 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} /> ); diff --git a/src/features/Events/EventPreview/EventPreviewActionMenu.tsx b/src/features/Events/EventPreview/EventPreviewActionMenu.tsx index 3dbd284..1907da1 100644 --- a/src/features/Events/EventPreview/EventPreviewActionMenu.tsx +++ b/src/features/Events/EventPreview/EventPreviewActionMenu.tsx @@ -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 ( diff --git a/src/features/Events/EventPreview/EventPreviewDetails.tsx b/src/features/Events/EventPreview/EventPreviewDetails.tsx index 41005e0..03cc923 100644 --- a/src/features/Events/EventPreview/EventPreviewDetails.tsx +++ b/src/features/Events/EventPreview/EventPreviewDetails.tsx @@ -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({ )} + {/* Resource */} + {resources && ( + + + + } + content={resources.map((resource, index) => ( + + + {resource.cn} + {index < resources.length - 1 ? "," : ""} + + + {t(`eventPreview.${resource.partstat}`)} + + + ))} + style={{ + fontSize: "16px", + fontFamily: "'Inter', sans-serif", + }} + /> + )} + {/* Description */} {event.description && ( { + 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} /> ); diff --git a/src/features/Events/EventsTypes.ts b/src/features/Events/EventsTypes.ts index 5e4c35c..9fd7283 100644 --- a/src/features/Events/EventsTypes.ts +++ b/src/features/Events/EventsTypes.ts @@ -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 { diff --git a/src/features/User/models/attendee.ts b/src/features/User/models/attendee.ts index 3471415..782a954 100644 --- a/src/features/User/models/attendee.ts +++ b/src/features/User/models/attendee.ts @@ -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 { diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index 6b77d77..3e73216 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -45,7 +45,7 @@ export async function getUserDetails(id: string): Promise { export async function getResourceDetails(id: string): Promise { const resource = await api - .get(`linagora.esn.resource/api/resources/${id}`) + .get(`linagora.esn.resource/api/resources/${encodeURIComponent(id)}`) .json(); return resource as ResourceData; } diff --git a/src/locales/en.json b/src/locales/en.json index ca816e8..e61a79d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fr.json b/src/locales/fr.json index 9254330..1938a78 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -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", diff --git a/src/locales/ru.json b/src/locales/ru.json index c842da7..10d639e 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": "Присоединиться к видеоконференции", diff --git a/src/locales/vi.json b/src/locales/vi.json index e757c25..cd47902 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -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", diff --git a/src/utils/eventFormTempStorage.ts b/src/utils/eventFormTempStorage.ts index 7e29641..561e954 100644 --- a/src/utils/eventFormTempStorage.ts +++ b/src/utils/eventFormTempStorage.ts @@ -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 ?? []); }