From 0dd7f1b2fdf0fe16a343a47cb8a028cca9fa0776 Mon Sep 17 00:00:00 2001 From: lethemanh Date: Tue, 17 Mar 2026 15:33:15 +0700 Subject: [PATCH] #599 ability to register to a resource calendar (#631) Co-authored-by: lethemanh --- __test__/components/Calendar.test.tsx | 2 +- .../components/CalendarResources.test.tsx | 188 ++++++++++ .../components/EventErrorHandling.test.tsx | 2 +- __test__/components/ResourceSearch.test.tsx | 224 ++++++++++++ __test__/components/useUserSearch.test.tsx | 102 ++++++ .../api/addCalendarResourceAsync.test.tsx | 128 +++++++ .../services/getCalendarsListAsync.test.tsx | 14 +- src/components/Attendees/PeopleSearch.tsx | 78 +--- src/components/Attendees/ResourceIcon.tsx | 54 +++ src/components/Attendees/ResourceSearch.tsx | 335 +++++++++++++++++ src/components/Attendees/useUserSearch.ts | 81 +++++ src/components/Calendar/CalendarResources.tsx | 344 ++++++++++++++++++ src/components/Calendar/CalendarSelection.tsx | 20 +- src/features/Calendars/CalendarSlice.ts | 24 ++ .../Calendars/api/addCalendarResourceAsync.ts | 53 +++ src/features/Calendars/useCalendarLoader.ts | 13 + src/locales/en.json | 15 +- src/locales/fr.json | 15 +- src/locales/ru.json | 15 +- src/locales/vi.json | 15 +- 20 files changed, 1647 insertions(+), 75 deletions(-) create mode 100644 __test__/components/CalendarResources.test.tsx create mode 100644 __test__/components/ResourceSearch.test.tsx create mode 100644 __test__/components/useUserSearch.test.tsx create mode 100644 __test__/features/Calendars/api/addCalendarResourceAsync.test.tsx create mode 100644 src/components/Attendees/ResourceIcon.tsx create mode 100644 src/components/Attendees/ResourceSearch.tsx create mode 100644 src/components/Attendees/useUserSearch.ts create mode 100644 src/components/Calendar/CalendarResources.tsx create mode 100644 src/features/Calendars/api/addCalendarResourceAsync.ts diff --git a/__test__/components/Calendar.test.tsx b/__test__/components/Calendar.test.tsx index 9a6019d..ab8874b 100644 --- a/__test__/components/Calendar.test.tsx +++ b/__test__/components/Calendar.test.tsx @@ -572,7 +572,7 @@ describe("calendar Availability search", () => { const callCount = spy.mock.calls.length; expect(callCount).toBeGreaterThan(0); - expect(callCount).toBeLessThanOrEqual(24); + expect(callCount).toBeLessThanOrEqual(36); }); it("prefetches hidden calendars only after active load completes", async () => { diff --git a/__test__/components/CalendarResources.test.tsx b/__test__/components/CalendarResources.test.tsx new file mode 100644 index 0000000..e46afe8 --- /dev/null +++ b/__test__/components/CalendarResources.test.tsx @@ -0,0 +1,188 @@ +import CalendarResources from "@/components/Calendar/CalendarResources"; +import { getCalendars } from "@/features/Calendars/CalendarApi"; +import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { renderWithProviders } from "../utils/Renderwithproviders"; + +jest.mock("@/features/Calendars/CalendarApi"); +jest.mock("@/features/Calendars/api/addCalendarResourceAsync"); +jest.mock("@/components/Attendees/ResourceSearch", () => ({ + ResourceSearch: ({ + onChange, + }: { + onChange: ( + event: null, + value: { displayName: string; openpaasId: string }[] + ) => void; + }) => ( +
+ +
+ ), +})); + +const mockedGetCalendars = getCalendars as jest.Mock; +const mockedAddCalendarResourceAsync = + addCalendarResourceAsync as unknown as jest.Mock; + +describe("CalendarResources", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const baseUser = { + userData: { + sub: "test", + email: "test@test.com", + sid: "mockSid", + openpaasId: "user1", + }, + tokens: { accessToken: "token" }, + }; + + const setup = (isOpen = true) => { + const onClose = jest.fn(); + renderWithProviders(, { + user: baseUser, + }); + return { onClose }; + }; + + it("renders correctly and closes on cancel", () => { + const { onClose } = setup(); + + expect(screen.getByText("calendar.browseResources")).toBeInTheDocument(); + + const cancelButton = screen.getByRole("button", { + name: "common.cancel", + }); + fireEvent.click(cancelButton); + + expect(onClose).toHaveBeenCalled(); + }); + + it("does not render when isOpen is false", () => { + setup(false); + expect( + screen.queryByText("calendar.browseResources") + ).not.toBeInTheDocument(); + }); + + it("fetches resource calendars and adds them using Promise.allSettled logic", async () => { + const mockCalendarDataA = { + _embedded: { + "dav:calendar": [ + { + _links: { self: { href: "/calendars/room-a-id/cal-a.json" } }, + "dav:name": "Room A Calendar", + "caldav:description": "Main room A calendar", + color: "red", + }, + ], + }, + }; + + mockedGetCalendars.mockImplementation((userId) => { + if (userId === "room-a-id") return Promise.resolve(mockCalendarDataA); + // Simulate failure for room-b + if (userId === "room-b-id") + return Promise.reject(new Error("Failed fetching Room B")); + return Promise.resolve([]); + }); + + const mockDispatchResult = { + unwrap: () => Promise.resolve({ type: "success" }), + }; + mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult); + + const { onClose } = setup(); + + // Trigger resource selection + const mockSelectBtn = screen.getByTestId("mock-resource-search-select"); + await act(async () => { + fireEvent.click(mockSelectBtn); + }); + + // After selection, the button should be enabled and say Add + const addButton = await screen.findByRole("button", { + name: "actions.add", + }); + expect(addButton).not.toBeDisabled(); + + // Click add to trigger addCalendarResourceAsync + await act(async () => { + fireEvent.click(addButton); + }); + + await waitFor(() => { + // It should only have been called for Room A because Room B failed (Promise.allSettled) + expect(mockedAddCalendarResourceAsync).toHaveBeenCalledTimes(1); + + const payload = mockedAddCalendarResourceAsync.mock.calls[0][0]; + expect(payload).toEqual( + expect.objectContaining({ + userId: "user1", + calId: expect.any(String), + cal: expect.objectContaining({ + cal: mockCalendarDataA._embedded["dav:calendar"][0], + }), + }) + ); + }); + + expect(onClose).toHaveBeenCalled(); + }); + + it("adds existing user details fallback logic within submit handler", async () => { + const mockCalendarDataA = { + _embedded: { + "dav:calendar": [ + { + _links: { self: { href: "/calendars/room-a-id/cal-a.json" } }, + "dav:name": "Room A Calendar", + "caldav:description": "Main room A calendar", + color: "red", + }, + ], + }, + }; + + mockedGetCalendars.mockResolvedValueOnce(mockCalendarDataA); + mockedGetCalendars.mockResolvedValueOnce([]); // Mock array of size 2 (for both A and B but second resolves empty) + + // Simulate successful API response + const mockDispatchResult = { + unwrap: () => Promise.resolve(), + }; + mockedAddCalendarResourceAsync.mockReturnValue(() => mockDispatchResult); + + const { onClose } = setup(); + + // Trigger resource selection + const mockSelectBtn = screen.getByTestId("mock-resource-search-select"); + await act(async () => { + fireEvent.click(mockSelectBtn); + }); + + const addButton = await screen.findByRole("button", { + name: "actions.add", + }); + await act(async () => { + fireEvent.click(addButton); + }); + + await waitFor(() => { + expect(onClose).toHaveBeenCalled(); + }); + }); +}); diff --git a/__test__/components/EventErrorHandling.test.tsx b/__test__/components/EventErrorHandling.test.tsx index b0add0f..c929004 100644 --- a/__test__/components/EventErrorHandling.test.tsx +++ b/__test__/components/EventErrorHandling.test.tsx @@ -98,5 +98,5 @@ describe("Event Error Handling", () => { } consoleWarnSpy.mockRestore(); - }); + }, 15000); }); diff --git a/__test__/components/ResourceSearch.test.tsx b/__test__/components/ResourceSearch.test.tsx new file mode 100644 index 0000000..e2befca --- /dev/null +++ b/__test__/components/ResourceSearch.test.tsx @@ -0,0 +1,224 @@ +import { + ResourceSearch, + Resource, +} from "@/components/Attendees/ResourceSearch"; +import { searchUsers } from "@/features/User/userAPI"; +import { act, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { renderWithProviders } from "../utils/Renderwithproviders"; + +jest.mock("@/features/User/userAPI"); +const mockedSearchUsers = searchUsers as jest.MockedFunction< + typeof searchUsers +>; + +describe("ResourceSearch", () => { + const baseResource: Resource = { + displayName: "Projector Room", + }; + + function setup( + selectedResources: Resource[] = [], + props?: Partial> + ) { + const onChange = jest.fn(); + renderWithProviders( + + ); + return { onChange }; + } + + beforeEach(() => { + jest.useFakeTimers(); + mockedSearchUsers.mockReset(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("calls searchUsers after debounce when typing", async () => { + mockedSearchUsers.mockResolvedValueOnce([ + baseResource, + ] as unknown as Awaited>); + setup(); + + const input = screen.getByRole("combobox"); + await userEvent.type(input, "Room"); + await act(async () => { + jest.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(mockedSearchUsers).toHaveBeenCalledWith("Room", ["resource"]); + }); + }); + + it("renders search results and allows selection", async () => { + mockedSearchUsers.mockResolvedValueOnce([ + baseResource, + ] as unknown as Awaited>); + const { onChange } = setup(); + + const input = screen.getByRole("combobox"); + await userEvent.type(input, "Room"); + await act(async () => { + jest.advanceTimersByTime(300); + }); + + const option = await screen.findByText("Projector Room"); + await userEvent.click(option); + + await waitFor(() => { + expect(onChange).toHaveBeenCalled(); + }); + }); + + it("does not show already selected resources in options", async () => { + mockedSearchUsers.mockResolvedValueOnce([ + baseResource, + ] as unknown as Awaited>); + setup([baseResource]); + const input = screen.getByRole("combobox"); + await userEvent.type(input, "Projector"); + await act(async () => { + jest.advanceTimersByTime(300); + }); + + await waitFor(() => { + // It shouldn't be in the dropdown options anymore + const options = screen.queryAllByRole("option"); + expect( + options.find((opt) => opt.textContent === "Projector Room") + ).toBeUndefined(); + }); + }); + + it("respects disabled state", () => { + setup([], { disabled: true }); + expect(screen.getByRole("combobox")).toBeDisabled(); + }); + + it("no options doesn't show dropdown when input is empty", async () => { + mockedSearchUsers.mockResolvedValueOnce([ + baseResource, + ] as unknown as Awaited>); + setup(); + const input = screen.getByRole("combobox"); + + await userEvent.type(input, "Room"); + await act(async () => { + jest.advanceTimersByTime(300); + }); + + await waitFor(() => { + expect(screen.getByRole("listbox")).toBeInTheDocument(); + }); + + await userEvent.clear(input); + + await waitFor(() => { + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + }); + }); + + it("shows 'No results' when search succeeds but returns empty array", async () => { + mockedSearchUsers.mockResolvedValueOnce([]); + setup(); + + const input = screen.getByRole("combobox"); + await userEvent.type(input, "Room"); + + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + await Promise.resolve(); + }); + + const noResults = await screen.findByText( + "resourceSearch.noResults", + {}, + { timeout: 5000 } + ); + expect(noResults).toBeInTheDocument(); + }); + + it("clears options when search fails and shows error snackbar", async () => { + mockedSearchUsers.mockResolvedValueOnce([ + baseResource, + ] as unknown as Awaited>); + setup(); + + const input = screen.getByRole("combobox"); + await userEvent.type(input, "Room"); + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByText("Projector Room")).toBeInTheDocument(); + }); + + mockedSearchUsers.mockRejectedValueOnce(new Error("Network error")); + await userEvent.clear(input); + await userEvent.type(input, "Error"); + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + }); + + const errorMessage = await screen.findByText("resourceSearch.searchError"); + expect(errorMessage).toBeInTheDocument(); + + expect(screen.queryByText("Projector Room")).not.toBeInTheDocument(); + + mockedSearchUsers.mockResolvedValueOnce([ + baseResource, + ] as unknown as Awaited>); + await userEvent.clear(input); + await userEvent.type(input, "Room"); + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByText("Projector Room")).toBeInTheDocument(); + }); + }); + + it("shows loading text when searching", async () => { + let resolveSearch: (value: Resource[]) => void; + const searchPromise = new Promise((resolve) => { + resolveSearch = resolve; + }); + mockedSearchUsers.mockReturnValueOnce( + searchPromise as unknown as ReturnType + ); + setup(); + + const input = screen.getByRole("combobox"); + await userEvent.type(input, "Room"); + await act(async () => { + jest.advanceTimersByTime(300); + }); + + const loadingText = await screen.findByText( + "resourceSearch.loading", + {}, + { timeout: 5000 } + ); + expect(loadingText).toBeInTheDocument(); + + await act(async () => { + resolveSearch!([baseResource]); + await searchPromise; + }); + }); +}); diff --git a/__test__/components/useUserSearch.test.tsx b/__test__/components/useUserSearch.test.tsx new file mode 100644 index 0000000..b4be43f --- /dev/null +++ b/__test__/components/useUserSearch.test.tsx @@ -0,0 +1,102 @@ +import { renderHook, act } from "@testing-library/react"; +import { useUserSearch } from "@/components/Attendees/useUserSearch"; +import { searchUsers } from "@/features/User/userAPI"; + +jest.mock("@/features/User/userAPI", () => ({ + searchUsers: jest.fn(), +})); + +describe("useUserSearch", () => { + const mockSearchUsers = searchUsers as jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("should initialize with default values", () => { + const { result } = renderHook(() => + useUserSearch({ objectTypes: ["user"], errorMessage: "Error" }) + ); + + expect(result.current.query).toBe(""); + expect(result.current.loading).toBe(false); + expect(result.current.options).toEqual([]); + expect(result.current.hasSearched).toBe(false); + expect(result.current.isOpen).toBe(false); + expect(result.current.inputError).toBeNull(); + expect(result.current.snackbarOpen).toBe(false); + expect(result.current.snackbarMessage).toBe(""); + }); + + it("should debounce and fetch users when query changes", async () => { + const mockUsers = [{ displayName: "John Doe", email: "john@example.com" }]; + mockSearchUsers.mockResolvedValueOnce(mockUsers); + + const { result } = renderHook(() => + useUserSearch({ objectTypes: ["user"], errorMessage: "Error" }) + ); + + act(() => { + result.current.setQuery("John"); + }); + + expect(result.current.loading).toBe(false); // Before debounce + + // Wait for the mock promise to resolve within act + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); // allow microtasks to flush + }); + + expect(mockSearchUsers).toHaveBeenCalledWith("John", ["user"]); + expect(result.current.loading).toBe(false); + expect(result.current.options).toEqual(mockUsers); + expect(result.current.hasSearched).toBe(true); + }); + + it("should clear options and handle empty query", () => { + const { result } = renderHook(() => + useUserSearch({ objectTypes: ["user"], errorMessage: "Error" }) + ); + + act(() => { + result.current.setQuery(" "); // empty query with spaces + }); + + act(() => { + jest.advanceTimersByTime(300); + }); + + expect(mockSearchUsers).not.toHaveBeenCalled(); + expect(result.current.options).toEqual([]); + expect(result.current.loading).toBe(false); + expect(result.current.hasSearched).toBe(false); + }); + + it("should handle search errors and show the custom error message", async () => { + mockSearchUsers.mockRejectedValueOnce(new Error("API Error")); + + const { result } = renderHook(() => + useUserSearch({ objectTypes: ["user"], errorMessage: "Custom Error" }) + ); + + act(() => { + result.current.setQuery("FailedSearch"); + }); + + await act(async () => { + jest.advanceTimersByTime(300); + await Promise.resolve(); // allow microtasks to flush + }); + + expect(result.current.hasSearched).toBe(false); + expect(result.current.loading).toBe(false); + expect(result.current.snackbarOpen).toBe(true); + expect(result.current.snackbarMessage).toBe("Custom Error"); + }); +}); diff --git a/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx new file mode 100644 index 0000000..5260d05 --- /dev/null +++ b/__test__/features/Calendars/api/addCalendarResourceAsync.test.tsx @@ -0,0 +1,128 @@ +import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync"; +import { addSharedCalendar } from "@/features/Calendars/CalendarApi"; +import { getResourceDetails } from "@/features/User/userAPI"; +import { toRejectedError } from "@/utils/errorUtils"; +import { configureStore } from "@reduxjs/toolkit"; + +jest.mock("@/features/Calendars/CalendarApi"); +jest.mock("@/features/User/userAPI"); +jest.mock("@/utils/errorUtils"); + +const mockedAddSharedCalendar = addSharedCalendar as jest.Mock; +const mockedGetResourceDetails = getResourceDetails as jest.Mock; +const mockedToRejectedError = toRejectedError as jest.Mock; + +describe("addCalendarResourceAsync thunk", () => { + let store: ReturnType; + const dispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + store = configureStore({ + reducer: () => ({}), + }); + }); + + const mockPayload = { + userId: "user-123", + calId: "cal-123", + cal: { + color: { + background: "#000000", + foreground: "#FFFFFF", + }, + cal: { + "dav:name": "Resource Room A", + "caldav:description": "A meeting room", + _links: { + self: { + href: "/calendars/res-456/cal-123.json", + }, + }, + }, + }, + }; + + const mockResolvedResourceData = { + _id: "res-456", + id: "res-456", + name: "Resource Room A", + description: "A meeting room", + creator: "user-789", + deleted: false, + _rev: "1", + }; + + it("should add shared calendar, fetch resource details, map userData", async () => { + mockedGetResourceDetails.mockResolvedValueOnce(mockResolvedResourceData); + mockedAddSharedCalendar.mockResolvedValueOnce({}); + + const result = await addCalendarResourceAsync( + mockPayload as unknown as Parameters[0] + )(dispatch, store.getState, undefined); + + expect(mockedAddSharedCalendar).toHaveBeenCalledWith( + mockPayload.userId, + mockPayload.calId, + mockPayload.cal + ); + expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456"); + + expect(result.type).toBe("calendars/addCalendarResource/fulfilled"); + expect(result.payload).toEqual({ + calId: "res-456/cal-123", + color: { background: "#000000", foreground: "#FFFFFF" }, + desc: "A meeting room", + link: "/calendars/user-123/cal-123.json", + name: "Resource Room A", + owner: mockResolvedResourceData, + }); + }); + + 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); + + const result = await addCalendarResourceAsync( + mockPayload as unknown as Parameters[0] + )(dispatch, store.getState, undefined); + + expect(mockedAddSharedCalendar).toHaveBeenCalledWith( + mockPayload.userId, + mockPayload.calId, + mockPayload.cal + ); + expect(mockedGetResourceDetails).toHaveBeenCalledWith("res-456"); + + expect(mockedToRejectedError).toHaveBeenCalledWith(errorDetails); + + expect(result.type).toBe("calendars/addCalendarResource/rejected"); + expect(result.payload).toEqual(mockRejectedErrorResult); + }); + + it("should handle error if addSharedCalendar fails", async () => { + const errorAdd = new Error("Add failed"); + mockedAddSharedCalendar.mockRejectedValueOnce(errorAdd); + const mockRejectedErrorResult = { message: "Add failed" }; + mockedToRejectedError.mockReturnValueOnce(mockRejectedErrorResult); + + const result = await addCalendarResourceAsync( + mockPayload as unknown as Parameters[0] + )(dispatch, store.getState, undefined); + + expect(mockedAddSharedCalendar).toHaveBeenCalledWith( + mockPayload.userId, + mockPayload.calId, + mockPayload.cal + ); + expect(mockedGetResourceDetails).not.toHaveBeenCalled(); + + expect(mockedToRejectedError).toHaveBeenCalledWith(errorAdd); + + expect(result.type).toBe("calendars/addCalendarResource/rejected"); + expect(result.payload).toEqual(mockRejectedErrorResult); + }); +}); diff --git a/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx b/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx index 62cbc7e..c51b899 100644 --- a/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx +++ b/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx @@ -15,10 +15,9 @@ jest.mock("@/features/Calendars/utils/normalizeCalendar"); const mockedGetOpenPaasUser = getOpenPaasUser as jest.Mock; const mockedGetUserDetails = getUserDetails as jest.Mock; -const mockedgetResourceDetails = getResourceDetails as jest.Mock; +const mockedGetResourceDetails = getResourceDetails as jest.Mock; const mockedGetCalendars = getCalendars as jest.Mock; const mockedFormatReduxError = formatReduxError as jest.Mock; -const mockedToRejectedError = toRejectedError as jest.Mock; const mockedNormalizeCalendar = normalizeCalendar as jest.Mock; describe("getCalendarsListAsync", () => { @@ -143,7 +142,12 @@ describe("getCalendarsListAsync", () => { response: { status: 500 }, message: "Server Error", }); - mockedToRejectedError.mockReturnValueOnce({ + + // toRejectedError is imported from a mocked module; provide the expected return + const { toRejectedError } = jest.requireMock("@/utils/errorUtils") as { + toRejectedError: jest.Mock; + }; + toRejectedError.mockReturnValueOnce({ status: 500, message: "Server Error", }); @@ -175,7 +179,7 @@ describe("getCalendarsListAsync", () => { // getUserDetails fails with 404 for the resource ID mockedGetUserDetails.mockRejectedValueOnce({ response: { status: 404 } }); // Then getResourceDetails is called and succeeds - mockedgetResourceDetails.mockResolvedValueOnce({ creator: "creator-456" }); + mockedGetResourceDetails.mockResolvedValueOnce({ creator: "creator-456" }); // Then getUserDetails is called for the creator and succeeds mockedGetUserDetails.mockResolvedValueOnce({ firstname: "Creator", @@ -187,7 +191,7 @@ describe("getCalendarsListAsync", () => { const result = await thunk(dispatch, getState, undefined); const payload = result.payload as any; - expect(mockedgetResourceDetails).toHaveBeenCalledWith("resource-123"); + expect(mockedGetResourceDetails).toHaveBeenCalledWith("resource-123"); expect(mockedGetUserDetails).toHaveBeenCalledWith("creator-456"); expect(payload.importedCalendars["cal-1"].owner).toEqual({ firstname: "Creator", diff --git a/src/components/Attendees/PeopleSearch.tsx b/src/components/Attendees/PeopleSearch.tsx index 759288e..2840bad 100644 --- a/src/components/Attendees/PeopleSearch.tsx +++ b/src/components/Attendees/PeopleSearch.tsx @@ -1,9 +1,8 @@ import { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils"; import { stringAvatar } from "@/components/Event/utils/eventUtils"; +import { useUserSearch } from "./useUserSearch"; import { SnackbarAlert } from "@/components/Loading/SnackBarAlert"; import CloseIcon from "@mui/icons-material/Close"; - -import { searchUsers } from "@/features/User/userAPI"; import { Autocomplete, Avatar, @@ -22,8 +21,6 @@ import PeopleOutlineOutlinedIcon from "@mui/icons-material/PeopleOutlineOutlined import { HTMLAttributes, useCallback, - useEffect, - useState, type ReactNode, type SyntheticEvent, } from "react"; @@ -82,21 +79,29 @@ export function PeopleSearch({ getChipIcon?: (user: User) => ReactNode; }) { const { t } = useI18n(); - const [query, setQuery] = useState(""); - const [loading, setLoading] = useState(false); - const [options, setOptions] = useState([]); - const [hasSearched, setHasSearched] = useState(false); - const [isOpen, setIsOpen] = useState(false); + const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder"); + const errorMessage = t("peopleSearch.searchError"); + + const { + query, + setQuery, + loading, + options, + hasSearched, + isOpen, + setIsOpen, + inputError, + setInputError, + snackbarOpen, + setSnackbarOpen, + snackbarMessage, + setSnackbarMessage, + } = useUserSearch({ objectTypes, errorMessage }); const isValidEmail = (email: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); - const [inputError, setInputError] = useState(null); - const [snackbarOpen, setSnackbarOpen] = useState(false); - const [snackbarMessage, setSnackbarMessage] = useState(""); const theme = useTheme(); - const searchPlaceholder = placeholder ?? t("peopleSearch.placeholder"); - const handleBlurCommit = useCallback( (event: React.SyntheticEvent) => { const trimmed = query.trim(); @@ -116,52 +121,9 @@ export function PeopleSearch({ onChange(event, [...selectedUsers, newUser]); setQuery(""); }, - [query, selectedUsers, onChange, t] + [query, selectedUsers, onChange, t, setInputError, setQuery] ); - useEffect(() => { - let cancelled = false; - - const delayDebounceFn = setTimeout(async () => { - if (!query.trim()) { - if (!cancelled) { - setOptions([]); - setLoading(false); - setHasSearched(false); - } - return; - } - - if (!cancelled) { - setLoading(true); - setHasSearched(false); - } - - try { - const res = await searchUsers(query, objectTypes); - if (!cancelled) { - setOptions(res); - setHasSearched(true); - } - } catch { - if (!cancelled) { - setHasSearched(false); - setSnackbarMessage(t("peopleSearch.searchError")); - setSnackbarOpen(true); - } - } finally { - if (!cancelled) { - setLoading(false); - } - } - }, 300); - - return () => { - cancelled = true; - clearTimeout(delayDebounceFn); - }; - }, [objectTypes, query, t]); - const defaultRenderInput = useCallback( (params: AutocompleteRenderInputParams) => { const inputProps = { diff --git a/src/components/Attendees/ResourceIcon.tsx b/src/components/Attendees/ResourceIcon.tsx new file mode 100644 index 0000000..2237ece --- /dev/null +++ b/src/components/Attendees/ResourceIcon.tsx @@ -0,0 +1,54 @@ +import { Avatar } from "@linagora/twake-mui"; +import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined"; +import CalendarMonthOutlinedIcon from "@mui/icons-material/CalendarMonthOutlined"; +import PhoneIphoneOutlinedIcon from "@mui/icons-material/PhoneIphoneOutlined"; +import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined"; +import VideoCameraBackOutlinedIcon from "@mui/icons-material/VideoCameraBackOutlined"; +import MeetingRoomOutlinedIcon from "@mui/icons-material/MeetingRoomOutlined"; +import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined"; +import ViewComfyAltOutlinedIcon from "@mui/icons-material/ViewComfyAltOutlined"; +import TvOutlinedIcon from "@mui/icons-material/TvOutlined"; +import type { SvgIconComponent } from "@mui/icons-material"; + +/** + * displayName → MUI icon mapping. + * Add new entries here when new resources are created. + */ +const RESOURCE_ICON_MAP: Record = { + "Astreinte OSSA": PeopleAltOutlinedIcon, + "Chômage Partiel": CalendarMonthOutlinedIcon, + Congés: CalendarMonthOutlinedIcon, + COPIL: CalendarMonthOutlinedIcon, + "Iphone 11 Pro Max": PhoneIphoneOutlinedIcon, + "Note de Service": DescriptionOutlinedIcon, + "OSS Events": CalendarMonthOutlinedIcon, + "Permanence OSSA": PeopleAltOutlinedIcon, + "Projo salle 404": VideoCameraBackOutlinedIcon, + "Salle-bat-A-S215-visio-15p": MeetingRoomOutlinedIcon, + "Salle-bat-A-S216-ecran-5p": MeetingRoomOutlinedIcon, + "Salle-bat-B-S217-10p": MeetingRoomOutlinedIcon, + "Salle-bat-C-S218-ecran-20p": MeetingRoomOutlinedIcon, + "Salle-bat-D-S218-visio-amphi-200p": MeetingRoomOutlinedIcon, + Télétravail: LayersOutlinedIcon, + "TV - VN": TvOutlinedIcon, + "Veille COPIL": LayersOutlinedIcon, + "White Board - VN": ViewComfyAltOutlinedIcon, +}; + +function getIconForDisplayName(displayName: string): SvgIconComponent { + return RESOURCE_ICON_MAP[displayName] ?? LayersOutlinedIcon; +} + +interface ResourceIconProps { + displayName: string; +} + +export function ResourceIcon({ displayName }: ResourceIconProps) { + const IconComponent = getIconForDisplayName(displayName); + + return ( + + + + ); +} diff --git a/src/components/Attendees/ResourceSearch.tsx b/src/components/Attendees/ResourceSearch.tsx new file mode 100644 index 0000000..aa78874 --- /dev/null +++ b/src/components/Attendees/ResourceSearch.tsx @@ -0,0 +1,335 @@ +import { getAccessiblePair } from "@/components/Calendar/utils/calendarColorsUtils"; +import { useUserSearch } from "./useUserSearch"; +import { ResourceIcon } from "./ResourceIcon"; +import { SnackbarAlert } from "@/components/Loading/SnackBarAlert"; +import { + Autocomplete, + Chip, + CircularProgress, + ListItem, + ListItemAvatar, + ListItemText, + PaperProps, + PopperProps, + TextField, + useTheme, + Typography, + type AutocompleteRenderInputParams, +} from "@linagora/twake-mui"; +import SearchIcon from "@mui/icons-material/Search"; +import { + HTMLAttributes, + useCallback, + type ReactNode, + type SyntheticEvent, +} from "react"; +import { useI18n } from "twake-i18n"; + +export interface Resource { + email?: string; + displayName: string; + avatarUrl?: string; + openpaasId?: string; + color?: Record; +} + +export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams { + error?: boolean; + helperText?: string | null; + placeholder?: string; + label?: string; + onKeyDown?: (e: React.KeyboardEvent) => void; +} + +export function ResourceSearch({ + selectedResources, + onChange, + objectTypes, + disabled, + freeSolo, + onToggleEventPreview, + placeholder, + inputSlot, + customRenderInput, + customSlotProps, + hideLabel, +}: { + selectedResources: Resource[]; + onChange: (event: SyntheticEvent, users: Resource[]) => void; + objectTypes: string[]; + disabled?: boolean; + freeSolo?: boolean; + onToggleEventPreview?: () => void; + placeholder?: string; + inputSlot?: ( + params: ExtendedAutocompleteRenderInputParams + ) => React.ReactNode; + customRenderInput?: ( + params: AutocompleteRenderInputParams, + query: string, + setQuery: (value: string) => void + ) => ReactNode; + customSlotProps?: { + popper?: Partial; + paper?: Partial; + listbox?: Partial>; + }; + hideLabel?: boolean; +}) { + const { t } = useI18n(); + const searchPlaceholder = placeholder ?? t("resourceSearch.placeholder"); + const errorMessage = t("resourceSearch.searchError"); + + const { + query, + setQuery, + loading, + options, + hasSearched, + isOpen, + setIsOpen, + inputError, + setInputError, + snackbarOpen, + setSnackbarOpen, + snackbarMessage, + setSnackbarMessage, + } = useUserSearch({ objectTypes, errorMessage }); + + const theme = useTheme(); + + const handleBlurCommit = useCallback( + (event: React.SyntheticEvent) => { + const trimmed = query.trim(); + if (!trimmed) return; + if (selectedResources.find((u) => u.displayName === trimmed)) { + setQuery(""); + return; + } + setInputError(null); + const newResource: Resource = { displayName: trimmed }; + onChange(event, [...selectedResources, newResource]); + setQuery(""); + }, + [query, selectedResources, onChange, setInputError, setQuery] + ); + + const defaultRenderInput = useCallback( + (params: AutocompleteRenderInputParams) => { + const inputProps = { + ...params.InputProps, + startAdornment: ( + <> + {!selectedResources?.length ? ( + + ) : null} + {params.InputProps.startAdornment} + + ), + endAdornment: ( + <> + {loading ? : null} + {!selectedResources?.length ? params.InputProps.endAdornment : null} + + ), + }; + + const enhancedParams = { + ...params, + InputProps: inputProps, + inputProps: { + ...params.inputProps, + autoComplete: "off", + }, + }; + + const handleEnterKey = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && onToggleEventPreview) { + e.preventDefault(); + onToggleEventPreview(); + } + }; + + const defaultTextFieldProps = { + error: !!inputError, + helperText: inputError, + placeholder: searchPlaceholder, + label: "", + onKeyDown: handleEnterKey, + slotProps: { + input: { + ...inputProps, + }, + }, + }; + + if (inputSlot) { + return ( + <> + {!hideLabel && ( + + {t("resourceSearch.label")} + + )} + {inputSlot({ + ...enhancedParams, + error: !!inputError, + helperText: inputError, + placeholder: searchPlaceholder, + label: "", + onKeyDown: handleEnterKey, + })} + + ); + } + + return ( + <> + {!hideLabel && ( + + {t("resourceSearch.label")} + + )} + + + ); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + inputError, + t, + onToggleEventPreview, + loading, + searchPlaceholder, + selectedResources?.length, + ] + ); + + return ( + <> + 0) + : isOpen && !!query && (loading || hasSearched) + } + onOpen={() => setIsOpen(true)} + onClose={() => setIsOpen(false)} + disabled={disabled} + loading={loading} + filterOptions={(options: Resource[]) => options} + fullWidth + noOptionsText={t("resourceSearch.noResults")} + loadingText={t("resourceSearch.loading")} + getOptionLabel={(option: Resource | string) => { + if (typeof option === "object") { + return option.displayName; + } else { + return option; + } + }} + sx={{ + "& .MuiAutocomplete-inputRoot": { + py: 0, + }, + }} + filterSelectedOptions + value={selectedResources} + inputValue={query} + onInputChange={(_event, value: string) => setQuery(value)} + onChange={(event, value: string[] | Resource[]) => { + setInputError(null); + const mapped = value + .map((v: string | Resource) => + typeof v === "string" ? { displayName: v.trim() } : v + ) + .filter((v) => v.displayName.trim().length > 0); + onChange(event, mapped); + }} + slotProps={{ + ...customSlotProps, + popper: { + placement: "bottom-start", + sx: { minWidth: "300px", ...customSlotProps?.popper?.sx }, + ...customSlotProps?.popper, + }, + }} + // When render input is custom, the adornments should be handled by the custom component + forcePopupIcon={!customRenderInput} + disableClearable={!!customRenderInput} + renderInput={(params) => + customRenderInput + ? customRenderInput(params, query, setQuery) + : defaultRenderInput(params) + } + renderOption={(props, option: Resource) => { + if ( + selectedResources.find((u) => u.displayName === option.displayName) + ) + return null; + const { key, ...otherProps } = props; + return ( + + + + + + + ); + }} + renderValue={(value: string[] | Resource[], getTagProps) => + value.map((option: string | Resource, index) => { + const isString = typeof option === "string"; + const label = isString ? option : option.displayName; + const chipColor = isString + ? theme.palette.grey[300] + : (option.color?.light ?? theme.palette.grey[300]); + const textColor = getAccessiblePair(chipColor, theme); + + return ( + + ); + }) + } + /> + { + setSnackbarOpen(open); + if (!open) { + setSnackbarMessage(""); + } + }} + message={snackbarMessage} + severity="error" + /> + + ); +} diff --git a/src/components/Attendees/useUserSearch.ts b/src/components/Attendees/useUserSearch.ts new file mode 100644 index 0000000..5d0c693 --- /dev/null +++ b/src/components/Attendees/useUserSearch.ts @@ -0,0 +1,81 @@ +import { useState, useEffect } from "react"; +import { searchUsers } from "@/features/User/userAPI"; + +export interface UseUserSearchProps { + objectTypes: string[]; + errorMessage: string; +} + +export function useUserSearch({ + objectTypes, + errorMessage, +}: UseUserSearchProps) { + const [query, setQuery] = useState(""); + const [loading, setLoading] = useState(false); + const [options, setOptions] = useState([]); + const [hasSearched, setHasSearched] = useState(false); + const [isOpen, setIsOpen] = useState(false); + + const [inputError, setInputError] = useState(null); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(""); + + useEffect(() => { + let cancelled = false; + + const delayDebounceFn = setTimeout(async () => { + if (!query.trim()) { + if (!cancelled) { + setOptions([]); + setLoading(false); + setHasSearched(false); + } + return; + } + + if (!cancelled) { + setLoading(true); + setHasSearched(false); + } + + try { + const res = await searchUsers(query, objectTypes); + if (!cancelled) { + setOptions(res as unknown as T[]); + setHasSearched(true); + } + } catch { + if (!cancelled) { + setHasSearched(false); + setSnackbarMessage(errorMessage); + setSnackbarOpen(true); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }, 300); + + return () => { + cancelled = true; + clearTimeout(delayDebounceFn); + }; + }, [objectTypes, query, errorMessage]); + + return { + query, + setQuery, + loading, + options, + hasSearched, + isOpen, + setIsOpen, + inputError, + setInputError, + snackbarOpen, + setSnackbarOpen, + snackbarMessage, + setSnackbarMessage, + }; +} diff --git a/src/components/Calendar/CalendarResources.tsx b/src/components/Calendar/CalendarResources.tsx new file mode 100644 index 0000000..e4cdfa3 --- /dev/null +++ b/src/components/Calendar/CalendarResources.tsx @@ -0,0 +1,344 @@ +import { useAppDispatch, useAppSelector } from "@/app/hooks"; +import { getCalendars } from "@/features/Calendars/CalendarApi"; +import { Calendar } from "@/features/Calendars/CalendarTypes"; +import { CalendarData } from "@/features/Calendars/types/CalendarData"; +import { renameDefault } from "@/utils/renameDefault"; +import { + Box, + Button, + IconButton, + TextField, + Typography, + useTheme, +} from "@linagora/twake-mui"; +import CloseIcon from "@mui/icons-material/Close"; +import { useRef, useState } from "react"; +import { useI18n } from "twake-i18n"; +import { ResponsiveDialog } from "../Dialog"; +import { ColorPicker } from "./CalendarColorPicker"; +import { getAccessiblePair } from "./utils/calendarColorsUtils"; +import { defaultColors } from "@/utils/defaultColors"; +import { addCalendarResourceAsync } from "@/features/Calendars/api/addCalendarResourceAsync"; +import { Resource, ResourceSearch } from "../Attendees/ResourceSearch"; +import { ResourceIcon } from "../Attendees/ResourceIcon"; + +interface CalendarWithOwner { + cal: CalendarData; + owner: Resource; +} + +function CalendarItem({ + cal, + onRemove, + onColorChange, +}: { + cal: CalendarWithOwner; + onRemove: () => void; + onColorChange: (color: Record) => void; +}) { + const theme = useTheme(); + const { t } = useI18n(); + return ( + + + + + {renameDefault(cal.cal["dav:name"], cal.owner.displayName, t, false)} + + + + + + + + + + + ); +} + +function SelectedCalendarsList({ + calendars, + selectedCal, + onRemove, + onColorChange, +}: { + calendars: Record; + selectedCal: CalendarWithOwner[]; + onRemove: (cal: CalendarWithOwner) => void; + onColorChange: ( + cal: CalendarWithOwner, + color: Record + ) => void; +}) { + const { t } = useI18n(); + if (selectedCal.length === 0) return null; + + const groupedByOwner = selectedCal.reduce< + Record< + string, + { + owner: Resource; + visibleCals: CalendarWithOwner[]; + alreadyExisting: boolean; + } + > + >((acc, cal) => { + const exists = Object.values(calendars).some( + (existing: Calendar) => + existing.id === + cal.cal?._links?.self?.href + ?.replace("/calendars/", "") + .replace(".json", "") + ); + + if (!acc[cal.owner.displayName]) { + acc[cal.owner.displayName] = { + owner: cal.owner, + visibleCals: [], + alreadyExisting: false, + }; + } + + if (exists) { + acc[cal.owner.displayName].alreadyExisting = true; + } else { + acc[cal.owner.displayName].visibleCals.push(cal); + } + + return acc; + }, {}); + + return ( + + + {t("common.resource")} + + + {Object.values(groupedByOwner).map( + ({ owner, visibleCals, alreadyExisting }) => ( + + {visibleCals.length > 0 ? ( + visibleCals.map((cal) => + cal.cal ? ( + onRemove(cal)} + onColorChange={(color) => onColorChange(cal, color)} + /> + ) : ( + + {t("calendar.noPublicCalendarsFor", { + name: owner.displayName, + })} + + ) + ) + ) : alreadyExisting ? ( + + {t("calendar.noMoreCalendarsFor", { name: owner.displayName })} + + ) : null} + + ) + )} + + ); +} + +export default function CalendarResources({ + open, + onClose, +}: { + open: boolean; + onClose: (ids?: string[]) => void; +}) { + const dispatch = useAppDispatch(); + const theme = useTheme(); + + const openpaasId = + useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; + const calendars = useAppSelector((state) => state.calendars.list); + + const [selectedCal, setSelectedCalendars] = useState([]); + const [selectedResources, setSelectedResources] = useState([]); + + const fetchSeqRef = useRef(0); + + const handleSave = async () => { + if (selectedCal.length > 0) { + const results = await Promise.allSettled( + selectedCal.map(async (cal) => { + const calId = crypto.randomUUID(); + const exists = Object.values(calendars).some( + (existing: Calendar) => + existing.id === + cal.cal?._links?.self?.href + ?.replace("/calendars/", "") + .replace(".json", "") + ); + if (!exists && cal.cal) { + await dispatch( + addCalendarResourceAsync({ + userId: openpaasId, + calId, + cal: { + ...cal, + color: cal.cal["apple:color"] + ? { + light: cal.cal["apple:color"], + dark: getAccessiblePair(cal.cal["apple:color"], theme), + } + : defaultColors[0], + }, + }) + ).unwrap(); + return cal.cal._links.self?.href + ?.replace("/calendars/", "") + .replace(".json", ""); + } + return null; + }) + ); + + const idList = results + .filter((r) => r.status === "fulfilled") + .map((r) => (r as PromiseFulfilledResult).value) + .filter(Boolean) as string[]; + + onClose(idList); + } else { + onClose(); + } + setSelectedCalendars([]); + setSelectedResources([]); + }; + const { t } = useI18n(); + + const handleClose = () => { + fetchSeqRef.current += 1; // invalidate in-flight fetch results + onClose(); + setSelectedCalendars([]); + setSelectedResources([]); + }; + + return ( + + + + + } + > + } + onChange={async (_event: React.SyntheticEvent, value: Resource[]) => { + const requestSeq = ++fetchSeqRef.current; + setSelectedResources(value); + + const results = await Promise.allSettled( + value.map(async (user: Resource) => { + if (user?.openpaasId) { + const cals = await getCalendars( + user.openpaasId, + "sharedPublic=true&" + ); + return cals._embedded?.["dav:calendar"] + ? cals._embedded["dav:calendar"].map((cal) => ({ + cal, + owner: user, + })) + : { cal: undefined, owner: user }; + } + return null; + }) + ); + + const successfulCals = results + .filter((result) => result.status === "fulfilled") + .map((result) => (result as PromiseFulfilledResult).value) + .flat() + .filter(Boolean); + + if (requestSeq !== fetchSeqRef.current) return; + setSelectedCalendars(successfulCals as CalendarWithOwner[]); + }} + /> + + { + if (!cal.cal?._links?.self?.href) return; + setSelectedCalendars((prev) => + prev.filter( + (c) => c.cal?._links?.self?.href !== cal.cal._links.self?.href + ) + ); + if ( + !selectedCal.find( + (c) => + cal.owner.displayName === c.owner.displayName && + c.cal?._links?.self?.href !== cal.cal._links.self?.href + ) + ) { + setSelectedResources((prev) => + prev.filter((u) => u.displayName !== cal.owner.displayName) + ); + } + }} + onColorChange={(cal, color) => + setSelectedCalendars((prev) => + prev.map((prevcal) => + prevcal.owner.displayName === cal.owner.displayName && + prevcal.cal._links.self?.href === cal.cal._links.self?.href + ? { + ...prevcal, + cal: { + ...prevcal.cal, + "apple:color": color.light, + }, + } + : prevcal + ) + ) + } + /> + + ); +} diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index 095fb6c..facb8c3 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -25,6 +25,7 @@ import CalendarPopover from "./CalendarModal"; import CalendarSearch from "./CalendarSearch"; import { DeleteCalendarDialog } from "./DeleteCalendarDialog"; import { OwnerCaption } from "./OwnerCaption"; +import CalendarResources from "./CalendarResources"; function CalendarAccordion({ title, @@ -161,7 +162,8 @@ export default function CalendarSelection({ const [anchorElCal, setAnchorElCal] = useState(null); const [anchorElCalOthers, setAnchorElCalOthers] = useState(null); - // const [anchorElCalResources, setAnchorElCalResources] = useState(null); + const [anchorElCalResources, setAnchorElCalResources] = + useState(null); return ( <> @@ -212,7 +214,10 @@ export default function CalendarSelection({ title={t("calendar.resources")} calendars={resourceCalendars} selectedCalendars={selectedCalendars} - showAddButton={false} + onAddClick={() => { + setAnchorElCalResources(document.body); + }} + showAddButton handleToggle={handleCalendarToggle} setOpen={(_) => { // TO DO: Implement open resource selection @@ -238,6 +243,17 @@ export default function CalendarSelection({ } }} /> + { + setAnchorElCalResources(null); + if (newResourceIds?.length) { + newResourceIds.forEach((id) => { + handleCalendarToggle(id); + }); + } + }} + /> ); } diff --git a/src/features/Calendars/CalendarSlice.ts b/src/features/Calendars/CalendarSlice.ts index 86ed1a7..27cd547 100644 --- a/src/features/Calendars/CalendarSlice.ts +++ b/src/features/Calendars/CalendarSlice.ts @@ -2,6 +2,7 @@ import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid"; import { browserDefaultTimeZone } from "@/utils/timezone"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { CalendarEvent } from "../Events/EventsTypes"; +import { addCalendarResourceAsync } from "./api/addCalendarResourceAsync"; import { Calendar } from "./CalendarTypes"; import { addSharedCalendarAsync, @@ -272,6 +273,19 @@ const CalendarSlice = createSlice({ } as Calendar; state.error = null; }) + .addCase(addCalendarResourceAsync.fulfilled, (state, action) => { + state.pending = false; + state.list[action.payload.calId] = { + color: action.payload.color, + id: action.payload.calId, + link: action.payload.link, + description: action.payload.desc, + name: action.payload.name, + events: {}, + owner: action.payload.owner, + } as Calendar; + state.error = null; + }) .addCase(removeCalendarAsync.fulfilled, (state) => { state.pending = false; state.error = null; @@ -362,6 +376,9 @@ const CalendarSlice = createSlice({ .addCase(addSharedCalendarAsync.pending, (state) => { state.pending = true; }) + .addCase(addCalendarResourceAsync.pending, (state) => { + state.pending = true; + }) .addCase(removeCalendarAsync.pending, (state) => { state.pending = true; }) @@ -480,6 +497,13 @@ const CalendarSlice = createSlice({ action.error.message || "Failed to add shared calendar"; }) + .addCase(addCalendarResourceAsync.rejected, (state, action) => { + state.pending = false; + state.error = + action.payload?.message || + action.error.message || + "Failed to add calendar resource"; + }) .addCase(removeCalendarAsync.rejected, (state, action) => { state.pending = false; state.error = diff --git a/src/features/Calendars/api/addCalendarResourceAsync.ts b/src/features/Calendars/api/addCalendarResourceAsync.ts new file mode 100644 index 0000000..5dfc8bb --- /dev/null +++ b/src/features/Calendars/api/addCalendarResourceAsync.ts @@ -0,0 +1,53 @@ +import { OpenPaasUserData } from "@/features/User/type/OpenPaasUserData"; +import { getResourceDetails } 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"; + +export const addCalendarResourceAsync = createAsyncThunk< + { + calId: string; + color: Record; + link: string; + name: string; + desc: string; + owner: OpenPaasUserData; + }, + { + userId: string; + calId: string; + cal: Omit & { + owner?: Omit & { email?: string }; + }; + }, + { rejectValue: RejectedError } +>( + "calendars/addCalendarResource", + async ({ userId, calId, cal }, { rejectWithValue }) => { + try { + await addSharedCalendar(userId, calId, cal); + const ownerData = await getResourceDetails( + cal.cal._links.self.href + .replace("/calendars/", "") + .replace(".json", "") + .split("/")[0] + ); + + return { + calId: cal.cal._links.self?.href + ?.replace("/calendars/", "") + .replace(".json", ""), + color: cal.color, + link: `/calendars/${userId}/${calId}.json`, + desc: cal.cal["caldav:description"] ?? "", + name: cal.cal["dav:name"] ?? "", + owner: ownerData, + }; + } catch (err) { + return rejectWithValue(toRejectedError(err)); + } + } +); diff --git a/src/features/Calendars/useCalendarLoader.ts b/src/features/Calendars/useCalendarLoader.ts index b5bcf61..3086a7a 100644 --- a/src/features/Calendars/useCalendarLoader.ts +++ b/src/features/Calendars/useCalendarLoader.ts @@ -257,6 +257,19 @@ export function useCalendarDataLoader({ [selectedCalendars, calendars] ); + useEffect(() => { + const nonSelectedCalendars = Object.keys( + fetchedIntervalsRef.current + ).filter((calId) => !selectedCalendars.includes(calId)); + if (nonSelectedCalendars?.length) { + nonSelectedCalendars.forEach((calId) => { + // Remove these non-selected calendar IDs to ensure these IDs will be fetched again on every subscribe / selection + delete fetchedIntervalsRef.current[calId]; + delete inFlightRef.current[calId]; + }); + } + }, [selectedCalendars]); + useEffect(() => { const toApiDate = (ms: number) => formatDateToYYYYMMDDTHHMMSS(new Date(ms)); calendarsWithClearedCache.forEach(({ id, cleared }) => { diff --git a/src/locales/en.json b/src/locales/en.json index 4f399a9..6896dd5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -26,7 +26,8 @@ "personalWarning": "You will lose all events in this calendar.", "sharedWarning": "You will lose access to its events. You will still be able to add it back later." }, - "resources": "Resources" + "resources": "Resources", + "browseResources": "Browse resources" }, "actions": { "modify": "Modify", @@ -54,7 +55,8 @@ "you": "You", "select_timezone": "Select Timezone", "moreOptions": "More options", - "search": "Search" + "search": "Search", + "resource": "Resource" }, "search": { "searchIn": "Search in", @@ -347,5 +349,14 @@ "subtitle": "Please open Twake Calendar on a desktop to enjoy all features. A fully optimized mobile version is coming soon.", "title": "Mobile version coming soon" } + }, + "resourceSearch": { + "label": "Search for resources", + "placeholder": "Search by name", + "availabilityPlaceholder": "Check availability", + "invalidEmail": "%{email} is not a valid email address", + "searchError": "Unable to fetch resources right now. Please try again.", + "noResults": "No results", + "loading": "Loading..." } } diff --git a/src/locales/fr.json b/src/locales/fr.json index 1ba80fc..71c09f6 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -26,7 +26,8 @@ "personalWarning": "Vous allez perdre tous les événements de ce calendrier.", "sharedWarning": "Vous allez perdre l’accès à ses événements. Vous pourrez toujours le rajouter plus tard." }, - "resources": "Ressources" + "resources": "Ressources", + "browseResources": "Parcourir les ressources" }, "actions": { "modify": "Modifier", @@ -54,7 +55,8 @@ "you": "Vous", "select_timezone": "Sélectionner le fuseau horaire", "moreOptions": "Plus d'options", - "search": "Rechercher" + "search": "Rechercher", + "resource": "Ressources" }, "search": { "searchIn": "Rechercher dans", @@ -348,5 +350,14 @@ "subtitle": "Veuillez ouvrir Twake Calendar sur un ordinateur pour profiter de toutes les fonctionnalités. Une version mobile optimisée arrive bientôt.", "title": "Version mobile bientôt disponible" } + }, + "resourceSearch": { + "label": "Rechercher des ressources", + "placeholder": "Rechercher par nom", + "availabilityPlaceholder": "Vérifier la disponibilité", + "invalidEmail": "%{email} n'est pas une adresse e-mail valide", + "searchError": "Impossible de récupérer les ressources pour le moment. Veuillez réessayer.", + "noResults": "Aucun résultat", + "loading": "Chargement..." } } diff --git a/src/locales/ru.json b/src/locales/ru.json index 9d731f7..b8a5fbd 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -26,7 +26,8 @@ "personalWarning": "Вы потеряете все события в этом календаре.", "sharedWarning": "Вы потеряете доступ. Позже можно добавить снова." }, - "resources": "Ресурсы" + "resources": "Ресурсы", + "browseResources": "Просмотреть ресурсы" }, "actions": { "modify": "Изменить", @@ -54,7 +55,8 @@ "you": "Вы", "select_timezone": "Выберите часовой пояс", "moreOptions": "Больше параметров", - "search": "Поиск" + "search": "Поиск", + "resource": "Ресурсы" }, "search": { "searchIn": "Поиск в", @@ -348,5 +350,14 @@ "subtitle": "Пожалуйста, откройте Twake Calendar на компьютере, чтобы воспользоваться всеми функциями. Полностью оптимизированная мобильная версия скоро появится.", "title": "Мобильная версия скоро будет доступна" } + }, + "resourceSearch": { + "label": "Поиск ресурсов", + "placeholder": "Поиск по имени", + "availabilityPlaceholder": "Проверить доступность", + "invalidEmail": "%{email} не является действительным адресом электронной почты", + "searchError": "Не удалось получить ресурсы. Попробуйте снова.", + "noResults": "Нет результатов", + "loading": "Загрузка..." } } diff --git a/src/locales/vi.json b/src/locales/vi.json index f5bf983..8f30121 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -26,7 +26,8 @@ "personalWarning": "Bạn sẽ mất tất cả sự kiện trong lịch này.", "sharedWarning": "Bạn sẽ mất quyền truy cập các sự kiện của lịch. Bạn vẫn có thể thêm lại sau." }, - "resources": "Tài nguyên" + "resources": "Tài nguyên", + "browseResources": "Tìm kiếm tài nguyên" }, "actions": { "modify": "Chỉnh sửa", @@ -54,7 +55,8 @@ "you": "Bạn", "select_timezone": "Chọn múi giờ", "moreOptions": "Thêm tùy chọn", - "search": "Tìm kiếm" + "search": "Tìm kiếm", + "resource": "Tài nguyên" }, "search": { "searchIn": "Tìm kiếm trong", @@ -346,5 +348,14 @@ "subtitle": "Vui lòng mở Twake Calendar trên máy tính để sử dụng đầy đủ tính năng. Phiên bản di động được tối ưu hóa hoàn toàn sẽ sớm ra mắt.", "title": "Phiên bản di động sắp ra mắt" } + }, + "resourceSearch": { + "label": "Tìm kiếm tài nguyên", + "placeholder": "Tìm kiếm theo tên", + "availabilityPlaceholder": "Kiểm tra tình trạng", + "invalidEmail": "%{email} không phải là địa chỉ email hợp lệ", + "searchError": "Không thể lấy tài nguyên. Vui lòng thử lại.", + "noResults": "Không có kết quả", + "loading": "Đang tải..." } }