From 9898278fb5bd92214c7be7b4e8197eed0d6ecaac Mon Sep 17 00:00:00 2001 From: lethemanh Date: Mon, 16 Mar 2026 23:35:35 +0700 Subject: [PATCH] #597 display resource calendar (#630) --- .../services/getCalendarsListAsync.test.tsx | 199 ++++++++++++++++++ __test__/features/user/userAPI.test.tsx | 19 ++ src/components/Calendar/CalendarSelection.tsx | 24 ++- .../services/getCalendarsListAsync.ts | 37 +++- src/features/User/type/OpenPaasUserData.ts | 1 + src/features/User/type/ResourceData.ts | 19 ++ src/features/User/userAPI.ts | 8 + src/locales/en.json | 3 +- src/locales/fr.json | 3 +- src/locales/ru.json | 3 +- src/locales/vi.json | 3 +- 11 files changed, 313 insertions(+), 6 deletions(-) create mode 100644 __test__/features/Calendars/services/getCalendarsListAsync.test.tsx create mode 100644 src/features/User/type/ResourceData.ts diff --git a/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx b/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx new file mode 100644 index 0000000..62cbc7e --- /dev/null +++ b/__test__/features/Calendars/services/getCalendarsListAsync.test.tsx @@ -0,0 +1,199 @@ +import { getCalendarsListAsync } from "@/features/Calendars/services/getCalendarsListAsync"; +import { + getOpenPaasUser, + getResourceDetails, + getUserDetails, +} from "@/features/User/userAPI"; +import { getCalendars } from "@/features/Calendars/CalendarApi"; +import { formatReduxError, toRejectedError } from "@/utils/errorUtils"; +import { normalizeCalendar } from "@/features/Calendars/utils/normalizeCalendar"; + +jest.mock("@/features/User/userAPI"); +jest.mock("@/features/Calendars/CalendarApi"); +jest.mock("@/utils/errorUtils"); +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 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", () => { + let dispatch: jest.Mock; + let getState: jest.Mock; + + beforeEach(() => { + dispatch = jest.fn(); + getState = jest.fn(); + jest.clearAllMocks(); + + mockedFormatReduxError.mockImplementation((err) => { + if (err?.message) return err.message; + if (typeof err === "string") return err; + return JSON.stringify(err); + }); + }); + + it("should handle successful execution and merge with existing calendars", async () => { + getState.mockReturnValue({ + calendars: { + list: { + "cal-existing": { + id: "cal-existing", + color: { light: "red", dark: "#FFF" }, + events: { "event-1": {} }, + }, + }, + }, + user: { + userData: { openpaasId: "user-123" }, + }, + }); + + const mockCalendarsResponse = { + _embedded: { + "dav:calendar": [{ id: "cal-existing" }, { id: "cal-new" }], + }, + }; + mockedGetCalendars.mockResolvedValue(mockCalendarsResponse); + + mockedNormalizeCalendar + .mockReturnValueOnce({ + cal: { "dav:name": "Existing Cal", "apple:color": "blue" }, + id: "cal-existing", + ownerId: "user-123", + description: "old cal", + delegated: false, + link: "/link/1", + visibility: 1, + access: 3, + }) + .mockReturnValueOnce({ + cal: { "dav:name": "New Cal" }, + id: "cal-new", + ownerId: "user-456", + description: "new cal", + delegated: true, + link: "/link/2", + visibility: 2, + access: 2, + invite: [{ href: "", principal: "", access: 3, inviteStatus: 1 }], + }); + + mockedGetUserDetails + .mockResolvedValueOnce({ + firstname: "John", + lastname: "Doe", + emails: ["john@example.com"], + }) + .mockResolvedValueOnce({ + firstname: "Jane", + lastname: "Smith", + emails: ["jane@example.com"], + }); + + const thunk = getCalendarsListAsync(); + const result = await thunk(dispatch, getState, undefined); + + expect(result.type).toBe("calendars/getCalendars/fulfilled"); + const payload = result.payload as { + importedCalendars: any; + errors: string; + }; + + expect(payload.errors).toBe(""); + expect(Object.keys(payload.importedCalendars)).toHaveLength(2); + + expect(payload.importedCalendars["cal-existing"]).toMatchObject({ + id: "cal-existing", + color: { light: "blue", dark: "#FFF" }, + events: { "event-1": {} }, + }); + + expect(payload.importedCalendars["cal-new"]).toMatchObject({ + id: "cal-new", + name: "New Cal", + owner: { firstname: "Jane", lastname: "Smith" }, + }); + + expect(mockedGetOpenPaasUser).not.toHaveBeenCalled(); // User ID existed in state + expect(mockedGetCalendars).toHaveBeenCalledWith("user-123"); + }); + + it("should fetch user using getOpenPaasUser if openpaasId is not in state", async () => { + getState.mockReturnValue({ calendars: {}, user: {} }); + mockedGetOpenPaasUser.mockResolvedValue({ id: "fetched-user-123" }); + mockedGetCalendars.mockResolvedValue({ _embedded: { "dav:calendar": [] } }); + + const thunk = getCalendarsListAsync(); + await thunk(dispatch, getState, undefined); + + expect(mockedGetOpenPaasUser).toHaveBeenCalled(); + expect(mockedGetCalendars).toHaveBeenCalledWith("fetched-user-123"); + }); + + it("should handle error when API call fails", async () => { + getState.mockReturnValue({ calendars: {}, user: {} }); + mockedGetOpenPaasUser.mockResolvedValue({ id: "fetched-user-123" }); + + mockedGetCalendars.mockRejectedValue({ + response: { status: 500 }, + message: "Server Error", + }); + mockedToRejectedError.mockReturnValueOnce({ + status: 500, + message: "Server Error", + }); + + const thunk = getCalendarsListAsync(); + const result = await thunk(dispatch, getState, undefined); + + expect(result.type).toBe("calendars/getCalendars/rejected"); + expect(result.payload).toEqual({ + status: 500, + message: "Server Error", + }); + }); + + it("should fetch resource detail as fallback when user not found", async () => { + getState.mockReturnValue({ + calendars: {}, + user: { userData: { openpaasId: "user-123" } }, + }); + mockedGetCalendars.mockResolvedValue({ + _embedded: { "dav:calendar": [{ id: "cal-1" }] }, + }); + mockedNormalizeCalendar.mockReturnValue({ + cal: { "dav:name": "Resource Cal" }, + id: "cal-1", + ownerId: "resource-123", + }); + + // getUserDetails fails with 404 for the resource ID + mockedGetUserDetails.mockRejectedValueOnce({ response: { status: 404 } }); + // Then getResourceDetails is called and succeeds + mockedgetResourceDetails.mockResolvedValueOnce({ creator: "creator-456" }); + // Then getUserDetails is called for the creator and succeeds + mockedGetUserDetails.mockResolvedValueOnce({ + firstname: "Creator", + lastname: "User", + emails: [], + }); + + const thunk = getCalendarsListAsync(); + const result = await thunk(dispatch, getState, undefined); + + const payload = result.payload as any; + expect(mockedgetResourceDetails).toHaveBeenCalledWith("resource-123"); + expect(mockedGetUserDetails).toHaveBeenCalledWith("creator-456"); + expect(payload.importedCalendars["cal-1"].owner).toEqual({ + firstname: "Creator", + lastname: "User", + emails: [], + resource: true, + }); + }); +}); diff --git a/__test__/features/user/userAPI.test.tsx b/__test__/features/user/userAPI.test.tsx index edf56d8..215c981 100644 --- a/__test__/features/user/userAPI.test.tsx +++ b/__test__/features/user/userAPI.test.tsx @@ -2,6 +2,7 @@ import { clientConfig } from "@/features/User/oidcAuth"; import { getOpenPaasUser, updateUserConfigurations, + getResourceDetails, } from "@/features/User/userAPI"; import { api } from "@/utils/apiUtils"; @@ -24,6 +25,24 @@ describe("getOpenPaasUser", () => { }); }); +describe("getResourceDetails", () => { + it("should fetch and return resource details", async () => { + const mockResource = { _id: "res-123", name: "Meeting Room A" }; + const resourceId = "res-123"; + + (api.get as jest.Mock).mockReturnValue({ + json: jest.fn().mockResolvedValue(mockResource), + }); + + const result = await getResourceDetails(resourceId); + + expect(api.get).toHaveBeenCalledWith( + `linagora.esn.resource/api/resources/${resourceId}` + ); + expect(result).toEqual(mockResource); + }); +}); + describe("updateUserConfigurations", () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/components/Calendar/CalendarSelection.tsx b/src/components/Calendar/CalendarSelection.tsx index 102c10c..aad75c2 100644 --- a/src/components/Calendar/CalendarSelection.tsx +++ b/src/components/Calendar/CalendarSelection.tsx @@ -136,7 +136,16 @@ export default function CalendarSelection({ (id) => extractEventBaseUuid(id) !== userId && calendars[id]?.delegated ); const sharedCalendars = Object.keys(calendars || {}).filter( - (id) => extractEventBaseUuid(id) !== userId && !calendars?.[id]?.delegated + (id) => + extractEventBaseUuid(id) !== userId && + !calendars?.[id]?.delegated && + !calendars?.[id]?.owner?.resource + ); + const resourceCalendars = Object.keys(calendars || {}).filter( + (id) => + extractEventBaseUuid(id) !== userId && + !calendars?.[id]?.delegated && + calendars?.[id]?.owner?.resource ); const handleCalendarToggle = (name: string) => { @@ -149,6 +158,7 @@ export default function CalendarSelection({ const [anchorElCal, setAnchorElCal] = useState(null); const [anchorElCalOthers, setAnchorElCalOthers] = useState(null); + // const [anchorElCalResources, setAnchorElCalResources] = useState(null); return ( <> @@ -194,6 +204,18 @@ export default function CalendarSelection({ }} defaultExpanded /> + + { + // TO DO: Implement open resource selection + }} + defaultExpanded + /> (); const OWNER_BATCH_SIZE = 20; + const fetchResourceData = async (ownerId: string) => { + try { + const data = await getResourceDetails(ownerId); + const ownerData = await getUserDetails(data.creator); + ownerDataMap.set(ownerId, { + ...ownerData, + resource: true, + }); + } catch (error) { + console.error( + `Failed to fetch resource details for ${ownerId}:`, + error + ); + ownerDataMap.set(ownerId, { + firstname: "", + lastname: "Unknown User", + emails: [], + resource: true, + }); + errors.push(formatReduxError(error)); + } + }; + const fetchOwnerData = async (ownerId: string) => { try { const data = await getUserDetails(ownerId); ownerDataMap.set(ownerId, data); } catch (error) { + const status = (error as { response?: { status?: number } }).response + ?.status; + + if (status === 404) { + await fetchResourceData(ownerId); + return; + } + console.error(`Failed to fetch user details for ${ownerId}:`, error); ownerDataMap.set(ownerId, { firstname: "", diff --git a/src/features/User/type/OpenPaasUserData.ts b/src/features/User/type/OpenPaasUserData.ts index 67dd800..bfd9908 100644 --- a/src/features/User/type/OpenPaasUserData.ts +++ b/src/features/User/type/OpenPaasUserData.ts @@ -9,6 +9,7 @@ export interface OpenPaasUserData { modules?: ModuleConfiguration[]; }; emails: string[]; + resource?: boolean; } export function ToUserData( diff --git a/src/features/User/type/ResourceData.ts b/src/features/User/type/ResourceData.ts new file mode 100644 index 0000000..26f1471 --- /dev/null +++ b/src/features/User/type/ResourceData.ts @@ -0,0 +1,19 @@ +export interface ResourceData { + _id: string; + name: string; + description: string; + type: string; + icon: string; + deleted: boolean; + creator: string; + administrators: { + _id: string; + id: string; + objectType: string; + }[]; + timestamps: { + creation: string; + updatedAt: string; + }; + resource?: boolean; +} diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index f6ed783..6b77d77 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -7,6 +7,7 @@ import { ModuleConfiguration, SearchResponseItem, } from "./userDataTypes"; +import { ResourceData } from "./type/ResourceData"; export async function getOpenPaasUser() { const user = await api.get(`api/user`); @@ -42,6 +43,13 @@ export async function getUserDetails(id: string): Promise { return user as OpenPaasUserData; } +export async function getResourceDetails(id: string): Promise { + const resource = await api + .get(`linagora.esn.resource/api/resources/${id}`) + .json(); + return resource as ResourceData; +} + export interface UserConfigurationUpdates { language?: string; notifications?: Record; diff --git a/src/locales/en.json b/src/locales/en.json index 38c9027..4f399a9 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -25,7 +25,8 @@ "title": "Remove %{name}?", "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" }, "actions": { "modify": "Modify", diff --git a/src/locales/fr.json b/src/locales/fr.json index 9bbe057..1ba80fc 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -25,7 +25,8 @@ "title": "Supprimer %{name} ?", "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" }, "actions": { "modify": "Modifier", diff --git a/src/locales/ru.json b/src/locales/ru.json index ef63987..9d731f7 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -25,7 +25,8 @@ "title": "Удалить %{name}?", "personalWarning": "Вы потеряете все события в этом календаре.", "sharedWarning": "Вы потеряете доступ. Позже можно добавить снова." - } + }, + "resources": "Ресурсы" }, "actions": { "modify": "Изменить", diff --git a/src/locales/vi.json b/src/locales/vi.json index 1fb7a2e..f5bf983 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -25,7 +25,8 @@ "title": "Xóa %{name}?", "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" }, "actions": { "modify": "Chỉnh sửa",