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..."
}
}